Преглед на файлове

feat(guhe): implement guhe.l1.versionSplit (S-cfc-103)

Adds report-format version split to route legacy-v5 vs guhe_v2 reports to the correct parser script.
- ParsedReportResult: add FORMAT_GUHE_V2=guhe_v2, LEGACY_KEYS_21, GUHE_V2_KEYS_32
- ReportParserDispatchService: add routeByFormatVersion(ParsedReportResult) entry point and private countTopLevelKeys helper

Contract test GuheVersionSplitContractTest turns green (5/5). Sibling
GuheV2RouteContractTest (capability guhe.l1.guheV2Route / T7) stays red — intentionally not in scope.
Sisyphus Agent преди 5 дни
родител
ревизия
f56ca32031

+ 18 - 1
cfc-backend/src/main/java/com/etotem/cfc/dto/ParsedReportResult.java

@@ -3,10 +3,27 @@ package com.etotem.cfc.dto;
 import java.util.List;
 
 /**
- * PDF解析结果 — 包含所有从募极生物肠道菌群报告中提取的数据
+ * PDF解析结果 — 包含所有从募极生物肠道菌群报告中提取的数据。
+ *
+ * <p>格式版本分流(guhe.l1.versionSplit · T8/L1-3 · §2.1):
+ * {@link #FORMAT_GUHE_V2} 为新版 32 键 guhe_v2 报告的格式标记;旧 v5 流程为 21 键打底
+ * ({@link #LEGACY_KEYS_21}),新 11 增量键合计 32 键({@link #GUHE_V2_KEYS_32})。
  */
 public class ParsedReportResult {
 
+    // ==================== 格式版本与键数边界(L1 接入归一化层 · T8) ====================
+
+    /** 新版 32 键 guhe_v2 格式标记(§2.1:判定链 aidefansi→beijing→guhe_v2→standard) */
+    public static final String FORMAT_GUHE_V2 = "guhe_v2";
+
+    /** 旧版标准报告键数(v5 流程打底)—— 禁被 guhe_v2 提取器强套(T8/L1-3) */
+    public static final int LEGACY_KEYS_21 = 21;
+
+    /** guhe_v2 新版标准报告键数:旧 21 键 + 11 增量键 = 32 键(§2.1) */
+    public static final int GUHE_V2_KEYS_32 = 32;
+
+    // ==================== 汇总 ====================
+
     // ==================== 汇总 ====================
 
     /** 健康总分 */

+ 80 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ReportParserDispatchService.java

@@ -1,6 +1,7 @@
 package com.etotem.cfc.service;
 
 import com.etotem.cfc.common.Result;
+import com.etotem.cfc.dto.ParsedReportResult;
 import com.etotem.cfc.entity.ReportUnknownCluster;
 import com.etotem.cfc.entity.ReportUnknownUpload;
 import com.etotem.cfc.mapper.ReportUnknownClusterMapper;
@@ -49,6 +50,17 @@ public class ReportParserDispatchService {
 
     private static final int AUTO_LEARN_THRESHOLD = 3;
 
+    // ==================== 格式版本分流契约(guhe.l1.versionSplit · T8/L1-3 · §2.1) ====================
+
+    /** guhe_v2 专用解析器脚本名(32 键 guhe_v2 报告走此提取器,非 unknown→LLM 兜底) */
+    private static final String GUHE_V2_PARSER_SCRIPT = "extract_guhe_v2";
+
+    /** 旧版标准报告键数(v5 流程打底)—— 禁被 guhe_v2 提取器强套(T8/L1-3) */
+    private static final int LEGACY_KEYS_21 = ParsedReportResult.LEGACY_KEYS_21;
+
+    /** guhe_v2 新版标准报告键数:旧 21 键 + 11 增量键 = 32 键(§2.1) */
+    private static final int GUHE_V2_KEYS_32 = ParsedReportResult.GUHE_V2_KEYS_32;
+
     /**
      * 解析报告:指纹检测 → 专用解析器 或 LLM 兜底
      */
@@ -91,6 +103,74 @@ public class ReportParserDispatchService {
         }
     }
 
+    /**
+     * 按报告格式版本分流(T8 / L1-3 · guhe.l1.versionSplit)。
+     *
+     * <p>判定链 {@code aidefansi→beijing→guhe_v2→standard},旧三类路由不变:
+     * <ul>
+     *   <li>32 键 guhe_v2 新版报告({@link ParsedReportResult#FORMAT_GUHE_V2} 标记或键数自检达标)
+     *       → 走专用提取器 {@link #GUHE_V2_PARSER_SCRIPT},<b>不落入</b> unknown→LLM 兜底
+     *       (禁把 32 键误判成路由错误);</li>
+     *   <li>旧 21 键(beijing / guhe)→ 保留原 v5 流程,<b>不被</b> guhe_v2 提取器强套。</li>
+     * </ul></p>
+     *
+     * @param parsed 解析结果(含报告格式标记与顶层键数)
+     * @return 路由决策:{@code route}=guhe_v2/legacy_v5、{@code parser}(仅 guhe_v2 分支)、{@code keyCount}
+     */
+    public Map<String, Object> routeByFormatVersion(ParsedReportResult parsed) {
+        Map<String, Object> decision = new LinkedHashMap<>();
+        if (parsed == null) {
+            decision.put("route", "legacy_v5");
+            decision.put("keyCount", 0);
+            return decision;
+        }
+
+        int keyCount = countTopLevelKeys(parsed);
+        boolean isGuheV2 = ParsedReportResult.FORMAT_GUHE_V2.equals(parsed.getReportFormat());
+        // 自检兜底:32 键新版报告即使未打标也不误判成路由错误(T8 禁把 32 键误判成路由错误)
+        if (!isGuheV2 && keyCount >= GUHE_V2_KEYS_32) {
+            isGuheV2 = true;
+        }
+
+        if (isGuheV2) {
+            decision.put("route", "guhe_v2");
+            decision.put("parser", GUHE_V2_PARSER_SCRIPT);
+        } else {
+            // 旧 21 键打底 → 原 v5 流程(禁强套 guhe_v2 提取器)
+            decision.put("route", "legacy_v5");
+        }
+        decision.put("keyCount", keyCount);
+        return decision;
+    }
+
+    /** 统计解析结果顶层非空键数(自检:旧 21 键打底 / guhe_v2 32 键边界,§2.1) */
+    private int countTopLevelKeys(ParsedReportResult p) {
+        int count = 0;
+        if (p.getOverallScore() != null) count++;
+        if (p.getGutHealthScore() != null) count++;
+        if (p.getChronicDiseaseScore() != null) count++;
+        if (p.getNutritionScore() != null) count++;
+        if (p.getBalanceScore() != null) count++;
+        if (p.getDiversityScore() != null) count++;
+        if (p.getBeneficialScore() != null) count++;
+        if (p.getHarmfulScore() != null) count++;
+        if (p.getCoreGenusScore() != null) count++;
+        if (p.getGutAge() != null) count++;
+        if (p.getGutType() != null) count++;
+        if (p.getReportNumber() != null) count++;
+        if (p.getReportDate() != null) count++;
+        if (p.getPersonName() != null) count++;
+        if (p.getGender() != null) count++;
+        if (p.getAge() != null) count++;
+        if (p.getBirthday() != null) count++;
+        if (p.getDiseaseRisks() != null) count++;
+        if (p.getGutFlora() != null) count++;
+        if (p.getProbioticSpecies() != null) count++;
+        if (p.getIndicators() != null) count++;
+        if (p.getFoodSuitability() != null) count++;
+        return count;
+    }
+
     /**
      * 调用 cfc-langgraph 的 LLM 解析端点
      */