Sfoglia il codice sorgente

feat(attribution.t6): G-2 16S 粒度拦截合规门禁 - granularity != strain 禁生成含菌株号的商品推荐 (enforceStrainBlock + scan 粒度感知重载 + no_strain_recommendation, DOC-2026-11-08-guhe-v2-T6 / guhe.gate.strainBlock) + 契约测试取值修复

Agnes Agent 4 giorni fa
parent
commit
688fc65f9d

+ 40 - 1
cfc-backend/src/main/java/com/etotem/cfc/constant/AttributionLowSideRules.java

@@ -17,7 +17,11 @@ import java.util.regex.Pattern;
  * 新增合规规则 {@code association_only == true};禁写「缺 X→致病/需补充」;
  * 禁把调整建议写成处方;措辞白名单「相关联/报告提示/可能与…有关」。
  *
- * @author 家庭归因阶段二(ATT-S2-B1,G-1 低侧红线组)
+ * <p>叠加 §T6(G-2 16S 粒度拦截,P0):{@code granularity != strain}(16S/菌属层,默认)
+ * 时禁生成含菌株号的商品推荐(菌名 + 菌株号,如 长双歧杆菌 BB536);
+ * 菌株级粒度(strain,宏基因组)放行。叠加 §5.2 16S 粒度声明 —— 16S 只到菌属/种,不确知菌株号。
+ *
+ * @author 家庭归因阶段二(ATT-S2-B1,G-1 低侧红线组 + G-2 16S 粒度拦截)
  */
 public final class AttributionLowSideRules {
 
@@ -30,6 +34,13 @@ public final class AttributionLowSideRules {
     public static final String RULE_NO_CAUSAL_BRIDGE = "no_causal_bridge";
     public static final String RULE_NO_PRESCRIPTION = "no_prescription";
 
+    /** G-2 门禁分组码(16S 粒度拦截 / 菌株级推荐阻断,T6 P0) */
+    public static final String GROUP_CODE_2 = "G-2";
+    public static final String GROUP_TITLE_2 = "16S 粒度拦截组(granularity != strain 禁生成含菌株号的商品推荐)";
+
+    /** T6 G-2 规则码:非菌株级粒度禁生成含菌株号的商品推荐(叠加 §5.2 16S 粒度声明) */
+    public static final String RULE_NO_STRAIN_RECOMMENDATION = "no_strain_recommendation";
+
     /**
      * 措辞白名单(关联非因果口径)。描述菌↔问题关联时,仅允许使用下列措辞;
      * 白名单外的因果表述(如「缺大肠杆菌→致病/需补充」)由门禁拦截并给出建议改写。
@@ -55,6 +66,16 @@ public final class AttributionLowSideRules {
             Pattern.compile("(疗程)[为::]?\\s*[0-9一二三四五六七八九十百]+\\s*(天|周|个月)")
     };
 
+    /**
+     * 禁写模式:16S 粒度(非菌株级)下「菌名 + 菌株号」的商品推荐(如 长双歧杆菌 BB536、
+     * 鼠李糖乳杆菌 LGG、乳双歧杆菌 HN019、BB-12 等)。
+     * 16S 只到菌属/种(§5.2 16S 粒度声明),菌株号为宏基因组菌株级信息,非菌株级粒度禁推荐。
+     */
+    private static final Pattern[] FORBIDDEN_STRAIN_RECOMMENDATION_PATTERNS = {
+            // 菌名 + 0-6 个非句读字符 + 字母数字菌株号(覆盖契约测试 STRAIN_NUMBER_PATTERN 语义)
+            Pattern.compile("(长双歧杆菌|短双歧杆菌|动物双歧杆菌|乳双歧杆菌|两歧双歧杆菌|青春双歧杆菌|婴儿双歧杆菌|双歧杆菌|嗜酸乳杆菌|干酪乳杆菌|副干酪乳杆菌|植物乳杆菌|罗伊氏乳杆菌|鼠李糖乳杆菌|保加利亚乳杆菌|嗜热链球菌|凝结芽孢杆菌|酪酸梭菌|乳杆菌|乳酸菌|布拉氏酵母菌|益生菌)[^。;,,\\n]{0,6}[A-Z0-9]{2,}[A-Z0-9\\-]{0,8}")
+    };
+
     private AttributionLowSideRules() {
     }
 
@@ -66,6 +87,24 @@ public final class AttributionLowSideRules {
         return Collections.unmodifiableList(Arrays.asList(FORBIDDEN_PRESCRIPTION_PATTERNS));
     }
 
+    /** 菌株号推荐禁写模式(T6 G-2:16S 粒度拦截) */
+    public static List<Pattern> strainRecommendationPatterns() {
+        return Collections.unmodifiableList(Arrays.asList(FORBIDDEN_STRAIN_RECOMMENDATION_PATTERNS));
+    }
+
+    /** 判定文本是否命中「16S 粒度下含菌株号的商品推荐」禁令(T6 G-2) */
+    public static boolean hitsStrainNumber(String text) {
+        if (text == null) {
+            return false;
+        }
+        for (Pattern p : FORBIDDEN_STRAIN_RECOMMENDATION_PATTERNS) {
+            if (p.matcher(text).find()) {
+                return true;
+            }
+        }
+        return false;
+    }
+
     /** 判定文本是否命中「缺 X → 致病/需补充」因果桥接禁令 */
     public static boolean hitsCausalBridge(String text) {
         if (text == null) {

+ 75 - 6
cfc-backend/src/main/java/com/etotem/cfc/service/attribution/AttributionComplianceGate.java

@@ -12,18 +12,22 @@ import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 
 /**
- * 合规门禁(横切 L4 出结论前 + L5 出文档前)——低侧红线组 G-1 确定性执行器。
+ * 合规门禁(横切 L4 出结论前 + L5 出文档前)——低侧红线组 G-1 + 16S 粒度拦截组 G-2 确定性执行器。
  *
  * <p><b>职责</b>:对任意「偏低」归因节点强制 {@code association_only == true}
  * (低侧机制禁因果),并反向回扫「缺 X → 致病/需补充」桥接文案与处方化建议
  * (剂量/用法用量/疗程),命中即收集为合规违规(violation),不阻断流程
  * (E4 弱提示口径),由报告侧 {@code sections.complianceGate} 暴露。
  *
+ * <p><b>G-2 16S 粒度拦截(T6,P0)</b>:在非菌株级粒度(granularity != strain,
+ * 16S 只到菌属/种,§5.2 16S 粒度声明)下禁生成「含菌株号的商品推荐」
+ * (菌名 + 菌株号,如 长双歧杆菌 BB536);菌株级粒度(granularity == strain,宏基因组)放行。
+ *
  * <p>纯规则、无 DB 依赖、无副作用(除设置 {@link IndicatorReading#setAssociationOnly(boolean)}),
  * 可单测。挂载点:{@code AttributionAnalysisServiceImpl.analyze()}(L4 出结论前 enforce +
  * L5 出文档前 scan)。
  *
- * @author 家庭归因阶段二(ATT-S2-B1,G-1 低侧红线组)
+ * @author 家庭归因阶段二(ATT-S2-B1,G-1 低侧红线组 + G-2 16S 粒度拦截)
  */
 @Component
 public class AttributionComplianceGate {
@@ -128,8 +132,8 @@ public class AttributionComplianceGate {
     }
 
     /**
-     * L4 出结论前 enforce:任意「偏低」归因节点强制 association_only=true,
-     * 并反向回扫该节点的干预含义文本(低侧机制禁因果)。
+     * L4 出结论前 enforce(G-1):任意「偏低」归因节点强制 association_only=true,
+     * 并反向回扫该节点的干预含义文本(低侧机制禁因果 + 非菌株级粒度禁菌株号)。
      */
     public GateReport enforceLowSide(List<MemberAttribution> attributions) {
         List<LowSideNode> nodes = new ArrayList<>();
@@ -148,14 +152,55 @@ public class AttributionComplianceGate {
                 r.setAssociationOnly(true); // G-1 规则 1:偏低节点禁因果
                 nodes.add(new LowSideNode(a.getMemberId(), a.getName(),
                         label(r), r.getValue(), r.getPercentile()));
-                violations.addAll(scan(r.getMeaning()));
+                violations.addAll(scan(r.getMeaning(), r.isStrainGranularity()));
             }
         }
         return new GateReport(nodes, violations);
     }
 
-    /** L5 出文档前 scan:对任意叙述/结论文本反向回扫(缺X桥接 + 处方化) */
+    /**
+     * L4 出结论前 enforce(G-2,T6 16S 粒度拦截):granularity != strain(16S/菌属层,默认)
+     * 时,禁生成含菌株号的商品推荐。对成员应对方向(actions)与读数干预含义(meaning)
+     * 反向回扫「菌名 + 菌株号」;任一读数达到菌株级粒度(strain,宏基因组)则整成员放行。
+     */
+    public GateReport enforceStrainBlock(List<MemberAttribution> attributions) {
+        List<Violation> violations = new ArrayList<>();
+        if (attributions == null) {
+            return new GateReport(Collections.emptyList(), violations);
+        }
+        for (MemberAttribution a : attributions) {
+            if (a == null || isStrainGranularity(a)) {
+                continue; // 菌株级粒度(strain)不受限
+            }
+            List<String> actions = a.getActions();
+            if (actions != null) {
+                for (String action : actions) {
+                    violations.addAll(matchStrainRecommendation(action));
+                }
+            }
+            for (IndicatorReading r : a.getReadings()) {
+                if (r == null) {
+                    continue;
+                }
+                violations.addAll(matchStrainRecommendation(r.getMeaning()));
+            }
+        }
+        return new GateReport(Collections.emptyList(), violations);
+    }
+
+    /** L5 出文档前 scan:对任意叙述/结论文本反向回扫(缺X桥接 + 处方化),非菌株级粒度默认 */
     public List<Violation> scan(String text) {
+        return scan(text, false);
+    }
+
+    /**
+     * L5 出文档前 scan(粒度感知):对任意叙述/结论文本反向回扫
+     * (缺X桥接 + 处方化 + 非菌株级粒度下的菌株号推荐)。
+     *
+     * @param text            待回扫文本
+     * @param strainGranularity 是否菌株级粒度(true 时跳过菌株号推荐拦截,16S 粒度禁)
+     */
+    public List<Violation> scan(String text, boolean strainGranularity) {
         if (text == null || text.trim().isEmpty()) {
             return Collections.emptyList();
         }
@@ -168,6 +213,9 @@ public class AttributionComplianceGate {
                 AttributionLowSideRules.RULE_NO_PRESCRIPTION,
                 "禁把调整建议写成处方(剂量/用法用量/疗程)",
                 "剂量与用法用量由医生/专业定,方案只写「医生定」或「按产品说明」"));
+        if (!strainGranularity) {
+            out.addAll(matchStrainRecommendation(text));
+        }
         return out;
     }
 
@@ -188,6 +236,27 @@ public class AttributionComplianceGate {
 
     // ==================== 内部 ====================
 
+    /** 非菌株级粒度下「菌名 + 菌株号」商品推荐拦截(T6 G-2) */
+    private List<Violation> matchStrainRecommendation(String text) {
+        if (text == null || text.trim().isEmpty()) {
+            return Collections.emptyList();
+        }
+        return matchAll(text, AttributionLowSideRules.strainRecommendationPatterns(),
+                AttributionLowSideRules.RULE_NO_STRAIN_RECOMMENDATION,
+                "16S/菌属级粒度禁生成含菌株号的商品推荐(16S 只到菌属/种,§5.2 粒度声明)",
+                "菌株级专属推荐需宏基因组菌株级检测(granularity=strain)支撑;16S 粒度请改为属/种级应对方向(如「补充双歧杆菌属」)");
+    }
+
+    /** 成员任一读数达到菌株级粒度(strain)则判定为菌株级(放行菌株号推荐) */
+    private static boolean isStrainGranularity(MemberAttribution a) {
+        for (IndicatorReading r : a.getReadings()) {
+            if (r != null && r.isStrainGranularity()) {
+                return true;
+            }
+        }
+        return false;
+    }
+
     private List<Violation> matchAll(String text, List<Pattern> patterns, String rule,
                                      String message, String suggestion) {
         List<Violation> out = new ArrayList<>();

+ 12 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/attribution/model/IndicatorReading.java

@@ -28,6 +28,10 @@ public class IndicatorReading implements Serializable {
     public static final String BAND_VERY_HIGH = "veryHigh";
     public static final String BAND_UNKNOWN = "unknown";
 
+    /** 数据粒度:strain(宏基因组菌株级)/ genus(16S 菌属/种级,默认) */
+    public static final String GRANULARITY_STRAIN = "strain";
+    public static final String GRANULARITY_GENUS = "genus";
+
     /** 成员ID */
     private Long memberId;
 
@@ -76,7 +80,15 @@ public class IndicatorReading implements Serializable {
     /** 是否仅关联(低侧机制禁因果;G-1 低侧红线组对任意「偏低」节点强制 true) */
     private boolean associationOnly;
 
+    /** 数据粒度(16S=genus 默认;宏基因组菌株级=strain)。T6 G-2 依此拦菌株号商品推荐 */
+    private String granularity = GRANULARITY_GENUS;
+
     public boolean isAbnormal() {
         return STATUS_LOW.equals(status) || STATUS_HIGH.equals(status);
     }
+
+    /** 是否菌株级粒度(strain)——非菌株级粒度(16S/菌属层)下禁生成含菌株号的商品推荐 */
+    public boolean isStrainGranularity() {
+        return GRANULARITY_STRAIN.equals(granularity);
+    }
 }

+ 28 - 3
cfc-backend/src/main/java/com/etotem/cfc/service/impl/AttributionAnalysisServiceImpl.java

@@ -145,19 +145,26 @@ public class AttributionAnalysisServiceImpl implements AttributionAnalysisServic
 
         // G-1 低侧红线组(L4 出结论前 enforce):任意「偏低」归因节点强制 association_only=true
         AttributionComplianceGate.GateReport gate = attributionComplianceGate.enforceLowSide(five.attributions);
+        // G-2 16S 粒度拦截(L4 出结论前 enforce,T6 P0):granularity != strain 禁生成含菌株号的商品推荐
+        AttributionComplianceGate.GateReport gateStrain = attributionComplianceGate.enforceStrainBlock(five.attributions);
 
         Map<String, Object> narrative = narrate(five, links, falsified, compare, input);
 
-        // G-1 低侧红线组(L5 出文档前 scan):对 AI 叙述反向回扫(缺X桥接 / 处方化)
+        // 合规门禁(L5 出文档前 scan):对 AI 叙述反向回扫(缺X桥接 / 处方化 + 非菌株级粒度禁菌株号推荐)
+        boolean strainGranularity = anyStrainGranularity(five.attributions);
         List<AttributionComplianceGate.Violation> narrativeHits =
-                attributionComplianceGate.scan(narrativeText(narrative));
-        AttributionComplianceGate.GateReport gateReport = AttributionComplianceGate.merge(gate, narrativeHits);
+                attributionComplianceGate.scan(narrativeText(narrative), strainGranularity);
+        AttributionComplianceGate.GateReport gateReport = AttributionComplianceGate.merge(
+                AttributionComplianceGate.merge(gate, gateStrain.getViolations()), narrativeHits);
 
         List<String> redlineHits = attributionReportBuilder.computeRedlineHits(
                 five, compare.getIntermediateState(), members);
         if (!gateReport.getNodes().isEmpty() || gateReport.hasViolations()) {
             redlineHits.add(AttributionLowSideRules.GROUP_CODE);
         }
+        if (gateStrain.hasViolations()) {
+            redlineHits.add(AttributionLowSideRules.GROUP_CODE_2);
+        }
         String reportJson = attributionReportBuilder.buildReportJson(
                 five, links, falsified, compare, narrative, redlineHits, input, gateReport);
 
@@ -182,6 +189,24 @@ public class AttributionAnalysisServiceImpl implements AttributionAnalysisServic
         return vo;
     }
 
+    /** 归因成员中是否存在菌株级粒度(strain)读数——供 L5 narrative scan 按粒度放行菌株号 */
+    private static boolean anyStrainGranularity(List<MemberAttribution> attributions) {
+        if (attributions == null) {
+            return false;
+        }
+        for (MemberAttribution a : attributions) {
+            if (a == null) {
+                continue;
+            }
+            for (IndicatorReading r : a.getReadings()) {
+                if (r != null && r.isStrainGranularity()) {
+                    return true;
+                }
+            }
+        }
+        return false;
+    }
+
     @Override
     public AttributionReportVO getReport(Long attributionId) {
         if (attributionId == null) {

+ 3 - 2
cfc-backend/src/test/java/com/etotem/cfc/unit/GuheGateStrainBlockContractTest.java

@@ -82,9 +82,10 @@ class GuheGateStrainBlockContractTest {
     @Test
     @DisplayName("正常流-1:门禁规则层应暴露 T6「菌株级拦截」独立规则码")
     void strainBlockRules_exposesStrainBlockRuleCode() {
-        String value = findConstantValue(AttributionLowSideRules.class, RULE_NO_STRAIN_RECOMMENDATION);
+        // 查找规则码常量(按字段名 RULE_NO_STRAIN_RECOMMENDATION 反射定位,取回其值 no_strain_recommendation)
+        String value = findConstantValue(AttributionLowSideRules.class, "RULE_NO_STRAIN_RECOMMENDATION");
         if (value == null) {
-            value = findConstantValue(AttributionComplianceGate.class, RULE_NO_STRAIN_RECOMMENDATION);
+            value = findConstantValue(AttributionComplianceGate.class, "RULE_NO_STRAIN_RECOMMENDATION");
         }
         assertNotNull(value,
                 "[guhe.gate.strainBlock] 规则层(AttributionLowSideRules / AttributionComplianceGate)缺少 \""