|
|
@@ -0,0 +1,224 @@
|
|
|
+package com.etotem.cfc.service.attribution;
|
|
|
+
|
|
|
+import org.springframework.stereotype.Component;
|
|
|
+
|
|
|
+import java.util.ArrayList;
|
|
|
+import java.util.Arrays;
|
|
|
+import java.util.Collections;
|
|
|
+import java.util.LinkedHashMap;
|
|
|
+import java.util.List;
|
|
|
+import java.util.Map;
|
|
|
+import java.util.regex.Matcher;
|
|
|
+import java.util.regex.Pattern;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 个人层双断言(L2-4,写库前门禁)。
|
|
|
+ *
|
|
|
+ * <p><b>职责</b>:在把菌群报告「个人层」数据写入知识库(写库)之前,执行两级确定性断言,
|
|
|
+ * 防止个人层丰度/干预文本混入知识层、以及分页表拼接残段污染:
|
|
|
+ * <ol>
|
|
|
+ * <li><b>键名级</b>:个人层条目键必须命中 {@link #PERSONAL_KEYS} 白名单
|
|
|
+ * (键名级 {@code _PERSONAL_KEYS});否则判为「知识层键泄漏 / 分页残段伪键」。</li>
|
|
|
+ * <li><b>字符串内容级</b>:对条目内容扫全文,命中
|
|
|
+ * {@link #PAGE_TAIL_PATTERN 分页残段}(如 {@code 34/167 0.0033 参考 0-0.1746% ND})
|
|
|
+ * 或 {@link #ND_TOKEN_PATTERN ND 参考} 即判违规。</li>
|
|
|
+ * </ol>
|
|
|
+ *
|
|
|
+ * <p><b>正样本</b>「大肠杆菌改善方式以『苦丁茶』开头」必须通过——纯干预文本不含分页残段 /
|
|
|
+ * ND 参考,键名在白名单内,断言不得误杀合法个人层干预内容。
|
|
|
+ *
|
|
|
+ * <p>纯规则、无 DB 依赖、无副作用,可单测。挂载点:写库前(个人层数据入库前),与
|
|
|
+ * {@link AttributionComplianceGate}(G-1 低侧红线,L4/L5 出结论/出文档前)互补。
|
|
|
+ *
|
|
|
+ * @author 浠艾福平台程序修改(guhe_v2 兼容 · T2 个人层双断言 L2-4)
|
|
|
+ */
|
|
|
+@Component
|
|
|
+public class PersonalLayerDualAssertion {
|
|
|
+
|
|
|
+ // ==================== 规则码 ====================
|
|
|
+
|
|
|
+ /** 键名级断言:个人层键不在白名单 */
|
|
|
+ public static final String RULE_KEY_NOT_PERSONAL = "key_not_personal";
|
|
|
+ /** 字符串内容级断言:分页残段(如 34/167 …) */
|
|
|
+ public static final String RULE_PAGE_TAIL_REMNANT = "page_tail_remnant";
|
|
|
+ /** 字符串内容级断言:ND 参考(低于检出丰度标注泄漏) */
|
|
|
+ public static final String RULE_ND_TOKEN = "nd_reference";
|
|
|
+
|
|
|
+ // ==================== 键名级白名单(_PERSONAL_KEYS) ====================
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 个人层键名白名单 {@code _PERSONAL_KEYS}。
|
|
|
+ *
|
|
|
+ * <p>仅这些键可作为个人层条目写入;超出即视为知识层键泄漏或分页残段伪键。
|
|
|
+ * 涵盖谷禾/北京菌群报告个人层字段:丰度、正常范围、人群水平、检出率、状态、
|
|
|
+ * 菌种说明、改善方式、评估值、参考范围。
|
|
|
+ */
|
|
|
+ public static final List<String> PERSONAL_KEYS = Collections.unmodifiableList(Arrays.asList(
|
|
|
+ "丰度", "正常范围", "人群水平", "检出率", "状态", "说明", "改善方式",
|
|
|
+ "评估值", "参考范围"));
|
|
|
+
|
|
|
+ // ==================== 字符串内容级:分页残段 / ND 参考 ====================
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 分页残段(「与健康相关菌评估」分页表拼接):
|
|
|
+ * 形如 {@code 34/167 0.0033 参考 0-0.1746% ND} —— 页码 {@code \d{1,3}/16\d} 为强特征。
|
|
|
+ */
|
|
|
+ public static final Pattern PAGE_TAIL_PATTERN =
|
|
|
+ Pattern.compile("\\d{1,3}/16\\d");
|
|
|
+
|
|
|
+ /** ND 参考:低于检出丰度标注(ND = not detected)泄漏到内容层。 */
|
|
|
+ public static final Pattern ND_TOKEN_PATTERN =
|
|
|
+ Pattern.compile("ND\\s*参考");
|
|
|
+
|
|
|
+ /** 知识层键泄漏(「缺 X → 补 X」桥接伪键名,个人层不应出现)。 */
|
|
|
+ public static final Pattern KNOWLEDGE_LEAK_KEY_PATTERN =
|
|
|
+ Pattern.compile("缺[\\u4e00-\\u9fff]{1,10}补[\\u4e00-\\u9fff]{1,10}");
|
|
|
+
|
|
|
+ private static final Pattern[] CONTENT_PATTERNS = {
|
|
|
+ PAGE_TAIL_PATTERN, ND_TOKEN_PATTERN, KNOWLEDGE_LEAK_KEY_PATTERN
|
|
|
+ };
|
|
|
+
|
|
|
+ // ==================== 单条断言 ====================
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 写库前对单条个人层条目执行双断言(键名级 + 内容级)。
|
|
|
+ *
|
|
|
+ * @param key 个人层条目键(如「大肠杆菌改善方式」)
|
|
|
+ * @param content 条目内容(如「苦丁茶…」)
|
|
|
+ * @return 违规列表;空表示该条目通过(正样本应返回空)
|
|
|
+ */
|
|
|
+ public List<Violation> assertEntry(String key, String content) {
|
|
|
+ List<Violation> out = new ArrayList<>();
|
|
|
+ if (key != null && !isPersonalKey(key)) {
|
|
|
+ out.add(new Violation(RULE_KEY_NOT_PERSONAL,
|
|
|
+ "个人层键「" + key + "」不在 _PERSONAL_KEYS 白名单(知识层键泄漏或分页残段伪键)",
|
|
|
+ key, "个人层仅允许键:" + PERSONAL_KEYS));
|
|
|
+ }
|
|
|
+ out.addAll(scanContent(content));
|
|
|
+ return out;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 字符串内容级断言:扫全文,命中分页残段 / ND 参考 / 知识层泄漏即收集违规。
|
|
|
+ *
|
|
|
+ * @param content 条目内容(可空)
|
|
|
+ * @return 违规列表;空表示内容干净
|
|
|
+ */
|
|
|
+ public List<Violation> scanContent(String content) {
|
|
|
+ if (content == null || content.trim().isEmpty()) {
|
|
|
+ return Collections.emptyList();
|
|
|
+ }
|
|
|
+ List<Violation> out = new ArrayList<>();
|
|
|
+ collect(out, content, PAGE_TAIL_PATTERN, RULE_PAGE_TAIL_REMNANT,
|
|
|
+ "内容含分页残段(页码 \\d{1,3}/16\\d),个人层不得混入知识层分页表拼接",
|
|
|
+ "写库前须 _strip_table_tail() 清洗分页表拼接残段");
|
|
|
+ collect(out, content, ND_TOKEN_PATTERN, RULE_ND_TOKEN,
|
|
|
+ "内容含「ND 参考」(低于检出丰度标注),不应写入知识层",
|
|
|
+ "ND 仅作个人层「低于检出丰度」记录,参考下限记 0");
|
|
|
+ collect(out, content, KNOWLEDGE_LEAK_KEY_PATTERN, RULE_KEY_NOT_PERSONAL,
|
|
|
+ "内容含「缺 X → 补 X」桥接伪键(知识层键泄漏)",
|
|
|
+ "低侧机制仅关联非因果,禁缺 X→补 X(叠加 16S 粒度声明)");
|
|
|
+ return out;
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 批量断言(个人层 Map) ====================
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 写库前对个人层条目集合执行双断言。
|
|
|
+ *
|
|
|
+ * @param entries 键 → 内容(如「大肠杆菌改善方式」→「苦丁茶…」)
|
|
|
+ * @return 全部违规;空表示整批通过
|
|
|
+ */
|
|
|
+ public List<Violation> assertEntries(Map<String, String> entries) {
|
|
|
+ List<Violation> out = new ArrayList<>();
|
|
|
+ if (entries == null || entries.isEmpty()) {
|
|
|
+ return out;
|
|
|
+ }
|
|
|
+ for (Map.Entry<String, String> e : entries.entrySet()) {
|
|
|
+ out.addAll(assertEntry(e.getKey(), e.getValue()));
|
|
|
+ }
|
|
|
+ return out;
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 便捷判断 ====================
|
|
|
+
|
|
|
+ /** 键是否为合法个人层键(键名级断言通过)。 */
|
|
|
+ public static boolean isPersonalKey(String key) {
|
|
|
+ if (key == null) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ for (String pk : PERSONAL_KEYS) {
|
|
|
+ if (pk.equals(key) || key.endsWith(pk)) {
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 内容是否干净(无分页残段 / ND 参考 / 知识层泄漏)。 */
|
|
|
+ public static boolean isCleanContent(String content) {
|
|
|
+ if (content == null || content.trim().isEmpty()) {
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ for (Pattern p : CONTENT_PATTERNS) {
|
|
|
+ if (p.matcher(content).find()) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ 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;
|
|
|
+ }
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public String toString() {
|
|
|
+ return "Violation{rule='" + rule + "', snippet='" + snippet + "'}";
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 内部 ====================
|
|
|
+
|
|
|
+ private void collect(List<Violation> out, String text, Pattern pattern,
|
|
|
+ String rule, String message, String suggestion) {
|
|
|
+ Matcher m = pattern.matcher(text);
|
|
|
+ while (m.find()) {
|
|
|
+ out.add(new Violation(rule, message, snippet(text, m.start(), m.end()), suggestion));
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ 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();
|
|
|
+ }
|
|
|
+}
|