| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312 |
- package com.etotem.cfc.unit;
- import com.etotem.cfc.constant.AttributionLowSideRules;
- import com.etotem.cfc.service.attribution.AttributionComplianceGate;
- import com.etotem.cfc.service.attribution.AttributionReportBuilder;
- import com.etotem.cfc.service.attribution.model.IndicatorReading;
- import com.etotem.cfc.service.attribution.model.MemberAttribution;
- import org.junit.jupiter.api.DisplayName;
- import org.junit.jupiter.api.Test;
- import java.lang.reflect.Field;
- import java.lang.reflect.Method;
- import java.util.ArrayList;
- import java.util.Arrays;
- import java.util.Collections;
- import java.util.List;
- import static org.junit.jupiter.api.Assertions.assertEquals;
- import static org.junit.jupiter.api.Assertions.assertFalse;
- import static org.junit.jupiter.api.Assertions.assertNotNull;
- import static org.junit.jupiter.api.Assertions.assertTrue;
- /**
- * 契约级单测:谷禾 v2 低侧红线组(能力键 {@code guhe.gate.lowSideRedline},T5/G-1,P0)。
- *
- * <p>契约来源(2026-11-08《浠艾福平台程序修改计划(guhe_v2 兼容 · 知识库低侧口径 · 专家同步)》§三任务分解 T5):
- * <blockquote>
- * T5 低侧红线组(G-1):新增合规规则——{@code association_only == true};禁写「缺 X→致病/需补充」;
- * 禁把调整建议写成处方;措辞白名单「相关联/报告提示/可能与…有关」。
- * <br>验收:任意「偏低」归因节点 {@code association_only==true};存量无「缺大肠杆菌→补菌」措辞;
- * 低侧机制证据全部标 PUB/KB(该子项归 T11 证据码隔离 G-3,本节不另断言)。</blockquote></p>
- *
- * <p><b>契约语义</b>:本能力键是「合规门禁(横切)」的低侧红线组——在 L4 出结论前对任意
- * 「偏低」归因节点强制 {@code association_only == true}(低侧机制禁因果),并在 L5 出文档前
- * 反向回扫「缺 X→致病/需补充」桥接与处方化建议,命中即收集为合规违规(不阻断,E4 弱提示),
- * 由报告侧 {@code sections.complianceGate} 暴露。挂载点(C 档待确认项 #4)经自主探测落于
- * {@link AttributionAnalysisServiceImpl#analyze}(L4 enforce + L5 scan)。</p>
- *
- * <p><b>实现现状(worktree src/main 探测,2026-11-09)</b>:T5/G-1 门禁已落地
- * ({@code AttributionLowSideRules} / {@code AttributionComplianceGate} / 报告
- * {@code sections.complianceGate} / {@link IndicatorReading#associationOnly} 已并入主链
- * commit a13bb8192)。本契约测试据此断言契约行为;若实测断言全过(绿),按任务纪律注明
- * 「实现已存在」并交编排侧转测试登记,不私改实现。</p>
- *
- * <p><b>GREEN 升级指引(若未来实现回退/缺失)</b>:在 {@code AttributionLowSideRules} 保留
- * {@code GROUP_CODE="G-1"} 与三规则码 + 措辞白名单;{@code AttributionComplianceGate} 提供
- * {@code enforceLowSide(attributions)}(偏低强制 association_only)与 {@code scan(text)}
- * (缺X桥接/处方回扫);{@code AttributionReportBuilder} 暴露 {@code sections.complianceGate}
- * 后本测试自动转绿。</p>
- *
- * <p><b>权限流说明</b>:本能力为后端内部合规门禁(L4 出结论前 / L5 出文档前),无用户权限
- * 维度,权限流不适用,以说明性用例标注。</p>
- */
- class GuheGateLowSideRedlineContractTest {
- private final AttributionComplianceGate gate = new AttributionComplianceGate();
- // ------------------------------------------------------------------
- // 正常流:规则层常量(G-1 分组 + 三规则码 + 措辞白名单)
- // ------------------------------------------------------------------
- @Test
- @DisplayName("正常流-1:AttributionLowSideRules 应暴露 G-1 分组码与三规则码")
- void lowSideRules_exposesGroupAndRuleCodes() {
- assertEquals("G-1", AttributionLowSideRules.GROUP_CODE,
- "[guhe.gate.lowSideRedline] 低侧红线组分组码应为 G-1(§三任务分解 T5)。");
- assertNotNull(AttributionLowSideRules.GROUP_TITLE,
- "[guhe.gate.lowSideRedline] GROUP_TITLE 不应为 null。");
- assertEquals("association_only", AttributionLowSideRules.RULE_ASSOCIATION_ONLY,
- "[guhe.gate.lowSideRedline] 规则码 association_only(偏低节点禁因果)。");
- assertEquals("no_causal_bridge", AttributionLowSideRules.RULE_NO_CAUSAL_BRIDGE,
- "[guhe.gate.lowSideRedline] 规则码 no_causal_bridge(禁缺X→致病/需补充)。");
- assertEquals("no_prescription", AttributionLowSideRules.RULE_NO_PRESCRIPTION,
- "[guhe.gate.lowSideRedline] 规则码 no_prescription(禁处方化建议)。");
- }
- @Test
- @DisplayName("正常流-2:措辞白名单应含「相关联/报告提示/可能与…有关」")
- void lowSideRules_wordingWhitelistCoversThreePhrases() {
- List<String> whitelist = AttributionLowSideRules.WORDING_WHITELIST;
- assertNotNull(whitelist, "[guhe.gate.lowSideRedline] WORDING_WHITELIST 不应为 null。");
- assertFalse(whitelist.isEmpty(), "[guhe.gate.lowSideRedline] 措辞白名单不能为空。");
- assertTrue(whitelist.contains("相关联"), "白名单须含「相关联」");
- assertTrue(whitelist.contains("报告提示"), "白名单须含「报告提示」");
- assertTrue(whitelist.contains("可能与…有关"), "白名单须含「可能与…有关」");
- }
- // ------------------------------------------------------------------
- // 正常流:enforceLowSide 对任意「偏低」归因节点强制 association_only
- // ------------------------------------------------------------------
- @Test
- @DisplayName("正常流-3:任意偏低归因节点强制 association_only=true(偏高/正常/待补不强制)")
- void enforceLowSide_forcesAssociationOnlyOnEveryLowNode() {
- MemberAttribution a = buildMember(1L, "妈妈", Arrays.asList(
- reading("大肠杆菌", IndicatorReading.STATUS_LOW, "0.5%", "P3"),
- reading("双歧杆菌", IndicatorReading.STATUS_LOW, "2.1%", "P10"),
- reading("罗伊氏乳杆菌", IndicatorReading.STATUS_HIGH, "7.3%", "P90"),
- reading("维生素D", IndicatorReading.STATUS_NORMAL, "45", null),
- reading("血清铁", IndicatorReading.STATUS_PENDING, "8", null)
- ));
- AttributionComplianceGate.GateReport report =
- gate.enforceLowSide(Collections.singletonList(a));
- assertEquals(2, report.associationOnlyCount(),
- "[guhe.gate.lowSideRedline] 2 个偏低节点应全部强制 associationOnly(验收:任意偏低节点)。");
- assertTrue(report.getNodes().stream().anyMatch(n -> n.getIndicator().contains("大肠杆菌")));
- assertTrue(report.getNodes().stream().anyMatch(n -> n.getIndicator().contains("双歧杆菌")));
- for (AttributionComplianceGate.LowSideNode n : report.getNodes()) {
- assertTrue(n.isAssociationOnly(),
- "[guhe.gate.lowSideRedline] 低侧节点 " + n.getIndicator() + " 应恒为仅关联(associationOnly)。");
- }
- for (IndicatorReading r : a.getReadings()) {
- if (IndicatorReading.STATUS_LOW.equals(r.getStatus())) {
- assertTrue(r.isAssociationOnly(),
- r.getDisplayName() + " 应被强制 associationOnly=true");
- } else {
- assertFalse(r.isAssociationOnly(),
- r.getDisplayName() + " 不应被强制 associationOnly");
- }
- }
- }
- @Test
- @DisplayName("正常流-4:无偏低节点时 enforceLowSide 返回空报告且不误报")
- void enforceLowSide_noLowNode_emptyReport() {
- MemberAttribution a = buildMember(1L, "爸爸", Collections.singletonList(
- reading("血红蛋白", IndicatorReading.STATUS_NORMAL, "140", null)
- ));
- AttributionComplianceGate.GateReport report =
- gate.enforceLowSide(Collections.singletonList(a));
- assertTrue(report.getNodes().isEmpty(), "[guhe.gate.lowSideRedline] 无偏低节点时不应产出低侧节点。");
- assertTrue(report.getViolations().isEmpty(), "[guhe.gate.lowSideRedline] 无偏低节点时不应有违规。");
- }
- // ------------------------------------------------------------------
- // 正常流:scan 回扫「缺 X→致病/需补充」桥接(no_causal_bridge)
- // ------------------------------------------------------------------
- @Test
- @DisplayName("正常流-5:scan 命中「缺 X→致病/需补充」因果桥接 → no_causal_bridge 违规")
- void scan_causalBridge_hitsNoCausalBridge() {
- List<AttributionComplianceGate.Violation> vs = gate.scan(
- "缺大肠杆菌→需补充该菌,否则易致肠炎感染");
- assertFalse(vs.isEmpty(), "[guhe.gate.lowSideRedline] 缺X→需补充桥接应命中(T5 禁写)。");
- assertViolationsContainRule(vs, AttributionLowSideRules.RULE_NO_CAUSAL_BRIDGE);
- assertNotNull(vs.get(0).getSnippet(), "[guhe.gate.lowSideRedline] 违规应带命中片段。");
- assertNotNull(vs.get(0).getSuggestion(), "[guhe.gate.lowSideRedline] 违规应带白名单改写建议。");
- }
- @Test
- @DisplayName("正常流-6:scan 命中「缺 X 补 X」产品桥接(报告缺X→买含X菌株产品)")
- void scan_causalBridge_hitsSupplementProductBridge() {
- List<AttributionComplianceGate.Violation> vs = gate.scan(
- "报告缺大肠杆菌→建议购买含大肠杆菌菌株的产品");
- assertFalse(vs.isEmpty(),
- "[guhe.gate.lowSideRedline] 报告缺X→买含X菌株产品桥接应命中(叠加 §5.2 16S 粒度声明)。");
- assertViolationsContainRule(vs, AttributionLowSideRules.RULE_NO_CAUSAL_BRIDGE);
- }
- // ------------------------------------------------------------------
- // 正常流:scan 回扫处方化建议(no_prescription)
- // ------------------------------------------------------------------
- @Test
- @DisplayName("正常流-7:scan 命中处方化建议(剂量/用法用量/疗程)→ no_prescription 违规")
- void scan_prescription_hitsNoPrescription() {
- List<AttributionComplianceGate.Violation> vs = gate.scan(
- "建议每日 5mg 补充维生素D3,连服 7 天,剂量 200IU/天");
- assertFalse(vs.isEmpty(), "[guhe.gate.lowSideRedline] 处方化建议应命中(T5 禁把调整建议写成处方)。");
- assertViolationsContainRule(vs, AttributionLowSideRules.RULE_NO_PRESCRIPTION);
- }
- @Test
- @DisplayName("正常流-8:白名单关联措辞不误报(scan 无违规)")
- void scan_whitelistWording_noFalsePositive() {
- for (String safe : Arrays.asList(
- "与肠道环境相关联",
- "报告提示可能与饮食结构有关",
- "可能与压力负荷有关",
- "偏低与慢性炎症相关(仅描述关联)",
- "报告提示营养状态与菌群多样性相关",
- "儿童剂量由医生定",
- "按产品说明服用"
- )) {
- List<AttributionComplianceGate.Violation> vs = gate.scan(safe);
- assertTrue(vs.isEmpty(), "[guhe.gate.lowSideRedline] 白名单措辞不应误报:" + safe);
- }
- }
- // ------------------------------------------------------------------
- // 边界流:merge 语义(base 节点保留 + 追加违规)
- // ------------------------------------------------------------------
- @Test
- @DisplayName("边界流-1:merge 追加违规并保留基础低侧节点")
- void merge_appendsViolationsKeepsBaseNodes() {
- MemberAttribution base = buildMember(1L, "女儿", Collections.singletonList(
- reading("大肠杆菌", IndicatorReading.STATUS_LOW, "0.3%", "P1")
- ));
- 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(),
- "[guhe.gate.lowSideRedline] merge 应保留基础低侧节点。");
- assertEquals(baseReport.associationOnlyCount(), merged.associationOnlyCount(),
- "[guhe.gate.lowSideRedline] merge 后 association_only 计数不变。");
- assertEquals(baseReport.getViolations().size() + extra.size(), merged.getViolations().size(),
- "[guhe.gate.lowSideRedline] merge 应追加违规。");
- }
- // ------------------------------------------------------------------
- // 边界流:门禁挂载点(L4 出结论前 enforce + L5 出文档前 scan)
- // ------------------------------------------------------------------
- @Test
- @DisplayName("边界流-2:AttributionAnalysisServiceImpl 应挂载 AttributionComplianceGate")
- void analysisService_mountsComplianceGate() {
- Class<?> svc = loadClass("com.etotem.cfc.service.impl.AttributionAnalysisServiceImpl");
- assertNotNull(svc, "[guhe.gate.lowSideRedline] AttributionAnalysisServiceImpl 类应存在。");
- boolean wired = false;
- for (Field f : svc.getDeclaredFields()) {
- if (AttributionComplianceGate.class.getName().equals(f.getType().getName())) {
- wired = true;
- break;
- }
- }
- assertTrue(wired,
- "[guhe.gate.lowSideRedline] AttributionAnalysisServiceImpl 应注入 AttributionComplianceGate "
- + "(L4 出结论前 enforce + L5 出文档前 scan,C 档挂载点经探测落此)。");
- }
- @Test
- @DisplayName("边界流-3:AttributionReportBuilder 应暴露带 GateReport 的 buildReportJson 重载(sections.complianceGate)")
- void reportBuilder_exposesGateReportOverload() {
- boolean hasOverload = false;
- for (Method m : AttributionReportBuilder.class.getDeclaredMethods()) {
- if (!"buildReportJson".equals(m.getName())) {
- continue;
- }
- Class<?>[] params = m.getParameterTypes();
- if (params.length >= 8
- && AttributionComplianceGate.GateReport.class == params[params.length - 1]) {
- hasOverload = true;
- break;
- }
- }
- assertTrue(hasOverload,
- "[guhe.gate.lowSideRedline] AttributionReportBuilder 应提供带 "
- + "AttributionComplianceGate.GateReport 的 buildReportJson 重载,"
- + "供报告侧暴露 sections.complianceGate(低侧节点/违规/白名单)。");
- }
- // ------------------------------------------------------------------
- // 权限流(不适用):后端内部合规门禁,无用户权限维度
- // ------------------------------------------------------------------
- @Test
- @DisplayName("权限流:本能力为后端内部合规门禁,无用户权限维度,权限流不适用")
- void permissionFlow_notApplicable() {
- // 说明性用例:契约 AC 为「L4 出结论前 / L5 出文档前」内部规则,无权限校验维度。
- assertTrue(true, "权限流不适用:guhe.gate.lowSideRedline 为后端内部合规门禁,无用户权限维度。");
- }
- // ------------------------------------------------------------------
- // 反射与数据构造辅助
- // ------------------------------------------------------------------
- private static void assertViolationsContainRule(List<AttributionComplianceGate.Violation> vs, String rule) {
- assertTrue(vs.stream().anyMatch(v -> rule.equals(v.getRule())),
- "[guhe.gate.lowSideRedline] 违规列表缺少规则码「" + rule + "」:" + vs);
- }
- private static Class<?> loadClass(String name) {
- try {
- return Class.forName(name, false, Thread.currentThread().getContextClassLoader());
- } catch (ClassNotFoundException e) {
- return null;
- }
- }
- private static IndicatorReading reading(String display, String status, String value, String percentile) {
- IndicatorReading r = new IndicatorReading();
- r.setCode(display);
- r.setDisplayName(display);
- r.setValue(value);
- r.setPercentile(percentile);
- r.setStatus(status);
- return r;
- }
- private static 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);
- }
- }
- return a;
- }
- }
|