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

feat(attribution.g1): G-1 low-side redline compliance gate - association_only enforce + no causal bridge/prescription + sections.complianceGate

Agnes Agent 5 дней назад
Родитель
Сommit
a13bb81921

+ 94 - 0
cfc-backend/src/main/java/com/etotem/cfc/constant/AttributionLowSideRules.java

@@ -0,0 +1,94 @@
+package com.etotem.cfc.constant;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.regex.Pattern;
+
+/**
+ * 低侧红线组 G-1(合规门禁·横切 L4 出结论前 / L5 出文档前)。
+ *
+ * <p><b>合规即服务</b>:措辞白名单与违规探测由规则层硬编码,AI 只产叙述,不负责合规。
+ * 门禁不改变引擎结论,只在「偏低」归因节点上强制 {@code association_only==true},
+ * 并把「缺 X → 致病/需补充」桥接文案与处方化建议收集为合规命中(violations),
+ * 由报告侧(sections.complianceGate / redlines 命中 G-1)暴露,流程本身不阻断。
+ *
+ * <p>对应平台修改计划 §三任务分解 §T5(P0):
+ * 新增合规规则 {@code association_only == true};禁写「缺 X→致病/需补充」;
+ * 禁把调整建议写成处方;措辞白名单「相关联/报告提示/可能与…有关」。
+ *
+ * @author 家庭归因阶段二(ATT-S2-B1,G-1 低侧红线组)
+ */
+public final class AttributionLowSideRules {
+
+    /** 门禁分组码 */
+    public static final String GROUP_CODE = "G-1";
+    public static final String GROUP_TITLE = "低侧红线组(关联非因果 · 禁缺X桥接 · 禁处方化)";
+
+    /** 规则码 */
+    public static final String RULE_ASSOCIATION_ONLY = "association_only";
+    public static final String RULE_NO_CAUSAL_BRIDGE = "no_causal_bridge";
+    public static final String RULE_NO_PRESCRIPTION = "no_prescription";
+
+    /**
+     * 措辞白名单(关联非因果口径)。描述菌↔问题关联时,仅允许使用下列措辞;
+     * 白名单外的因果表述(如「缺大肠杆菌→致病/需补充」)由门禁拦截并给出建议改写。
+     */
+    public static final List<String> WORDING_WHITELIST = Collections.unmodifiableList(Arrays.asList(
+            "相关联", "报告提示", "可能与…有关", "可能与", "有关"));
+
+    /** 白名单兜底建议(命中违规时给下游的建议改写) */
+    public static final String WHITELIST_HINT =
+            "请改用措辞白名单表述(相关联 / 报告提示 / 可能与…有关),不写因果、不写处方、不给剂量。";
+
+    /** 禁写模式:「缺/缺乏/不足/偏低 X → 致病/需补充」类因果桥接 */
+    private static final Pattern[] FORBIDDEN_CAUSAL_PATTERNS = {
+            Pattern.compile("(缺|缺乏|不足|偏低|偏少|少)[^。;,,\\n]{0,12}(→|->|⇒|➡|导致|引起|造成|可致|会引发|将会引发)[^。;,,\\n]{0,16}(致病|患病|得病|生病|疾病|感染|发炎|受损|破坏|功能下降|需补充|需要补充|要补充|必须补充|需补|要补|补菌|补充菌|需用药|服药)"),
+            Pattern.compile("(缺|缺乏|报告缺)[^。;,,\\n]{0,12}(→|->|⇒|➡)[^。;,,\\n]{0,24}(买|购买|选购|下单|服用|补充)")
+    };
+
+    /** 禁写模式:把调整建议写成处方(剂量/用法用量/疗程) */
+    private static final Pattern[] FORBIDDEN_PRESCRIPTION_PATTERNS = {
+            Pattern.compile("(每日|每天|每次|一日|连服|连续服用|服用|口服)\\s*[0-9一二三四五六七八九十百]+\\s*(mg|毫克|g|克|μg|ug|微克|ml|毫升|IU|万单位|粒|片|袋|支|滴|次)"),
+            Pattern.compile("剂量[为::]?\\s*[0-9一二三四五六七八九十百]+\\s*(mg|毫克|g|克|μg|ug|微克|ml|毫升|IU|万单位|粒|片|袋|支|滴)"),
+            Pattern.compile("[0-9一二三四五六七八九十百]+\\s*(天|周|个月)\\s*(疗程|连服)"),
+            Pattern.compile("(疗程)[为::]?\\s*[0-9一二三四五六七八九十百]+\\s*(天|周|个月)")
+    };
+
+    private AttributionLowSideRules() {
+    }
+
+    public static List<Pattern> causalPatterns() {
+        return Collections.unmodifiableList(Arrays.asList(FORBIDDEN_CAUSAL_PATTERNS));
+    }
+
+    public static List<Pattern> prescriptionPatterns() {
+        return Collections.unmodifiableList(Arrays.asList(FORBIDDEN_PRESCRIPTION_PATTERNS));
+    }
+
+    /** 判定文本是否命中「缺 X → 致病/需补充」因果桥接禁令 */
+    public static boolean hitsCausalBridge(String text) {
+        if (text == null) {
+            return false;
+        }
+        for (Pattern p : FORBIDDEN_CAUSAL_PATTERNS) {
+            if (p.matcher(text).find()) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /** 判定文本是否命中「把调整建议写成处方」禁令 */
+    public static boolean hitsPrescription(String text) {
+        if (text == null) {
+            return false;
+        }
+        for (Pattern p : FORBIDDEN_PRESCRIPTION_PATTERNS) {
+            if (p.matcher(text).find()) {
+                return true;
+            }
+        }
+        return false;
+    }
+}

+ 213 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/attribution/AttributionComplianceGate.java

@@ -0,0 +1,213 @@
+package com.etotem.cfc.service.attribution;
+
+import com.etotem.cfc.constant.AttributionLowSideRules;
+import com.etotem.cfc.service.attribution.model.IndicatorReading;
+import com.etotem.cfc.service.attribution.model.MemberAttribution;
+import org.springframework.stereotype.Component;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * 合规门禁(横切 L4 出结论前 + L5 出文档前)——低侧红线组 G-1 确定性执行器。
+ *
+ * <p><b>职责</b>:对任意「偏低」归因节点强制 {@code association_only == true}
+ * (低侧机制禁因果),并反向回扫「缺 X → 致病/需补充」桥接文案与处方化建议
+ * (剂量/用法用量/疗程),命中即收集为合规违规(violation),不阻断流程
+ * (E4 弱提示口径),由报告侧 {@code sections.complianceGate} 暴露。
+ *
+ * <p>纯规则、无 DB 依赖、无副作用(除设置 {@link IndicatorReading#setAssociationOnly(boolean)}),
+ * 可单测。挂载点:{@code AttributionAnalysisServiceImpl.analyze()}(L4 出结论前 enforce +
+ * L5 出文档前 scan)。
+ *
+ * @author 家庭归因阶段二(ATT-S2-B1,G-1 低侧红线组)
+ */
+@Component
+public class AttributionComplianceGate {
+
+    /** 关联非因果节点(「偏低」归因节点,强制 association_only=true) */
+    public static final class LowSideNode {
+        private final Long memberId;
+        private final String memberName;
+        private final String indicator;
+        private final String value;
+        private final String percentile;
+
+        public LowSideNode(Long memberId, String memberName, String indicator, String value, String percentile) {
+            this.memberId = memberId;
+            this.memberName = memberName;
+            this.indicator = indicator;
+            this.value = value;
+            this.percentile = percentile;
+        }
+
+        public Long getMemberId() {
+            return memberId;
+        }
+
+        public String getMemberName() {
+            return memberName;
+        }
+
+        public String getIndicator() {
+            return indicator;
+        }
+
+        public String getValue() {
+            return value;
+        }
+
+        public String getPercentile() {
+            return percentile;
+        }
+
+        public boolean isAssociationOnly() {
+            return true; // 低侧红线组恒为「仅关联」
+        }
+    }
+
+    /** 单条合规违规 */
+    public static final class Violation {
+        private final String rule;
+        private final String message;
+        private final String snippet;
+        private final String suggestion;
+
+        public Violation(String rule, String message, String snippet, String suggestion) {
+            this.rule = rule;
+            this.message = message;
+            this.snippet = snippet;
+            this.suggestion = suggestion;
+        }
+
+        public String getRule() {
+            return rule;
+        }
+
+        public String getMessage() {
+            return message;
+        }
+
+        public String getSnippet() {
+            return snippet;
+        }
+
+        public String getSuggestion() {
+            return suggestion;
+        }
+    }
+
+    /** 门禁执行报告(低侧节点 + 违规清单) */
+    public static final class GateReport {
+        private final List<LowSideNode> nodes;
+        private final List<Violation> violations;
+
+        public GateReport(List<LowSideNode> nodes, List<Violation> violations) {
+            this.nodes = nodes == null ? Collections.emptyList() : nodes;
+            this.violations = violations == null ? Collections.emptyList() : violations;
+        }
+
+        public List<LowSideNode> getNodes() {
+            return nodes;
+        }
+
+        public List<Violation> getViolations() {
+            return violations;
+        }
+
+        public int associationOnlyCount() {
+            return nodes.size();
+        }
+
+        public boolean hasViolations() {
+            return !violations.isEmpty();
+        }
+    }
+
+    /**
+     * L4 出结论前 enforce:任意「偏低」归因节点强制 association_only=true,
+     * 并反向回扫该节点的干预含义文本(低侧机制禁因果)。
+     */
+    public GateReport enforceLowSide(List<MemberAttribution> attributions) {
+        List<LowSideNode> nodes = new ArrayList<>();
+        List<Violation> violations = new ArrayList<>();
+        if (attributions == null) {
+            return new GateReport(nodes, violations);
+        }
+        for (MemberAttribution a : attributions) {
+            if (a == null) {
+                continue;
+            }
+            for (IndicatorReading r : a.getReadings()) {
+                if (r == null || !IndicatorReading.STATUS_LOW.equals(r.getStatus())) {
+                    continue;
+                }
+                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()));
+            }
+        }
+        return new GateReport(nodes, violations);
+    }
+
+    /** L5 出文档前 scan:对任意叙述/结论文本反向回扫(缺X桥接 + 处方化) */
+    public List<Violation> scan(String text) {
+        if (text == null || text.trim().isEmpty()) {
+            return Collections.emptyList();
+        }
+        List<Violation> out = new ArrayList<>();
+        out.addAll(matchAll(text, AttributionLowSideRules.causalPatterns(),
+                AttributionLowSideRules.RULE_NO_CAUSAL_BRIDGE,
+                "禁写「缺 X→致病/需补充」因果桥接(低侧机制仅关联非因果)",
+                AttributionLowSideRules.WHITELIST_HINT));
+        out.addAll(matchAll(text, AttributionLowSideRules.prescriptionPatterns(),
+                AttributionLowSideRules.RULE_NO_PRESCRIPTION,
+                "禁把调整建议写成处方(剂量/用法用量/疗程)",
+                "剂量与用法用量由医生/专业定,方案只写「医生定」或「按产品说明」"));
+        return out;
+    }
+
+    /** 合并两份门禁报告(base 违规 + 追加违规,节点以 base 为准) */
+    public static GateReport merge(GateReport base, List<Violation> extra) {
+        List<Violation> all = new ArrayList<>();
+        if (base != null) {
+            all.addAll(base.getViolations());
+            if (extra == null) {
+                return new GateReport(base.getNodes(), all);
+            }
+        }
+        if (extra != null) {
+            all.addAll(extra);
+        }
+        return new GateReport(base == null ? Collections.emptyList() : base.getNodes(), all);
+    }
+
+    // ==================== 内部 ====================
+
+    private List<Violation> matchAll(String text, List<Pattern> patterns, String rule,
+                                     String message, String suggestion) {
+        List<Violation> out = new ArrayList<>();
+        for (Pattern p : patterns) {
+            Matcher m = p.matcher(text);
+            while (m.find()) {
+                out.add(new Violation(rule, message, snippet(text, m.start(), m.end()), suggestion));
+            }
+        }
+        return out;
+    }
+
+    private static String snippet(String text, int start, int end) {
+        int s = Math.max(0, start - 8);
+        int e = Math.min(text.length(), end + 8);
+        return text.substring(s, e).replace('\n', ' ').trim();
+    }
+
+    private static String label(IndicatorReading r) {
+        return r.getDisplayName() == null || r.getDisplayName().trim().isEmpty()
+                ? (r.getCode() == null ? "" : r.getCode()) : r.getDisplayName();
+    }
+}

+ 52 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/attribution/AttributionReportBuilder.java

@@ -1,6 +1,7 @@
 package com.etotem.cfc.service.attribution;
 package com.etotem.cfc.service.attribution;
 
 
 import com.etotem.cfc.constant.AttributionRedlines;
 import com.etotem.cfc.constant.AttributionRedlines;
+import com.etotem.cfc.constant.AttributionLowSideRules;
 import com.etotem.cfc.dto.AttributionInput;
 import com.etotem.cfc.dto.AttributionInput;
 import com.etotem.cfc.entity.AttributionReport;
 import com.etotem.cfc.entity.AttributionReport;
 import com.etotem.cfc.service.attribution.model.CauseLink;
 import com.etotem.cfc.service.attribution.model.CauseLink;
@@ -99,6 +100,13 @@ public class AttributionReportBuilder {
                                    List<FalsifiedItem> falsified, CompareResult compare,
                                    List<FalsifiedItem> falsified, CompareResult compare,
                                    Map<String, Object> narrative, List<String> redlineHits,
                                    Map<String, Object> narrative, List<String> redlineHits,
                                    AttributionInput input) {
                                    AttributionInput input) {
+        return buildReportJson(five, links, falsified, compare, narrative, redlineHits, input, null);
+    }
+
+    public String buildReportJson(FiveDimAttributionEngine.FiveDimResult five, List<CauseLink> links,
+                                   List<FalsifiedItem> falsified, CompareResult compare,
+                                   Map<String, Object> narrative, List<String> redlineHits,
+                                   AttributionInput input, AttributionComplianceGate.GateReport gate) {
         ObjectNode root = objectMapper.createObjectNode();
         ObjectNode root = objectMapper.createObjectNode();
         root.put("schemaVersion", "1.0");
         root.put("schemaVersion", "1.0");
         root.put("target", "family_attribution");
         root.put("target", "family_attribution");
@@ -143,6 +151,7 @@ public class AttributionReportBuilder {
                 row.put("status", r.getStatus());
                 row.put("status", r.getStatus());
                 row.put("normSource", r.getNormSource());
                 row.put("normSource", r.getNormSource());
                 row.put("meaning", r.getMeaning() == null ? "" : r.getMeaning());
                 row.put("meaning", r.getMeaning() == null ? "" : r.getMeaning());
+                row.put("associationOnly", r.isAssociationOnly());
             }
             }
         }
         }
 
 
@@ -262,6 +271,49 @@ public class AttributionReportBuilder {
             rn.put("scope", r.scope);
             rn.put("scope", r.scope);
             rn.put("hit", redlineHits.contains(r.code));
             rn.put("hit", redlineHits.contains(r.code));
         }
         }
+
+        // 合规门禁·低侧红线组 G-1(L4 出结论前 enforce + L5 出文档前 scan 结果快照)
+        ObjectNode gateNode = sections.putObject("complianceGate");
+        gateNode.put("group", AttributionLowSideRules.GROUP_CODE);
+        gateNode.put("title", AttributionLowSideRules.GROUP_TITLE);
+        gateNode.put("active", gate != null);
+        gateNode.put("associationOnly", gate != null && gate.associationOnlyCount() > 0);
+        ArrayNode whitelist = gateNode.putArray("wordingWhitelist");
+        for (String w : AttributionLowSideRules.WORDING_WHITELIST) {
+            whitelist.add(w);
+        }
+        ArrayNode lowSideNodes = gateNode.putArray("lowSideNodes");
+        ArrayNode gateViolations = gateNode.putArray("violations");
+        if (gate != null) {
+            for (AttributionComplianceGate.LowSideNode n : gate.getNodes()) {
+                ObjectNode node = lowSideNodes.addObject();
+                node.put("memberId", n.getMemberId());
+                node.put("memberName", n.getMemberName());
+                node.put("indicator", n.getIndicator());
+                node.put("value", n.getValue());
+                node.put("percentile", n.getPercentile());
+                node.put("associationOnly", n.isAssociationOnly());
+            }
+            for (AttributionComplianceGate.Violation v : gate.getViolations()) {
+                ObjectNode vn = gateViolations.addObject();
+                vn.put("rule", v.getRule());
+                vn.put("message", v.getMessage());
+                vn.put("snippet", v.getSnippet());
+                vn.put("suggestion", v.getSuggestion());
+            }
+        }
+        // 低侧红线组命中 → 报告红线清单追加 G-1 标记
+        if (gate != null && (gate.associationOnlyCount() > 0 || gate.hasViolations())
+                && redlineHits.contains(AttributionLowSideRules.GROUP_CODE)) {
+            ObjectNode g1 = redlines.addObject();
+            g1.put("code", AttributionLowSideRules.GROUP_CODE);
+            g1.put("level", "P0");
+            g1.put("title", AttributionLowSideRules.GROUP_TITLE);
+            g1.put("message", "任意「偏低」归因节点 association_only==true;禁写「缺 X→致病/需补充」;"
+                    + "禁把调整建议写成处方;措辞白名单「相关联/报告提示/可能与…有关」。");
+            g1.put("scope", "③④");
+            g1.put("hit", true);
+        }
         ObjectNode referral = sections.putObject("referral");
         ObjectNode referral = sections.putObject("referral");
         boolean rl02 = redlineHits.contains("RL02");
         boolean rl02 = redlineHits.contains("RL02");
         referral.put("suggested", rl02);
         referral.put("suggested", rl02);

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

@@ -73,6 +73,9 @@ public class IndicatorReading implements Serializable {
     /** 干预含义 */
     /** 干预含义 */
     private String meaning;
     private String meaning;
 
 
+    /** 是否仅关联(低侧机制禁因果;G-1 低侧红线组对任意「偏低」节点强制 true) */
+    private boolean associationOnly;
+
     public boolean isAbnormal() {
     public boolean isAbnormal() {
         return STATUS_LOW.equals(status) || STATUS_HIGH.equals(status);
         return STATUS_LOW.equals(status) || STATUS_HIGH.equals(status);
     }
     }

+ 35 - 1
cfc-backend/src/main/java/com/etotem/cfc/service/impl/AttributionAnalysisServiceImpl.java

@@ -21,7 +21,10 @@ import com.etotem.cfc.mapper.HealthGutFloraMapper;
 import com.etotem.cfc.mapper.HealthNormReferenceMapper;
 import com.etotem.cfc.mapper.HealthNormReferenceMapper;
 import com.etotem.cfc.mapper.HealthReportMapper;
 import com.etotem.cfc.mapper.HealthReportMapper;
 import com.etotem.cfc.mapper.SurveyRecordMapper;
 import com.etotem.cfc.mapper.SurveyRecordMapper;
+import com.etotem.cfc.constant.AttributionLowSideRules;
+import com.etotem.cfc.constant.AttributionRedlines;
 import com.etotem.cfc.service.AttributionAnalysisService;
 import com.etotem.cfc.service.AttributionAnalysisService;
+import com.etotem.cfc.service.attribution.AttributionComplianceGate;
 import com.etotem.cfc.service.attribution.AttributionNarrativeService;
 import com.etotem.cfc.service.attribution.AttributionNarrativeService;
 import com.etotem.cfc.service.attribution.AttributionReportBuilder;
 import com.etotem.cfc.service.attribution.AttributionReportBuilder;
 import com.etotem.cfc.service.attribution.FalsificationEngine;
 import com.etotem.cfc.service.attribution.FalsificationEngine;
@@ -119,6 +122,9 @@ public class AttributionAnalysisServiceImpl implements AttributionAnalysisServic
     @Resource
     @Resource
     private AttributionReportBuilder attributionReportBuilder;
     private AttributionReportBuilder attributionReportBuilder;
 
 
+    @Resource
+    private AttributionComplianceGate attributionComplianceGate;
+
     // ==================== 主入口 ====================
     // ==================== 主入口 ====================
 
 
     @Override
     @Override
@@ -137,12 +143,23 @@ public class AttributionAnalysisServiceImpl implements AttributionAnalysisServic
         List<CauseLink> links = mainCauseChainEngine.chain(five.attributions, five.familyMap);
         List<CauseLink> links = mainCauseChainEngine.chain(five.attributions, five.familyMap);
         List<FalsifiedItem> falsified = falsificationEngine.falsify(five.attributions, compare.getReadings(), flags);
         List<FalsifiedItem> falsified = falsificationEngine.falsify(five.attributions, compare.getReadings(), flags);
 
 
+        // G-1 低侧红线组(L4 出结论前 enforce):任意「偏低」归因节点强制 association_only=true
+        AttributionComplianceGate.GateReport gate = attributionComplianceGate.enforceLowSide(five.attributions);
+
         Map<String, Object> narrative = narrate(five, links, falsified, compare, input);
         Map<String, Object> narrative = narrate(five, links, falsified, compare, input);
 
 
+        // G-1 低侧红线组(L5 出文档前 scan):对 AI 叙述反向回扫(缺X桥接 / 处方化)
+        List<AttributionComplianceGate.Violation> narrativeHits =
+                attributionComplianceGate.scan(narrativeText(narrative));
+        AttributionComplianceGate.GateReport gateReport = AttributionComplianceGate.merge(gate, narrativeHits);
+
         List<String> redlineHits = attributionReportBuilder.computeRedlineHits(
         List<String> redlineHits = attributionReportBuilder.computeRedlineHits(
                 five, compare.getIntermediateState(), members);
                 five, compare.getIntermediateState(), members);
+        if (!gateReport.getNodes().isEmpty() || gateReport.hasViolations()) {
+            redlineHits.add(AttributionLowSideRules.GROUP_CODE);
+        }
         String reportJson = attributionReportBuilder.buildReportJson(
         String reportJson = attributionReportBuilder.buildReportJson(
-                five, links, falsified, compare, narrative, redlineHits, input);
+                five, links, falsified, compare, narrative, redlineHits, input, gateReport);
 
 
         AttributionReport report = new AttributionReport();
         AttributionReport report = new AttributionReport();
         report.setFamilyId(input.getFamilyId());
         report.setFamilyId(input.getFamilyId());
@@ -692,6 +709,23 @@ public class AttributionAnalysisServiceImpl implements AttributionAnalysisServic
         return sb.toString();
         return sb.toString();
     }
     }
 
 
+    /** 把 AI 叙述节点拼成一行供 L5 scan 回扫(conclusionHeadline + closingLine + coachQuestions) */
+    private String narrativeText(Map<String, Object> narrative) {
+        if (narrative == null) {
+            return "";
+        }
+        StringBuilder sb = new StringBuilder();
+        sb.append(str(narrative.get("conclusionHeadline"), ""));
+        sb.append('\n').append(str(narrative.get("closingLine"), ""));
+        Object coachObj = narrative.get("coachQuestions");
+        if (coachObj instanceof List) {
+            for (Object q : (List<?>) coachObj) {
+                sb.append('\n').append(String.valueOf(q));
+            }
+        }
+        return sb.toString();
+    }
+
     private static Double parseDouble(String s) {
     private static Double parseDouble(String s) {
         if (s == null) {
         if (s == null) {
             return null;
             return null;

+ 214 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/AttributionComplianceGateTest.java

@@ -0,0 +1,214 @@
+package com.etotem.cfc.unit;
+
+import com.etotem.cfc.constant.AttributionLowSideRules;
+import com.etotem.cfc.service.attribution.AttributionComplianceGate;
+import com.etotem.cfc.service.attribution.model.IndicatorReading;
+import com.etotem.cfc.service.attribution.model.MemberAttribution;
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * ATT-S2-B1 / G-1 低侧红线组:AttributionComplianceGate 纯规则门禁的单元测试(T5 P0)。
+ *
+ * <p>覆盖:
+ * <ul>
+ *   <li>association_only 强制于所有偏低节点、不触碰偏高/正常/待补;</li>
+ *   <li>禁写「缺 X→致病/需补充」桥接命中且给出白名单建议;</li>
+ *   <li>禁把调整建议写成处方(剂量/用法/疗程)命中与不误报;</li>
+ *   <li>措辞白名单常量完整。</li>
+ * </ul>
+ */
+class AttributionComplianceGateTest {
+
+    private final AttributionComplianceGate gate = new AttributionComplianceGate();
+
+    // ============================================================
+    // association_only 强制
+    // ============================================================
+    @Test
+    void 任意偏低归因节点都强制AssociationOnly() {
+        MemberAttribution a = buildMember(1L, "妈妈", Arrays.asList(
+                reading("血清铁", "偏低",  "8"),          // STATUS_LOW
+                reading("双歧杆菌", "偏低",  "2.1%"),      // STATUS_LOW
+                reading("罗伊氏乳杆菌", "偏高", "7.3%"),     // STATUS_HIGH
+                reading("维生素D",   "待补",  "18")          // STATUS_PENDING
+        ));
+
+        AttributionComplianceGate.GateReport report = gate.enforceLowSide(Collections.singletonList(a));
+
+        assertEquals(2, report.associationOnlyCount(), "应有 2 个偏低节点 associationOnly");
+        assertTrue(report.getNodes().stream().anyMatch(n -> n.getIndicator().contains("血清铁")));
+        assertTrue(report.getNodes().stream().anyMatch(n -> n.getIndicator().contains("双歧杆菌")));
+
+        for (IndicatorReading r : a.getReadings()) {
+            if ("偏低".equals(r.getStatus())) {
+                assertTrue(r.isAssociationOnly(), r.getDisplayName() + " 应被强制为 associationOnly=true");
+            } else {
+                assertFalse(r.isAssociationOnly(), r.getDisplayName() + " 不应被强制");
+            }
+        }
+    }
+
+    @Test
+    void 无偏低节点时EnforceLowSide返回空报告且不报错() {
+        MemberAttribution a = buildMember(1L, "爸爸", Collections.singletonList(
+                reading("血红蛋白", "正常", "140")
+        ));
+        AttributionComplianceGate.GateReport report = gate.enforceLowSide(Collections.singletonList(a));
+        assertTrue(report.getNodes().isEmpty());
+        assertTrue(report.getViolations().isEmpty());
+    }
+
+    // ============================================================
+    // 禁写「缺 X → 致病/需补充」桥接
+    // ============================================================
+    @Test
+    void 禁写缺X致病桥接_命中并出违规() {
+        String hit = "缺大肠杆菌→需补充该菌,否则易致肠炎感染";
+        List<AttributionComplianceGate.Violation> v = gate.scan(hit);
+        assertFalse(v.isEmpty(), "应命中禁写桥接禁令");
+        assertViolationsContainRule(v, AttributionLowSideRules.RULE_NO_CAUSAL_BRIDGE);
+        assertNotNull(v.get(0).getSnippet());
+    }
+
+    @Test
+    void 禁写缺X致病桥接_箭头用各种写法也命中() {
+        for (String src : Arrays.asList(
+                "菌群偏低导致肠屏障受损",
+                "不足可引起致病,引发感染"
+        )) {
+            List<AttributionComplianceGate.Violation> v = gate.scan(src);
+            assertFalse(v.isEmpty(), "应命中 '" + src + "'");
+            assertViolationsContainRule(v, AttributionLowSideRules.RULE_NO_CAUSAL_BRIDGE);
+        }
+    }
+
+    @Test
+    void 禁写缺X致病桥接_关联措辞不误报() {
+        // 措辞白名单允许的表述
+        for (String safe : Arrays.asList(
+                "与肠道环境相关联",
+                "报告提示可能与饮食结构有关",
+                "可能与压力负荷有关",
+                "偏低与慢性炎症相关(仅描述关联)",
+                "报告提示营养状态与菌群多样性相关"
+        )) {
+            List<AttributionComplianceGate.Violation> v = gate.scan(safe);
+            assertTrue(v.isEmpty(), "误报: " + safe);
+        }
+    }
+
+    // ============================================================
+    // 禁把调整建议写成处方
+    // ============================================================
+    @Test
+    void 禁把调整建议写成处方_命中() {
+        String hit = "每日 5mg 补充维生素D3,连服 7 天;剂量 200IU/次";
+        List<AttributionComplianceGate.Violation> v = gate.scan(hit);
+        assertFalse(v.isEmpty(), "应命中禁处方禁令");
+        assertViolationsContainRule(v, AttributionLowSideRules.RULE_NO_PRESCRIPTION);
+    }
+
+    @Test
+    void 禁把调整建议写成处方_不误报儿童医生定() {
+        // 计划侧常见的「儿童剂量由医生定」「按产品说明」等不应触发
+        for (String safe : Arrays.asList(
+                "儿童剂量由医生定",
+                "成人 5g/天起,儿童遵医嘱",
+                "按产品说明服用",
+                "不与抗生素同服",
+                "益生菌按说明使用"
+        )) {
+            List<AttributionComplianceGate.Violation> v = gate.scan(safe);
+            assertTrue(v.isEmpty(), "误报: " + safe);
+        }
+    }
+
+    // ============================================================
+    // 白名单与常量
+    // ============================================================
+    @Test
+    void 措辞白名单非空且含规定三项() {
+        List<String> list = AttributionLowSideRules.WORDING_WHITELIST;
+        assertFalse(list.isEmpty());
+        assertTrue(list.contains("相关联"), "白名单须含「相关联」");
+        assertTrue(list.contains("报告提示"), "白名单须含「报告提示」");
+        assertTrue(list.contains("可能与…有关"), "白名单须含「可能与…有关」");
+    }
+
+    @Test
+    void 分组码与规则码常量存在() {
+        assertEquals("G-1", AttributionLowSideRules.GROUP_CODE);
+        assertEquals("association_only", AttributionLowSideRules.RULE_ASSOCIATION_ONLY);
+        assertEquals("no_causal_bridge", AttributionLowSideRules.RULE_NO_CAUSAL_BRIDGE);
+        assertEquals("no_prescription", AttributionLowSideRules.RULE_NO_PRESCRIPTION);
+    }
+
+    // ============================================================
+    // merge 语义
+    // ============================================================
+    @Test
+    void merge_追加违规保留基础节点() {
+        MemberAttribution base = buildMember(1L, "女儿", Collections.singletonList(reading("情绪商数", "偏低", "35")));
+        AttributionComplianceGate.GateReport baseReport = gate.enforceLowSide(Collections.singletonList(base));
+        List<AttributionComplianceGate.Violation> extra = gate.scan("缺锌导致缺钙致病");
+        AttributionComplianceGate.GateReport merged = AttributionComplianceGate.merge(baseReport, extra);
+
+        assertEquals(baseReport.getNodes().size(), merged.getNodes().size(), "merge 应保留基础节点");
+        assertEquals(baseReport.associationOnlyCount(), merged.associationOnlyCount(), "merge 关联计数一致");
+        assertEquals(baseReport.getViolations().size() + extra.size(),
+                merged.getViolations().size(), "merge 应追加违规");
+    }
+
+    @Test
+    void merge_baseNull_extraNonNull_只取extra() {
+        List<AttributionComplianceGate.Violation> extra = gate.scan("每日100mg维C");
+        AttributionComplianceGate.GateReport merged = AttributionComplianceGate.merge(null, extra);
+        assertNotNull(merged);
+        assertTrue(merged.getNodes().isEmpty());
+        assertFalse(merged.getViolations().isEmpty());
+    }
+
+    // ---- helpers ----
+
+    private IndicatorReading reading(String display, String status, String value) {
+        IndicatorReading r = new IndicatorReading();
+        r.setCode(display);
+        r.setDisplayName(display);
+        r.setValue(value);
+        r.setStatus(status);
+        return r;
+    }
+
+    private MemberAttribution buildMember(Long memberId, String name, List<IndicatorReading> readings) {
+        MemberAttribution a = new MemberAttribution();
+        a.setMemberId(memberId);
+        a.setName(name);
+        a.setRole("mom");
+        a.setRoleLabel("妈妈");
+        a.setReadings(new ArrayList<>(readings));
+        a.setDimensionEvidence(new java.util.LinkedHashMap<>());
+        a.setFiveDim(new java.util.LinkedHashMap<>());
+        a.setWeakDimensions(new ArrayList<>());
+        a.setActions(new ArrayList<>());
+        for (IndicatorReading r : readings) {
+            if (r.isAbnormal()) a.setAbnormalCount(a.getAbnormalCount() + 1);
+            if (r.isAbnormal() && "偏高".equals(r.getStatus()) && (r.getCode() + " " + r.getDisplayName()).toLowerCase().contains("reuteri")) {
+                r.setRetractable(true);
+                a.setRetractableCount(a.getRetractableCount() + 1);
+            }
+        }
+        return a;
+    }
+
+    private static void assertViolationsContainRule(List<AttributionComplianceGate.Violation> vs, String rule) {
+        assertTrue(vs.stream().anyMatch(v -> rule.equals(v.getRule())),
+                "违规列表缺少规则码「" + rule + "」:" + vs);
+    }
+}

+ 46 - 11
cfc-backend/src/test/java/com/etotem/cfc/unit/AttributionPipelineMockTest.java

@@ -1,6 +1,7 @@
 package com.etotem.cfc.unit;
 package com.etotem.cfc.unit;
 
 
 import com.etotem.cfc.constant.AttributionRedlines;
 import com.etotem.cfc.constant.AttributionRedlines;
+import com.etotem.cfc.constant.AttributionLowSideRules;
 import com.etotem.cfc.dto.AttributionInput;
 import com.etotem.cfc.dto.AttributionInput;
 import com.etotem.cfc.dto.AttributionReportVO;
 import com.etotem.cfc.dto.AttributionReportVO;
 import com.etotem.cfc.entity.AttributionReport;
 import com.etotem.cfc.entity.AttributionReport;
@@ -19,6 +20,7 @@ import com.etotem.cfc.mapper.HealthNormReferenceMapper;
 import com.etotem.cfc.mapper.HealthReportMapper;
 import com.etotem.cfc.mapper.HealthReportMapper;
 import com.etotem.cfc.mapper.SurveyRecordMapper;
 import com.etotem.cfc.mapper.SurveyRecordMapper;
 import com.etotem.cfc.service.AttributionAnalysisService;
 import com.etotem.cfc.service.AttributionAnalysisService;
+import com.etotem.cfc.service.attribution.AttributionComplianceGate;
 import com.etotem.cfc.service.attribution.AttributionNarrativeService;
 import com.etotem.cfc.service.attribution.AttributionNarrativeService;
 import com.etotem.cfc.service.attribution.AttributionReportBuilder;
 import com.etotem.cfc.service.attribution.AttributionReportBuilder;
 import com.etotem.cfc.service.attribution.FalsificationEngine;
 import com.etotem.cfc.service.attribution.FalsificationEngine;
@@ -129,6 +131,7 @@ class AttributionPipelineMockTest {
         setField(svc, "falsificationEngine", falsificationEngine);
         setField(svc, "falsificationEngine", falsificationEngine);
         setField(svc, "attributionNarrativeService", narrativeService);
         setField(svc, "attributionNarrativeService", narrativeService);
         setField(svc, "attributionReportBuilder", reportBuilder);
         setField(svc, "attributionReportBuilder", reportBuilder);
+        setField(svc, "attributionComplianceGate", new AttributionComplianceGate());
         service = svc;
         service = svc;
     }
     }
 
 
@@ -204,12 +207,19 @@ class AttributionPipelineMockTest {
 
 
         // --- 5) 被证伪项 ---
         // --- 5) 被证伪项 ---
         List<FalsifiedItem> falsified = falsificationEngine.falsify(five.attributions, compare.getReadings(), flags);
         List<FalsifiedItem> falsified = falsificationEngine.falsify(five.attributions, compare.getReadings(), flags);
-        // 应含“补充剂印记类异常”
+        // 应含"补充剂印记类异常"
         assertTrue(falsified.stream().anyMatch(f -> f.getItem().contains("补充剂印记")), "有补充剂印记被证伪项");
         assertTrue(falsified.stream().anyMatch(f -> f.getItem().contains("补充剂印记")), "有补充剂印记被证伪项");
-        // 应含“常模缺失项归因无效”
+        // 应含"常模缺失项归因无效"
         assertTrue(falsified.stream().anyMatch(f -> f.getItem().contains("常模缺失")), "有常模缺失被证伪项");
         assertTrue(falsified.stream().anyMatch(f -> f.getItem().contains("常模缺失")), "有常模缺失被证伪项");
 
 
-        // --- 6) AI 叙述(规则兜底,AI 关闭)---
+        // --- 6) G-1 低侧红线组(L4 出结论前 enforce):任意「偏低」归因节点 association_only=true
+        AttributionComplianceGate.GateReport gate = new AttributionComplianceGate().enforceLowSide(five.attributions);
+        assertFalse(gate.getNodes().isEmpty(), "至少应有 1 个偏低节点 associationOnly=true");
+        for (AttributionComplianceGate.LowSideNode n : gate.getNodes()) {
+            assertTrue(n.isAssociationOnly(), n.getIndicator() + " associationOnly 应强制为 true");
+        }
+
+        // --- 7) L5 出文档前 scan(对 AI 叙述反向回扫)
         Map<String, Object> narrativeCtx = new LinkedHashMap<>();
         Map<String, Object> narrativeCtx = new LinkedHashMap<>();
         narrativeCtx.put("attributions", toAttributionView(five.attributions));
         narrativeCtx.put("attributions", toAttributionView(five.attributions));
         narrativeCtx.put("familyMap", five.familyMap);
         narrativeCtx.put("familyMap", five.familyMap);
@@ -221,15 +231,21 @@ class AttributionPipelineMockTest {
         assertNotNull(narrative.get("conclusionHeadline"));
         assertNotNull(narrative.get("conclusionHeadline"));
         assertNotNull(narrative.get("coachQuestions"));
         assertNotNull(narrative.get("coachQuestions"));
         assertNotNull(narrative.get("closingLine"));
         assertNotNull(narrative.get("closingLine"));
+        // L5 scan
+        String narText = narrative.get("conclusionHeadline") + "\n" + narrative.get("closingLine");
+        List<AttributionComplianceGate.Violation> narHits = new AttributionComplianceGate().scan(narText);
+        AttributionComplianceGate.GateReport gateReport = AttributionComplianceGate.merge(gate, narHits);
 
 
-        // --- 7) 报告 JSON 构建(十节)---
+        // --- 8) 报告 JSON 构建(十节 + G-1 complianceGate 段)
         AttributionInput input = new AttributionInput();
         AttributionInput input = new AttributionInput();
         input.setFamilyId(1L);
         input.setFamilyId(1L);
         input.setMemberIds(Arrays.asList(1L, 2L, 3L, 4L));
         input.setMemberIds(Arrays.asList(1L, 2L, 3L, 4L));
+        List<String> redlineHits = reportBuilder.computeRedlineHits(five, compare.getIntermediateState(), members);
+        if (!gateReport.getNodes().isEmpty() || gateReport.hasViolations()) {
+            redlineHits.add(AttributionLowSideRules.GROUP_CODE);
+        }
         String reportJson = reportBuilder.buildReportJson(
         String reportJson = reportBuilder.buildReportJson(
-                five, links, falsified, compare, narrative,
-                reportBuilder.computeRedlineHits(five, compare.getIntermediateState(), members),
-                input);
+                five, links, falsified, compare, narrative, redlineHits, input, gateReport);
 
 
         // 验收:十节结构
         // 验收:十节结构
         JsonNode root = objectMapper.readTree(reportJson);
         JsonNode root = objectMapper.readTree(reportJson);
@@ -245,8 +261,16 @@ class AttributionPipelineMockTest {
         // 1 结论先行
         // 1 结论先行
         assertNotNull(sections.get("conclusion"));
         assertNotNull(sections.get("conclusion"));
         assertNotNull(sections.get("conclusion").path("headline").asText());
         assertNotNull(sections.get("conclusion").path("headline").asText());
-        // 2 数据速览
+        // 2 数据速览(含 associationOnly 字段)
         assertTrue(sections.get("overview").isArray());
         assertTrue(sections.get("overview").isArray());
+        boolean hasAssocField = false;
+        for (JsonNode row : sections.get("overview")) {
+            if (row.has("associationOnly")) {
+                hasAssocField = true;
+                break;
+            }
+        }
+        assertTrue(hasAssocField, "overview 行须携带 associationOnly 字段");
         // 3 逐人归因
         // 3 逐人归因
         JsonNode perMember = sections.get("perMember");
         JsonNode perMember = sections.get("perMember");
         assertEquals(4, perMember.size());
         assertEquals(4, perMember.size());
@@ -263,10 +287,9 @@ class AttributionPipelineMockTest {
         assertTrue(sections.get("coachQuestions").isArray());
         assertTrue(sections.get("coachQuestions").isArray());
         // 8 问诊清单
         // 8 问诊清单
         assertNotNull(sections.get("questionnaire"));
         assertNotNull(sections.get("questionnaire"));
-        // 9 红线与转介
+        // 9 红线与转介(RL01-RL08 + G-1 可能追加)
         JsonNode redlines = sections.get("redlines");
         JsonNode redlines = sections.get("redlines");
         assertTrue(redlines.isArray());
         assertTrue(redlines.isArray());
-        // RL01-RL08 共 8 条
         Set<String> codes = new LinkedHashSet<>();
         Set<String> codes = new LinkedHashSet<>();
         for (JsonNode r : redlines) {
         for (JsonNode r : redlines) {
             codes.add(r.path("code").asText());
             codes.add(r.path("code").asText());
@@ -274,6 +297,7 @@ class AttributionPipelineMockTest {
         for (String code : new String[]{"RL01","RL02","RL03","RL04","RL05","RL06","RL07","RL08"}) {
         for (String code : new String[]{"RL01","RL02","RL03","RL04","RL05","RL06","RL07","RL08"}) {
             assertTrue(codes.contains(code), "红线缺失: " + code);
             assertTrue(codes.contains(code), "红线缺失: " + code);
         }
         }
+        assertTrue(codes.contains(AttributionLowSideRules.GROUP_CODE), "命中节点时应追加 G-1 红线");
         // RL02 hit(女儿 心 短板)→ 命中
         // RL02 hit(女儿 心 短板)→ 命中
         boolean rl02hit = false;
         boolean rl02hit = false;
         for (JsonNode r : redlines) {
         for (JsonNode r : redlines) {
@@ -299,7 +323,18 @@ class AttributionPipelineMockTest {
         // 状态 READY
         // 状态 READY
         assertEquals(AttributionReport.STATUS_READY, root.path("status").asText());
         assertEquals(AttributionReport.STATUS_READY, root.path("status").asText());
 
 
-        System.out.println("✅ B1 验收通过:十节结构 + 中间态 5 键 + RL01/RL02 命中 + status=READY");
+        // ---- G-1 合规门禁段落地校验 ----
+        JsonNode cg = sections.get("complianceGate");
+        assertNotNull(cg, "sections.complianceGate 缺失");
+        assertEquals(AttributionLowSideRules.GROUP_CODE, cg.path("group").asText());
+        assertEquals(true, cg.path("active").asBoolean());
+        assertEquals(true, cg.path("associationOnly").asBoolean());
+        assertEquals(gate.getNodes().size(), cg.get("lowSideNodes").size(), "G-1 节点数应与 enforce 一致");
+        for (JsonNode node : cg.get("lowSideNodes")) {
+            assertTrue(node.path("associationOnly").asBoolean(), "G-1 节点必须关联非因果");
+        }
+
+        System.out.println("✅ B1 验收通过:十节结构 + 中间态 5 键 + RL01/RL02/G-1 命中 + status=READY");
     }
     }
 
 
     // ============================================================
     // ============================================================