|
@@ -3,6 +3,7 @@ package com.etotem.cfc.service;
|
|
|
import com.etotem.cfc.dto.ParsedReportPayload;
|
|
import com.etotem.cfc.dto.ParsedReportPayload;
|
|
|
import com.etotem.cfc.entity.*;
|
|
import com.etotem.cfc.entity.*;
|
|
|
import com.etotem.cfc.mapper.FamilyMemberMapper;
|
|
import com.etotem.cfc.mapper.FamilyMemberMapper;
|
|
|
|
|
+import com.fasterxml.jackson.core.type.TypeReference;
|
|
|
import com.fasterxml.jackson.databind.JsonNode;
|
|
import com.fasterxml.jackson.databind.JsonNode;
|
|
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
|
import lombok.extern.slf4j.Slf4j;
|
|
import lombok.extern.slf4j.Slf4j;
|
|
@@ -21,6 +22,7 @@ import java.text.SimpleDateFormat;
|
|
|
import java.util.*;
|
|
import java.util.*;
|
|
|
import java.util.concurrent.Executor;
|
|
import java.util.concurrent.Executor;
|
|
|
import java.util.concurrent.TimeUnit;
|
|
import java.util.concurrent.TimeUnit;
|
|
|
|
|
+import java.util.function.Consumer;
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
|
* 报告异步采集服务:上传后进入后台队列,指纹判定类型后走专用脚本或 opencode,
|
|
* 报告异步采集服务:上传后进入后台队列,指纹判定类型后走专用脚本或 opencode,
|
|
@@ -137,6 +139,12 @@ public class ReportCollectService {
|
|
|
if (!"unknown".equals(typeId) && fp.get("parserPath") != null) {
|
|
if (!"unknown".equals(typeId) && fp.get("parserPath") != null) {
|
|
|
method = METHOD_FINGERPRINT;
|
|
method = METHOD_FINGERPRINT;
|
|
|
payload = parseWithDedicatedScript(pdfPath, (String) fp.get("parserPath"));
|
|
payload = parseWithDedicatedScript(pdfPath, (String) fp.get("parserPath"));
|
|
|
|
|
+ // 专用解析脚本缺失/失败时回退 opencode,避免已知类型整单失败
|
|
|
|
|
+ if (payload == null) {
|
|
|
|
|
+ log.warn("已知类型专用脚本解析为空,回退 opencode typeId={}", typeId);
|
|
|
|
|
+ method = METHOD_OPENCODE;
|
|
|
|
|
+ payload = parseWithOpencode(pdfPath, draft.getOriginalFilename());
|
|
|
|
|
+ }
|
|
|
} else {
|
|
} else {
|
|
|
method = METHOD_OPENCODE;
|
|
method = METHOD_OPENCODE;
|
|
|
payload = parseWithOpencode(pdfPath, draft.getOriginalFilename());
|
|
payload = parseWithOpencode(pdfPath, draft.getOriginalFilename());
|
|
@@ -196,30 +204,151 @@ public class ReportCollectService {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- /** 未知类型:交本地 opencode 服务,提示词带 PDF 内容(后端先 pdftotext 提取文本内联),期望返回结构化 JSON */
|
|
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 未知类型:交本地 opencode 服务。因 headless serve 仅单轮文本生成可靠且输出有限,
|
|
|
|
|
+ * 采用分块单轮解析:每一块独立 session、单轮返回该 section 的 JSON,块间互不依赖,
|
|
|
|
|
+ * 最后合并为完整 Payload,避免单轮输出截断导致整单失败。
|
|
|
|
|
+ */
|
|
|
private ParsedReportPayload.Payload parseWithOpencode(String pdfPath, String fileName) {
|
|
private ParsedReportPayload.Payload parseWithOpencode(String pdfPath, String fileName) {
|
|
|
String pdfText = extractPdfText(pdfPath);
|
|
String pdfText = extractPdfText(pdfPath);
|
|
|
- String sessionId = opencodeCreateSession();
|
|
|
|
|
- String prompt = "下面是健康检测报告(文件名:" + (fileName != null ? fileName : pdfPath)
|
|
|
|
|
- + ")用 pdftotext 提取出的全文文本。请从中提取健康指标数据,并仅返回一个严格符合以下结构的 JSON(不要任何额外文字或 Markdown 包裹):\n"
|
|
|
|
|
- + "{\"summary\":{\"overallScore\":0,\"gutHealthScore\":0,\"reportNumber\":\"\",\"reportDate\":\"yyyy-MM-dd\",\"personName\":\"\",\"gender\":\"\"},\n"
|
|
|
|
|
- + "\"indicators\":[{\"category\":\"\",\"indicatorName\":\"\",\"indicatorValue\":\"\",\"unit\":\"\",\"refRange\":\"\",\"status\":\"\"}],\n"
|
|
|
|
|
- + "\"gutFlora\":[{\"bacteriaName\":\"\",\"bacteriaValue\":\"\",\"normalRange\":\"\",\"populationLevel\":\"\",\"detectionRate\":\"\",\"description\":\"\",\"category\":\"\",\"level\":\"\"}],\n"
|
|
|
|
|
- + "\"diseaseRisks\":[{\"diseaseName\":\"\",\"riskValue\":\"\",\"riskLevel\":\"\"}],\n"
|
|
|
|
|
- + "\"foods\":[]}\n"
|
|
|
|
|
- + "报告全文文本如下:\n" + pdfText + "\n"
|
|
|
|
|
- + "若无法提取某部分数据返回空数组或 null。";
|
|
|
|
|
- String reply = opencodeSendAndWait(sessionId, prompt);
|
|
|
|
|
- if (reply == null || reply.trim().isEmpty()) {
|
|
|
|
|
|
|
+ ParsedReportPayload.Payload payload = new ParsedReportPayload.Payload();
|
|
|
|
|
+ int produced = 0;
|
|
|
|
|
+
|
|
|
|
|
+ JsonNode sr = opencodeSection(pdfText, fileName,
|
|
|
|
|
+ "只提取 summary 和 diseaseRisks 两部分,其余字段不返回",
|
|
|
|
|
+ "{\"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\":\"\"}]}");
|
|
|
|
|
+ if (sr != null) {
|
|
|
|
|
+ if (sr.has("summary") && !sr.get("summary").isNull()) {
|
|
|
|
|
+ payload.setSummary(objectMapper.convertValue(sr.get("summary"), ParsedReportPayload.Summary.class));
|
|
|
|
|
+ produced++;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (sr.has("diseaseRisks") && sr.get("diseaseRisks").isArray()) {
|
|
|
|
|
+ payload.setDiseaseRisks(objectMapper.convertValue(sr.get("diseaseRisks"),
|
|
|
|
|
+ new TypeReference<List<ParsedReportPayload.DiseaseRisk>>() {}));
|
|
|
|
|
+ produced++;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ JsonNode ind = opencodeSection(pdfText, fileName,
|
|
|
|
|
+ "只提取 indicators 指标数组,其余字段不返回",
|
|
|
|
|
+ "{\"indicators\":[{\"category\":\"\",\"indicatorName\":\"\",\"indicatorValue\":\"\",\"unit\":\"\",\"refRange\":\"\",\"status\":\"\"}]}");
|
|
|
|
|
+ if (ind != null && ind.has("indicators") && ind.get("indicators").isArray()) {
|
|
|
|
|
+ payload.setIndicators(objectMapper.convertValue(ind.get("indicators"),
|
|
|
|
|
+ new TypeReference<List<ParsedReportPayload.Indicator>>() {}));
|
|
|
|
|
+ produced++;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ TypeReference<List<ParsedReportPayload.Flora>> floraType = new TypeReference<List<ParsedReportPayload.Flora>>() {};
|
|
|
|
|
+
|
|
|
|
|
+ JsonNode floraMain = opencodeSection(pdfText, fileName,
|
|
|
|
|
+ "只提取 gutFlora、probioticSpecies、pathogenGenus、pathogenDetection 四部分菌群数据,其余字段不返回",
|
|
|
|
|
+ "{\"gutFlora\":[{\"bacteriaName\":\"\",\"bacteriaValue\":\"\",\"normalRange\":\"\",\"populationLevel\":\"\",\"detectionRate\":\"\",\"description\":\"\",\"category\":\"\",\"level\":\"\"}],\"probioticSpecies\":[],\"pathogenGenus\":[],\"pathogenDetection\":[]}",
|
|
|
|
|
+ true);
|
|
|
|
|
+ if (floraMain != null) {
|
|
|
|
|
+ putFlora(payload::setGutFlora, floraMain, "gutFlora", floraType);
|
|
|
|
|
+ putFlora(payload::setProbioticSpecies, floraMain, "probioticSpecies", floraType);
|
|
|
|
|
+ putFlora(payload::setPathogenGenus, floraMain, "pathogenGenus", floraType);
|
|
|
|
|
+ putFlora(payload::setPathogenDetection, floraMain, "pathogenDetection", floraType);
|
|
|
|
|
+ if (payload.getGutFlora() != null || payload.getProbioticSpecies() != null
|
|
|
|
|
+ || payload.getPathogenGenus() != null || payload.getPathogenDetection() != null) {
|
|
|
|
|
+ produced++;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ JsonNode taxHigh = opencodeSection(pdfText, fileName,
|
|
|
|
|
+ "只提取 taxonomyClass、taxonomyOrder、taxonomyFamily 三个分类层级占比数据,其余字段不返回",
|
|
|
|
|
+ "{\"taxonomyClass\":[],\"taxonomyOrder\":[],\"taxonomyFamily\":[]}",
|
|
|
|
|
+ true);
|
|
|
|
|
+ if (taxHigh != null) {
|
|
|
|
|
+ putFlora(payload::setTaxonomyClass, taxHigh, "taxonomyClass", floraType);
|
|
|
|
|
+ putFlora(payload::setTaxonomyOrder, taxHigh, "taxonomyOrder", floraType);
|
|
|
|
|
+ putFlora(payload::setTaxonomyFamily, taxHigh, "taxonomyFamily", floraType);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ JsonNode taxLow = opencodeSection(pdfText, fileName,
|
|
|
|
|
+ "只提取 taxonomyGenus、taxonomySpecies 两个分类层级占比数据,其余字段不返回",
|
|
|
|
|
+ "{\"taxonomyGenus\":[],\"taxonomySpecies\":[]}",
|
|
|
|
|
+ true);
|
|
|
|
|
+ if (taxLow != null) {
|
|
|
|
|
+ putFlora(payload::setTaxonomyGenus, taxLow, "taxonomyGenus", floraType);
|
|
|
|
|
+ putFlora(payload::setTaxonomySpecies, taxLow, "taxonomySpecies", floraType);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (produced == 0
|
|
|
|
|
+ && payload.getGutFlora() == null && payload.getTaxonomyClass() == null
|
|
|
|
|
+ && payload.getTaxonomyGenus() == null && payload.getTaxonomySpecies() == null) {
|
|
|
return null;
|
|
return null;
|
|
|
}
|
|
}
|
|
|
- String json = extractJson(reply);
|
|
|
|
|
|
|
+ return payload;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private void putFlora(Consumer<List<ParsedReportPayload.Flora>> setter, JsonNode section, String field,
|
|
|
|
|
+ TypeReference<List<ParsedReportPayload.Flora>> type) {
|
|
|
try {
|
|
try {
|
|
|
- JsonNode node = objectMapper.readTree(json);
|
|
|
|
|
- return normalizeToPayload(node);
|
|
|
|
|
|
|
+ if (section.has(field) && section.get(field).isArray()) {
|
|
|
|
|
+ setter.accept(objectMapper.convertValue(section.get(field), type));
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ log.warn("菌群字段 {} 转换失败,跳过: {}", field, e.getMessage());
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private JsonNode opencodeSection(String pdfText, String fileName, String instruction, String schema) {
|
|
|
|
|
+ return opencodeSection(pdfText, fileName, instruction, schema, false);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 单轮分块解析:新建 session、发送单个提示词、校验返回为合法 JSON。
|
|
|
|
|
+ * 若第一轮输出被截断/非法,重试一次并指示精简输出(去掉空字段、用紧凑格式)。
|
|
|
|
|
+ * compactFirst=true 时首轮即要求精简(用于数据量大的菌群块,避免首轮就超预算)。
|
|
|
|
|
+ */
|
|
|
|
|
+ private JsonNode opencodeSection(String pdfText, String fileName, String instruction, String schema, boolean compactFirst) {
|
|
|
|
|
+ String prompt = buildSectionPrompt(pdfText, fileName, instruction, schema, compactFirst);
|
|
|
|
|
+ JsonNode node = sendOnceAndParse(prompt);
|
|
|
|
|
+ if (node != null) {
|
|
|
|
|
+ return node;
|
|
|
|
|
+ }
|
|
|
|
|
+ String retryPrompt = buildSectionPrompt(pdfText, fileName, instruction, schema, true);
|
|
|
|
|
+ return sendOnceAndParse(retryPrompt);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private String buildSectionPrompt(String pdfText, String fileName, String instruction, String schema, boolean compact) {
|
|
|
|
|
+ StringBuilder sb = new StringBuilder();
|
|
|
|
|
+ sb.append("下面是健康检测报告(文件名:").append(fileName != null ? fileName : "未知")
|
|
|
|
|
+ .append(")用 pdftotext 提取出的全文文本。").append(instruction)
|
|
|
|
|
+ .append(",仅返回严格符合以下结构的 JSON(不要任何额外文字或 Markdown 包裹):\n")
|
|
|
|
|
+ .append(schema).append('\n');
|
|
|
|
|
+ if (compact) {
|
|
|
|
|
+ sb.append("要求:1) 省略所有空字段(值为空或 null 的字段不要输出);2) 数值用紧凑格式;3) 每种数组最多返回 40 条(超出的按重要性取前 40 条);4) 不要省略合法数据。\n");
|
|
|
|
|
+ }
|
|
|
|
|
+ sb.append("报告全文文本如下:\n").append(pdfText).append('\n')
|
|
|
|
|
+ .append("若无法提取某部分数据返回空数组或 null。");
|
|
|
|
|
+ return sb.toString();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private JsonNode sendOnceAndParse(String prompt) {
|
|
|
|
|
+ String sessionId = opencodeCreateSession();
|
|
|
|
|
+ try {
|
|
|
|
|
+ String reply = opencodeSendAndWait(sessionId, prompt);
|
|
|
|
|
+ if (reply == null || reply.trim().isEmpty()) {
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ String json = extractJson(reply);
|
|
|
|
|
+ return objectMapper.readTree(json);
|
|
|
} catch (Exception e) {
|
|
} catch (Exception e) {
|
|
|
- log.warn("opencode 输出转载荷失败: {}", e.getMessage());
|
|
|
|
|
|
|
+ log.warn("opencode 分块输出转载荷失败: {}", e.getMessage());
|
|
|
return null;
|
|
return null;
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ deleteSession(sessionId);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private void deleteSession(String sessionId) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ restTemplate.exchange(opencodeBase + "/session/" + sessionId,
|
|
|
|
|
+ HttpMethod.DELETE, new HttpEntity<>(opencodeHeaders()), Void.class);
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ log.debug("opencode 删除会话失败 sessionId={}: {}", sessionId, e.getMessage());
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|