ReportCollectService.java 43 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941
  1. package com.etotem.cfc.service;
  2. import com.etotem.cfc.common.Result;
  3. import com.etotem.cfc.dto.DanReportUploadVO;
  4. import com.etotem.cfc.dto.ParsedReportPayload;
  5. import com.etotem.cfc.entity.*;
  6. import com.etotem.cfc.mapper.DanReportUploadMapper;
  7. import com.etotem.cfc.mapper.FamilyMemberMapper;
  8. import com.etotem.cfc.mapper.ReportTypeRegistryMapper;
  9. import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
  10. import com.fasterxml.jackson.core.type.TypeReference;
  11. import com.fasterxml.jackson.databind.JsonNode;
  12. import com.fasterxml.jackson.databind.ObjectMapper;
  13. import lombok.extern.slf4j.Slf4j;
  14. import org.springframework.beans.factory.annotation.Value;
  15. import org.springframework.http.*;
  16. import org.springframework.stereotype.Service;
  17. import org.springframework.web.client.RestTemplate;
  18. import javax.annotation.Resource;
  19. import java.io.BufferedReader;
  20. import java.io.File;
  21. import java.io.FileInputStream;
  22. import java.io.InputStreamReader;
  23. import java.math.BigDecimal;
  24. import java.nio.charset.StandardCharsets;
  25. import java.text.SimpleDateFormat;
  26. import java.util.*;
  27. import java.util.concurrent.Executor;
  28. import java.util.concurrent.TimeUnit;
  29. import java.util.function.Consumer;
  30. /**
  31. * 报告异步采集服务:上传后进入后台队列,指纹判定类型后走专用脚本或 opencode,
  32. * 解析完成自动入库,前端通过草稿 parse_status 轮询。
  33. */
  34. @Slf4j
  35. @Service
  36. public class ReportCollectService {
  37. @Resource
  38. private ReportFingerprintService fingerprintService;
  39. @Resource
  40. private LangGraphReportParseService langGraphReportParseService;
  41. @Resource
  42. private HealthReportDraftService healthReportDraftService;
  43. @Resource
  44. private HealthReportService healthReportService;
  45. @Resource
  46. private FoodService foodService;
  47. @Resource
  48. private DimensionScoreService dimensionScoreService;
  49. @Resource
  50. private FoodRecommendService foodRecommendService;
  51. @Resource
  52. private ReportBlockService reportBlockService;
  53. @Resource
  54. private ReportBlockAssembler reportBlockAssembler;
  55. @Resource
  56. private StorageService storageService;
  57. @Resource
  58. private FamilyMemberMapper familyMemberMapper;
  59. @Resource
  60. private ReportTypeRegistryMapper reportTypeRegistryMapper;
  61. @Resource
  62. private DanReportUploadMapper danReportUploadMapper;
  63. @Resource
  64. private DanReportParseService danReportParseService;
  65. @Resource
  66. private DanReportUploadService danReportUploadService;
  67. @Resource(name = "taskExecutor")
  68. private Executor taskExecutor;
  69. @Resource
  70. private TaskService taskService;
  71. @Resource
  72. private RestTemplate restTemplate;
  73. @Resource
  74. private ObjectMapper objectMapper;
  75. @Value("${report.collect.opencode-base:http://127.0.0.1:4090}")
  76. private String opencodeBase;
  77. @Value("${report.collect.opencode-user:opencode}")
  78. private String opencodeUser;
  79. @Value("${report.collect.opencode-password:IwinTrue@123}")
  80. private String opencodePassword;
  81. @Value("${report.collect.opencode-workdir:/app/cfc}")
  82. private String opencodeWorkdir;
  83. @Value("${report.collect.opencode-model-id:agnes-2.5-flash}")
  84. private String opencodeModelId;
  85. @Value("${report.collect.opencode-model-provider:agnes-ai}")
  86. private String opencodeModelProvider;
  87. /** 常量:解析方式 */
  88. public static final String METHOD_FINGERPRINT = "fingerprint_script";
  89. public static final String METHOD_LANGGRAPH_SCRIPT = "langgraph_script";
  90. public static final String METHOD_LANGGRAPH_DETECT = "langgraph_detect";
  91. public static final String METHOD_OPENCODE = "opencode";
  92. /** 状态常量:与草稿 parse_status 对齐 */
  93. public static final String STATUS_COLLECTING = "collecting";
  94. public static final String STATUS_COMPLETED = "completed";
  95. public static final String STATUS_FAILED = "failed";
  96. /**
  97. * 提交异步采集任务。上传完成后调用,立即返回。
  98. */
  99. public void submitAsyncCollect(Long draftId) {
  100. try {
  101. taskExecutor.execute(() -> collect(draftId));
  102. } catch (Exception e) {
  103. log.error("提交异步采集任务失败 draftId={}", draftId, e);
  104. healthReportDraftService.markCollectFailed(draftId, "提交任务失败: " + e.getMessage());
  105. }
  106. }
  107. /**
  108. * 采集主流程:指纹判定 → 已知专用脚本 / 未知 opencode → 解析 JSON → 自动入库
  109. */
  110. public void collect(Long draftId) {
  111. HealthReportDraft draft = healthReportDraftService.getDraftById(draftId);
  112. if (draft == null) {
  113. log.warn("异步采集草稿不存在 draftId={}", draftId);
  114. return;
  115. }
  116. if (STATUS_COMPLETED.equals(draft.getParseStatus())) {
  117. return;
  118. }
  119. try {
  120. healthReportDraftService.markCollecting(draftId, null);
  121. String pdfPath = resolvePdfPath(draft);
  122. if (pdfPath == null) {
  123. healthReportDraftService.markCollectFailed(draftId, "无法获取报告文件路径");
  124. return;
  125. }
  126. // 文件类型预检:采集链路(指纹引擎/LangGraph/opencode)仅支持 PDF,
  127. // 图片等非 PDF 文件直接标记失败,避免 PyPDF2 报 EOF marker not found 等错误
  128. if (!isPdfFile(pdfPath)) {
  129. log.warn("采集跳过非PDF文件 draftId={}, path={}, filename={}",
  130. draftId, pdfPath, draft.getOriginalFilename());
  131. healthReportDraftService.markCollectFailed(draftId, "仅支持PDF报告文件,请上传PDF格式");
  132. return;
  133. }
  134. // 指纹判定
  135. Map<String, Object> fp = fingerprintService.detect(pdfPath, draft.getOriginalFilename());
  136. String typeId = (String) fp.get("typeId");
  137. // DAN 家族分流:指纹识别到 dan_* 类型(如 dan_a2)→ Java 同步解析 + DAN 入库,
  138. // 不进入健康报告管道(parserPath 为空的 dan 类型指纹也不会走 LangGraph 脚本分支)
  139. if (typeId != null && !"unknown".equals(typeId)) {
  140. ReportTypeRegistry registry = reportTypeRegistryMapper.selectOne(
  141. new LambdaQueryWrapper<ReportTypeRegistry>()
  142. .eq(ReportTypeRegistry::getTypeId, typeId)
  143. .last("LIMIT 1"));
  144. if (registry != null && "dan".equals(registry.getFamily())) {
  145. collectDanReport(draft, pdfPath, registry);
  146. return;
  147. }
  148. }
  149. ParsedReportPayload.Payload payload;
  150. String method;
  151. if (!"unknown".equals(typeId) && fp.get("parserPath") != null) {
  152. // 指纹成功:脚本已迁移到 LangGraph(/report/parse,算法 + LLM 兜底)
  153. method = METHOD_LANGGRAPH_SCRIPT;
  154. payload = langGraphReportParseService.parseByScript(pdfPath);
  155. // LangGraph 脚本解析为空时回退 opencode,避免已知类型整单失败
  156. if (payload == null) {
  157. log.warn("已知类型 LangGraph 脚本解析为空,回退 opencode typeId={}", typeId);
  158. method = METHOD_OPENCODE;
  159. payload = parseWithOpencode(pdfPath, draft.getOriginalFilename());
  160. }
  161. } else {
  162. // 无指纹:先用 LangGraph 识别(detect-type → parse-typed / script),识别不了再 opencode
  163. method = METHOD_LANGGRAPH_DETECT;
  164. payload = langGraphReportParseService.parseWithDetect(pdfPath);
  165. if (payload == null) {
  166. log.warn("无指纹 LangGraph 识别/解析为空,回退 opencode typeId={}", typeId);
  167. method = METHOD_OPENCODE;
  168. payload = parseWithOpencode(pdfPath, draft.getOriginalFilename());
  169. }
  170. }
  171. if (payload == null) {
  172. healthReportDraftService.markCollectFailed(draftId, "解析结果为空或无效");
  173. return;
  174. }
  175. harmReportPayloadValid(payload);
  176. healthReportDraftService.updateParseMethod(draftId, method);
  177. healthReportDraftService.updatePayload(draftId, objectMapper.writeValueAsString(payload));
  178. insertReport(draft, payload);
  179. log.info("异步采集完成 draftId={}, method={}", draftId, method);
  180. } catch (Exception e) {
  181. log.error("异步采集失败 draftId={}", draftId, e);
  182. healthReportDraftService.markCollectFailed(draftId, e.getMessage());
  183. }
  184. }
  185. /**
  186. * DAN 家族报告采集:指纹识别到 dan_* 类型(如 dan_a2)后走 Java 同步解析 + DAN 入库。
  187. * 复用 DanReportUploadService.confirmUpload(@Transactional:result 入库 / 展示块 / 汇总 / 五维刷新 / 自动方案),
  188. * 完成草稿闭环:reportType 置为识别类型 + markCollectCompleted(reportId = dan upload id),
  189. * 前端 collect/status 据此跳转 dan-assessment/report-result?reportId=...
  190. */
  191. private void collectDanReport(HealthReportDraft draft, String pdfPath, ReportTypeRegistry registry) {
  192. Long draftId = draft.getId();
  193. Long memberId = draft.getMemberId();
  194. if (memberId == null) {
  195. healthReportDraftService.markCollectFailed(draftId,
  196. "已识别为" + registry.getDisplayName() + "报告,请重新上传并选择报告归属成员");
  197. return;
  198. }
  199. String dimension = resolveDanDimension(registry.getTypeId());
  200. try {
  201. // Java 同步解析(与 uploadAndParse 同一解析器)
  202. DanReportParseService.DanParsedReport parsed;
  203. try (FileInputStream fis = new FileInputStream(new File(pdfPath))) {
  204. parsed = danReportParseService.parse(fis, dimension);
  205. }
  206. if (parsed == null || parsed.getItems() == null || parsed.getItems().isEmpty()) {
  207. healthReportDraftService.markCollectFailed(draftId,
  208. "已识别为" + registry.getDisplayName() + "报告,但未能提取有效指标");
  209. return;
  210. }
  211. // 构建 DAN 上传记录(对齐 uploadAndParse 字段)
  212. DanReportUpload upload = new DanReportUpload();
  213. upload.setFamilyMemberId(memberId);
  214. upload.setUploaderUserId(draft.getUserId());
  215. upload.setDimension(dimension);
  216. Long familyId = draft.getFamilyId();
  217. if (familyId == null) {
  218. familyId = resolveDanFamilyId(memberId);
  219. }
  220. upload.setFamilyId(familyId);
  221. upload.setFileUrl(draft.getFileUrl());
  222. upload.setFileId(toShortFileId(draft.getFileUrl()));
  223. upload.setFileName(draft.getOriginalFilename());
  224. upload.setFileSize(new File(pdfPath).length());
  225. upload.setUploadedAt(new Date());
  226. upload.setDraftStatus(DanReportUpload.DRAFT_PENDING);
  227. upload.setParseStatus(DanReportUpload.PARSE_SUCCESS);
  228. upload.setParserVersion("v1");
  229. upload.setParsedItems(objectMapper.writeValueAsString(parsed.getItems()));
  230. upload.setParsedExtra(objectMapper.writeValueAsString(parsed.getExtra()));
  231. upload.setParsedSummary(parsed.getSummary());
  232. upload.setParsedSuggestions(parsed.getSuggestions());
  233. upload.setReportType(parsed.getReportType());
  234. upload.setParsedAt(new Date());
  235. upload.setEditCount(0);
  236. upload.setCreatedAt(new Date());
  237. upload.setUpdatedAt(new Date());
  238. danReportUploadMapper.insert(upload);
  239. // 自动确认入库(result + blocks + 汇总 + 五维 + 自动方案)
  240. Map<String, Object> extra = parsed.getExtra();
  241. String childName = extra != null && extra.get("name") != null ? String.valueOf(extra.get("name")) : "";
  242. String assessmentDate = extra != null && extra.get("reportDate") != null
  243. ? String.valueOf(extra.get("reportDate")) : "";
  244. Result<DanReportUploadVO> confirm = danReportUploadService.confirmUpload(
  245. upload.getId(), draft.getUserId(), childName, assessmentDate);
  246. if (confirm == null || confirm.getCode() == null || confirm.getCode() != 200) {
  247. healthReportDraftService.markCollectFailed(draftId,
  248. "DAN报告确认入库失败: " + (confirm != null ? confirm.getMessage() : "无响应"));
  249. return;
  250. }
  251. // 草稿闭环:reportType 置为识别类型(前端按 dan_* 跳转)+ completed
  252. healthReportDraftService.updateReportType(draftId, registry.getTypeId());
  253. healthReportDraftService.markCollectCompleted(draftId, upload.getId());
  254. log.info("DAN家族采集完成 draftId={}, typeId={}, danUploadId={}, dimension={}",
  255. draftId, registry.getTypeId(), upload.getId(), dimension);
  256. } catch (Exception e) {
  257. log.error("DAN家族采集失败 draftId={}, typeId={}", draftId, registry.getTypeId(), e);
  258. healthReportDraftService.markCollectFailed(draftId, e.getMessage());
  259. }
  260. }
  261. /** dan 家族 typeId → 测评维度:dan_a2 → mind、dan_b4 → wisdom 等;兼容 dan_<dim> 语义名,否则默认 mind */
  262. private String resolveDanDimension(String typeId) {
  263. if (typeId != null && typeId.startsWith("dan_")) {
  264. String code = typeId.substring(4);
  265. // 兼容语义维度名(如 dan_mind / dan_wisdom)
  266. if (DanReportUpload.DIM_MIND.equals(code) || DanReportUpload.DIM_WISDOM.equals(code)
  267. || DanReportUpload.DIM_COGNITION.equals(code) || DanReportUpload.DIM_LEARNING.equals(code)
  268. || DanReportUpload.DIM_BEHAVIOR.equals(code) || DanReportUpload.DIM_CAREER.equals(code)
  269. || DanReportUpload.DIM_CAMPUS.equals(code) || DanReportUpload.DIM_ADOLESCENT.equals(code)) {
  270. return code;
  271. }
  272. // report_type_registry.typeId 编码 → 测评维度
  273. switch (code) {
  274. case "a1": return DanReportUpload.DIM_COGNITION;
  275. case "a2": return DanReportUpload.DIM_MIND;
  276. case "b2": return DanReportUpload.DIM_BEHAVIOR;
  277. case "b3": return DanReportUpload.DIM_LEARNING;
  278. case "b4": return DanReportUpload.DIM_WISDOM;
  279. case "b5": return DanReportUpload.DIM_ADOLESCENT;
  280. case "b6": return DanReportUpload.DIM_CAREER;
  281. case "c1": return DanReportUpload.DIM_CAMPUS;
  282. default: break;
  283. }
  284. }
  285. return DanReportUpload.DIM_MIND;
  286. }
  287. private Long resolveDanFamilyId(Long memberId) {
  288. if (memberId == null) {
  289. return null;
  290. }
  291. FamilyMember member = familyMemberMapper.selectById(memberId);
  292. return member != null ? member.getFamilyId() : null;
  293. }
  294. /** file_id 存相对路径或文件名(对齐 DanReportUploadService.toShortFileId,避免 Data truncation) */
  295. private String toShortFileId(String fileUrl) {
  296. if (fileUrl == null || fileUrl.isEmpty()) {
  297. return null;
  298. }
  299. int uploadsIdx = fileUrl.indexOf("/uploads/");
  300. if (uploadsIdx >= 0) {
  301. return fileUrl.substring(uploadsIdx + 1);
  302. }
  303. int slash = fileUrl.lastIndexOf('/');
  304. if (slash >= 0 && slash < fileUrl.length() - 1) {
  305. return fileUrl.substring(slash + 1);
  306. }
  307. return fileUrl.length() > 500 ? fileUrl.substring(0, 500) : fileUrl;
  308. }
  309. private void harmReportPayloadValid(ParsedReportPayload.Payload payload) {
  310. if (payload.getSummary() == null && (payload.getIndicators() == null || payload.getIndicators().isEmpty())
  311. && (payload.getGutFlora() == null || payload.getGutFlora().isEmpty())) {
  312. throw new IllegalArgumentException("解析结果中未提取到有效指标数据");
  313. }
  314. }
  315. private String resolvePdfPath(HealthReportDraft draft) {
  316. if (draft.getFileUrl() == null) {
  317. return null;
  318. }
  319. try {
  320. String p = storageService.getFilePath(draft.getFileUrl());
  321. File f = new File(p);
  322. return f.exists() ? f.getAbsolutePath() : null;
  323. } catch (Exception e) {
  324. log.warn("解析文件路径失败 fileUrl={}", draft.getFileUrl(), e);
  325. return null;
  326. }
  327. }
  328. /**
  329. * 检查文件是否为 PDF(通过魔数 %PDF- 判断)。
  330. * 指纹引擎、LangGraph 解析、opencode 均要求 PDF 输入。
  331. */
  332. private boolean isPdfFile(String filePath) {
  333. try (FileInputStream fis = new FileInputStream(filePath)) {
  334. byte[] header = new byte[5];
  335. int read = fis.read(header);
  336. return read == 5 && new String(header, StandardCharsets.US_ASCII).startsWith("%PDF-");
  337. } catch (Exception e) {
  338. log.debug("PDF魔数检查失败 path={}: {}", filePath, e.getMessage());
  339. return false;
  340. }
  341. }
  342. /** 未知类型:交本地 opencode 服务。因 headless serve 仅单轮文本生成可靠且输出有限,
  343. * 采用分块单轮解析:每一块独立 session、单轮返回该 section 的 JSON,块间互不依赖,
  344. * 最后合并为完整 Payload,避免单轮输出截断导致整单失败。
  345. */
  346. private ParsedReportPayload.Payload parseWithOpencode(String pdfPath, String fileName) {
  347. String pdfText = extractPdfText(pdfPath);
  348. ParsedReportPayload.Payload payload = new ParsedReportPayload.Payload();
  349. int produced = 0;
  350. JsonNode sr = opencodeSection(pdfText, fileName,
  351. "只提取 summary 和 diseaseRisks 两部分,其余字段不返回",
  352. "{\"summary\":{\"overallScore\":0,\"gutHealthScore\":0,\"chronicDiseaseScore\":0,\"nutritionScore\":0,\"balanceScore\":0,\"diversityScore\":0,\"beneficialScore\":0,\"harmfulScore\":0,\"coreGenusScore\":0,\"gutAge\":\"\",\"gutType\":\"\",\"shannonIndex\":\"\",\"reportNumber\":\"\",\"reportDate\":\"yyyy-MM-dd\",\"personName\":\"\",\"gender\":\"\",\"age\":0,\"birthday\":\"\"},\"diseaseRisks\":[{\"diseaseName\":\"\",\"riskValue\":\"\",\"riskLevel\":\"\"}]}");
  353. if (sr != null) {
  354. if (sr.has("summary") && !sr.get("summary").isNull()) {
  355. payload.setSummary(objectMapper.convertValue(sr.get("summary"), ParsedReportPayload.Summary.class));
  356. produced++;
  357. }
  358. if (sr.has("diseaseRisks") && sr.get("diseaseRisks").isArray()) {
  359. payload.setDiseaseRisks(objectMapper.convertValue(sr.get("diseaseRisks"),
  360. new TypeReference<List<ParsedReportPayload.DiseaseRisk>>() {}));
  361. produced++;
  362. }
  363. }
  364. JsonNode ind = opencodeSection(pdfText, fileName,
  365. "只提取 indicators 指标数组,其余字段不返回",
  366. "{\"indicators\":[{\"category\":\"\",\"indicatorName\":\"\",\"indicatorValue\":\"\",\"unit\":\"\",\"refRange\":\"\",\"status\":\"\"}]}");
  367. if (ind != null && ind.has("indicators") && ind.get("indicators").isArray()) {
  368. payload.setIndicators(objectMapper.convertValue(ind.get("indicators"),
  369. new TypeReference<List<ParsedReportPayload.Indicator>>() {}));
  370. produced++;
  371. }
  372. TypeReference<List<ParsedReportPayload.Flora>> floraType = new TypeReference<List<ParsedReportPayload.Flora>>() {};
  373. JsonNode floraMain = opencodeSection(pdfText, fileName,
  374. "只提取 gutFlora、probioticSpecies、pathogenGenus、pathogenDetection 四部分菌群数据,其余字段不返回",
  375. "{\"gutFlora\":[{\"bacteriaName\":\"\",\"bacteriaValue\":\"\",\"normalRange\":\"\",\"populationLevel\":\"\",\"detectionRate\":\"\",\"description\":\"\",\"category\":\"\",\"level\":\"\"}],\"probioticSpecies\":[],\"pathogenGenus\":[],\"pathogenDetection\":[]}",
  376. true);
  377. if (floraMain != null) {
  378. putFlora(payload::setGutFlora, floraMain, "gutFlora", floraType);
  379. putFlora(payload::setProbioticSpecies, floraMain, "probioticSpecies", floraType);
  380. putFlora(payload::setPathogenGenus, floraMain, "pathogenGenus", floraType);
  381. putFlora(payload::setPathogenDetection, floraMain, "pathogenDetection", floraType);
  382. if (payload.getGutFlora() != null || payload.getProbioticSpecies() != null
  383. || payload.getPathogenGenus() != null || payload.getPathogenDetection() != null) {
  384. produced++;
  385. }
  386. }
  387. JsonNode taxHigh = opencodeSection(pdfText, fileName,
  388. "只提取 taxonomyClass、taxonomyOrder、taxonomyFamily 三个分类层级占比数据,其余字段不返回",
  389. "{\"taxonomyClass\":[],\"taxonomyOrder\":[],\"taxonomyFamily\":[]}",
  390. true);
  391. if (taxHigh != null) {
  392. putFlora(payload::setTaxonomyClass, taxHigh, "taxonomyClass", floraType);
  393. putFlora(payload::setTaxonomyOrder, taxHigh, "taxonomyOrder", floraType);
  394. putFlora(payload::setTaxonomyFamily, taxHigh, "taxonomyFamily", floraType);
  395. }
  396. JsonNode taxLow = opencodeSection(pdfText, fileName,
  397. "只提取 taxonomyGenus、taxonomySpecies 两个分类层级占比数据,其余字段不返回",
  398. "{\"taxonomyGenus\":[],\"taxonomySpecies\":[]}",
  399. true);
  400. if (taxLow != null) {
  401. putFlora(payload::setTaxonomyGenus, taxLow, "taxonomyGenus", floraType);
  402. putFlora(payload::setTaxonomySpecies, taxLow, "taxonomySpecies", floraType);
  403. }
  404. if (produced == 0
  405. && payload.getGutFlora() == null && payload.getTaxonomyClass() == null
  406. && payload.getTaxonomyGenus() == null && payload.getTaxonomySpecies() == null) {
  407. return null;
  408. }
  409. return payload;
  410. }
  411. private void putFlora(Consumer<List<ParsedReportPayload.Flora>> setter, JsonNode section, String field,
  412. TypeReference<List<ParsedReportPayload.Flora>> type) {
  413. try {
  414. if (section.has(field) && section.get(field).isArray()) {
  415. setter.accept(objectMapper.convertValue(section.get(field), type));
  416. }
  417. } catch (Exception e) {
  418. log.warn("菌群字段 {} 转换失败,跳过: {}", field, e.getMessage());
  419. }
  420. }
  421. private JsonNode opencodeSection(String pdfText, String fileName, String instruction, String schema) {
  422. return opencodeSection(pdfText, fileName, instruction, schema, false);
  423. }
  424. /**
  425. * 单轮分块解析:新建 session、发送单个提示词、校验返回为合法 JSON。
  426. * 若第一轮输出被截断/非法,重试一次并指示精简输出(去掉空字段、用紧凑格式)。
  427. * compactFirst=true 时首轮即要求精简(用于数据量大的菌群块,避免首轮就超预算)。
  428. */
  429. private JsonNode opencodeSection(String pdfText, String fileName, String instruction, String schema, boolean compactFirst) {
  430. String prompt = buildSectionPrompt(pdfText, fileName, instruction, schema, compactFirst);
  431. JsonNode node = sendOnceAndParse(prompt);
  432. if (node != null) {
  433. return node;
  434. }
  435. String retryPrompt = buildSectionPrompt(pdfText, fileName, instruction, schema, true);
  436. return sendOnceAndParse(retryPrompt);
  437. }
  438. private String buildSectionPrompt(String pdfText, String fileName, String instruction, String schema, boolean compact) {
  439. StringBuilder sb = new StringBuilder();
  440. sb.append("下面是健康检测报告(文件名:").append(fileName != null ? fileName : "未知")
  441. .append(")用 pdftotext 提取出的全文文本。").append(instruction)
  442. .append(",仅返回严格符合以下结构的 JSON(不要任何额外文字或 Markdown 包裹):\n")
  443. .append(schema).append('\n');
  444. if (compact) {
  445. sb.append("要求:1) 省略所有空字段(值为空或 null 的字段不要输出);2) 数值用紧凑格式;3) 每种数组最多返回 40 条(超出的按重要性取前 40 条);4) 不要省略合法数据。\n");
  446. }
  447. sb.append("报告全文文本如下:\n").append(pdfText).append('\n')
  448. .append("若无法提取某部分数据返回空数组或 null。");
  449. return sb.toString();
  450. }
  451. private JsonNode sendOnceAndParse(String prompt) {
  452. String sessionId = opencodeCreateSession();
  453. try {
  454. String reply = opencodeSendAndWait(sessionId, prompt);
  455. if (reply == null || reply.trim().isEmpty()) {
  456. return null;
  457. }
  458. String json = extractJson(reply);
  459. return objectMapper.readTree(json);
  460. } catch (Exception e) {
  461. log.warn("opencode 分块输出转载荷失败: {}", e.getMessage());
  462. return null;
  463. } finally {
  464. deleteSession(sessionId);
  465. }
  466. }
  467. private void deleteSession(String sessionId) {
  468. try {
  469. restTemplate.exchange(opencodeBase + "/session/" + sessionId,
  470. HttpMethod.DELETE, new HttpEntity<>(opencodeHeaders()), Void.class);
  471. } catch (Exception e) {
  472. log.debug("opencode 删除会话失败 sessionId={}: {}", sessionId, e.getMessage());
  473. }
  474. }
  475. /** 用 pdftotext 提取 PDF 文本,失败时返回可读提示(避免模型误读图片型 PDF) */
  476. private String extractPdfText(String pdfPath) {
  477. try {
  478. File file = new File(pdfPath);
  479. if (!file.exists() || !file.isFile()) {
  480. return "PDF 文件不存在或不是文件:" + pdfPath;
  481. }
  482. ProcessBuilder pb = new ProcessBuilder("pdftotext", "-layout", pdfPath, "-");
  483. pb.redirectErrorStream(true);
  484. Process process = pb.start();
  485. StringBuilder sb = new StringBuilder();
  486. try (BufferedReader reader = new BufferedReader(
  487. new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) {
  488. String line;
  489. while ((line = reader.readLine()) != null) {
  490. sb.append(line).append('\n');
  491. }
  492. }
  493. if (!process.waitFor(30, TimeUnit.SECONDS)) {
  494. process.destroyForcibly();
  495. log.warn("pdftotext 超时 pdfPath={}", pdfPath);
  496. return "PDF 文本提取超时。" + pdfPath;
  497. }
  498. String text = sb.toString();
  499. if (text.trim().isEmpty()) {
  500. return "该 PDF 可能为扫描图片,无可用文本。" + pdfPath;
  501. }
  502. // 控制提示词体积:仅保留前 60k 字符,超长截断
  503. return text.length() > 60000 ? text.substring(0, 60000) : text;
  504. } catch (Exception e) {
  505. log.warn("pdftotext 提取失败 pdfPath={}: {}", pdfPath, e.getMessage());
  506. return "PDF 文本提取失败:" + e.getMessage();
  507. }
  508. }
  509. /** 从回复文本中提取 JSON(容忍 code fence 或前导说明) */
  510. private String extractJson(String reply) {
  511. String s = reply.trim();
  512. int start = s.indexOf('{');
  513. int end = s.lastIndexOf('}');
  514. if (start >= 0 && end > start) {
  515. return s.substring(start, end + 1);
  516. }
  517. return s;
  518. }
  519. /** 采集完成后自动入库(复用 confirm 的入库逻辑) */
  520. private void insertReport(HealthReportDraft draft, ParsedReportPayload.Payload payload) {
  521. Long subjectId = null;
  522. if (draft.getFamilyId() != null && payload.getSummary() != null
  523. && payload.getSummary().getPersonName() != null) {
  524. subjectId = healthReportService.matchFamilyMemberByName(
  525. payload.getSummary().getPersonName(), draft.getFamilyId());
  526. }
  527. HealthReport report = buildReportFromPayload(draft, payload, subjectId);
  528. List<HealthIndicator> indicators = buildIndicatorsFromPayload(payload);
  529. List<HealthGutFlora> gutFlora = buildGutFloraFromPayload(payload);
  530. List<HealthDiseaseRisk> diseaseRisks = buildDiseaseRisksFromPayload(payload);
  531. HealthReport created = healthReportService.createReport(report, indicators, gutFlora, diseaseRisks);
  532. try {
  533. healthReportService.savePayloadSnapshot(created.getId(), draft.getUserId(), "create", null,
  534. objectMapper.writeValueAsString(payload));
  535. } catch (Exception e) {
  536. log.warn("保存载荷快照失败 reportId={}", created.getId(), e);
  537. }
  538. if (payload.getFoods() != null && !payload.getFoods().isEmpty()) {
  539. try {
  540. List<Food> foods = buildFoodsFromPayload(payload);
  541. foodService.saveFoodsForReport(created.getId(), foods);
  542. } catch (Exception e) {
  543. log.warn("保存食材推荐失败 reportId={}", created.getId(), e);
  544. }
  545. }
  546. if (subjectId != null) {
  547. healthReportService.bindReportToMember(created.getId(), subjectId);
  548. try {
  549. dimensionScoreService.refreshFromGutReport(subjectId, created.getId());
  550. } catch (Exception e) {
  551. log.warn("刷新7维健康评分失败 memberId={}", subjectId, e);
  552. }
  553. try {
  554. foodRecommendService.recalculateForUser(subjectId);
  555. } catch (Exception e) {
  556. log.warn("刷新食材推荐指数失败 memberId={}", subjectId, e);
  557. }
  558. }
  559. try {
  560. reportBlockService.save(created.getReportType(), created.getId(),
  561. reportBlockAssembler.assembleWithTemplate(created.getReportType(), payload));
  562. } catch (Exception e) {
  563. log.warn("保存展示块失败 reportId={}", created.getId(), e);
  564. }
  565. healthReportDraftService.markCollectCompleted(draft.getId(), created.getId());
  566. // 新手任务联动:完成首次报告上传任务(老用户无任务记录时静默跳过)
  567. try { taskService.completeOnboardingTask(draft.getUserId(), "REPORT_UPLOAD"); } catch (Exception e) { log.warn("新手任务 REPORT_UPLOAD 完成失败: {}", e.getMessage()); }
  568. }
  569. private HealthReport buildReportFromPayload(HealthReportDraft draft, ParsedReportPayload.Payload payload, Long subjectId) {
  570. HealthReport report = new HealthReport();
  571. report.setUserId(draft.getUserId());
  572. report.setFamilyId(draft.getFamilyId());
  573. report.setSubjectId(subjectId);
  574. report.setReportType(draft.getReportType());
  575. report.setFileUrl(draft.getFileUrl());
  576. report.setFileHash(draft.getFileHash());
  577. ParsedReportPayload.Summary s = payload.getSummary();
  578. if (s != null) {
  579. report.setOverallScore(s.getOverallScore());
  580. report.setGutHealthScore(s.getGutHealthScore());
  581. report.setChronicDiseaseScore(s.getChronicDiseaseScore());
  582. report.setNutritionScore(s.getNutritionScore());
  583. report.setBalanceScore(s.getBalanceScore());
  584. report.setDiversityScore(s.getDiversityScore());
  585. report.setBeneficialScore(s.getBeneficialScore());
  586. report.setHarmfulScore(s.getHarmfulScore());
  587. report.setCoreGenusScore(s.getCoreGenusScore());
  588. report.setGutAge(s.getGutAge());
  589. report.setGutType(s.getGutType());
  590. report.setShannonIndex(s.getShannonIndex());
  591. report.setReportNumber(s.getReportNumber());
  592. report.setPersonName(s.getPersonName());
  593. report.setReportDate(parseDate(s.getReportDate()));
  594. }
  595. if (report.getReportDate() == null) {
  596. report.setReportDate(new Date());
  597. }
  598. report.setRemark("异步采集自动入库");
  599. return report;
  600. }
  601. private Date parseDate(String dateStr) {
  602. if (dateStr == null || dateStr.isEmpty()) {
  603. return null;
  604. }
  605. try {
  606. SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
  607. return sdf.parse(dateStr);
  608. } catch (Exception e) {
  609. try {
  610. SimpleDateFormat sdf2 = new SimpleDateFormat("yyyy/MM/dd");
  611. return sdf2.parse(dateStr);
  612. } catch (Exception e2) {
  613. return null;
  614. }
  615. }
  616. }
  617. private List<HealthIndicator> buildIndicatorsFromPayload(ParsedReportPayload.Payload payload) {
  618. List<ParsedReportPayload.Indicator> list = payload.getIndicators();
  619. if (list == null || list.isEmpty()) return new ArrayList<>();
  620. List<HealthIndicator> indicators = new ArrayList<>();
  621. for (int i = 0; i < list.size(); i++) {
  622. ParsedReportPayload.Indicator pi = list.get(i);
  623. HealthIndicator ind = new HealthIndicator();
  624. ind.setCategory(pi.getCategory());
  625. ind.setIndicatorName(pi.getIndicatorName());
  626. ind.setIndicatorValue(pi.getIndicatorValue());
  627. ind.setUnit(pi.getUnit());
  628. ind.setRefRange(pi.getRefRange());
  629. ind.setStatus(pi.getStatus());
  630. ind.setSymptoms(pi.getSymptoms());
  631. ind.setSortOrder(i);
  632. ind.setCollectionDate(parseDate(pi.getCollectionDate()));
  633. indicators.add(ind);
  634. }
  635. return indicators;
  636. }
  637. private List<HealthGutFlora> buildGutFloraFromPayload(ParsedReportPayload.Payload payload) {
  638. List<HealthGutFlora> result = new ArrayList<>();
  639. addFloraList(result, payload.getGutFlora());
  640. addFloraList(result, payload.getProbioticSpecies());
  641. addFloraList(result, payload.getTaxonomyClass());
  642. addFloraList(result, payload.getTaxonomyOrder());
  643. addFloraList(result, payload.getTaxonomyFamily());
  644. addFloraList(result, payload.getTaxonomyGenus());
  645. addFloraList(result, payload.getTaxonomySpecies());
  646. addFloraList(result, payload.getPathogenGenus());
  647. addFloraList(result, payload.getPathogenDetection());
  648. return result;
  649. }
  650. private void addFloraList(List<HealthGutFlora> target, List<ParsedReportPayload.Flora> source) {
  651. if (source != null) {
  652. for (ParsedReportPayload.Flora pf : source) {
  653. String bacteriaName = pf.getBacteriaName();
  654. if (bacteriaName == null || bacteriaName.trim().isEmpty()) {
  655. log.warn("跳过无菌种名称的菌群数据(category={}),避免 NOT NULL 约束冲突", pf.getCategory());
  656. continue;
  657. }
  658. HealthGutFlora gf = new HealthGutFlora();
  659. gf.setBacteriaName(bacteriaName);
  660. gf.setBacteriaValue(pf.getBacteriaValue());
  661. gf.setNormalRange(pf.getNormalRange());
  662. gf.setPopulationLevel(pf.getPopulationLevel());
  663. gf.setDetectionRate(pf.getDetectionRate());
  664. gf.setDescription(pf.getDescription());
  665. gf.setCategory(normalizeFloraCategory(pf.getCategory()));
  666. gf.setLevel(normalizeFloraLevel(pf.getCategory(), pf.getLevel()));
  667. target.add(gf);
  668. }
  669. }
  670. }
  671. private String normalizeFloraCategory(String category) {
  672. if (category == null) return null;
  673. if (category.endsWith("构成")) {
  674. return HealthGutFlora.CATEGORY_TAXONOMY;
  675. }
  676. return category;
  677. }
  678. private String normalizeFloraLevel(String category, String level) {
  679. if (level == null) return null;
  680. String normalized = level;
  681. if ("门".equals(level)) normalized = HealthGutFlora.LEVEL_PHYLUM;
  682. else if ("纲".equals(level)) normalized = HealthGutFlora.LEVEL_CLASS;
  683. else if ("目".equals(level)) normalized = HealthGutFlora.LEVEL_ORDER;
  684. else if ("科".equals(level)) normalized = HealthGutFlora.LEVEL_FAMILY;
  685. else if ("属".equals(level)) normalized = HealthGutFlora.LEVEL_GENUS;
  686. else if ("种".equals(level)) normalized = HealthGutFlora.LEVEL_SPECIES;
  687. return normalized;
  688. }
  689. private List<HealthDiseaseRisk> buildDiseaseRisksFromPayload(ParsedReportPayload.Payload payload) {
  690. List<ParsedReportPayload.DiseaseRisk> list = payload.getDiseaseRisks();
  691. if (list == null || list.isEmpty()) return new ArrayList<>();
  692. List<HealthDiseaseRisk> risks = new ArrayList<>();
  693. for (ParsedReportPayload.DiseaseRisk pr : list) {
  694. HealthDiseaseRisk dr = new HealthDiseaseRisk();
  695. dr.setDiseaseName(pr.getDiseaseName());
  696. dr.setRiskValue(pr.getRiskValue());
  697. dr.setRiskLevel(pr.getRiskLevel());
  698. risks.add(dr);
  699. }
  700. return risks;
  701. }
  702. private List<Food> buildFoodsFromPayload(ParsedReportPayload.Payload payload) {
  703. List<ParsedReportPayload.FoodItem> list = payload.getFoods();
  704. if (list == null || list.isEmpty()) return new ArrayList<>();
  705. List<Food> foods = new ArrayList<>();
  706. for (ParsedReportPayload.FoodItem pf : list) {
  707. Food food = new Food();
  708. food.setName(pf.getName());
  709. food.setCategory(pf.getCategory());
  710. food.setScore(pf.getScore());
  711. food.setEnergyKj(pf.getEnergyKj());
  712. food.setProtein(pf.getProtein());
  713. food.setFat(pf.getFat());
  714. food.setCarbs(pf.getCarbs());
  715. food.setStarch(pf.getStarch());
  716. food.setFiber(pf.getFiber());
  717. food.setCholesterol(pf.getCholesterol());
  718. food.setStatus("recommended");
  719. foods.add(food);
  720. }
  721. return foods;
  722. }
  723. // ===== opencode 本地服务 HTTP 客户端(协议对齐 运营文案/opencode_client.py) =====
  724. private String opencodeCreateSession() {
  725. try {
  726. String url = opencodeBase + "/session?directory="
  727. + java.net.URLEncoder.encode(opencodeWorkdir, StandardCharsets.UTF_8.name());
  728. Map<String, Object> body = new LinkedHashMap<>();
  729. body.put("title", "report-collect-" + System.currentTimeMillis());
  730. body.put("agent", "report-collect");
  731. // 显式固定模型(Provider/Model id 在 body 中传递,服务端才尊重;query 参数会被忽略)
  732. Map<String, Object> model = new LinkedHashMap<>();
  733. model.put("id", opencodeModelId);
  734. model.put("providerID", opencodeModelProvider);
  735. body.put("model", model);
  736. ResponseEntity<Map> resp = restTemplate.exchange(url, HttpMethod.POST,
  737. new HttpEntity<>(body, opencodeHeaders()), Map.class);
  738. Map<?, ?> data = resp.getBody();
  739. if (data == null || data.get("id") == null) {
  740. throw new IllegalStateException("opencode 创建会话未返回 session_id");
  741. }
  742. return data.get("id").toString();
  743. } catch (Exception e) {
  744. throw new IllegalStateException("opencode 创建会话失败: " + e.getMessage(), e);
  745. }
  746. }
  747. /** 发送提示词并轮询取回 assistant 最终文本回复 */
  748. private String opencodeSendAndWait(String sessionId, String prompt) {
  749. HttpHeaders headers = opencodeHeaders();
  750. List<Map<String, Object>> parts = new ArrayList<>();
  751. Map<String, Object> part = new LinkedHashMap<>();
  752. part.put("type", "text");
  753. part.put("text", prompt);
  754. parts.add(part);
  755. Map<String, Object> body = new LinkedHashMap<>();
  756. body.put("parts", parts);
  757. body.put("agent", "report-collect");
  758. // 只读约束:仅允许分析/读取,禁用执行/写入类工具(与本地运营脚本对齐)
  759. Map<String, Object> tools = new LinkedHashMap<>();
  760. for (String t : new String[]{"bash", "shell", "write", "edit", "apply_patch",
  761. "task", "dispatch", "webfetch_post", "chrome_launch"}) {
  762. tools.put(t, Boolean.FALSE);
  763. }
  764. body.put("tools", tools);
  765. restTemplate.exchange(opencodeBase + "/session/" + sessionId + "/prompt_async",
  766. HttpMethod.POST, new HttpEntity<>(body, headers), Void.class);
  767. long deadline = System.currentTimeMillis() + 180000;
  768. long pollInterval = 5000;
  769. int before = countMessages(sessionId);
  770. while (System.currentTimeMillis() < deadline) {
  771. try {
  772. Thread.sleep(pollInterval);
  773. } catch (InterruptedException e) {
  774. Thread.currentThread().interrupt();
  775. return null;
  776. }
  777. List<Map<String, Object>> msgs = listMessages(sessionId);
  778. if (msgs == null || msgs.isEmpty()) {
  779. continue;
  780. }
  781. Map<String, Object> last = msgs.get(msgs.size() - 1);
  782. String role = roleOf(last);
  783. if ("assistant".equals(role)) {
  784. String text = lastAssistantText(last);
  785. if (text != null && !text.trim().isEmpty()) {
  786. return text;
  787. }
  788. }
  789. if (msgs.size() > before) {
  790. for (int i = msgs.size() - 1; i >= 0; i--) {
  791. String r = roleOf(msgs.get(i));
  792. if ("assistant".equals(r)) {
  793. String t = lastAssistantText(msgs.get(i));
  794. if (t != null && !t.trim().isEmpty()) {
  795. return t;
  796. }
  797. }
  798. }
  799. }
  800. }
  801. throw new IllegalStateException("等待 opencode 回复超时(180s)");
  802. }
  803. @SuppressWarnings("unchecked")
  804. private String roleOf(Map<String, Object> msg) {
  805. Map<String, Object> info = (Map<String, Object>) msg.get("info");
  806. return info != null ? (String) info.get("role") : null;
  807. }
  808. @SuppressWarnings("unchecked")
  809. private String lastAssistantText(Map<String, Object> msg) {
  810. Object partsObj = msg.get("parts");
  811. if (!(partsObj instanceof List)) return null;
  812. List<Object> parts = (List<Object>) partsObj;
  813. String text = null;
  814. for (Object pObj : parts) {
  815. if (pObj instanceof Map) {
  816. Map<String, Object> p = (Map<String, Object>) pObj;
  817. if ("text".equals(p.get("type")) && p.get("text") != null) {
  818. text = p.get("text").toString();
  819. }
  820. }
  821. }
  822. return text;
  823. }
  824. private int countMessages(String sessionId) {
  825. List<Map<String, Object>> msgs = listMessages(sessionId);
  826. return msgs == null ? 0 : msgs.size();
  827. }
  828. @SuppressWarnings("unchecked")
  829. private List<Map<String, Object>> listMessages(String sessionId) {
  830. try {
  831. ResponseEntity<List> resp = restTemplate.exchange(
  832. opencodeBase + "/session/" + sessionId + "/message",
  833. HttpMethod.GET, new HttpEntity<>(opencodeHeaders()), List.class);
  834. return resp.getBody();
  835. } catch (Exception e) {
  836. log.warn("opencode 查询消息失败 session={}", sessionId, e);
  837. return null;
  838. }
  839. }
  840. private HttpHeaders opencodeHeaders() {
  841. String token = Base64.getEncoder().encodeToString(
  842. (opencodeUser + ":" + opencodePassword).getBytes(StandardCharsets.UTF_8));
  843. HttpHeaders headers = new HttpHeaders();
  844. headers.setContentType(MediaType.APPLICATION_JSON);
  845. headers.set("Authorization", "Basic " + token);
  846. return headers;
  847. }
  848. }