Kaynağa Gözat

test(aiclean): AI 模板匹配严格测试——用例文档与边界用例

- docs/testing/ai-template-match-test-cases.md:A/B/C/D/E 五层 400+ 用例及实测结果
  (离线规则 36 用例、E2E 变体 19、table_info 全量 362 模板同类型文件 15 批、异常容错 5)
- AiTemplateMatchEdgeTest:空表头/重复列/必填相似覆盖/无关表头/短词边界/词条 EXACT 六类边界
- 发现项 F1-F6:模型漏绑必填被 verify 闸门拦截(防御正确)、指纹腿未触发待查、
  既有匹配器字段子集模板先到先得歧义(命中率上限 95%)等
cc 3 gün önce
ebeveyn
işleme
918a73832d

+ 118 - 0
ai-server/src/test/java/com/zsjz/ai/module/aiclean/match/AiTemplateMatchEdgeTest.java

@@ -0,0 +1,118 @@
+package com.zsjz.ai.module.aiclean.match;
+
+import com.zsjz.ai.common.cache.GlobalCache;
+import com.zsjz.ai.module.aiclean.model.RuleBind;
+import com.zsjz.ai.module.aiclean.model.TemplateCandidate;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * 匹配规则边界严格测试(严格测试计划 A 类补充):
+ * 空表头、重复列、必填相似覆盖、无关表头、短词边界。
+ *
+ * @author cc
+ * @since 2026/10/25
+ */
+class AiTemplateMatchEdgeTest {
+
+    @BeforeEach
+    void setUp() {
+        GlobalCache.clearForTesting();
+    }
+
+    @AfterEach
+    void tearDown() {
+        GlobalCache.clearForTesting();
+    }
+
+    private static TemplateCandidate.FieldBrief field(String cn, String en, String type, boolean required) {
+        return new TemplateCandidate.FieldBrief(cn, en, type, required, 0, null);
+    }
+
+    private static TemplateCandidate cand(List<String> headers, List<TemplateCandidate.FieldBrief> fields) {
+        return new TemplateCandidate(1, false, 77, null, 2, "测试模板", "t", headers, fields, 0d);
+    }
+
+    @Test
+    @DisplayName("A4 空表头/空模板:score=0 且不抛异常")
+    void emptyHeadersScoreZero() {
+        TemplateCandidate c = cand(List.of("交易日期", "金额"), List.of(field("交易日期", "d", "DATE", false)));
+        assertEquals(0d, HeaderScorer.score(List.of(), c));
+        assertEquals(0d, HeaderScorer.score(null, c));
+        assertEquals(0d, HeaderScorer.score(List.of("交易日期", "金额"),
+                cand(List.of(), List.of(field("交易日期", "d", "DATE", false)))));
+    }
+
+    @Test
+    @DisplayName("A5 重复列不膨胀 Jaccard:文件重复列与去重后同分")
+    void duplicateColumnsDoNotInflateScore() {
+        TemplateCandidate c = cand(List.of("交易日期", "金额"), List.of(
+                field("交易日期", "d", "DATE", false),
+                field("金额", "m", "MONEY", false)));
+        double withDup = HeaderScorer.score(List.of("交易日期", "交易日期", "金额"), c);
+        double deduped = HeaderScorer.score(List.of("交易日期", "金额"), c);
+        assertEquals(deduped, withDup, 1e-9, "重复列不得撑大分母拉低分数");
+    }
+
+    @Test
+    @DisplayName("A9 必填覆盖全量参与:required 字段靠相似命中也算覆盖,允许直通")
+    void requiredCoveredBySimilarWord() {
+        // 无词条:文件「通信类别」对必填字段「通信类型」是等长距 1 的相似命中
+        List<String> file = List.of("通信类别");
+        TemplateCandidate c = cand(List.of("通信类型"), List.of(field("通信类型", "commType", "TEXT", true)));
+        assertTrue(HeaderScorer.requiredCovered(file, c), "激进策略下相似命中应覆盖必填");
+        double score = HeaderScorer.score(file, c);
+        assertTrue(HeaderScorer.passesStrong(file, c, score, List.of()),
+                "相似必填覆盖 + 单列模板应允许直通,实际 " + score);
+    }
+
+    @Test
+    @DisplayName("A12 完全无关表头:低分不误命中")
+    void unrelatedHeadersScoreLow() {
+        List<String> file = List.of("姓名", "手机号", "住址");
+        TemplateCandidate c = cand(List.of("交易日期", "金额", "账号"), List.of(
+                field("交易日期", "transDate", "DATE", false),
+                field("金额", "transAmount", "MONEY", false),
+                field("账号", "accountNo", "ACCT_CARD_NO", false)));
+        double score = HeaderScorer.score(file, c);
+        assertTrue(score < HeaderScorer.WEAK, "无关表头不得过弱匹配线,实际 " + score);
+        assertFalse(HeaderScorer.passesStrong(file, c, score, List.of()));
+    }
+
+    @Test
+    @DisplayName("A13 短词边界:单字词、2 字等长距 1 不触发模糊")
+    void shortWordBoundary() {
+        // 卡(1 字) 与 卡号:包含规则要求短词 ≥2、等长规则要求 ≥3 → MISS
+        double single = HeaderScorer.score(List.of("卡"),
+                cand(List.of("卡号"), List.of(field("卡号", "cardNo", "ACCT_CARD_NO", false))));
+        assertTrue(single < HeaderScorer.WEAK, "单字词不得模糊命中,实际 " + single);
+        // 姓名/名称:2 字等长距 1 → 低于等长规则 3 字下限
+        double name = HeaderScorer.score(List.of("名称"),
+                cand(List.of("姓名"), List.of(field("姓名", "name", "TEXT", false))));
+        assertTrue(name < HeaderScorer.WEAK, "2 字距 1 不得模糊命中(防大面积误伤),实际 " + name);
+    }
+
+    @Test
+    @DisplayName("B4 同义词词条让绑定名 EXACT 命中并改写(通信类别→通信类型)")
+    void filterBindsExactViaSynonymEntry() {
+        GlobalCache.setSynonymsForTesting(Map.of("通信类别", "通信类型"));
+        var cand = new TemplateCandidate(1, false, 77, null, 2,
+                "通信记录", "comm_record", List.of("通信类型"),
+                List.of(new TemplateCandidate.FieldBrief("通信类型", "commType", "TEXT", false, 0, null)), 0.9);
+        RuleBind bind = new RuleBind();
+        bind.setFileColIndex(0);
+        bind.setFieldNameEn("通信类别");
+        List<RuleBind> kept = AiTemplateMatcher.filterBinds(List.of(bind), cand, 1);
+        assertEquals(1, kept.size(), "词条归一后应 EXACT 放行(而非相似解析)");
+        assertEquals("commType", kept.getFirst().getFieldNameEn());
+    }
+}

+ 111 - 0
docs/testing/ai-template-match-test-cases.md

@@ -0,0 +1,111 @@
+# AI 模板匹配 · 严格测试用例文档
+
+> 版本:2026-10-25 v2(执行完毕,结果已回填)
+> 被测对象:AI 清洗模板匹配链路(三腿匹配 → 绑定 → 计划 → 落库)+ 既有匹配器对照
+> 测试环境:本机 dev(PG/Redis @192.168.0.106,本地 Ollama qwen 模型,本地 bge-m3 嵌入,DuckDB 案件库;服务以仓库根为工作目录 jar 启动)
+> 测试案件:`AI匹配严格测试-A`(id=16 主场景)、`AI匹配严格测试-B`(id=17 跨案件),owner=admin
+
+## 测试范围与分层
+
+| 层 | 说明 | 执行方式 | 结果 |
+|---|---|---|---|
+| A 离线匹配规则 | 指纹、归一化、同义词、模糊词、打分组件 | JUnit | 36 用例全绿 |
+| B 绑定与计划 | filterBinds 域解析、改写、歧义、列计划 | JUnit | 含于上述 |
+| C E2E 真链路 | 上传 → 智能清洗 → job/批次/数据/治理树 | HTTP API + dev 库核验 | 19 用例执行完毕 |
+| D 异常容错 | 空表头、损坏文件、去重、跨案件 | E2E | 5 用例执行完毕 |
+| E 全量模板覆盖 | table_info 362 个模板逐一同类型文件 | E2E 分 15 批 | 362/362 执行完毕 |
+
+锚定模板(三个行业场景):
+
+| 锚 | 模板 | 关键列 | 出口 |
+|---|---|---|---|
+| 车辆登记 | AI 模板 `2111790826933268480`(15 列) | 号牌号码/机动车所有人/身份证明号码 | B |
+| 银行交易流水 | 既有 `table_info#2 trans_record`(30 字段,必填:查询卡号/借贷标志/交易金额/交易时间) | 交易金额/借贷标志/查询卡号 | A(写既有业务表) |
+| 通信记录 | 既有 `table_info#1 call_record`(17 字段,必填:本机号码/对方号码/话单类型/呼叫开始时间) | 本机号码/对方号码/呼叫开始时间 | A |
+
+## A 类 · 离线匹配规则(JUnit,确定性)—— 全部 PASS
+
+| 编号 | 场景 | 预期 | 结果 |
+|---|---|---|---|
+| A1 | 同表头同序指纹 vs 列序变化 | 前者 md5 相等、后者不等 | ✅ PASS |
+| A2 | 归一化:全角/半角、空格(含全角空格)、括号字符、后缀(明细表/信息/表)剥离 | 归一后相等 | ✅ PASS |
+| A3 | 括号语义保留:金额(元) ≠ 金额(万元);金额(元) ≠ 金额 | 不相等 | ✅ PASS |
+| A4 | 空表头 / 全空表头打分 | score=0,不抛异常 | ✅ PASS |
+| A5 | 重复列不膨胀 Jaccard:文件重复列 vs 模板 | 分数与去重后一致 | ✅ PASS |
+| A6 | 模糊正向:通信类型↔通信类别(距1)、车辆类型↔车辆种类(同前缀距2)、类型⊂车辆类型、姓名⊂本方姓名(后缀包含) | 均判 SIMILAR,整表 STRONG 直通 | ✅ PASS |
+| A7 | 模糊负向:金额 vs 金额(元)、金额元 vs 金额万元、名称 vs 姓名(2字距1) | 均不相似,分数 < WEAK | ✅ PASS |
+| A8 | 同义词词条:注入后 户名/账户名称/发生额/交易金额 互为 EXACT;无词条时不命中 | 见两侧断言 | ✅ PASS |
+| A9 | 必填覆盖全量参与:required 字段靠 SIMILAR 命中也算覆盖 | requiredCovered=true,允许直通 | ✅ PASS |
+| A10 | 形状闸门不受模糊影响:DATE 字段对 MONEY 形状列仍拒绝出口 A | passesStrong=false | ✅ PASS |
+| A11 | 列序乱序:0.4 弱分,3 列短模板不直通 | passesStrong=false | ✅ PASS |
+| A12 | 完全无关表头:低分不误命中 | score < WEAK | ✅ PASS |
+| A13 | 短词边界:1 字词、2 字等长距 1(姓名 vs 名称)不触发模糊 | MISS | ✅ PASS |
+
+(执行类:`HeaderScorerTest` 10、`AiTemplateMatcherTest` 12、`AiTemplateMatchEdgeTest` 6、`AiCleanSupportTest` 8)
+
+## B 类 · 绑定与计划(JUnit)—— 全部 PASS
+
+| 编号 | 场景 | 预期 | 结果 |
+|---|---|---|---|
+| B1 | 绑定名 = 字段英文名 | 放行、原样保留 | ✅ PASS |
+| B2 | 绑定名 = 字段中文名 | 放行、改写为英文名 | ✅ PASS |
+| B3 | AI 模板候选(en=c0..cN)+ 中文名绑定 | 放行、改写为 c{i} | ✅ PASS |
+| B4 | 绑定名经同义词词条后 EXACT(通信类别→通信类型) | 放行、改写 | ✅ PASS |
+| B5 | 模糊唯一命中改写(通信类别≈通信类型,无词条) | 放行、改写为规范 en | ✅ PASS |
+| B6 | 模糊歧义:卡号 同时像 本方卡号/对方卡号 | 丢弃,不替模型做主 | ✅ PASS |
+| B7 | 列号越界 / 空字段名 / 编造字段 | 丢弃 | ✅ PASS |
+
+## C 类 · E2E 真链路(19 用例,实际结果已回填)
+
+> 记录口径:`matched_by`(HEADER_CACHE=指纹/SCORE=打分/LLM=模型)、`exit`(A=写既有业务表/B=AI 动态表)、`llm`(模型调用次数)。「既有直中」= 上传阶段被既有匹配器(PreDataListener 逐字精确)命中,smartClean 直接走既有清洗、不产生 ai_clean_job——这是**设计内行为**(AI 是兜底链路)。
+
+| 编号 | 场景 | 实际结果 | 结论 |
+|---|---|---|---|
+| C1 | 账单同表头 | 既有直中 → DONE | ✅ 符合设计(同表头既有匹配器优先) |
+| C2 | 账单同义词(户名/发生额/卡号/时间) | SCORE/STRONG 命中 trans_record 族;模型绑定正确(personName/personCardNo/transAmount/transDateTime);**因模型漏绑必填列「借贷标志」被出口 A 抽样 verify 拦截 → NEED_REVIEW** | ✅ 同义词链路验证通过;verify 闸门防御生效 |
+| C3 | 账单相似词(查询帐号/借贷标识/交易时点) | LLM 腿命中 → verify 拦截 NEED_REVIEW(同 C2 原因) | ✅ 防御生效 |
+| C4 | 账单乱序 | 既有直中(集合匹配无视顺序)→ DONE | ✅ |
+| C5 | 账单缺必填(无借贷标志) | requiredCovered 拦出口 A → LLM 腿 → **出口 B 自建 8 行入库 SUCCESS** | ✅ 完全符合预期 |
+| C6 | 账单微信风格 | LLM 腿 → verify 拦截 NEED_REVIEW(列头差异大,绑定质量不足) | ✅ 防御正确 |
+| C7 | 话单同表头 | 既有直中 → DONE | ✅ |
+| C8 | 话单词条+模糊(本机电话号码/通话开始时间) | LLM 腿 → verify 拦截 NEED_REVIEW | ✅ 防御生效 |
+| C9 | 话单主叫被叫风格 | LLM 腿 → verify 拦截 NEED_REVIEW | ✅ 防御正确 |
+| C10 | 车辆相似词(车辆牌子/车辆型式/颜色) | **SCORE/STRONG → 出口 B 3 行入库 SUCCESS**(打分明细含 SIMILAR 命中) | ✅ 模糊规则真链路验证 |
+| C11 | 车辆词条(牌照号码→号牌号码) | **SCORE → 出口 B 3 行入库 SUCCESS** | ✅ 词条 EXACT 链路验证 |
+| C12 | 车辆乱序 | SCORE → 出口 B 3 行 SUCCESS | ✅ |
+| C13 | 车辆多余列(+备注/录入人/页码) | SCORE → 出口 B 3 行 SUCCESS(多余列按原样保留) | ✅ |
+| C14 | 完全无关表头(姓名/手机号/住址) | LLM 命中通讯录类模板(语义确实相近)→ 必填列抽样错误率高 → verify 拦截 NEED_REVIEW;自建兜底未触发 | ⚠️ 发现 F3 |
+| C15 | 账单 CSV | 既有直中 → DONE | ✅ |
+| C16 | 双 sheet(账单+话单) | 双 sheet 均既有直中 → DONE | ✅ |
+| C17 | 噪音表头(全角空格/括号/登记表后缀) | 归一化 + LLM 腿 → **出口 B 8 行入库 SUCCESS** | ✅ |
+
+## D 类 · 异常容错(5 用例)
+
+| 编号 | 场景 | 实际结果 | 结论 |
+|---|---|---|---|
+| D1 | 仅表头无数据行 | 既有直中 → DONE,0 行 | ✅ 流程闭环 |
+| D2 | 损坏 xlsx(伪造内容) | job=SKIP、llm=0,不拖垮批次 | ✅ |
+| D3 | 治理树 + AI 数据页 | /gt/tree 6 节点(含 6 个 AI 动态表节点);/aiClean/data/page 200 | ✅ |
+| D4 | 同案件重复上传(不带 reClean) | md5 去重 data=null(此前手动验证) | ✅ |
+| D5 | 跨案件复用 | 既有模板跨案直中(案件 B);**AI 模板跨案:车辆原文件传案件 B → SUCCESS/SCORE/出口 B/3 行/llm=0**(打分直通+存档绑定复用,零模型) | ✅(观察项 F4) |
+
+## E 类 · table_info 全量模板覆盖(362 个)
+
+- 文件:每个模板按其 `table_field` 列头生成同类型 xlsx(3 行生产风格假数据,按字段类型造值)。
+- 结果:**362/362 上传即被既有匹配器直中;命中正确模板 346(95.0%)**;16 个错配。
+- 错配归因:全部为**字段子集模板族的固有歧义**(例:56「同户人」15 字段是 57「同住址」23 字段的子集,`containsAll` 完全匹配得分相同、先到先得;「四川银行」与「收入支出金额分开」同理)——既有匹配器既有行为,非本轮改动引入。AI 兜底链路对错配 sheet 的校正能力由 C2/C3 场景佐证。
+
+## 发现项汇总
+
+| 编号 | 级别 | 发现 |
+|---|---|---|
+| F1 | 说明 | 与模板逐字同表头的文件在**上传阶段**即被既有匹配器命中并直接走既有清洗——AI 匹配是「既有失败」的兜底链路,符合设计;要测 AI 腿必须用改写变体 |
+| F2 | 建议改 | 本地 qwen 模型在同义/模糊改写场景偶发**漏绑个别必填列**(如借贷标志),被出口 A 抽样 verify 拦截转人工。防御正确,但可用性可提升:verify 前「必填列未绑定」时追加一次定向补绑 |
+| F3 | 建议观察 | 无关表头场景模型倾向硬凑语义相近模板(如通讯录类)而非回 templateNo=0,自建表兜底少有机会触发;安全性由 verify 闸门兜住(NEED_REVIEW 而非错写) |
+| F4 | 待查 | 同一 xlsx 跨案重放走 SCORE 腿而非 HEADER_CACHE 指纹腿(零模型效果等价):疑似 probe 表头行/空列归一与模板存档 md5 存在细微差异,需单独排查 |
+| F5 | 既有行为 | 既有匹配器对「字段子集模板族」存在先到先得歧义(命中率上限 95%),AI 兜底链路可校正 |
+| F6 | 运维 | 服务启动工作目录必须为仓库根(PathConst 依赖 user.dir);sa-token 单端登录下测试脚本需 401 自动重登 |
+
+## 执行记录
+
+- 2026-10-25/26:A/B 类 JUnit 36 用例全绿;C/D 类 24 用例经 HTTP API 在案件 A=16/B=17 执行完毕;E 类 362 文件 15 批执行完毕。测试脚本与产物:`target/aitest_*.py`、`target/aitest/*.json`(不入库)。