GuheGateLowSideRedlineContractTest.java 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312
  1. package com.etotem.cfc.unit;
  2. import com.etotem.cfc.constant.AttributionLowSideRules;
  3. import com.etotem.cfc.service.attribution.AttributionComplianceGate;
  4. import com.etotem.cfc.service.attribution.AttributionReportBuilder;
  5. import com.etotem.cfc.service.attribution.model.IndicatorReading;
  6. import com.etotem.cfc.service.attribution.model.MemberAttribution;
  7. import org.junit.jupiter.api.DisplayName;
  8. import org.junit.jupiter.api.Test;
  9. import java.lang.reflect.Field;
  10. import java.lang.reflect.Method;
  11. import java.util.ArrayList;
  12. import java.util.Arrays;
  13. import java.util.Collections;
  14. import java.util.List;
  15. import static org.junit.jupiter.api.Assertions.assertEquals;
  16. import static org.junit.jupiter.api.Assertions.assertFalse;
  17. import static org.junit.jupiter.api.Assertions.assertNotNull;
  18. import static org.junit.jupiter.api.Assertions.assertTrue;
  19. /**
  20. * 契约级单测:谷禾 v2 低侧红线组(能力键 {@code guhe.gate.lowSideRedline},T5/G-1,P0)。
  21. *
  22. * <p>契约来源(2026-11-08《浠艾福平台程序修改计划(guhe_v2 兼容 · 知识库低侧口径 · 专家同步)》§三任务分解 T5):
  23. * <blockquote>
  24. * T5 低侧红线组(G-1):新增合规规则——{@code association_only == true};禁写「缺 X→致病/需补充」;
  25. * 禁把调整建议写成处方;措辞白名单「相关联/报告提示/可能与…有关」。
  26. * <br>验收:任意「偏低」归因节点 {@code association_only==true};存量无「缺大肠杆菌→补菌」措辞;
  27. * 低侧机制证据全部标 PUB/KB(该子项归 T11 证据码隔离 G-3,本节不另断言)。</blockquote></p>
  28. *
  29. * <p><b>契约语义</b>:本能力键是「合规门禁(横切)」的低侧红线组——在 L4 出结论前对任意
  30. * 「偏低」归因节点强制 {@code association_only == true}(低侧机制禁因果),并在 L5 出文档前
  31. * 反向回扫「缺 X→致病/需补充」桥接与处方化建议,命中即收集为合规违规(不阻断,E4 弱提示),
  32. * 由报告侧 {@code sections.complianceGate} 暴露。挂载点(C 档待确认项 #4)经自主探测落于
  33. * {@link AttributionAnalysisServiceImpl#analyze}(L4 enforce + L5 scan)。</p>
  34. *
  35. * <p><b>实现现状(worktree src/main 探测,2026-11-09)</b>:T5/G-1 门禁已落地
  36. * ({@code AttributionLowSideRules} / {@code AttributionComplianceGate} / 报告
  37. * {@code sections.complianceGate} / {@link IndicatorReading#associationOnly} 已并入主链
  38. * commit a13bb8192)。本契约测试据此断言契约行为;若实测断言全过(绿),按任务纪律注明
  39. * 「实现已存在」并交编排侧转测试登记,不私改实现。</p>
  40. *
  41. * <p><b>GREEN 升级指引(若未来实现回退/缺失)</b>:在 {@code AttributionLowSideRules} 保留
  42. * {@code GROUP_CODE="G-1"} 与三规则码 + 措辞白名单;{@code AttributionComplianceGate} 提供
  43. * {@code enforceLowSide(attributions)}(偏低强制 association_only)与 {@code scan(text)}
  44. * (缺X桥接/处方回扫);{@code AttributionReportBuilder} 暴露 {@code sections.complianceGate}
  45. * 后本测试自动转绿。</p>
  46. *
  47. * <p><b>权限流说明</b>:本能力为后端内部合规门禁(L4 出结论前 / L5 出文档前),无用户权限
  48. * 维度,权限流不适用,以说明性用例标注。</p>
  49. */
  50. class GuheGateLowSideRedlineContractTest {
  51. private final AttributionComplianceGate gate = new AttributionComplianceGate();
  52. // ------------------------------------------------------------------
  53. // 正常流:规则层常量(G-1 分组 + 三规则码 + 措辞白名单)
  54. // ------------------------------------------------------------------
  55. @Test
  56. @DisplayName("正常流-1:AttributionLowSideRules 应暴露 G-1 分组码与三规则码")
  57. void lowSideRules_exposesGroupAndRuleCodes() {
  58. assertEquals("G-1", AttributionLowSideRules.GROUP_CODE,
  59. "[guhe.gate.lowSideRedline] 低侧红线组分组码应为 G-1(§三任务分解 T5)。");
  60. assertNotNull(AttributionLowSideRules.GROUP_TITLE,
  61. "[guhe.gate.lowSideRedline] GROUP_TITLE 不应为 null。");
  62. assertEquals("association_only", AttributionLowSideRules.RULE_ASSOCIATION_ONLY,
  63. "[guhe.gate.lowSideRedline] 规则码 association_only(偏低节点禁因果)。");
  64. assertEquals("no_causal_bridge", AttributionLowSideRules.RULE_NO_CAUSAL_BRIDGE,
  65. "[guhe.gate.lowSideRedline] 规则码 no_causal_bridge(禁缺X→致病/需补充)。");
  66. assertEquals("no_prescription", AttributionLowSideRules.RULE_NO_PRESCRIPTION,
  67. "[guhe.gate.lowSideRedline] 规则码 no_prescription(禁处方化建议)。");
  68. }
  69. @Test
  70. @DisplayName("正常流-2:措辞白名单应含「相关联/报告提示/可能与…有关」")
  71. void lowSideRules_wordingWhitelistCoversThreePhrases() {
  72. List<String> whitelist = AttributionLowSideRules.WORDING_WHITELIST;
  73. assertNotNull(whitelist, "[guhe.gate.lowSideRedline] WORDING_WHITELIST 不应为 null。");
  74. assertFalse(whitelist.isEmpty(), "[guhe.gate.lowSideRedline] 措辞白名单不能为空。");
  75. assertTrue(whitelist.contains("相关联"), "白名单须含「相关联」");
  76. assertTrue(whitelist.contains("报告提示"), "白名单须含「报告提示」");
  77. assertTrue(whitelist.contains("可能与…有关"), "白名单须含「可能与…有关」");
  78. }
  79. // ------------------------------------------------------------------
  80. // 正常流:enforceLowSide 对任意「偏低」归因节点强制 association_only
  81. // ------------------------------------------------------------------
  82. @Test
  83. @DisplayName("正常流-3:任意偏低归因节点强制 association_only=true(偏高/正常/待补不强制)")
  84. void enforceLowSide_forcesAssociationOnlyOnEveryLowNode() {
  85. MemberAttribution a = buildMember(1L, "妈妈", Arrays.asList(
  86. reading("大肠杆菌", IndicatorReading.STATUS_LOW, "0.5%", "P3"),
  87. reading("双歧杆菌", IndicatorReading.STATUS_LOW, "2.1%", "P10"),
  88. reading("罗伊氏乳杆菌", IndicatorReading.STATUS_HIGH, "7.3%", "P90"),
  89. reading("维生素D", IndicatorReading.STATUS_NORMAL, "45", null),
  90. reading("血清铁", IndicatorReading.STATUS_PENDING, "8", null)
  91. ));
  92. AttributionComplianceGate.GateReport report =
  93. gate.enforceLowSide(Collections.singletonList(a));
  94. assertEquals(2, report.associationOnlyCount(),
  95. "[guhe.gate.lowSideRedline] 2 个偏低节点应全部强制 associationOnly(验收:任意偏低节点)。");
  96. assertTrue(report.getNodes().stream().anyMatch(n -> n.getIndicator().contains("大肠杆菌")));
  97. assertTrue(report.getNodes().stream().anyMatch(n -> n.getIndicator().contains("双歧杆菌")));
  98. for (AttributionComplianceGate.LowSideNode n : report.getNodes()) {
  99. assertTrue(n.isAssociationOnly(),
  100. "[guhe.gate.lowSideRedline] 低侧节点 " + n.getIndicator() + " 应恒为仅关联(associationOnly)。");
  101. }
  102. for (IndicatorReading r : a.getReadings()) {
  103. if (IndicatorReading.STATUS_LOW.equals(r.getStatus())) {
  104. assertTrue(r.isAssociationOnly(),
  105. r.getDisplayName() + " 应被强制 associationOnly=true");
  106. } else {
  107. assertFalse(r.isAssociationOnly(),
  108. r.getDisplayName() + " 不应被强制 associationOnly");
  109. }
  110. }
  111. }
  112. @Test
  113. @DisplayName("正常流-4:无偏低节点时 enforceLowSide 返回空报告且不误报")
  114. void enforceLowSide_noLowNode_emptyReport() {
  115. MemberAttribution a = buildMember(1L, "爸爸", Collections.singletonList(
  116. reading("血红蛋白", IndicatorReading.STATUS_NORMAL, "140", null)
  117. ));
  118. AttributionComplianceGate.GateReport report =
  119. gate.enforceLowSide(Collections.singletonList(a));
  120. assertTrue(report.getNodes().isEmpty(), "[guhe.gate.lowSideRedline] 无偏低节点时不应产出低侧节点。");
  121. assertTrue(report.getViolations().isEmpty(), "[guhe.gate.lowSideRedline] 无偏低节点时不应有违规。");
  122. }
  123. // ------------------------------------------------------------------
  124. // 正常流:scan 回扫「缺 X→致病/需补充」桥接(no_causal_bridge)
  125. // ------------------------------------------------------------------
  126. @Test
  127. @DisplayName("正常流-5:scan 命中「缺 X→致病/需补充」因果桥接 → no_causal_bridge 违规")
  128. void scan_causalBridge_hitsNoCausalBridge() {
  129. List<AttributionComplianceGate.Violation> vs = gate.scan(
  130. "缺大肠杆菌→需补充该菌,否则易致肠炎感染");
  131. assertFalse(vs.isEmpty(), "[guhe.gate.lowSideRedline] 缺X→需补充桥接应命中(T5 禁写)。");
  132. assertViolationsContainRule(vs, AttributionLowSideRules.RULE_NO_CAUSAL_BRIDGE);
  133. assertNotNull(vs.get(0).getSnippet(), "[guhe.gate.lowSideRedline] 违规应带命中片段。");
  134. assertNotNull(vs.get(0).getSuggestion(), "[guhe.gate.lowSideRedline] 违规应带白名单改写建议。");
  135. }
  136. @Test
  137. @DisplayName("正常流-6:scan 命中「缺 X 补 X」产品桥接(报告缺X→买含X菌株产品)")
  138. void scan_causalBridge_hitsSupplementProductBridge() {
  139. List<AttributionComplianceGate.Violation> vs = gate.scan(
  140. "报告缺大肠杆菌→建议购买含大肠杆菌菌株的产品");
  141. assertFalse(vs.isEmpty(),
  142. "[guhe.gate.lowSideRedline] 报告缺X→买含X菌株产品桥接应命中(叠加 §5.2 16S 粒度声明)。");
  143. assertViolationsContainRule(vs, AttributionLowSideRules.RULE_NO_CAUSAL_BRIDGE);
  144. }
  145. // ------------------------------------------------------------------
  146. // 正常流:scan 回扫处方化建议(no_prescription)
  147. // ------------------------------------------------------------------
  148. @Test
  149. @DisplayName("正常流-7:scan 命中处方化建议(剂量/用法用量/疗程)→ no_prescription 违规")
  150. void scan_prescription_hitsNoPrescription() {
  151. List<AttributionComplianceGate.Violation> vs = gate.scan(
  152. "建议每日 5mg 补充维生素D3,连服 7 天,剂量 200IU/天");
  153. assertFalse(vs.isEmpty(), "[guhe.gate.lowSideRedline] 处方化建议应命中(T5 禁把调整建议写成处方)。");
  154. assertViolationsContainRule(vs, AttributionLowSideRules.RULE_NO_PRESCRIPTION);
  155. }
  156. @Test
  157. @DisplayName("正常流-8:白名单关联措辞不误报(scan 无违规)")
  158. void scan_whitelistWording_noFalsePositive() {
  159. for (String safe : Arrays.asList(
  160. "与肠道环境相关联",
  161. "报告提示可能与饮食结构有关",
  162. "可能与压力负荷有关",
  163. "偏低与慢性炎症相关(仅描述关联)",
  164. "报告提示营养状态与菌群多样性相关",
  165. "儿童剂量由医生定",
  166. "按产品说明服用"
  167. )) {
  168. List<AttributionComplianceGate.Violation> vs = gate.scan(safe);
  169. assertTrue(vs.isEmpty(), "[guhe.gate.lowSideRedline] 白名单措辞不应误报:" + safe);
  170. }
  171. }
  172. // ------------------------------------------------------------------
  173. // 边界流:merge 语义(base 节点保留 + 追加违规)
  174. // ------------------------------------------------------------------
  175. @Test
  176. @DisplayName("边界流-1:merge 追加违规并保留基础低侧节点")
  177. void merge_appendsViolationsKeepsBaseNodes() {
  178. MemberAttribution base = buildMember(1L, "女儿", Collections.singletonList(
  179. reading("大肠杆菌", IndicatorReading.STATUS_LOW, "0.3%", "P1")
  180. ));
  181. AttributionComplianceGate.GateReport baseReport = gate.enforceLowSide(Collections.singletonList(base));
  182. List<AttributionComplianceGate.Violation> extra = gate.scan("缺锌导致缺钙致病");
  183. AttributionComplianceGate.GateReport merged = AttributionComplianceGate.merge(baseReport, extra);
  184. assertEquals(baseReport.getNodes().size(), merged.getNodes().size(),
  185. "[guhe.gate.lowSideRedline] merge 应保留基础低侧节点。");
  186. assertEquals(baseReport.associationOnlyCount(), merged.associationOnlyCount(),
  187. "[guhe.gate.lowSideRedline] merge 后 association_only 计数不变。");
  188. assertEquals(baseReport.getViolations().size() + extra.size(), merged.getViolations().size(),
  189. "[guhe.gate.lowSideRedline] merge 应追加违规。");
  190. }
  191. // ------------------------------------------------------------------
  192. // 边界流:门禁挂载点(L4 出结论前 enforce + L5 出文档前 scan)
  193. // ------------------------------------------------------------------
  194. @Test
  195. @DisplayName("边界流-2:AttributionAnalysisServiceImpl 应挂载 AttributionComplianceGate")
  196. void analysisService_mountsComplianceGate() {
  197. Class<?> svc = loadClass("com.etotem.cfc.service.impl.AttributionAnalysisServiceImpl");
  198. assertNotNull(svc, "[guhe.gate.lowSideRedline] AttributionAnalysisServiceImpl 类应存在。");
  199. boolean wired = false;
  200. for (Field f : svc.getDeclaredFields()) {
  201. if (AttributionComplianceGate.class.getName().equals(f.getType().getName())) {
  202. wired = true;
  203. break;
  204. }
  205. }
  206. assertTrue(wired,
  207. "[guhe.gate.lowSideRedline] AttributionAnalysisServiceImpl 应注入 AttributionComplianceGate "
  208. + "(L4 出结论前 enforce + L5 出文档前 scan,C 档挂载点经探测落此)。");
  209. }
  210. @Test
  211. @DisplayName("边界流-3:AttributionReportBuilder 应暴露带 GateReport 的 buildReportJson 重载(sections.complianceGate)")
  212. void reportBuilder_exposesGateReportOverload() {
  213. boolean hasOverload = false;
  214. for (Method m : AttributionReportBuilder.class.getDeclaredMethods()) {
  215. if (!"buildReportJson".equals(m.getName())) {
  216. continue;
  217. }
  218. Class<?>[] params = m.getParameterTypes();
  219. if (params.length >= 8
  220. && AttributionComplianceGate.GateReport.class == params[params.length - 1]) {
  221. hasOverload = true;
  222. break;
  223. }
  224. }
  225. assertTrue(hasOverload,
  226. "[guhe.gate.lowSideRedline] AttributionReportBuilder 应提供带 "
  227. + "AttributionComplianceGate.GateReport 的 buildReportJson 重载,"
  228. + "供报告侧暴露 sections.complianceGate(低侧节点/违规/白名单)。");
  229. }
  230. // ------------------------------------------------------------------
  231. // 权限流(不适用):后端内部合规门禁,无用户权限维度
  232. // ------------------------------------------------------------------
  233. @Test
  234. @DisplayName("权限流:本能力为后端内部合规门禁,无用户权限维度,权限流不适用")
  235. void permissionFlow_notApplicable() {
  236. // 说明性用例:契约 AC 为「L4 出结论前 / L5 出文档前」内部规则,无权限校验维度。
  237. assertTrue(true, "权限流不适用:guhe.gate.lowSideRedline 为后端内部合规门禁,无用户权限维度。");
  238. }
  239. // ------------------------------------------------------------------
  240. // 反射与数据构造辅助
  241. // ------------------------------------------------------------------
  242. private static void assertViolationsContainRule(List<AttributionComplianceGate.Violation> vs, String rule) {
  243. assertTrue(vs.stream().anyMatch(v -> rule.equals(v.getRule())),
  244. "[guhe.gate.lowSideRedline] 违规列表缺少规则码「" + rule + "」:" + vs);
  245. }
  246. private static Class<?> loadClass(String name) {
  247. try {
  248. return Class.forName(name, false, Thread.currentThread().getContextClassLoader());
  249. } catch (ClassNotFoundException e) {
  250. return null;
  251. }
  252. }
  253. private static IndicatorReading reading(String display, String status, String value, String percentile) {
  254. IndicatorReading r = new IndicatorReading();
  255. r.setCode(display);
  256. r.setDisplayName(display);
  257. r.setValue(value);
  258. r.setPercentile(percentile);
  259. r.setStatus(status);
  260. return r;
  261. }
  262. private static MemberAttribution buildMember(Long memberId, String name, List<IndicatorReading> readings) {
  263. MemberAttribution a = new MemberAttribution();
  264. a.setMemberId(memberId);
  265. a.setName(name);
  266. a.setRole("mom");
  267. a.setRoleLabel("妈妈");
  268. a.setReadings(new ArrayList<>(readings));
  269. a.setDimensionEvidence(new java.util.LinkedHashMap<>());
  270. a.setFiveDim(new java.util.LinkedHashMap<>());
  271. a.setWeakDimensions(new ArrayList<>());
  272. a.setActions(new ArrayList<>());
  273. for (IndicatorReading r : readings) {
  274. if (r.isAbnormal()) {
  275. a.setAbnormalCount(a.getAbnormalCount() + 1);
  276. }
  277. }
  278. return a;
  279. }
  280. }