Просмотр исходного кода

feat: 报告解析迁移到 LangGraph - 新增 LangGraphReportParseService

- 新增 LangGraphReportParseService(/report/parse 算法+LLM兜底)
- ReportCollectService: 已知类型解析改走 langgraph_script,空回退 opencode
- ReportFingerprintService: 脚本缺失日志降为 debug
Xiaogang Liao 1 месяц назад
Родитель
Сommit
d485c9b9b6

+ 347 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/LangGraphReportParseService.java

@@ -0,0 +1,347 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.dto.ParsedReportPayload;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.stereotype.Service;
+import org.springframework.web.client.RestTemplate;
+
+import javax.annotation.Resource;
+import java.math.BigDecimal;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * LangGraph 报告解析服务(采集流程)。
+ *
+ * 采集方案:先指纹识别,指纹成功走"脚本"——脚本已迁移到 LangGraph,即 /report/parse
+ * (pdf_parser 算法 + LLM 兜底,输出归一化为 Java 可消费的 snake_case 结构);
+ * 无指纹则先用 /report/detect-type 识别:识别到具体类型(brain_status /
+ * cognitive_aptitude / scanned_image)走 /parse-typed 用其结果;识别不了(generic)
+ * 仍尝试 /report/parse(算法可解析标准肠道菌群 triplet/inline/beijing 格式,实测
+ * detect-type 返回 generic 时 parse 依然成功);仍无有效数据返回 null,由调用方回退 opencode。
+ */
+@Slf4j
+@Service
+public class LangGraphReportParseService {
+
+    /** LangGraph 识别出的类型:无法识别 */
+    public static final String DETECTED_GENERIC = "generic";
+
+    @Resource
+    private RestTemplate restTemplate;
+
+    @Resource
+    private ObjectMapper objectMapper;
+
+    @Value("${langgraph.base-url:http://localhost:9000}")
+    private String langgraphBaseUrl;
+
+    /**
+     * 指纹成功路径:调 LangGraph 脚本(/report/parse,算法 + LLM 兜底)。
+     * 转换后无有效指标数据时返回 null。
+     */
+    public ParsedReportPayload.Payload parseByScript(String pdfPath) {
+        Map<String, Object> data = postParse("/api/v1/report/parse", pdfPath);
+        if (data == null) {
+            return null;
+        }
+        ParsedReportPayload.Payload payload = buildPayloadFromLgData(data);
+        return isValid(payload) ? payload : null;
+    }
+
+    /**
+     * 无指纹路径:先用 LangGraph 识别报告类型。
+     * 识别到具体类型(非 generic)→ /parse-typed 专用解析;
+     * generic 或 typed 结果无效 → 仍试 /report/parse(算法可解析标准菌群格式)。
+     * 均无有效数据时返回 null,调用方回退 opencode。
+     */
+    public ParsedReportPayload.Payload parseWithDetect(String pdfPath) {
+        String detected = detectType(pdfPath);
+        if (detected != null && !DETECTED_GENERIC.equals(detected)) {
+            ParsedReportPayload.Payload typed = parseByType(pdfPath, detected);
+            if (typed != null) {
+                log.info("LangGraph 识别到类型 {} 并解析成功", detected);
+                return typed;
+            }
+            log.warn("LangGraph 识别类型 {} 但解析结果无效,回退算法脚本", detected);
+        } else {
+            log.info("LangGraph 无法识别报告类型(generic),尝试算法脚本解析");
+        }
+        return parseByScript(pdfPath);
+    }
+
+    /** 调 /report/detect-type 识别报告类型,失败返回 null */
+    public String detectType(String pdfPath) {
+        Map<String, Object> data = postParse("/api/v1/report/detect-type", pdfPath);
+        if (data == null) {
+            return null;
+        }
+        Object detected = data.get("detectedType");
+        return detected != null ? detected.toString() : null;
+    }
+
+    /** 调 /report/parse-typed 按指定类型解析(brain_status/cognitive_aptitude/scanned_image) */
+    private ParsedReportPayload.Payload parseByType(String pdfPath, String reportType) {
+        Map<String, Object> body = new HashMap<>();
+        body.put("file_path", pdfPath);
+        body.put("report_type", reportType);
+        Map<String, Object> data = postParse("/api/v1/report/parse-typed", body);
+        if (data == null) {
+            return null;
+        }
+        ParsedReportPayload.Payload payload = buildPayloadFromTypedData(data);
+        return isValid(payload) ? payload : null;
+    }
+
+    // ===== HTTP 调用 =====
+
+    /** 调 LangGraph 端点(仅为 file_path 场景),返回 data 字典;网络错误/非200/空 data 返回 null */
+    private Map<String, Object> postParse(String path, String pdfPath) {
+        Map<String, Object> body = new HashMap<>();
+        body.put("file_path", pdfPath);
+        return postParse(path, body);
+    }
+
+    @SuppressWarnings("unchecked")
+    private Map<String, Object> postParse(String path, Map<String, Object> body) {
+        try {
+            HttpHeaders headers = new HttpHeaders();
+            headers.setContentType(MediaType.APPLICATION_JSON);
+            HttpEntity<String> entity = new HttpEntity<>(objectMapper.writeValueAsString(body), headers);
+            ResponseEntity<Map> resp = restTemplate.postForEntity(langgraphBaseUrl + path, entity, Map.class);
+            Map<String, Object> respBody = resp.getBody();
+            if (respBody == null || !Integer.valueOf(200).equals(respBody.get("code"))) {
+                log.warn("LangGraph 端点 {} 返回异常: code={}, message={}",
+                        path, respBody != null ? respBody.get("code") : null,
+                        respBody != null ? respBody.get("message") : null);
+                return null;
+            }
+            Object data = respBody.get("data");
+            if (!(data instanceof Map) || ((Map<String, Object>) data).isEmpty()) {
+                log.warn("LangGraph 端点 {} 返回空 data", path);
+                return null;
+            }
+            return (Map<String, Object>) data;
+        } catch (Exception e) {
+            log.warn("LangGraph 端点 {} 调用失败: {}", path, e.getMessage());
+            return null;
+        }
+    }
+
+    // ===== 转换:LangGraph 输出 → ParsedReportPayload.Payload =====
+
+    /** 判定载荷是否含有效指标数据(与 ReportCollectService.harmReportPayloadValid 一致) */
+    private boolean isValid(ParsedReportPayload.Payload payload) {
+        return payload != null
+                && (payload.getSummary() != null
+                || (payload.getIndicators() != null && !payload.getIndicators().isEmpty())
+                || (payload.getGutFlora() != null && !payload.getGutFlora().isEmpty()));
+    }
+
+    /** 转换 /report/parse 输出(overview 等 snake_case + 菌群列表),逻辑与 HealthReportController 一致 */
+    private ParsedReportPayload.Payload buildPayloadFromLgData(Map<String, Object> lgData) {
+        ParsedReportPayload.Payload payload = new ParsedReportPayload.Payload();
+
+        Map<String, Object> overview = (Map<String, Object>) lgData.get("overview");
+        if (overview != null) {
+            ParsedReportPayload.Summary summary = new ParsedReportPayload.Summary();
+            summary.setPersonName(objectToString(overview.get("person_name")));
+            summary.setAge(toInteger(overview.get("age")));
+            summary.setGender(objectToString(overview.get("gender")));
+            summary.setOverallScore(toInteger(overview.get("overallScore")));
+            summary.setGutHealthScore(toInteger(overview.get("gutHealthScore")));
+            summary.setChronicDiseaseScore(toInteger(overview.get("chronicDiseaseScore")));
+            summary.setNutritionScore(toInteger(overview.get("nutritionScore")));
+            summary.setBalanceScore(toInteger(overview.get("balanceScore")));
+            summary.setDiversityScore(toInteger(overview.get("diversityScore")));
+            summary.setBeneficialScore(toInteger(overview.get("beneficialScore")));
+            summary.setHarmfulScore(toInteger(overview.get("harmfulScore")));
+            summary.setCoreGenusScore(toInteger(overview.get("coreGenusScore")));
+            summary.setGutAge(objectToString(overview.get("gutAge")));
+            summary.setGutType(objectToString(overview.get("gutType")));
+            summary.setShannonIndex(objectToString(overview.get("shannonIndex")));
+            summary.setReportNumber(objectToString(overview.get("report_number")));
+            summary.setReportDate(objectToString(overview.get("report_date")));
+            summary.setBirthday(objectToString(overview.get("birthday")));
+            payload.setSummary(summary);
+        }
+
+        List<Map<String, Object>> diseaseRisks = (List<Map<String, Object>>) lgData.get("disease_risks");
+        if (diseaseRisks != null) {
+            List<ParsedReportPayload.DiseaseRisk> risks = new ArrayList<>();
+            for (Map<String, Object> dr : diseaseRisks) {
+                ParsedReportPayload.DiseaseRisk risk = new ParsedReportPayload.DiseaseRisk();
+                risk.setDiseaseName(objectToString(dr.get("name")));
+                risk.setRiskValue(objectToString(dr.get("value")));
+                risk.setRiskLevel(objectToString(dr.get("status")));
+                risks.add(risk);
+            }
+            payload.setDiseaseRisks(risks);
+        }
+
+        List<Map<String, Object>> indicators = (List<Map<String, Object>>) lgData.get("indicators");
+        if (indicators != null) {
+            List<ParsedReportPayload.Indicator> inds = new ArrayList<>();
+            for (Map<String, Object> ind : indicators) {
+                ParsedReportPayload.Indicator indicator = new ParsedReportPayload.Indicator();
+                indicator.setCategory(objectToString(ind.get("category")));
+                indicator.setIndicatorName(objectToString(ind.get("indicatorName")));
+                indicator.setIndicatorValue(objectToString(ind.get("indicatorValue")));
+                indicator.setUnit(objectToString(ind.get("unit")));
+                indicator.setRefRange(objectToString(ind.get("refRange")));
+                indicator.setStatus(objectToString(ind.get("status")));
+                indicator.setSymptoms(objectToString(ind.get("symptoms")));
+                inds.add(indicator);
+            }
+            payload.setIndicators(inds);
+        }
+
+        payload.setGutFlora(convertLgFloraList(lgData.get("gut_flora")));
+        payload.setProbioticSpecies(convertLgFloraList(lgData.get("probiotic_species")));
+        payload.setTaxonomyClass(convertLgFloraList(lgData.get("taxonomy_class")));
+        payload.setTaxonomyOrder(convertLgFloraList(lgData.get("taxonomy_order")));
+        payload.setTaxonomyFamily(convertLgFloraList(lgData.get("taxonomy_family")));
+        payload.setTaxonomyGenus(convertLgFloraList(lgData.get("taxonomy_genus")));
+        payload.setTaxonomySpecies(convertLgFloraList(lgData.get("taxonomy_species")));
+        payload.setPathogenGenus(convertLgFloraList(lgData.get("pathogen_genus")));
+        payload.setPathogenDetection(convertLgFloraList(lgData.get("pathogen_detection")));
+        payload.setFoods(convertLgFoodList(lgData.get("foods")));
+
+        return payload;
+    }
+
+    /**
+     * 转换 /parse-typed 输出(camelCase:summary.personName / indicators[].name/value/...)。
+     * 脑状态/智力潜能等认知类报告使用该结构,可提取 summary + indicators 到统一载荷。
+     */
+    private ParsedReportPayload.Payload buildPayloadFromTypedData(Map<String, Object> data) {
+        ParsedReportPayload.Payload payload = new ParsedReportPayload.Payload();
+
+        Map<String, Object> summary = (Map<String, Object>) data.get("summary");
+        if (summary != null) {
+            ParsedReportPayload.Summary s = new ParsedReportPayload.Summary();
+            s.setPersonName(objectToString(summary.get("personName")));
+            s.setAge(toInteger(summary.get("age")));
+            s.setGender(objectToString(summary.get("gender")));
+            s.setReportDate(objectToString(summary.get("reportDate")));
+            s.setReportNumber(objectToString(summary.get("reportNumber")));
+            s.setOverallScore(toInteger(summary.get("overallScore")));
+            payload.setSummary(s);
+        }
+
+        List<Map<String, Object>> indicators = (List<Map<String, Object>>) data.get("indicators");
+        if (indicators != null) {
+            List<ParsedReportPayload.Indicator> inds = new ArrayList<>();
+            for (Map<String, Object> ind : indicators) {
+                ParsedReportPayload.Indicator indicator = new ParsedReportPayload.Indicator();
+                indicator.setCategory(objectToString(ind.get("category")));
+                indicator.setIndicatorName(objectToString(ind.get("name")));
+                indicator.setIndicatorValue(objectToString(ind.get("value")));
+                indicator.setStatus(objectToString(ind.get("status")));
+                inds.add(indicator);
+            }
+            payload.setIndicators(inds);
+        }
+
+        return payload;
+    }
+
+    /** 将 LangGraph 输出的菌群列表(snake_case 元素)转换为 Flora 列表,键缺失时安全返回空列表 */
+    private List<ParsedReportPayload.Flora> convertLgFloraList(Object raw) {
+        List<ParsedReportPayload.Flora> floraList = new ArrayList<>();
+        if (!(raw instanceof List)) {
+            return floraList;
+        }
+        for (Object item : (List<?>) raw) {
+            if (!(item instanceof Map)) {
+                continue;
+            }
+            Map<String, Object> m = (Map<String, Object>) item;
+            ParsedReportPayload.Flora f = new ParsedReportPayload.Flora();
+            f.setBacteriaName(objectToString(m.get("name")));
+            f.setBacteriaValue(objectToString(m.get("value")));
+            f.setNormalRange(objectToString(m.get("normal_range")));
+            f.setPopulationLevel(objectToString(m.get("population_level")));
+            f.setDetectionRate(objectToString(m.get("detection_rate")));
+            f.setDescription(objectToString(m.get("description")));
+            f.setCategory(objectToString(m.get("category")));
+            f.setLevel(objectToString(m.get("level")));
+            if (f.getBacteriaName() == null && f.getDescription() == null) {
+                continue;
+            }
+            floraList.add(f);
+        }
+        return floraList;
+    }
+
+    /** 将 LangGraph 输出的饮食推荐列表(snake_case 元素)转换为 FoodItem 列表 */
+    private List<ParsedReportPayload.FoodItem> convertLgFoodList(Object raw) {
+        List<ParsedReportPayload.FoodItem> foodList = new ArrayList<>();
+        if (!(raw instanceof List)) {
+            return foodList;
+        }
+        for (Object item : (List<?>) raw) {
+            if (!(item instanceof Map)) {
+                continue;
+            }
+            Map<String, Object> m = (Map<String, Object>) item;
+            ParsedReportPayload.FoodItem fi = new ParsedReportPayload.FoodItem();
+            fi.setName(objectToString(m.get("name")));
+            fi.setCategory(objectToString(m.get("category")));
+            fi.setScore(toInteger(m.get("score")));
+            fi.setEnergyKj(toDecimal(m.get("energy_kj")));
+            fi.setProtein(toDecimal(m.get("protein")));
+            fi.setFat(toDecimal(m.get("fat")));
+            fi.setCarbs(toDecimal(m.get("carbs")));
+            fi.setStarch(toDecimal(m.get("starch")));
+            fi.setFiber(toDecimal(m.get("fiber")));
+            fi.setCholesterol(toDecimal(m.get("cholesterol")));
+            if (fi.getName() == null) {
+                continue;
+            }
+            foodList.add(fi);
+        }
+        return foodList;
+    }
+
+    private static BigDecimal toDecimal(Object value) {
+        if (value == null) {
+            return null;
+        }
+        if (value instanceof BigDecimal) {
+            return (BigDecimal) value;
+        }
+        if (value instanceof Number) {
+            return BigDecimal.valueOf(((Number) value).doubleValue());
+        }
+        try {
+            return new BigDecimal(value.toString());
+        } catch (Exception e) {
+            return null;
+        }
+    }
+
+    private static Integer toInteger(Object value) {
+        if (value == null) return null;
+        if (value instanceof Integer) return (Integer) value;
+        if (value instanceof Number) return ((Number) value).intValue();
+        try {
+            return Integer.valueOf(value.toString());
+        } catch (Exception e) {
+            return null;
+        }
+    }
+
+    private static String objectToString(Object value) {
+        if (value == null) return null;
+        return value.toString();
+    }
+}

+ 25 - 29
cfc-backend/src/main/java/com/etotem/cfc/service/ReportCollectService.java

@@ -35,6 +35,9 @@ public class ReportCollectService {
     @Resource
     @Resource
     private ReportFingerprintService fingerprintService;
     private ReportFingerprintService fingerprintService;
 
 
+    @Resource
+    private LangGraphReportParseService langGraphReportParseService;
+
     @Resource
     @Resource
     private HealthReportDraftService healthReportDraftService;
     private HealthReportDraftService healthReportDraftService;
 
 
@@ -91,6 +94,8 @@ public class ReportCollectService {
 
 
     /** 常量:解析方式 */
     /** 常量:解析方式 */
     public static final String METHOD_FINGERPRINT = "fingerprint_script";
     public static final String METHOD_FINGERPRINT = "fingerprint_script";
+    public static final String METHOD_LANGGRAPH_SCRIPT = "langgraph_script";
+    public static final String METHOD_LANGGRAPH_DETECT = "langgraph_detect";
     public static final String METHOD_OPENCODE = "opencode";
     public static final String METHOD_OPENCODE = "opencode";
 
 
     /** 状态常量:与草稿 parse_status 对齐 */
     /** 状态常量:与草稿 parse_status 对齐 */
@@ -137,17 +142,24 @@ public class ReportCollectService {
             ParsedReportPayload.Payload payload;
             ParsedReportPayload.Payload payload;
             String method;
             String method;
             if (!"unknown".equals(typeId) && fp.get("parserPath") != null) {
             if (!"unknown".equals(typeId) && fp.get("parserPath") != null) {
-                method = METHOD_FINGERPRINT;
-                payload = parseWithDedicatedScript(pdfPath, (String) fp.get("parserPath"));
-                // 专用解析脚本缺失/失败时回退 opencode,避免已知类型整单失败
+                // 指纹成功:脚本已迁移到 LangGraph(/report/parse,算法 + LLM 兜底)
+                method = METHOD_LANGGRAPH_SCRIPT;
+                payload = langGraphReportParseService.parseByScript(pdfPath);
+                // LangGraph 脚本解析为空时回退 opencode,避免已知类型整单失败
                 if (payload == null) {
                 if (payload == null) {
-                    log.warn("已知类型专用脚本解析为空,回退 opencode typeId={}", typeId);
+                    log.warn("已知类型 LangGraph 脚本解析为空,回退 opencode typeId={}", typeId);
                     method = METHOD_OPENCODE;
                     method = METHOD_OPENCODE;
                     payload = parseWithOpencode(pdfPath, draft.getOriginalFilename());
                     payload = parseWithOpencode(pdfPath, draft.getOriginalFilename());
                 }
                 }
             } else {
             } else {
-                method = METHOD_OPENCODE;
-                payload = parseWithOpencode(pdfPath, draft.getOriginalFilename());
+                // 无指纹:先用 LangGraph 识别(detect-type → parse-typed / script),识别不了再 opencode
+                method = METHOD_LANGGRAPH_DETECT;
+                payload = langGraphReportParseService.parseWithDetect(pdfPath);
+                if (payload == null) {
+                    log.warn("无指纹 LangGraph 识别/解析为空,回退 opencode typeId={}", typeId);
+                    method = METHOD_OPENCODE;
+                    payload = parseWithOpencode(pdfPath, draft.getOriginalFilename());
+                }
             }
             }
 
 
             if (payload == null) {
             if (payload == null) {
@@ -189,23 +201,7 @@ public class ReportCollectService {
         }
         }
     }
     }
 
 
-    /** 已知类型:调专用解析器脚本,期望输出符合 ParsedReportPayload 的 JSON */
-    private ParsedReportPayload.Payload parseWithDedicatedScript(String pdfPath, String parserPath) {
-        Map<String, Object> parsed = fingerprintService.runParser(parserPath, pdfPath);
-        if (parsed == null) {
-            return null;
-        }
-        try {
-            JsonNode node = objectMapper.valueToTree(parsed);
-            return normalizeToPayload(node);
-        } catch (Exception e) {
-            log.warn("专用脚本输出转载荷失败 parserPath={}", parserPath, e);
-            return null;
-        }
-    }
-
-    /**
-     * 未知类型:交本地 opencode 服务。因 headless serve 仅单轮文本生成可靠且输出有限,
+    /** 未知类型:交本地 opencode 服务。因 headless serve 仅单轮文本生成可靠且输出有限,
      * 采用分块单轮解析:每一块独立 session、单轮返回该 section 的 JSON,块间互不依赖,
      * 采用分块单轮解析:每一块独立 session、单轮返回该 section 的 JSON,块间互不依赖,
      * 最后合并为完整 Payload,避免单轮输出截断导致整单失败。
      * 最后合并为完整 Payload,避免单轮输出截断导致整单失败。
      */
      */
@@ -398,11 +394,6 @@ public class ReportCollectService {
         return s;
         return s;
     }
     }
 
 
-    /** 将任意 JSON 节点归一化为 ParsedReportPayload.Payload */
-    private ParsedReportPayload.Payload normalizeToPayload(JsonNode node) {
-        return objectMapper.convertValue(node, ParsedReportPayload.Payload.class);
-    }
-
     /** 采集完成后自动入库(复用 confirm 的入库逻辑) */
     /** 采集完成后自动入库(复用 confirm 的入库逻辑) */
     private void insertReport(HealthReportDraft draft, ParsedReportPayload.Payload payload) {
     private void insertReport(HealthReportDraft draft, ParsedReportPayload.Payload payload) {
         Long subjectId = null;
         Long subjectId = null;
@@ -547,8 +538,13 @@ public class ReportCollectService {
     private void addFloraList(List<HealthGutFlora> target, List<ParsedReportPayload.Flora> source) {
     private void addFloraList(List<HealthGutFlora> target, List<ParsedReportPayload.Flora> source) {
         if (source != null) {
         if (source != null) {
             for (ParsedReportPayload.Flora pf : source) {
             for (ParsedReportPayload.Flora pf : source) {
+                String bacteriaName = pf.getBacteriaName();
+                if (bacteriaName == null || bacteriaName.trim().isEmpty()) {
+                    log.warn("跳过无菌种名称的菌群数据(category={}),避免 NOT NULL 约束冲突", pf.getCategory());
+                    continue;
+                }
                 HealthGutFlora gf = new HealthGutFlora();
                 HealthGutFlora gf = new HealthGutFlora();
-                gf.setBacteriaName(pf.getBacteriaName());
+                gf.setBacteriaName(bacteriaName);
                 gf.setBacteriaValue(pf.getBacteriaValue());
                 gf.setBacteriaValue(pf.getBacteriaValue());
                 gf.setNormalRange(pf.getNormalRange());
                 gf.setNormalRange(pf.getNormalRange());
                 gf.setPopulationLevel(pf.getPopulationLevel());
                 gf.setPopulationLevel(pf.getPopulationLevel());

+ 1 - 1
cfc-backend/src/main/java/com/etotem/cfc/service/ReportFingerprintService.java

@@ -147,7 +147,7 @@ public class ReportFingerprintService {
         try {
         try {
             String scriptPath = getScriptPath(parserPath);
             String scriptPath = getScriptPath(parserPath);
             if (!new File(scriptPath).exists()) {
             if (!new File(scriptPath).exists()) {
-                log.warn("解析器脚本不存在: {}", scriptPath);
+                log.debug("解析器脚本不存在(调用方应处理空返回): {}", scriptPath);
                 return null;
                 return null;
             }
             }