Pārlūkot izejas kodu

feat(health): 北京菌群报告解析 - PdfParseService新增北京格式解析 + langgraph parse-typed/detect-type端点

Xiaogang Liao 3 nedēļas atpakaļ
vecāks
revīzija
9bc3c2d902

+ 858 - 9
cfc-backend/src/main/java/com/etotem/cfc/service/PdfParseService.java

@@ -134,38 +134,44 @@ public class PdfParseService {
     /**
      * 从纯文本中解析报告数据
      */
-    public ParsedReportResult parseText(String fullText) {
+public ParsedReportResult parseText(String fullText) {
         ParsedReportResult result = new ParsedReportResult();
 
         // 1. 归一化 Kangxi 部首
         String normalized = normalizeChinese(fullText);
 
-        // 2. 按行分割
-        String[] lines = normalized.split("\\r?\\n");
+        // 2. 检测是否为北京菌群报告格式
+        if (isBeijingFormat(normalized)) {
+            log.debug("PDF 检测格式: beijing");
+            return parseBeijingReport(normalized);
+        }
+
+        // 3. 按行分割
+        String[] lines = normalized.split("\r?\n");
         List<String> lineList = new ArrayList<>();
         for (String line : lines) {
             String trimmed = line.trim();
             if (!trimmed.isEmpty()) {
-                // 去除PDF提取中的特殊Unicode箭头字符(如  )
-                trimmed = trimmed.replaceAll("[\\uF000-\\uFFFF]", "").trim();
+                // 去除PDF提取中的特殊Unicode箭头字符
+                trimmed = trimmed.replaceAll("[\uF000-\uFFFF]", "").trim();
                 if (!trimmed.isEmpty()) {
                     lineList.add(trimmed);
                 }
             }
         }
 
-        // 3. 检测格式: triplet vs inline
+        // 4. 检测格式: triplet vs inline
         String format = detectFormat(lineList);
         log.debug("PDF 检测格式: {}", format);
 
-        // 4. 解析各部分
+        // 5. 解析各部分
         if ("inline".equals(format)) {
             parseInlineReport(normalized, lineList, result);
         } else {
             parseTripletReport(lineList, result);
         }
 
-        // 5. 解析食材推荐(格式无关)
+        // 6. 解析食材推荐(格式无关)
         List<ParsedFoodSuitability> foodSuitability = parseFoodSuitability(normalized);
         result.setFoodSuitability(foodSuitability);
 
@@ -1341,4 +1347,847 @@ public class PdfParseService {
         }
         return section.toString().trim();
     }
-}
+    // ======================== 北京菌群报告格式检测 ========================
+
+    /**
+     * 检测是否为北京菌群报告格式
+     * 特征: 包含"肠道菌群检测" + "高通量测序" + "健康整体评估"
+     */
+    private boolean isBeijingFormat(String text) {
+        return text.contains("肠道菌群检测") && text.contains("高通量测序")
+                && text.contains("健康整体评估");
+    }
+
+    // ======================== 北京菌群报告解析 ========================
+
+    /**
+     * 解析北京菌群报告 PDF 文本
+     * 输出结构与募极生物报告一致 (ParsedReportResult),缺失字段留 null
+     */
+    private ParsedReportResult parseBeijingReport(String fullText) {
+        ParsedReportResult result = new ParsedReportResult();
+
+        List<String> lines = new ArrayList<>();
+        for (String line : fullText.split("\r?\n")) {
+            String trimmed = line.trim();
+            if (!trimmed.isEmpty()) {
+                lines.add(trimmed);
+            }
+        }
+
+        // 1. 解析基本信息(姓名/年龄/性别/报告编号/日期/肠型)
+        parseBeijingSummary(lines, result);
+
+        // 2. 解析健康评分
+        parseBeijingScores(lines, result);
+
+        // 3. 解析肠道菌群表格(核心菌属/有益菌/中性菌/有害菌)
+        List<ParsedGutFlora> gutFlora = new ArrayList<>();
+        parseBeijingGutFloraTables(fullText, lines, gutFlora);
+        result.setGutFlora(gutFlora);
+
+        // 4. 解析疾病风险评估
+        List<ParsedDiseaseRisk> diseaseRisks = parseBeijingDiseaseRisks(fullText);
+        result.setDiseaseRisks(diseaseRisks);
+
+        // 5. 解析各类指标(营养素/维生素/微量元素/氨基酸/SCFA/抗生素/毒素清除)
+        List<ParsedIndicator> indicators = new ArrayList<>();
+        parseBeijingIndicators(fullText, lines, indicators);
+        result.setIndicators(indicators);
+
+        // 6. 解析分类层级表(门/纲/目/科/属)追加到 gutFlora
+        parseBeijingTaxonomyLevels(fullText, lines, gutFlora);
+
+        // 7. 解析胃肠道感染致病菌追加到 indicators
+        parseBeijingGastroInfections(fullText, lines, indicators);
+
+        // 8. 解析益生菌(species 级别)
+        List<ParsedGutFlora> probioticSpecies = parseBeijingProbiotics(fullText, lines);
+        result.setProbioticSpecies(probioticSpecies);
+
+        // 9. 北京报告无食材推荐表
+        result.setFoodSuitability(new ArrayList<>());
+
+        return result;
+    }
+
+    // ======================== 北京报告 — 基本信息 ========================
+
+    /**
+     * 解析北京报告基本信息:
+     * 页3: "车艳红 70\n女\n肠道菌群检测\n2026-043-26046\n粪便\n2026-07-28\n2026-07-30\n高通量测序"
+     * 页35: "您属于肠型 I"
+     */
+    private void parseBeijingSummary(List<String> lines, ParsedReportResult result) {
+        // 姓名 + 年龄(同行: "车艳红 70",可能隔一行"女"才到"肠道菌群检测")
+        for (int i = 0; i < lines.size(); i++) {
+            String line = lines.get(i);
+            if (line.contains("肠道菌群检测")) {
+                for (int back = 1; back <= 3 && i - back >= 0; back++) {
+                    String cand = lines.get(i - back);
+                    String[] parts = cand.split("\\s+");
+                    if (parts.length >= 2 && parts[0].matches("[\\u4e00-\\u9fa5·]{2,4}")) {
+                        try {
+                            int ageVal = Integer.parseInt(parts[1].replaceAll("[^0-9]", ""));
+                            if (ageVal >= 1 && ageVal <= 120) {
+                                result.setPersonName(parts[0].trim());
+                                result.setAge(ageVal);
+                            }
+                        } catch (NumberFormatException e) { /* ignore */ }
+                        break;
+                    }
+                }
+                break;
+            }
+        }
+
+        // 性别
+        for (String line : lines) {
+            if (line.equals("男") || line.equals("女")) {
+                result.setGender(line.equals("男") ? "male" : "female");
+                break;
+            }
+        }
+
+        // 报告编号(在"肠道菌群检测"之后)
+        for (int i = 0; i < lines.size(); i++) {
+            if (lines.get(i).contains("肠道菌群检测") && i + 1 < lines.size()) {
+                String next = lines.get(i + 1).trim();
+                if (!next.isEmpty() && next.length() <= 30) {
+                    result.setReportNumber(next);
+                }
+                break;
+            }
+        }
+
+        // 采样日期(在编号之后的第二个日期行)
+        for (int i = 0; i < lines.size(); i++) {
+            if (lines.get(i).contains("肠道菌群检测")) {
+                int dateIdx = i + 3;
+                if (dateIdx < lines.size()) {
+                    result.setReportDate(normalizeBeijingDate(lines.get(dateIdx).trim()));
+                }
+                break;
+            }
+        }
+
+        // 肠型
+        for (String line : lines) {
+            if (line.contains("您属于肠型")) {
+                java.util.regex.Matcher m = java.util.regex.Pattern
+                        .compile("肠型\\s*([A-Za-z0-9IVX]+)").matcher(line);
+                if (m.find()) {
+                    result.setGutType(m.group(1));
+                    break;
+                }
+            }
+        }
+    }
+
+    private String normalizeBeijingDate(String dateStr) {
+        String cleaned = dateStr.replaceAll("[年月日/]", "-").replaceAll("-+", "-").replaceAll("-$", "").trim();
+        if (cleaned.matches("\\d{4}-\\d{2}-\\d{2}")) return cleaned;
+        if (cleaned.matches("\\d{4}-\\d{2}")) return cleaned + "-01";
+        if (cleaned.matches("\\d{8}")) {
+            return cleaned.substring(0, 4) + "-" + cleaned.substring(4, 6) + "-" + cleaned.substring(6, 8);
+        }
+        return dateStr;
+    }
+
+    // ======================== 北京报告 — 健康评分 ========================
+
+    /**
+     * 解析北京报告健康评分:
+     * 页8: "肠道微生物健康指数 69 肠道亚健康"
+     * 页8: "肠道菌群平衡 78 菌群平衡"
+     */
+    private void parseBeijingScores(List<String> lines, ParsedReportResult result) {
+        Integer gmhi = findBeijingScore(lines, "肠道微生物健康指数");
+        if (gmhi != null) result.setGutHealthScore(gmhi);
+
+        Integer balance = findBeijingScore(lines, "肠道菌群平衡");
+        if (balance != null) result.setBalanceScore(balance);
+    }
+
+    private Integer findBeijingScore(List<String> lines, String keyword) {
+        // 严格匹配"关键词+空白+整数":跳过目录项("2肠道菌群平衡评估 2")、B/E比值参考条件("肠道菌群平衡 > 1")等干扰行
+        java.util.regex.Pattern p = java.util.regex.Pattern.compile(
+                java.util.regex.Pattern.quote(keyword) + "\\s+(\\d{1,3})(?!\\d)");
+        for (int i = lines.size() - 1; i >= 0; i--) {
+            java.util.regex.Matcher m = p.matcher(lines.get(i));
+            while (m.find()) {
+                int v = Integer.parseInt(m.group(1));
+                if (v >= 0 && v <= 100) {
+                    return v;
+                }
+            }
+        }
+        return null;
+    }
+
+    // ======================== 北京报告 — 菌群表格 ========================
+
+    /**
+     * 解析北京报告菌群表格:
+     * 格式: "拉丁学名 中文名称 检测结果(%) 参考范围" 四列表格
+     */
+    private void parseBeijingGutFloraTables(String fullText, List<String> lines,
+                                             List<ParsedGutFlora> result) {
+        String[][] sections = {
+                {"8.1肠道核心菌属", "核心菌属"},
+                {"8.2有益菌", "有益菌"},
+                {"8.3中性菌", "中性菌"},
+                {"8.4有害菌", "有害菌"}
+        };
+
+        for (String[] section : sections) {
+            String sectionMarker = section[0];
+            String category = section[1];
+
+            int startIdx = fullText.indexOf(sectionMarker);
+            if (startIdx == -1) continue;
+
+            int endIdx = fullText.length();
+            for (String[] nextSection : sections) {
+                if (nextSection[0].equals(sectionMarker)) continue;
+                int ni = fullText.indexOf(nextSection[0], startIdx + sectionMarker.length());
+                if (ni != -1 && ni < endIdx) {
+                    endIdx = ni;
+                }
+            }
+            int foodAllergyIdx = fullText.indexOf("食物过敏风险评估", startIdx);
+            if (foodAllergyIdx != -1 && foodAllergyIdx < endIdx) endIdx = foodAllergyIdx;
+            int giInfectionIdx = fullText.indexOf("胃肠道感染相关的肠菌评估", startIdx);
+            if (giInfectionIdx != -1 && giInfectionIdx < endIdx) endIdx = giInfectionIdx;
+            int nutrientIdx = fullText.indexOf("营养物质代谢及营养素评估", startIdx);
+            if (nutrientIdx != -1 && nutrientIdx < endIdx) endIdx = nutrientIdx;
+
+            String region = fullText.substring(startIdx, endIdx);
+            parseBeijingFloraRegion(region, category, result);
+        }
+    }
+
+    private void parseBeijingFloraRegion(String region, String category,
+                                          List<ParsedGutFlora> result) {
+        String[] regionLines = region.split("\r?\n");
+        boolean pastHeader = false;
+
+        for (String rawLine : regionLines) {
+            String line = rawLine.trim();
+            if (line.isEmpty()) continue;
+            if (line.contains("拉丁学名") && line.contains("中文名称")) {
+                pastHeader = true;
+                continue;
+            }
+            if (!pastHeader) continue;
+
+            if (line.startsWith("【备注】") || line.startsWith("【注释】") || line.startsWith("注释:")) continue;
+            if (line.startsWith("拉丁学名:") || line.startsWith("中文名称:")) continue;
+            if (line.startsWith("相对丰度:") || line.startsWith("人群水平:") || line.startsWith("人群检出率:")) continue;
+            if (line.contains("汇总结果")) continue;
+            if (line.matches("^\\d+$")) continue;
+            if (line.startsWith("8.") || line.startsWith("9.") || line.startsWith("10.") ||
+                line.startsWith("11.") || line.startsWith("12.") || line.startsWith("13.") || line.startsWith("14.")) {
+                continue;
+            }
+
+            ParsedGutFlora flora = parseBeijingFloraLine(line, category);
+            if (flora != null) {
+                result.add(flora);
+            }
+        }
+    }
+
+    /**
+     * 解析单行北京菌群数据
+     */
+    private ParsedGutFlora parseBeijingFloraLine(String line, String category) {
+        int chineseStart = -1;
+        for (int i = 0; i < line.length(); i++) {
+            char c = line.charAt(i);
+            if (c >= '\u4e00' && c <= '\u9fff') {
+                chineseStart = i;
+                break;
+            }
+        }
+        if (chineseStart <= 0) return null;
+
+        String latinName = line.substring(0, chineseStart).trim();
+        if (latinName.isEmpty() || !latinName.matches("[A-Z].*")) return null;
+
+        String rest = line.substring(chineseStart);
+        Matcher valueStartMatcher = Pattern.compile(
+                "[\\s]+(ND|\\d+\\.?\\d*)").matcher(rest);
+        if (!valueStartMatcher.find()) return null;
+
+        String chineseName = rest.substring(0, valueStartMatcher.start()).trim();
+        String cleanChineseName = chineseName.replaceAll("[\\s#\\*]+$", "").trim();
+
+        String afterChinese = rest.substring(valueStartMatcher.start()).trim();
+        String cleanedAfter = afterChinese.replaceAll("[\"\\u201C\\u201D]", "").trim();
+
+        String[] tokens = cleanedAfter.split("\\s+");
+        if (tokens.length < 2) return null;
+
+        String detectionValue = tokens[0].trim();
+        if (detectionValue.isEmpty()) return null;
+
+        String cleanValue = detectionValue.replaceAll("[#\\*]", "").trim();
+
+        StringBuilder refRange = new StringBuilder();
+        for (int i = 1; i < tokens.length; i++) {
+            if (refRange.length() > 0) refRange.append(" ");
+            refRange.append(tokens[i].trim());
+        }
+
+        String status = "正常";
+        if (detectionValue.equals("ND") || cleanValue.equals("0") || cleanValue.equals("0.0")) {
+            status = "未检出";
+        } else if (line.contains("\"") || line.contains("\u201C") || line.contains("\u201D")) {
+            status = determineBeijingFloraStatus(cleanValue, refRange.toString());
+        }
+
+        ParsedGutFlora flora = new ParsedGutFlora();
+        flora.setBacteriaName(cleanChineseName);
+        flora.setBacteriaValue(detectionValue.equals("ND") ? "ND" : cleanValue);
+        flora.setNormalRange(refRange.toString());
+        flora.setCategory(category);
+        flora.setLevel("GENUS");
+        flora.setStatus(status);
+
+        return flora;
+    }
+
+    private String determineBeijingFloraStatus(String value, String refRange) {
+        try {
+            double val = Double.parseDouble(value);
+            String[] rangeParts = refRange.split("\\s*-\\s*");
+            if (rangeParts.length >= 2) {
+                double low = Double.parseDouble(rangeParts[0].trim());
+                double high = Double.parseDouble(rangeParts[1].trim());
+                if (val < low) return "偏低";
+                if (val > high) return "偏高";
+                return "正常";
+            }
+        } catch (NumberFormatException e) {
+            // 解析失败
+        }
+        return "正常";
+    }
+
+    // ======================== 北京报告 — 疾病风险评估 ========================
+
+    private List<ParsedDiseaseRisk> parseBeijingDiseaseRisks(String fullText) {
+        List<ParsedDiseaseRisk> result = new ArrayList<>();
+        LinkedHashMap<String, ParsedDiseaseRisk> dedupMap = new LinkedHashMap<>();
+
+        int startIdx = fullText.indexOf("疾病风险评估总揽");
+        if (startIdx == -1) {
+            startIdx = fullText.indexOf("慢病控制");
+        }
+        if (startIdx == -1) return result;
+
+        int endIdx = fullText.indexOf("低风险 (0-0.3)", startIdx);
+        if (endIdx == -1) endIdx = fullText.indexOf("低风险", startIdx);
+        if (endIdx == -1) endIdx = Math.min(startIdx + 3000, fullText.length());
+
+        String region = fullText.substring(startIdx, endIdx);
+
+        Pattern riskPattern = Pattern.compile(
+                "([\u4e00-\u9fff][\u4e00-\u9fff\\s]+?)\\s+(0?\\.\\d+|\\d+\\.?\\d*)");
+
+        for (String line : region.split("\r?\n")) {
+            line = line.trim();
+            if (line.isEmpty()) continue;
+            if (line.contains("疾病风险评估") || line.contains("项目名称")
+                    || line.contains("风险指数") || line.contains("风险等级")
+                    || line.contains("慢病控制") || line.contains("总揽") || line.contains("总览")) continue;
+            if (line.matches("^\\d+$")) continue;
+
+            Matcher m = riskPattern.matcher(line);
+            if (m.matches()) {
+                String name = m.group(1).trim();
+                String value = m.group(2).trim();
+
+                if (name.contains("低风险") || name.contains("较低风险") || name.contains("中度风险")
+                        || name.contains("较高风险") || name.contains("高风险")) continue;
+
+                String riskLevel = beijingRiskLevel(value);
+
+                if (!dedupMap.containsKey(name)) {
+                    ParsedDiseaseRisk risk = new ParsedDiseaseRisk();
+                    risk.setDiseaseName(name);
+                    risk.setRiskValue(value);
+                    risk.setRiskLevel(riskLevel);
+                    dedupMap.put(name, risk);
+                }
+            }
+        }
+
+        result.addAll(dedupMap.values());
+        return result;
+    }
+
+    private String beijingRiskLevel(String valueStr) {
+        try {
+            double val = Double.parseDouble(valueStr);
+            if (val < 0.3) return "低风险";
+            if (val < 0.5) return "较低风险";
+            if (val < 0.7) return "中度风险";
+            if (val < 0.8) return "较高风险";
+            return "高风险";
+        } catch (NumberFormatException e) {
+            return "未知";
+        }
+    }
+
+    // ======================== 北京报告 — 指标解析 ========================
+
+    private void parseBeijingIndicators(String fullText, List<String> lines,
+                                         List<ParsedIndicator> result) {
+        LinkedHashMap<String, ParsedIndicator> dedupMap = new LinkedHashMap<>();
+
+        parseBeijingIndicatorSection(fullText, "11.1.1主要营养素", "主要营养评估", dedupMap);
+        parseBeijingIndicatorSection(fullText, "11.1.2糖类代谢", "糖类代谢", dedupMap);
+        parseBeijingIndicatorSection(fullText, "11.1.3脂类代谢", "脂类代谢", dedupMap);
+        parseBeijingIndicatorSection(fullText, "11.1.4嘌呤代谢", "嘌呤代谢", dedupMap);
+        parseBeijingIndicatorSection(fullText, "11.2.1维生素", "维生素评估", dedupMap);
+        parseBeijingIndicatorSection(fullText, "11.2.2微量元素", "微量元素评估", dedupMap);
+        parseBeijingIndicatorSection(fullText, "11.2.3天然色素", "天然色素", dedupMap);
+        parseBeijingIndicatorSection(fullText, "11.2.4氨基酸", "氨基酸评估", dedupMap);
+        parseBeijingIndicatorSection(fullText, "11.2.5三肽", "三肽", dedupMap);
+        parseBeijingIndicatorSection(fullText, "11.2.6胆汁酸", "胆汁酸", dedupMap);
+        parseBeijingIndicatorSection(fullText, "11.2.7神经递质", "神经递质与激素", dedupMap);
+        parseBeijingIndicatorSection(fullText, "11.2.8抗自由基", "抗自由基", dedupMap);
+        parseBeijingIndicatorSection(fullText, "11.2.9抗氧化", "抗氧化", dedupMap);
+
+        parseBeijingSCFASection(fullText, dedupMap);
+        parseBeijingPhenotypeSection(fullText, dedupMap);
+        parseBeijingImmunityScores(lines, dedupMap);
+        parseBeijingAntibioticSection(fullText, dedupMap);
+        parseBeijingToxinClearance(fullText, dedupMap);
+
+        result.addAll(dedupMap.values());
+    }
+
+    private void parseBeijingIndicatorSection(String fullText, String sectionHeader,
+                                              String category,
+                                              LinkedHashMap<String, ParsedIndicator> dedupMap) {
+        int startIdx = fullText.indexOf(sectionHeader);
+        if (startIdx == -1) return;
+
+        int endIdx = fullText.length();
+        String[] endMarkers = {"11.1.1", "11.1.2", "11.1.3", "11.1.4",
+                "11.2.1", "11.2.2", "11.2.3", "11.2.4", "11.2.5",
+                "11.2.6", "11.2.7", "11.2.8", "11.2.9",
+                "12抗生素风险评估", "13毒性物质清除",
+                "3.2.1", "3.2.2", "3.2.3", "3.2.4", "3.2.5",
+                "门级水平结果", "纲级水平结果", "目级水平结果",
+                "科级水平结果", "属级水平结果"};
+        for (String marker : endMarkers) {
+            if (marker.equals(sectionHeader)) continue;
+            int mi = fullText.indexOf(marker, startIdx + sectionHeader.length());
+            if (mi != -1 && mi < endIdx) {
+                endIdx = mi;
+            }
+        }
+
+        String region = fullText.substring(startIdx, endIdx);
+
+        for (String line : region.split("\r?\n")) {
+            line = line.trim();
+            if (line.isEmpty()) continue;
+            if (line.contains(sectionHeader)) continue;
+            if (line.contains("检测项目") || line.contains("评估值") || line.contains("参考范围")) continue;
+            if (line.contains("营养物质代谢能力") || line.contains("合成能力评估")) continue;
+            if (line.matches("^\\d+$")) continue;
+            if (line.startsWith("【备注】") || line.startsWith("【注释】")) continue;
+
+            ParsedIndicator ind = parseBeijingIndicatorLine(line, category);
+            if (ind != null && ind.getIndicatorName() != null && !ind.getIndicatorName().isEmpty()) {
+                String key = category + "_" + ind.getIndicatorName();
+                if (!dedupMap.containsKey(key)) {
+                    dedupMap.put(key, ind);
+                }
+            }
+        }
+    }
+
+    private ParsedIndicator parseBeijingIndicatorLine(String line, String category) {
+        Matcher refMatcher = Pattern.compile(">\\s*\\d+").matcher(line);
+        if (!refMatcher.find()) return null;
+
+        String refRange = refMatcher.group().trim();
+        String beforeRef = line.substring(0, refMatcher.start()).trim();
+
+        Matcher valMatcher = Pattern.compile("(\\d+)$").matcher(beforeRef);
+        if (!valMatcher.find()) return null;
+
+        String value = valMatcher.group(1);
+        String name = beforeRef.substring(0, valMatcher.start()).trim();
+
+        if (name.isEmpty()) return null;
+
+        String status = "正常";
+        try {
+            int val = Integer.parseInt(value);
+            if (refRange.contains(">")) {
+                String refNum = refRange.replaceAll("[^0-9]", "").trim();
+                if (!refNum.isEmpty()) {
+                    int ref = Integer.parseInt(refNum);
+                    if (val >= ref) status = "正常";
+                    else if (val >= ref * 0.5) status = "偏低";
+                    else status = "缺乏";
+                }
+            }
+        } catch (NumberFormatException e) { /* ignore */ }
+
+        ParsedIndicator ind = new ParsedIndicator();
+        ind.setCategory(category);
+        ind.setIndicatorName(name);
+        ind.setIndicatorValue(value);
+        ind.setStatus(status);
+        ind.setRefRange(refRange);
+        return ind;
+    }
+
+    private void parseBeijingSCFASection(String fullText,
+                                          LinkedHashMap<String, ParsedIndicator> dedupMap) {
+        String[] scfaNames = {"甲酸", "乙酸", "丙酸", "丁酸", "戊酸"};
+        for (String name : scfaNames) {
+            int nameIdx = fullText.indexOf(name);
+            if (nameIdx == -1) continue;
+            String context = fullText.substring(nameIdx, Math.min(nameIdx + 100, fullText.length()));
+            Matcher m = Pattern.compile(name + "\\s+(\\d+)\\s*>\\s*60").matcher(context);
+            if (m.find()) {
+                String key = "短链脂肪酸_" + name;
+                if (!dedupMap.containsKey(key)) {
+                    ParsedIndicator ind = new ParsedIndicator();
+                    ind.setCategory("短链脂肪酸");
+                    ind.setIndicatorName(name);
+                    ind.setIndicatorValue(m.group(1));
+                    ind.setStatus(Integer.parseInt(m.group(1)) >= 60 ? "正常" : "偏低");
+                    ind.setRefRange("> 60");
+                    dedupMap.put(key, ind);
+                }
+            }
+        }
+    }
+
+    private void parseBeijingPhenotypeSection(String fullText,
+                                              LinkedHashMap<String, ParsedIndicator> dedupMap) {
+        int startIdx = fullText.indexOf("肠道菌群表型评估");
+        if (startIdx == -1) return;
+
+        int endIdx = fullText.indexOf("肠道免疫力评估", startIdx);
+        if (endIdx == -1) endIdx = fullText.indexOf("6肠道免疫力评估", startIdx);
+        if (endIdx == -1) endIdx = Math.min(startIdx + 2000, fullText.length());
+
+        String region = fullText.substring(startIdx, endIdx);
+
+        for (String line : region.split("\r?\n")) {
+            line = line.trim();
+            if (line.isEmpty()) continue;
+            if (line.contains("检测项目") || line.contains("评估值") || line.contains("参考范围") || line.contains("结果评价")) continue;
+            if (line.contains("肠道菌群表型评估")) continue;
+            if (line.matches("^\\d+$")) continue;
+
+            Pattern p = Pattern.compile(
+                    "([\u4e00-\u9fff\\w]+)\\s+(0?\\.?\\d+)\\s+[\"\\u201C]?\\s*(\\d+[\\-~]\\d+\\.?\\d*)\\s+(正常|异常)");
+            Matcher m = p.matcher(line);
+            if (m.find()) {
+                String name = m.group(1).trim();
+                String value = m.group(2).trim();
+                String refRange = m.group(3).trim();
+                String status = m.group(4).trim();
+
+                String key = "肠道菌群表型_" + name;
+                if (!dedupMap.containsKey(key)) {
+                    ParsedIndicator ind = new ParsedIndicator();
+                    ind.setCategory("肠道菌群表型");
+                    ind.setIndicatorName(name);
+                    ind.setIndicatorValue(value);
+                    ind.setStatus(status);
+                    ind.setRefRange(refRange);
+                    dedupMap.put(key, ind);
+                }
+            }
+        }
+    }
+
+    private void parseBeijingImmunityScores(List<String> lines,
+                                             LinkedHashMap<String, ParsedIndicator> dedupMap) {
+        String[] scoreNames = {"肠道抗炎能力", "肠道免疫力", "肠道膳食纤维需求"};
+        for (String scoreName : scoreNames) {
+            for (String line : lines) {
+                if (line.contains(scoreName)) {
+                    Pattern p = Pattern.compile(scoreName + "\\s+(\\d+)\\s*>?\\s*(\\d+)?");
+                    Matcher m = p.matcher(line);
+                    if (m.find()) {
+                        String value = m.group(1);
+                        String refRange = m.group(2) != null ? "> " + m.group(2) : "";
+                        String key = "肠道免疫力评估_" + scoreName;
+                        if (!dedupMap.containsKey(key)) {
+                            ParsedIndicator ind = new ParsedIndicator();
+                            ind.setCategory("肠道免疫力评估");
+                            ind.setIndicatorName(scoreName);
+                            ind.setIndicatorValue(value);
+                            ind.setStatus("正常");
+                            ind.setRefRange(refRange);
+                            dedupMap.put(key, ind);
+                        }
+                    }
+                    break;
+                }
+            }
+        }
+    }
+
+    private void parseBeijingAntibioticSection(String fullText,
+                                                 LinkedHashMap<String, ParsedIndicator> dedupMap) {
+        int startIdx = fullText.indexOf("12抗生素风险评估");
+        if (startIdx == -1) {
+            startIdx = fullText.indexOf("抗生素风险评估");
+        }
+        if (startIdx == -1) return;
+
+        int endIdx = fullText.indexOf("13毒性物质清除", startIdx);
+        if (endIdx == -1) endIdx = fullText.indexOf("毒性物质清除能力评估", startIdx);
+        if (endIdx == -1) endIdx = fullText.indexOf("14慢病控制", startIdx);
+        if (endIdx == -1) endIdx = Math.min(startIdx + 3000, fullText.length());
+
+        String region = fullText.substring(startIdx, endIdx);
+
+        for (String line : region.split("\r?\n")) {
+            line = line.trim();
+            if (line.isEmpty()) continue;
+            if (line.contains("抗生素风险评估") || line.contains("类别") || line.contains("检测项目")) continue;
+            if (line.contains("检测结果") || line.contains("结果评价")) continue;
+            if (line.contains("耐药 (≥90)") || line.contains("注意 (70-90)") || line.contains("正常 (<70)")) continue;
+            if (line.matches("^\\d+$")) continue;
+
+            Matcher m = Pattern.compile("(.+?)\\s+(\\d+)\\s*$").matcher(line);
+            if (m.matches()) {
+                String name = m.group(1).trim();
+                String value = m.group(2).trim();
+
+                if (name.contains(" ")) {
+                    String[] parts = name.split("\\s+");
+                    name = parts[parts.length - 1].trim();
+                }
+
+                int val;
+                try {
+                    val = Integer.parseInt(value);
+                } catch (NumberFormatException e) {
+                    continue;
+                }
+                String status = val >= 90 ? "耐药" : (val >= 70 ? "注意" : "正常");
+
+                String key = "抗生素耐药_" + name;
+                if (!dedupMap.containsKey(key)) {
+                    ParsedIndicator ind = new ParsedIndicator();
+                    ind.setCategory("抗生素耐药");
+                    ind.setIndicatorName(name);
+                    ind.setIndicatorValue(value);
+                    ind.setStatus(status);
+                    dedupMap.put(key, ind);
+                }
+            }
+        }
+    }
+
+    private void parseBeijingToxinClearance(String fullText,
+                                            LinkedHashMap<String, ParsedIndicator> dedupMap) {
+        int startIdx = fullText.indexOf("13毒性物质清除能力评估");
+        if (startIdx == -1) {
+            startIdx = fullText.indexOf("毒性物质清除能力评估");
+        }
+        if (startIdx == -1) return;
+
+        int endIdx = fullText.indexOf("14慢病控制", startIdx);
+        if (endIdx == -1) endIdx = fullText.indexOf("慢病控制", startIdx);
+        if (endIdx == -1) endIdx = fullText.indexOf("第一部分", startIdx);
+        if (endIdx == -1) endIdx = Math.min(startIdx + 3000, fullText.length());
+
+        String region = fullText.substring(startIdx, endIdx);
+
+        for (String line : region.split("\r?\n")) {
+            line = line.trim();
+            if (line.isEmpty()) continue;
+            if (line.contains("毒性物质清除") || line.contains("检测项目") || line.contains("检测结果")) continue;
+            if (line.contains("结果评价")) continue;
+            if (line.contains("清除能力差") || line.contains("清除能力稍弱") || line.contains("清除能力正常")) continue;
+            if (line.matches("^\\d+$")) continue;
+
+            Matcher m = Pattern.compile("([\u4e00-\u9fff\\w]+)\\s+(\\d+)\\s*$").matcher(line);
+            if (m.matches()) {
+                String name = m.group(1).trim();
+                String value = m.group(2).trim();
+
+                int val;
+                try {
+                    val = Integer.parseInt(value);
+                } catch (NumberFormatException e) {
+                    continue;
+                }
+                String status = val > 70 ? "正常" : (val >= 10 ? "稍弱" : "差");
+
+                String key = "毒素清除_" + name;
+                if (!dedupMap.containsKey(key)) {
+                    ParsedIndicator ind = new ParsedIndicator();
+                    ind.setCategory("毒性物质清除能力");
+                    ind.setIndicatorName(name);
+                    ind.setIndicatorValue(value);
+                    ind.setStatus(status);
+                    dedupMap.put(key, ind);
+                }
+            }
+        }
+    }
+
+    // ======================== 北京报告 — 分类层级表 ========================
+
+    private void parseBeijingTaxonomyLevels(String fullText, List<String> lines,
+                                             List<ParsedGutFlora> result) {
+        String[][] levels = {
+                {"门级水平结果总揽", "门级水平结果总览", "门"},
+                {"纲级水平结果总揽", "纲级水平结果总览", "纲"},
+                {"目级水平结果总揽", "目级水平结果总览", "目"},
+                {"科级水平结果总揽", "科级水平结果总览", "科"},
+                {"属级水平结果总揽", "属级水平结果总览", "属"}
+        };
+
+        for (int li = 0; li < levels.length; li++) {
+            String header1 = levels[li][0];
+            String header2 = levels[li][1];
+            String levelChar = levels[li][2];
+
+            int levelStart = fullText.indexOf(header1);
+            if (levelStart == -1) levelStart = fullText.indexOf(header2);
+            if (levelStart == -1) continue;
+
+            int levelEnd = fullText.length();
+            if (li + 1 < levels.length) {
+                int nextStart = fullText.indexOf(levels[li + 1][0], levelStart);
+                if (nextStart == -1) nextStart = fullText.indexOf(levels[li + 1][1], levelStart);
+                if (nextStart != -1) levelEnd = nextStart;
+            }
+
+            String region = fullText.substring(levelStart, levelEnd);
+
+            for (String line : region.split("\r?\n")) {
+                line = line.trim();
+                if (line.isEmpty()) continue;
+                if (line.contains(header1) || line.contains(header2)) continue;
+                if (line.contains("拉丁学名") || line.contains("中文名称")) continue;
+                if (line.contains("相对丰度") || line.contains("人群水平") || line.contains("人群检出率")) continue;
+                if (line.startsWith("注释:") || line.startsWith("拉丁学名:") || line.startsWith("中文名称:")) continue;
+                if (line.startsWith("相对丰度:") || line.startsWith("人群水平:") || line.startsWith("人群检出率:")) continue;
+                if (line.matches("^\\d+$")) continue;
+
+                Pattern p = Pattern.compile(
+                        "^([A-Z][a-zA-Z\\-]+)\\s+" +
+                        "([\u4e00-\u9fff]+|\\-)\\s+" +
+                        "(\\d+\\.?\\d*)%\\s+" +
+                        "(\\d+\\.?\\d*)%\\s+" +
+                        "(\\d+\\.?\\d*)%?"
+                );
+                Matcher m = p.matcher(line);
+                if (m.find()) {
+                    ParsedGutFlora flora = new ParsedGutFlora();
+                    String chineseName = m.group(2).trim();
+                    flora.setBacteriaName("-".equals(chineseName) ? m.group(1).trim() : chineseName);
+                    flora.setBacteriaValue(m.group(3).trim());
+                    flora.setPopulationLevel(m.group(4).trim() + "%");
+                    flora.setDetectionRate(m.group(5).trim() + "%");
+                    flora.setCategory("菌" + levelChar + "构成");
+                    flora.setLevel(levelChar);
+                    result.add(flora);
+                }
+            }
+        }
+    }
+
+    // ======================== 北京报告 — 胃肠道感染致病菌 ========================
+
+    private void parseBeijingGastroInfections(String fullText, List<String> lines,
+                                               List<ParsedIndicator> result) {
+        int startIdx = fullText.indexOf("10胃肠道感染相关的肠菌评估");
+        if (startIdx == -1) startIdx = fullText.indexOf("胃肠道感染相关的肠菌评估");
+        if (startIdx == -1) return;
+
+        int tableStart = fullText.indexOf("拉丁学名", startIdx);
+        if (tableStart == -1) return;
+
+        int tableEnd = fullText.indexOf("【备注】", tableStart);
+        if (tableEnd == -1) tableEnd = fullText.indexOf("【注释】", tableStart);
+        if (tableEnd == -1) {
+            tableEnd = fullText.indexOf("11营养物质代谢", tableStart);
+        }
+        if (tableEnd == -1) tableEnd = Math.min(tableStart + 3000, fullText.length());
+
+        String region = fullText.substring(tableStart, tableEnd);
+
+        for (String line : region.split("\r?\n")) {
+            line = line.trim();
+            if (line.isEmpty()) continue;
+            if (line.contains("拉丁学名") || line.contains("中文名称")) continue;
+            if (line.contains("检测结果") || line.contains("参考范围")) continue;
+            if (line.contains("胃肠道感染相关的肠菌评估")) continue;
+            if (line.contains("当相对丰度高于")) continue;
+            if (line.matches("^\\d+$")) continue;
+
+            ParsedGutFlora flora = parseBeijingFloraLine(line, "胃肠道感染致病菌");
+            if (flora != null) {
+                ParsedIndicator ind = new ParsedIndicator();
+                ind.setCategory("主要消化道致病菌");
+                ind.setIndicatorName(flora.getBacteriaName());
+                ind.setIndicatorValue(flora.getBacteriaValue());
+                ind.setStatus(flora.getStatus());
+                ind.setRefRange(flora.getNormalRange());
+                result.add(ind);
+            }
+        }
+    }
+
+    // ======================== 北京报告 — 益生菌 ========================
+
+    private List<ParsedGutFlora> parseBeijingProbiotics(String fullText, List<String> lines) {
+        List<ParsedGutFlora> result = new ArrayList<>();
+
+        int startIdx = fullText.indexOf("8.2有益菌");
+        if (startIdx == -1) return result;
+
+        int endIdx = fullText.indexOf("8.3中性菌", startIdx);
+        if (endIdx == -1) endIdx = fullText.indexOf("8.3", startIdx);
+        if (endIdx == -1) endIdx = Math.min(startIdx + 5000, fullText.length());
+
+        String region = fullText.substring(startIdx, endIdx);
+
+        for (String line : region.split("\r?\n")) {
+            line = line.trim();
+            if (line.isEmpty()) continue;
+            if (line.contains("拉丁学名") || line.contains("中文名称")) continue;
+            if (line.contains("8.2有益菌") || line.contains("8.2")) continue;
+            if (line.contains("【备注】") || line.contains("【注释】")) continue;
+            if (line.matches("^\\d+$")) continue;
+
+            ParsedGutFlora flora = parseBeijingFloraLine(line, "益生菌");
+            if (flora != null) {
+                String firstPart = line.trim();
+                int spaceIdx = firstPart.indexOf(' ');
+                if (spaceIdx > 0) {
+                    String afterSpace = firstPart.substring(spaceIdx + 1).trim();
+                    if (!afterSpace.isEmpty() && Character.isLowerCase(afterSpace.charAt(0))) {
+                        flora.setLevel("SPECIES");
+                        result.add(flora);
+                    }
+                }
+            }
+        }
+
+        return result;
+    }
+
+}

+ 616 - 1
cfc-langgraph/app/agents/report_parse_agent.py

@@ -1,8 +1,9 @@
 """
-报告解析 Agent:算法解析 + LLM 兜底
+报告解析 Agent:算法解析 + LLM 兜底 + 多类型报告支持
 """
 import json
 import logging
+import base64
 from typing import Optional
 
 from app.config import settings
@@ -319,3 +320,617 @@ class ReportParseAgent:
         except Exception as e:
             logger.warning("LLM 解析失败: %s", e)
             return None
+
+    # ================================================================
+    # 多类型报告解析
+    # ================================================================
+
+    async def parse_by_type(self, file_path: str, report_type: str,
+                            extra_context: Optional[dict] = None) -> dict:
+        """按报告类型路由到专用解析器。
+
+        :param file_path: PDF 文件路径
+        :param report_type: 报告类型标识:
+            - "brain_status": 脑状态测量报告
+            - "cognitive_aptitude": 先天智力潜能/皮纹学测评报告
+            - "scanned_image": 扫描图片 PDF(无文字层,需多模态 LLM)
+            - "auto": 自动检测类型
+        :return: 结构化解析结果 dict
+        """
+        if report_type == "auto":
+            report_type = self._detect_report_type(file_path)
+            logger.info("自动检测报告类型: %s", report_type)
+
+        if report_type == "brain_status":
+            return await self._parse_brain_status(file_path)
+        elif report_type == "cognitive_aptitude":
+            return await self._parse_cognitive_aptitude(file_path)
+        elif report_type == "scanned_image":
+            return await self._parse_scanned_image(file_path)
+        else:
+            logger.info("未识别的报告类型 %s,走通用解析", report_type)
+            return await self.parse_generic(file_path, extra_context)
+
+    # ---- 格式检测 ----
+
+    def _detect_report_type(self, file_path: str) -> str:
+        """根据 PDF 文本内容自动检测报告类型。"""
+        try:
+            from PyPDF2 import PdfReader
+            reader = PdfReader(file_path)
+
+            # 检测是否有可提取文本
+            total_text = ""
+            for i in range(min(12, len(reader.pages))):
+                t = reader.pages[i].extract_text() or ""
+                total_text += t
+
+            if len(total_text.strip()) < 20:
+                # 前几页几乎无文字 → 可能是扫描图片 PDF
+                return "scanned_image"
+
+            # 脑状态测量报告
+            if "脑状态测量报告" in total_text or "大脑综合状态" in total_text:
+                return "brain_status"
+
+            # 智力潜能 / 皮纹学报告
+            if ("先天数据" in total_text or "智力潜能" in total_text
+                    or "皮纹" in total_text or "ATD" in total_text
+                    or "trc" in total_text.lower()):
+                return "cognitive_aptitude"
+
+            # 北京肠道菌群报告
+            if ("肠道菌群检测" in total_text and "高通量测序" in total_text):
+                return "gut_flora_beijing"
+
+            return "generic"
+        except Exception as e:
+            logger.warning("报告类型检测失败: %s", e)
+            return "generic"
+
+    # ---- 通用 LLM 调用 ----
+
+    async def _call_llm(self, prompt: str, timeout: int = 120) -> str:
+        """调用 LLM 并返回文本响应。
+
+        :raises RuntimeError: LLM 未配置或调用失败
+        """
+        if not self.llm_api_key:
+            raise RuntimeError("LLM 未配置")
+        import httpx
+        async with httpx.AsyncClient(timeout=timeout) as client:
+            resp = await client.post(
+                f"{settings.llm_base_url}/chat/completions",
+                json={
+                    "model": settings.llm_model or "gpt-4o",
+                    "messages": [{"role": "user", "content": prompt}],
+                    "temperature": 0.1,
+                },
+                headers={"Authorization": f"Bearer {self.llm_api_key}"},
+            )
+            resp.raise_for_status()
+            data = resp.json()
+            content = data['choices'][0]['message']['content']
+            return content.replace('```json', '').replace('```', '').strip()
+
+    async def _call_llm_vision(self, prompt: str, image_base64: str,
+                               timeout: int = 180) -> str:
+        """调用多模态 LLM(vision),传入图片 + 文字提示。
+
+        :raises RuntimeError: LLM 未配置或调用失败
+        """
+        if not self.llm_api_key:
+            raise RuntimeError("LLM 未配置")
+        import httpx
+        async with httpx.AsyncClient(timeout=timeout) as client:
+            resp = await client.post(
+                f"{settings.llm_base_url}/chat/completions",
+                json={
+                    "model": settings.llm_model or "gpt-4o",
+                    "messages": [
+                        {
+                            "role": "user",
+                            "content": [
+                                {"type": "text", "text": prompt},
+                                {
+                                    "type": "image_url",
+                                    "image_url": {
+                                        "url": f"data:image/png;base64,{image_base64}",
+                                    },
+                                },
+                            ],
+                        }
+                    ],
+                    "temperature": 0.1,
+                },
+                headers={"Authorization": f"Bearer {self.llm_api_key}"},
+            )
+            resp.raise_for_status()
+            data = resp.json()
+            content = data['choices'][0]['message']['content']
+            return content.replace('```json', '').replace('```', '').strip()
+
+    def _pdf_to_text(self, file_path: str, max_pages: int = 0) -> str:
+        """用 PyPDF2 提取 PDF 全文。max_pages=0 表示全部。"""
+        from PyPDF2 import PdfReader
+        reader = PdfReader(file_path)
+        n = len(reader.pages) if max_pages == 0 else min(max_pages, len(reader.pages))
+        return '\n'.join(reader.pages[i].extract_text() or '' for i in range(n))
+
+    # ---- 1. 脑状态测量报告 ----
+
+    async def _parse_brain_status(self, file_path: str) -> dict:
+        """解析脑状态测量报告。
+
+        文本可提取,数据清晰。使用 LLM 提取结构化数据。
+
+        返回结构:
+        {
+            "reportType": "brain_status",
+            "reportTypeFamily": "cognitive",
+            "summary": {
+                "personName": "张文远",
+                "age": 44,
+                "gender": "male",
+                "reportDate": "2026-05-03",
+                "reportNumber": "CL79474",
+                "overallScore": 82.1,
+                "overallLevel": "良好",
+            },
+            "indicators": [
+                {"name": "大脑综合状态得分", "value": "82.1", "category": "综合评分", "status": "良好"},
+                {"name": "脑供血问题风险评估", "value": "低风险", "category": "风险评估", "status": "低风险"},
+                ...
+            ],
+            "sections": [
+                {"title": "疲劳评估", "content": "...", "items": [...]},
+                {"title": "情绪评估", "content": "...", "items": [...]},
+                {"title": "睡眠评估", "content": "...", "items": [...]},
+            ]
+        }
+        """
+        logger.info("开始解析脑状态测量报告: %s", file_path)
+        text = self._pdf_to_text(file_path)
+
+        prompt = f"""你是一个脑状态测量报告解析专家。请从以下文本中提取结构化数据,返回JSON格式。
+
+报告文本内容:
+{text}
+
+请按以下JSON Schema返回:
+{{
+    "reportType": "brain_status",
+    "reportTypeFamily": "cognitive",
+    "summary": {{
+        "personName": "姓名",
+        "age": 年龄数字,
+        "gender": "male/female",
+        "reportDate": "报告日期 yyyy-MM-dd",
+        "reportNumber": "报告编号",
+        "overallScore": 综合状态得分数值,
+        "overallLevel": "良好/一般/较差等文字描述"
+    }},
+    "indicators": [
+        {{"name": "指标名称", "value": "数值或等级", "category": "分类", "status": "状态描述"}}
+    ],
+    "sections": [
+        {{
+            "title": "段落标题(如:健康风险评估/疲劳评估/情绪评估/睡眠评估等)",
+            "content": "段落摘要",
+            "items": [
+                {{"name": "子项名称", "value": "数值", "status": "状态/等级"}}
+            ]
+        }}
+    ]
+}}
+
+注意:
+1. 尽量提取所有出现的评估指标,包括:大脑综合状态得分、大脑健康状态得分、大脑能力状态得分、
+   脑供血问题风险、脑供氧问题风险、思维负荷、思维状态风险、大脑疲劳评估、用脑模式、
+   焦虑情绪评估、抑郁情绪评估、抵触情绪评估、安全感评估、情绪管理评估、综合情绪评估、
+   睡眠效果评估等
+2. 数值尽量提取数字,状态文字原样保留
+3. 只返回JSON,不要其他文字。"""
+
+        try:
+            content = await self._call_llm(prompt, timeout=120)
+            result = json.loads(content)
+            logger.info("脑状态报告解析完成: indicators=%d, sections=%d",
+                        len(result.get('indicators', [])),
+                        len(result.get('sections', [])))
+            return result
+        except Exception as e:
+            logger.error("脑状态报告解析失败: %s", e)
+            return {
+                "reportType": "brain_status",
+                "reportTypeFamily": "cognitive",
+                "error": str(e),
+                "summary": {},
+                "indicators": [],
+                "sections": [],
+            }
+
+    # ---- 2. 智力潜能 / 皮纹学测评报告 ----
+
+    async def _parse_cognitive_aptitude(self, file_path: str) -> dict:
+        """解析先天智力潜能/皮纹学测评报告。
+
+        115页,文本碎片化严重。分块提取 + LLM 合并。
+
+        返回结构:
+        {
+            "reportType": "cognitive_aptitude",
+            "reportTypeFamily": "cognitive",
+            "summary": {
+                "personName": "张老师",
+                "gender": "male",
+                "region": "北京",
+                "phone": "15901552192",
+                "testDate": "2026-04-03",
+                "birthday": "1982-02-06",
+            },
+            "indicators": [
+                {"name": "TRC", "value": "107+X+M", "category": "先天数据", "status": ""},
+                {"name": "ATD", "value": "35.5", "category": "思维敏捷性", "status": "超级敏感型"},
+                ...
+            ],
+            "sections": [
+                {"title": "智力潜能测评", "content": "...", "items": [...]},
+                {"title": "学习风格测评", "content": "...", "items": [...]},
+                {"title": "八大智能测试", "content": "...", "items": [...]},
+                ...
+            ]
+        }
+        """
+        logger.info("开始解析智力潜能测评报告: %s", file_path)
+
+        from PyPDF2 import PdfReader
+        reader = PdfReader(file_path)
+        total_pages = len(reader.pages)
+
+        # 分块提取文本 — 每块约 8000 字符
+        all_pages = []
+        for i in range(total_pages):
+            t = reader.pages[i].extract_text() or ''
+            all_pages.append((i + 1, t))
+
+        # 合并前 5 页(基本信息区)作为第一块
+        first_chunk = '\n'.join(t for _, t in all_pages[:5])
+        # 合并中间数据页(6-60页)作为第二块
+        mid_chunk = '\n'.join(t for _, t in all_pages[5:min(30, len(all_pages))])
+        # 合并后续页(30-62页有文字的)
+        later_chunk = '\n'.join(t for _, t in all_pages[30:min(62, len(all_pages))])
+
+        # 第一块: 基本信息 + 目录
+        prompt1 = f"""你是一个先天智力潜能(皮纹学)测评报告解析专家。请从以下文本中提取基本信息和目录结构,返回JSON格式。
+
+报告文本内容(前5页):
+{first_chunk}
+
+请按以下JSON Schema返回:
+{{
+    "reportType": "cognitive_aptitude",
+    "reportTypeFamily": "cognitive",
+    "summary": {{
+        "personName": "姓名",
+        "gender": "male/female",
+        "region": "地区",
+        "phone": "电话",
+        "testDate": "测评日期",
+        "birthday": "出生日期",
+        "trc": "TRC值(如107+X+M)",
+        "atd": "ATD角度值",
+        "learningType": "学习类型(听觉型/体觉型/视觉型)",
+        "motivationType": "动机类型",
+        "cognitiveType": "认知类型",
+        "brainDominance": "左脑型/右脑型/全脑型"
+    }},
+    "toc": ["章节1标题", "章节2标题", ...]
+}}
+
+只返回JSON,不要其他文字。"""
+
+        # 第二块: 智力潜能/学习风格/八大智能等核心数据
+        prompt2 = f"""你是一个先天智力潜能(皮纹学)测评报告解析专家。请从以下文本中提取测评指标,返回JSON格式。
+
+报告文本内容(数据页):
+{mid_chunk}
+
+请按以下JSON Schema返回:
+{{
+    "indicators": [
+        {{"name": "指标名称", "value": "数值", "category": "分类", "status": "状态/描述"}}
+    ],
+    "sections": [
+        {{
+            "title": "段落标题(如:智力潜能测评/学习风格测评/左右脑功能/先天性格/八大智能等)",
+            "content": "段落摘要",
+            "items": [{{"name": "子项名称", "value": "数值或描述", "status": "状态"}}]
+        }}
+    ]
+}}
+
+注意:
+1. 指标包括但不限于:TRC(总脊纹数)、ATD(思维敏捷性角)、各脑区指标、
+   八大智能(语言/逻辑数学/空间/身体动觉/音乐/人际/内省/自然观察)、
+   先天学习潜能、先天行为导向、先天学习管道等
+2. 如果某个指标的数值看起来是百分比或数值,直接提取
+3. 只返回JSON,不要其他文字。"""
+
+        # 第三块: 后续章节(学科建议/职业建议/心理学测试等)
+        prompt3 = f"""你是一个先天智力潜能(皮纹学)测评报告解析专家。请从以下文本中提取测评建议和心理学测试结果,返回JSON格式。
+
+报告文本内容(后续页面):
+{later_chunk}
+
+请按以下JSON Schema返回:
+{{
+    "sections2": [
+        {{
+            "title": "段落标题(如:性格色彩/大五人格/MBTI/学科选择/职业能力/感觉统合等)",
+            "content": "段落摘要",
+            "items": [{{"name": "子项名称", "value": "数值或描述", "status": "状态"}}]
+        }}
+    ],
+    "recommendations": [
+        {{"category": "建议类别", "content": "建议内容"}}
+    ]
+}}
+
+只返回JSON,不要其他文字。"""
+
+        result = {
+            "reportType": "cognitive_aptitude",
+            "reportTypeFamily": "cognitive",
+            "summary": {},
+            "indicators": [],
+            "sections": [],
+        }
+
+        try:
+            # 并行请求三块
+            import asyncio
+            tasks = []
+            if first_chunk.strip():
+                tasks.append(self._call_llm(prompt1, timeout=120))
+            if mid_chunk.strip():
+                tasks.append(self._call_llm(prompt2, timeout=120))
+            if later_chunk.strip():
+                tasks.append(self._call_llm(prompt3, timeout=120))
+
+            responses = await asyncio.gather(*tasks, return_exceptions=True)
+
+            # 合并结果
+            for i, resp in enumerate(responses):
+                if isinstance(resp, Exception):
+                    logger.warning("智力潜能报告第%d块解析失败: %s", i + 1, resp)
+                    continue
+                try:
+                    chunk_result = json.loads(str(resp))
+                    if i == 0:
+                        # 第一块: summary + toc
+                        result['summary'] = chunk_result.get('summary', {})
+                        result['toc'] = chunk_result.get('toc', [])
+                    elif i == 1:
+                        # 第二块: indicators + sections
+                        result['indicators'] = chunk_result.get('indicators', [])
+                        result['sections'] = chunk_result.get('sections', [])
+                    elif i == 2:
+                        # 第三块: sections2 + recommendations
+                        if chunk_result.get('sections2'):
+                            result['sections'].extend(chunk_result['sections2'])
+                        if chunk_result.get('recommendations'):
+                            result['recommendations'] = chunk_result['recommendations']
+                except (json.JSONDecodeError, KeyError) as e:
+                    logger.warning("智力潜能报告第%d块JSON解析失败: %s", i + 1, e)
+
+            logger.info("智力潜能报告解析完成: indicators=%d, sections=%d",
+                        len(result.get('indicators', [])),
+                        len(result.get('sections', [])))
+            return result
+        except Exception as e:
+            logger.error("智力潜能报告解析失败: %s", e)
+            return result
+
+    # ---- 3. 扫描图片 PDF(多模态 LLM) ----
+
+    async def _parse_scanned_image(self, file_path: str) -> dict:
+        """解析扫描图片 PDF(无文字层)。
+
+        流程:
+        1. 用 PyPDF2 提取页面图片(base64 编码)
+        2. 逐页发送给多模态 LLM 进行 OCR + 结构化提取
+        3. 合并各页结果
+
+        返回结构与 parse_generic 一致:
+        {
+            "reportType": "推断的类型",
+            "reportTypeFamily": "报告家族",
+            "summary": {...},
+            "indicators": [...],
+            "sections": [...]
+        }
+        """
+        logger.info("开始解析扫描图片PDF: %s", file_path)
+
+        from PyPDF2 import PdfReader
+        reader = PdfReader(file_path)
+        total_pages = len(reader.pages)
+
+        all_indicators = []
+        all_sections = []
+        summary = {}
+        detected_type = "unknown"
+
+        for page_idx in range(total_pages):
+            page = reader.pages[page_idx]
+            image_b64 = self._extract_page_image_base64(page)
+            if not image_b64:
+                logger.warning("第%d页无图片可提取", page_idx + 1)
+                continue
+
+            prompt = f"""请分析这张报告图片(第{page_idx + 1}页,共{total_pages}页),提取其中的所有结构化数据。
+
+请按以下JSON Schema返回:
+{{
+    "reportType": "推断的报告类型名称",
+    "reportTypeFamily": "报告家族分类(如: gut_flora/health_check/cognitive/brain_status/other)",
+    "summary": {{
+        "personName": "姓名(如果能识别)",
+        "reportDate": "报告日期(如果能识别)",
+        "reportNumber": "报告编号(如果能识别)",
+        "overallScore": "总分(如果可见)",
+        "interpretation": "本页内容摘要"
+    }},
+    "indicators": [
+        {{"name": "指标名称", "value": "数值", "category": "分类", "status": "状态"}}
+    ],
+    "sections": [
+        {{"title": "段落标题", "content": "段落内容摘要", "items": [{{"name": "...", "value": "..."}}]}}
+    ]
+}}
+
+注意:
+1. 请仔细阅读图片中的所有文字,包括表格数据、数值、状态描述
+2. 如果是菌群报告,提取菌属名称、丰度值、参考范围等
+3. 如果是体检报告,提取各项检查指标、数值、参考范围、异常标记
+4. 数值尽量提取精确数字,状态文字原样保留
+5. 只返回JSON,不要其他文字。"""
+
+            try:
+                content = await self._call_llm_vision(prompt, image_b64, timeout=180)
+                page_result = json.loads(content)
+
+                # 合并指标
+                if page_result.get('indicators'):
+                    all_indicators.extend(page_result['indicators'])
+
+                # 合并段落
+                if page_result.get('sections'):
+                    all_sections.extend(page_result['sections'])
+
+                # 合并基本信息(第一页优先)
+                if page_idx == 0 and page_result.get('summary'):
+                    summary = page_result['summary']
+
+                # 更新检测到的类型
+                if page_result.get('reportType') and detected_type == "unknown":
+                    detected_type = page_result['reportType']
+
+                logger.info("扫描PDF第%d页解析完成: indicators=%d",
+                            page_idx + 1, len(page_result.get('indicators', [])))
+            except Exception as e:
+                logger.warning("扫描PDF第%d页解析失败: %s", page_idx + 1, e)
+
+        result = {
+            "reportType": detected_type,
+            "reportTypeFamily": "other",
+            "summary": summary,
+            "indicators": all_indicators,
+            "sections": all_sections,
+        }
+
+        logger.info("扫描图片PDF解析完成: pages=%d, indicators=%d, sections=%d",
+                    total_pages, len(all_indicators), len(all_sections))
+        return result
+
+    def _extract_page_image_base64(self, page) -> str:
+        """从 PDF 页面对象中提取图片,返回 base64 编码字符串。
+
+        支持以下情况:
+        - 页面包含 /XObject 中的 /Image 类型对象
+        - 页面是整页图片(常见于扫描件)
+
+        :return: base64 编码的 PNG 图片,或空字符串(无图片)
+        """
+        try:
+            from PyPDF2 import PdfReader
+            import io
+
+            # 获取页面资源
+            resources = page.get('/Resources')
+            if not resources:
+                return ""
+
+            x_objects = resources.get('/XObject')
+            if not x_objects:
+                return ""
+
+            x_obj = x_objects.get_object()
+
+            # 找最大的图片对象
+            best_image = None
+            best_size = 0
+
+            for name in x_obj:
+                obj = x_obj[name]
+                obj_resolved = obj.get_object()
+
+                subtype = obj_resolved.get('/Subtype')
+                if subtype != '/Image':
+                    continue
+
+                width = int(obj_resolved.get('/Width', 0))
+                height = int(obj_resolved.get('/Height', 0))
+                size = width * height
+
+                if size > best_size:
+                    best_size = size
+                    best_image = obj_resolved
+
+            if not best_image:
+                return ""
+
+            # 提取图片数据
+            color_space = best_image.get('/ColorSpace', '/DeviceRGB')
+            bits_per_component = int(best_image.get('/BitsPerComponent', 8))
+            width = int(best_image.get('/Width', 0))
+            height = int(best_image.get('/Height', 0))
+
+            raw_data = best_image.get_data()
+
+            # 转换为 PIL Image → PNG → base64
+            # 尝试安装 Pillow(如果没有)
+            try:
+                from PIL import Image
+            except ImportError:
+                logger.warning("Pillow 未安装,尝试基础 base64 编码")
+                # 如果没有 Pillow,直接 base64 编码原始数据
+                return base64.b64encode(raw_data).decode('utf-8')
+
+            # 根据 ColorSpace 确定模式
+            if isinstance(color_space, str):
+                if 'RGB' in color_space or 'RGB' in str(color_space):
+                    mode = 'RGB'
+                elif 'Gray' in color_space or 'Gray' in str(color_space):
+                    mode = 'L'
+                elif 'CMYK' in color_space:
+                    mode = 'CMYK'
+                else:
+                    mode = 'RGB'
+            else:
+                mode = 'RGB'
+
+            if width > 0 and height > 0:
+                img = Image.frombytes(mode, (width, height), raw_data)
+
+                # CMYK → RGB
+                if mode == 'CMYK':
+                    img = img.convert('RGB')
+
+                # 缩小图片以减少 token 消耗(最大 2000px 边)
+                max_dim = 2000
+                if max(width, height) > max_dim:
+                    ratio = max_dim / max(width, height)
+                    new_size = (int(width * ratio), int(height * ratio))
+                    img = img.resize(new_size, Image.LANCZOS)
+
+                # 转 PNG
+                buf = io.BytesIO()
+                img.save(buf, format='PNG', optimize=True)
+                return base64.b64encode(buf.getvalue()).decode('utf-8')
+
+            return ""
+        except Exception as e:
+            logger.warning("提取页面图片失败: %s", e)
+            return ""

+ 57 - 0
cfc-langgraph/app/api/report_parse.py

@@ -75,3 +75,60 @@ async def parse_report_generic(req: GenericParseRequest):
     except Exception as e:
         logger.error("通用报告解析失败: %s", e, exc_info=True)
         return GenericParseResponse(code=500, message=f"解析失败: {str(e)}", data={})
+
+
+class TypedParseRequest(BaseModel):
+    file_path: str
+    report_type: str = "auto"
+    extra_context: Optional[dict] = None
+
+
+class TypedParseResponse(BaseModel):
+    code: int = 200
+    message: str = "ok"
+    data: dict = {}
+
+
+@router.post("/report/parse-typed", response_model=TypedParseResponse)
+async def parse_report_typed(req: TypedParseRequest):
+    """按报告类型解析。
+
+    支持的报告类型:
+    - auto: 自动检测(默认)
+    - brain_status: 脑状态测量报告
+    - cognitive_aptitude: 先天智力潜能/皮纹学测评报告
+    - scanned_image: 扫描图片 PDF(无文字层,需多模态 LLM)
+    - generic: 通用 LLM 解析
+    - gut_flora: 肠道菌群报告(算法解析 + LLM 兜底)
+    """
+    if not os.path.exists(req.file_path):
+        raise HTTPException(status_code=400, detail=f"文件不存在: {req.file_path}")
+
+    logger.info("report_parse_typed: file_path=%s type=%s",
+                req.file_path, req.report_type)
+    agent = get_agent()
+    try:
+        result = await agent.parse_by_type(req.file_path, req.report_type, req.extra_context)
+        return TypedParseResponse(data=result)
+    except Exception as e:
+        logger.error("类型报告解析失败: %s", e, exc_info=True)
+        return TypedParseResponse(code=500, message=f"解析失败: {str(e)}", data={})
+
+
+class TypedParseRequest2(BaseModel):
+    file_path: str
+
+
+@router.post("/report/detect-type", response_model=TypedParseResponse)
+async def detect_report_type(req: TypedParseRequest2):
+    """检测报告类型,返回检测结果(不执行解析)。"""
+    if not os.path.exists(req.file_path):
+        raise HTTPException(status_code=400, detail=f"文件不存在: {req.file_path}")
+
+    agent = get_agent()
+    try:
+        detected = agent._detect_report_type(req.file_path)
+        return TypedParseResponse(data={"detectedType": detected})
+    except Exception as e:
+        logger.error("报告类型检测失败: %s", e, exc_info=True)
+        return TypedParseResponse(code=500, message=f"检测失败: {str(e)}", data={})

+ 1 - 0
cfc-langgraph/requirements.txt

@@ -12,3 +12,4 @@ pytest>=8.0,<9.0
 PyPDF2>=3.0,<4.0
 faster-whisper==1.2.1
 av==18.1.0
+Pillow>=10.0,<11.0