# AI 大模型模板匹配 · 严格测试问题报告(2026-10-26) > 对应用例文档:[ai-llm-template-match-strict-20261026.md](ai-llm-template-match-strict-20261026.md) > 结论先行:本轮在「AI 大模型模板匹配」链路发现 **2 个 P0(AI 错配模板导致 0 行;既有模糊匹配把话单误判成通信登记信息并写错表)**、**3 个 P1(统计口径/金额清洗/本地模型并发)**、**3 个 P2(可观测性与模型输出校验)**。 > 2026-10-26 已按确认范围修复 **F-01、F-01B、F-03B、F-05、F-06、F-07**(见第 6 节「修复记录」);**F-01C、F-02、F-03、F-04、F-04B 按要求暂不处理**。 > 复现文件全部保留在 `docs/testing/fixtures/ai-llm-match-20261026/`;执行结果 JSON 在 `results/`。 ## 1. 环境 | 项 | 值 | |---|---| | 后端 | `ai-server` @ `http://127.0.0.1:8980/js/a`,从仓库根启动 | | 前端 | `ai-electron/frontend` @ `http://localhost:3100` | | 平台库 | PostgreSQL `192.168.0.106:5432/zsjz-ai` | | 模型 | Ollama `qwen`(Qwen3.8-27B)@ `http://10.66.66.66:8080/v1`;**本地模型只支持 1 个并发会话**(用户确认) | | AI 判定并发 | 测试后期统一 `-Dzsjz.ai-clean.max-concurrent=1`;默认值 3 的结果见 F-04 | | 嵌入 | 本地 bge-m3;AI 模板向量索引已就绪 | | 工作目录 | `E:\workspace\zsjz-ai`(`PathConst` 依赖) | | 账号 | `admin / admin123` | ## 2. 问题汇总 | 编号 | 级别 | 问题 | 复现文件 / 任务 | 状态 | |---|---|---|---|---| | F-01 | **P0** | 交通银行同义表被错误判成「农商行交易流水」模板 48,出口 A 写业务表;因模板缺少必填「本方卡号」,8 行数据全部被拒,业务表 0 行 | `bills/BILL-09_交通银行_同义.xlsx`;案件 24,job `2114558438624731136` | **已修复**(复测:上传闸门/兜底通过;模型裁决分支受端点阻塞) | | F-01B | **P0** | 导出话单/通信记录变体在上传阶段被既有模糊匹配器误判为「通信登记信息」模板 3;话单数据被写进 `call_opening_info`,只保留姓名/号码,通话方向/时间/时长等全部丢失;AI 链路根本没被触发 | `calls/CALL-09_导出话单_同义.xlsx`;案件 25,批次 `2114559120123633664` | **已修复**(复测:上传阶段通过;模型裁决分支受端点阻塞) | | F-01C | P2 | table_info 全量同表头覆盖:364 个模板中 345 正确、16 错配(子集模板先到先得)、3 失败(headers 与 table_field 不一致);12 个模板存在表头字段在 table_field 中不存在 | `full-template/`;`results/full-exact_20261026_113820.json` | 不修复(数据质量) | | F-02 | **P1** | 清洗完成统计把非数据行(标题/表头)算成“导入成功”:文件行显示成功 2、失败 8,但业务表 0 行;另一批次 UI 显示 32 条、实际 30 行 | 案件 24 同上;案件 19 批次 `1792983591254364400` | 不修复 | | F-03B | **P1** | AI 动态表的 `CALL_DIRECTION` 列把方向字典的整串别名写进单元格(如“主叫,短信发送话单,呼出,01,主叫话单”),不是“主叫/被叫”;账单 AI 表的借贷方向也保留 借/贷/D/C/收/付 原值未归一 | `calls/CALL-02/03/10/18`;`bills/BILL-02` AI 表 | **已修复**(复测通过:零模型 HEADER_CACHE 验证) | | F-03 | **P1** | 既有清洗对带千分位/货币符号/负号的金额未识别,直接写 `0.00`(1,234.50、-100.00、¥2,300.50、¥88.88、5,000.00 全部变 0.00) | `bills/BILL-01_通用流水_标准.xlsx`;案件 19 `trans_record_all` | 不修复 | | F-04 | **P1** | 默认 `max-concurrent=3` 时,本地单会话模型 2/3 调用超过 180s 硬超时并重试,批次卡在 JUDGING;单并发后恢复正常 | 案件 20/21;日志 `logs/backend-test2.log`、`backend-test3.log` | 不修复(环境/配置) | | F-04B | **P1** | 文件 AI 识别调度器仍是 `file-ai.max-concurrent=3`;含 txt 的批次会与 AI 清洗争用同一单会话模型,导致 `HTTP header parser received no bytes`、AI 清洗 FAIL/重试 | 案件 26,`EDGE-04_说明.txt` + `EDGE-06`;日志 `logs/backend-test5.log` | 不修复 | | F-05 | P2 | 出口 B 新建 AI 表的中文名与既有模板同名(“银行交易流水”/“通信记录”),处理日志只写“已写入 N 行(表 xxx)”,无法区分是既有业务表还是 AI 动态表 | 案件 19 批次 `1792983591254364400` | **已修复**(复测通过:新表名 AI_ 前缀 + 导入备注) | | F-06 | P2 | 出口 A 的二次 `askLlm` 只送 1 个候选,但模型返回 `templateNo=69` 越界序号;该路径不校验序号,仍使用模型 binds | 案件 24 draft 见 `results/bill_20261026_112312.json` | **已修复**(单测通过;E2E 受端点阻塞) | | F-07 | P2 | 同义/相似词改写后模型能语义选对模板,但出口 A 的确定性闸门(必填覆盖)不通过,最终建 AI 表而非写既有业务表;用户看到“命中模板”却得到新表 | 案件 19 `BILL-02`/`CALL-02` | **已修复**(单测通过;E2E 受端点阻塞) | ## 3. 详细复现 ### F-01(P0)交通银行同义表错配为「农商行交易流水」,数据全部被拒 **复现文件** `docs/testing/fixtures/ai-llm-match-20261026/bills/BILL-09_交通银行_同义.xlsx` 表头(11 列): ```text 银行卡号,借贷方向,发生额,交易日期,交易时间,姓名,对方户名,对方帐号,余额,对方金融机构网点名称,备注 ``` 相对模板 34「交通银行」的改写: ```text 交易卡号→银行卡号;借贷方标志→借贷方向;金额→发生额 ``` **复现步骤** 1. 新建案件(复现使用案件 id=24,名称 `AI大模型匹配话单行业-1026`)。 2. 上传 `BILL-09_交通银行_同义.xlsx`(工作表「交行流水」,表头在第 2 行)。 3. 点击「智能清洗」,等待批次结束(单并发约 1 分钟)。 4. 查看 `ai_clean_job` / 导入文件页 / `trans_record_all`。 **实际结果** ```json { "job_id": 2114558438624731136, "status": "DISPATCHED_A", "exit_code": "A", "matched_by": "SCORE", "match_tier": "STRONG", "ref_template_id": 48, "llm_calls": 1, "confidence": 0.9185, "file_status": "FILE_CLEAN_COMPLETE", "data_num": 10, "success_num": 2, "fail_num": 8 } ``` - `ai_clean_job.ref_template_id = 48`,模板 48 是「农商行交易流水」,不是期望的 34「交通银行」。 - 打分明细(`logs/backend-test5.log`,job 2114558438624731136): ```text AI 模板打分明细: #0 交易方向=SIMILAR(≈交易金额) | #1 交易金额=EXACT | #2 日期=EXACT | #3 交易网点=SIMILAR(≈交易金额) | #4 对方户名=EXACT | #5 对方账号=SIMILAR(≈对方户名) | #6 交易名称=SIMILAR(≈交易金额) | #7 余额=EXACT ← 农商行交易流水 ``` - 8 条 `clean_error_log` 全部是: ```text 第2行 ~ 第9行,[本方卡号] 数据为空 ``` - `trans_record_all` 实际 `total = 0`;文件行却显示 `successNum=2, failNum=8`。 - 导入文件页备注:`AI 匹配模板:农商行交易流水`。 **界面复核** - 案件 24 → 导入文件页,文件行显示:`导入成功 2 / 导入失败 8`,清洗方式/备注在 sheet 行(API 返回 `cleanNote=AI 匹配模板:农商行交易流水`)。 - 同页看不到「业务表 0 行」的事实,用户只会看到“2 条成功”。 **期望结果** - 应命中模板 34「交通银行」,或至少由模型在候选中裁决后走出口 B;绝不能把交行数据交给「农商行」模板清洗。 - 若无法确定,应 `NEED_REVIEW`,不写任何业务表。 **根因线索(代码走读)** 1. `HeaderScorer.score` 的覆盖率为「模板列被文件命中多少」:`coverage = hit / tplSet.size()`;**文件侧多出来的列完全不扣分**(`HeaderScorer.java` 的 `score`)。模板 48 只有 8 列,交行文件 11 列,7 个模板列命中即可拿高覆盖。 2. 相似词规则把 `交易方向` 与 `交易金额` 判为 SIMILAR(等长、共同前缀“交易”、编辑距离 ≤2),掩盖了“借贷方向/发生额”并不是同一个字段。 3. `passesStrong` 只校验候选模板的 `required` 是否覆盖 + 形状安全;候选模板 48 的必填(交易方向/交易金额/交易日期)都“覆盖”了,于是直接出口 A,**没有进入模型候选裁决**。 4. 模板 48 的 `main_id=2`,既有清洗主字段包含必填「本方卡号」;该字段不在文件表头中,导致 8 行全部拒绝,业务表 0 行。 **修复方式(2026-10-26 已实施)** - `HeaderScorer` 新增**文件侧覆盖率**(文件列有多少能在模板上找到归宿),并作为 `score` 的 header 组件之一(`0.60×模板列命中率 + 0.20×Jaccard + 0.20×文件侧覆盖率`);出口 A 另设硬门槛 `FILE_COVERAGE_MIN = 0.6`,`passesStrong` 与 `AiTemplateMatcher.exitOf` 都校验。 - 收紧 `similarWords` 的「等长 + 共同前缀 2 + 编辑距离 2」规则:差异尾部必须至少共享 1 个字,`交易方向/交易金额`、`交易网点/交易金额`、`交易日期/交易时间` 不再判 SIMILAR;`车辆类型/车辆种类`(共享「类」)等既有业务词对保持不变。 - 交行 11 列文件对 8 列模板的文件侧覆盖率为 6/11 ≈ 0.545,低于门槛 → 不再直通,转模型在候选里裁决(模型选交行模板且闸门通过则出口 A,否则出口 B)。 - 单测:`HeaderScorerTest#narrowTemplateCannotHijackWideFile`、`#wideTemplateCoversWholeFile`、`#fuzzyNeverCollapsesDifferentBusinessTokens`。 ### F-01B(P0)话单变体被既有模糊匹配误判为「通信登记信息」,写错表且字段丢失 **复现文件** `docs/testing/fixtures/ai-llm-match-20261026/calls/CALL-09_导出话单_同义.xlsx` 表头(11 列,按导出话单 42 的同义改写): ```text 本机号码,对方号码,通话类型,通话时间,使用人姓名,通话时长, 本方基站大区号,本方基站小区号,本方证件号码,本方号码归属地,对方号码归属地 ``` 改写:`本方号码→本机号码`、`主被叫标识→通话类型`、`本方姓名→使用人姓名`;期望模板 42「导出话单」。 **复现步骤** 1. 新建案件(复现使用案件 id=25)。 2. 上传 `CALL-09_导出话单_同义.xlsx`。 3. 上传结果直接显示 `SHEET_TEMPLATE_MATCHED_SUCCESS`、`templateId=3`(「通信登记信息」),**没有进入 AI 判定**。 4. 智能清洗后查看 `call_opening_info`。 **实际结果** ```json { "file": "CALL-09_导出话单_同义.xlsx", "sheet": "导出话单", "file_status": "FILE_CLEAN_COMPLETE", "template_id": 3, "data_num": 11, "success_num": 11, "fail_num": 0, "expected_template_id": 42 } ``` - `call_opening_info` 实际写入 10 行,只有 `personName` / `personPhone` 有值;`operatorId / activationDateTime / phoneType / phoneStatus` 全为空。 - 通话方向、通话时间、通话时长、对方号码、基站等列全部丢失;文件行仍显示成功 11、失败 0。 - 同一批次内 `CALL-08`(导出话单标准表头)被正确直中模板 42,说明模板本身可用;问题出在变体表头的匹配决策。 **根因线索** `PreDataListener.matchTemplate` 的“部分匹配”分支: ```java if (CollUtil.containsAll(tempFields, headVals)) { ... } // 完全匹配 else if (CollUtil.containsAll(headVals, tempMatchedFields)) { // 只要文件表头包含模板所有 matched=1 字段,就进入部分匹配 matchTemplateMap.put(templateId, intersection.size() + mustSize); } ``` 模板 3 的 `matched=1` 字段只有两个: ```text 使用人姓名, 本机号码 ``` 任何同时包含这两个表头的话单/导出话单,即使还有 9 个完全不同的列,也会成为模板 3 的候选;如果没有更强的完全匹配,就直接返回模板 3 并标记 `SHEET_TEMPLATE_MATCHED_SUCCESS`。AI 清洗只接管 `SHEET_TEMPLATE_MATCHED_FAIL`,所以这类文件永远不会进入 AI 匹配。 同一问题也影响: - `CALL-17_通信日期时间_同义.xlsx`:期望模板 64,实际上传直中模板 3; - `CALL-19_时长格式.xlsx`:期望模板 1,实际上传直中模板 3; - `IND-03_银行开户信息.xlsx`(跨行业组):期望模板 4「银行开户信息」(20 列),实际上传直中模板 2084276161「建设银行开户记录」(6 列),14 列数据不会被映射; - `IND-13_出入境记录.xlsx`(跨行业组):期望模板 52「出入境记录信息」(16 列),实际上传直中模板 596849484「出入境信息查询」(5 列),其余 11 列不会进入清洗。 跨行业例子的模板情况: ```text 模板 4「银行开户信息」matched=1 字段:客户名称, 卡号 模板 2084276161「建设银行开户记录」headers:户名,账号,证件号码,开户日期,余额,销户日期 文件 IND-03 表头:户名,证件号码,卡号,账号,开户日期,余额,可用余额,币种,...(20 列) ``` 文件把 `客户名称` 改成了 `户名`,模板 4 的必填 `客户名称` 不再命中;子模板 2084276161 的 6 个列全部能在文件里找到,于是部分匹配直接返回子模板,20 列里其余 14 列不会进入清洗。 **期望** - 上传阶段的模糊匹配应设置最低覆盖率/列数比例,或只允许“完全匹配”直通;部分匹配应保留为 `SHEET_TEMPLATE_MATCHED_FAIL`,交由 AI 模板匹配裁决。 - 至少不能把「通信记录/导出话单」写进「通信登记信息」表;写错时应显式 NEED_REVIEW,而不是成功。 **修复方式(2026-10-26 已实施)** - `PreDataListener.matchTemplate` 的「部分匹配」分支增加覆盖率阈值:重叠列数(交集 + 必填命中)必须 ≥ `0.6 × max(文件列数, 模板列数)`,且不少于 3 列(小模板按 `min(3, 文件列, 模板列)` 兜底)。 - 覆盖率不足时不再把模板放进候选,sheet 保持 `SHEET_TEMPLATE_MATCHED_FAIL`,由智能清洗的 AI 模板匹配裁决;完全匹配(表头逐字相同 / 模板包含全部表头)不受影响。 - CALL-09(11 列)对模板 3(6 字段、2 个 matched 字段)只重叠 2 列 → 拒绝直通;IND-03(20 列)对 6 列子模板即使全中也只有 6 列 → 拒绝直通;BILL-04(30 列表头 + 3 个额外列,重叠 30)仍正常直中。 - 单测:`PreDataListenerMatchTest#lowCoverageVariantFallsThroughToAi`(真 xlsx + 真 Fesod + 真 DuckDB)、`#exactHeaderStillMatchesAtUpload`、`#partialMatchNeedsBothSidesCoverage`。 ### F-01C(P2)table_info 全量同表头覆盖:345/364 正确,16 错配 + 3 失败 **执行方式** - 按 `table_info` 每个模板的 `headers` 生成 3 行生产风格数据,表头放在模板声明的 `line_no`,共 364 个 xlsx。 - 仅上传、不发起智能清洗(`run_tests.py full-exact --upload-only`),验证上传阶段既有匹配器能否正确直中;文件在 `full-template/`,结果在 `results/full-exact_20261026_113820.json`。 **结果** ```text files=364 sheet_success=361 sheet_fail=3 命中模板 id 与期望一致:345 命中模板 id 不一致:16 未命中(SHEET_TEMPLATE_MATCHED_FAIL):3 ``` **16 个错配(全部是子集模板先到先得)** | 期望模板 | 实际命中 | |---|---| | 57 同住址 | 56 同户人 | | 88496327 成都银行交易流水纪委调单 | 1746257711 | | 155064567 成都银行流水 | 1746257711 | | 687030857 农村商业银行成都开户信息 | 1106262359 | | 759819185 成都银行开户信息(DQXX) | 1720897654 | | 772432199 电信通联记录 | 1940093260 | | 798524514 语音清单 | 1940093260 | | 816952604 平安银行交易流水纪委调单 | 1699503043 | | 1018443797 四川银行 | 5 | | 1020340504 单位工行开户信息 | 847945890 | | 1149206312 成都银行交易(JYLS) | 95695751 | | 1318768623 短信明细记录 | 584544982 | | 1450844531 浦发银行调取账单流水 | 1302533924 | | 1517022216 中国银行信用卡交易记录 | 294976394 | | 1678225864 通信登记信息央地协查 | 23 电信通信登记信息 | | 1820032851 工商银行收付款明细 | 11548586 | **3 个失败** | 模板 | 原因 | |---|---| | 63 在逃同案撤销人员 | `table_info.headers` 有「在逃人员DNA编号」,`table_field` 只有「在逃人员人员DNA编号」,差一个字,完全匹配失败 | | 466958473(名称乱码) | 表头/字段中文均为乱码,且 headers 中有字段名不存在 | | 1652978732(名称乱码) | 同上,多个表头字段在 table_field 中不存在 | **12 个模板存在 headers 字段不在 table_field 中** ```text 6 转入转出账号(日期时间): 日志号, 传票号 7 主账号与收付款账号组合(日期时间): 户名, 可用余额 10 主叫被叫: cell_id, BILLING_VISIT_AREA_CODE通话地 11 借贷方发生额(日期时间): 明细号 17 同航班服务: 乘机时间, 到达日期, 到达时间 19 铁路订票信息查询: 发车时间 24 快递收发子模版: 物品名称 58 同车违章: 车牌号码 63 在逃同案撤销人员: 在逃人员DNA编号 466958473 / 1652978732: 名称/字段乱码 + 多个字段缺失 543445511 郴州农商银行: 本方卡号 ``` **结论** - 这不是 AI 模板匹配代码引入的问题,而是 `table_info.headers` 与 `table_field` 元数据不一致,以及既有匹配器「子集模板先到先得」的既有行为。 - 但它直接影响用户看到的结果:同表头文件会命中另一个模板,或在 AI 兜底前就被错误路由;修复时应优先统一元数据,再考虑子集模板的歧义消解。 ### F-02(P1)完成统计把标题/表头算成导入成功 **复现 A(业务表 0 行但显示成功 2)** - 文件/案件/job 同 F-01。 - 导入文件页:`数据量 10 / 导入成功 2 / 导入失败 8`;`trans_record_all total=0`。 - 8 条失败日志对应 8 个数据行,成功 2 很可能是标题行与表头行,但它们并没有落入业务表。 **复现 B(UI 32 条,实际 30 行)** - 案件 19,批次 `1792983591254364400`:`BILL-01_通用流水_标准.xlsx`(标题行 + 表头 + 8 数据行,既有直中)、`BILL-02`(AI 10 行)、`CALL-02`(AI 12 行)。 - UI 完成页显示「导入成功 32 条」,处理日志「成功 32 行」。 - 实际:既有表 `trans_record_all` 8 行 + AI 表 10 行 + AI 表 12 行 = 30 行。 - `ai_clean_batch.loaded_rows = 22`(只统计 AI 写入),既有链路按 `file_info.dataNum - failNum = 10 - 0 = 10` 计入,合计 32。 **根因线索** - `DmService` 完成统计: ```java int dataNum = 0, failNum = 0; for (FileInfo child : fileInfoList) { dataNum += child.getDataNum(); // 上传预览的物理行数(含标题/表头) failNum += child.getFailNum(); } int completedCount = dataNum - failNum; ``` - `AiSmartCleanService` 终态统计同样用 `dataNum - failNum` 作为既有 sheet 的“成功行数”。 - 因此只要文件有标题行/表头行,`dataNum` 就比真实数据行多,完成页会多报;而 AI 动态表用的是 `rowLoaded`,两种口径混在同一张卡里。 **期望** - 「导入成功(条)」必须等于实际落库数据行;标题/表头/空行不计入成功,也不应与 AI 的 `rowLoaded` 混算。 ### F-03(P1)既有清洗金额格式化值写成 0.00 **复现文件**:`bills/BILL-01_通用流水_标准.xlsx`(直接命中模板 2,走既有清洗)。 **实际** | 源值 | `trans_record_all.transAmount` | |---|---| | `1,234.50` | `0.00` | | `-100.00` | `0.00` | | `200.50` | `200.50` | | `¥2,300.50` | `0.00` | | `100.00` | `100.00` | | `¥88.88` | `0.00` | | `5,000.00` | `0.00` | | `45.60` | `45.60` | **根因线索** `TransStdDataCleaner`(main_id=2 使用)对 MONEY 字段: ```java if (ReUtil.isMatch(PatternPool.MONEY, copyContent)) { ... } else { copyContent = "0.00"; // 不匹配就静默写 0,不报错 } ``` 带千分位、货币符号、负号的金额不匹配 `PatternPool.MONEY`,被静默改写为 0.00;行仍然算成功。该问题在既有链路,不是 AI 模板匹配新增,但会被 AI 出口 A 复用,影响生产账单。 **期望** - 金额解析应支持千分位、¥/¥、括号负数、正负号;无法解析时应置空并计入 warning/失败,而不是静默 0.00。 ### F-03B(P1)AI 动态表方向字典写成整串别名 **复现** - `CALL-18_主被叫方向.xlsx`(案件 25,AI 模板 `2114559632961183744`)源值只有 `主叫/被叫/呼入/呼出`。 - `/aiClean/data/page?aiTemplateId=2114559632961183744` 实际: ```text c3 = "主叫,短信发送话单,呼出,01,主叫话单" c3 = "被叫,短信接收话单,呼入,02,被叫话单" c3 = "呼入" ``` - `CALL-02`、`CALL-03`、`CALL-10` 的 AI 表同样出现整串别名。 - 账单 AI 表 `BILL-02` 的 `借贷方向` 则保留 `借/贷/D/C/收/付` 原值,没有归一成统一方向。 **根因线索** - `table_field.direction_conf` 的格式是「标准方向 → 逗号分隔别名」: ```json {"主叫":"主叫,短信发送话单,呼出,01,主叫话单", "被叫":"被叫,短信接收话单,呼入,02,被叫话单"} ``` - `AiRecordCleaner.translateDirection` 直接 `dict.getOrDefault(v, v)`: - 当输入 `主叫` 时返回整串 `主叫,短信发送话单,...`; - 正确做法应是**反向建表**:把每个别名映射回标准方向(与 `GlobalCache.directionConfMap` 的 `tableId_value → key` 逻辑一致),或至少当输入等于 key 时返回 key。 - 该函数对所有 `DEBIT_CREDIT_FLAG` / `CALL_DIRECTION` 列生效;账单因为对应字段没有 `direction_conf`,才侥幸保留原值。 **期望** - AI 动态表方向列应写入标准方向(主叫/被叫;收入/支出),不能把别名词典整串落库。 **修复方式(2026-10-26 已实施)** - `AiRecordCleaner` 的方向翻译改为**反向建表**:按「标准方向 → 逗号分隔别名」解析后,建立 `别名/标准词 → 标准方向` 映射(与 `GlobalCache.directionConfMap` 的 `tableId_别名 → key` 同构);输入 `主叫` 返回 `主叫`,输入 `呼出/01/主叫话单` 同样返回 `主叫`。 - 字典按 JSON 原文缓存,避免逐格重复解析;坏 JSON 只记日志,不把整行判无效。 - 字典缺失或未命中时走兜底方向:`DEBIT_CREDIT_FLAG` 归一为 `收入/支出`(借=D=付 → 支出,贷=C=收 → 收入),`CALL_DIRECTION` 归一为 `主叫/被叫`(呼出 → 主叫,呼入 → 被叫);不认识的值仍原样保留。 - 单测:`AiRecordCleanerTest#directionDict`(生产字典格式、标准词命中、别名命中、无字典兜底、坏 JSON)。 ### F-04(P1)默认 3 并发与本地单会话模型不匹配 **复现** - 默认 `zsjz.ai-clean.max-concurrent=3`,一次批次 6 个 AI sheet。 - 日志(`logs/backend-test2.log`): ```text 11:06:42 ERROR LlmRetry: AI 模板匹配 第 1 次调用失败,1051ms 后重试: 模型调用超时(总时长 180s): Ollama:qwen 11:06:42 ERROR ... 第 1 次调用失败 ... 11:07:12 ERROR ... 第 1 次调用失败 ... ``` - 批次长时间停在 `JUDGING`,`ai_judged_count` 不增长;单并发后同一模型单次 30–120s 正常完成。 **结论** - 这是环境/默认配置不匹配,不是匹配逻辑错误;用户已确认本地模型只支持一个并发会话。 - 建议:本地模型默认 `max-concurrent=1`,或按模型提供方的并发能力配置;同时在 README/启动参数中写明。 ### F-04B(P1)文件 AI 识别与 AI 清洗争用单会话模型 **复现** - 同一批次同时上传 `EDGE-04_说明.txt`(非表格,触发文件 AI 识别)和一个待 AI 判定的表格 `EDGE-06_表头第10行.xlsx`。 - `logs/backend-test5.log`: ```text 11:34:18 ERROR [file-ai-0] 文件 AI 识别 第 1 次调用失败: 模型调用失败[Ollama:qwen]: HTTP transport error during streaming: SSE/NDJSON stream failed: java.io.IOException: HTTP/1.1 header parser received no bytes 11:35:04 ERROR [ai-clean-14] 直连模型调用失败: model=Ollama:qwen, error=HTTP transport error during streaming: ... header parser received no bytes 11:35:04 INFO AI 清洗任务结束: jobId=..., status=FAIL, 原因=模型未返回可解析的规则草稿 11:34:59 ERROR 文件 AI 识别失败: fileName=EDGE-04_说明.txt, 耗时=59939ms ``` - 同一时间段 `zsjz.file-ai.max-concurrent` 仍是 3(启动日志 `并发=3`),而 AI 清洗已经降到 1;两个调度器各自持有并发,底层模型只有一个会话。 **结论** - 单并发约束必须全局生效:`ai-clean`、`file-ai`(以及其它直调 `LlmService` 的链路)需要共享同一个模型信号量,或至少把 `file-ai.max-concurrent` 也设为 1。 - 这类争用表现为“模型返回不可解析/HTTP 断流”,容易被误判为模型能力问题;本轮 `EDGE-06` 的 FAIL 就是由此触发。 ### F-05(P2)AI 动态表与既有模板同名,日志无法区分 案件 19 完成页: ```text 「BILL-02_通用流水_同义词.xlsx」·银行流水 → 已写入 10 行(表「银行交易流水」) ``` - 该表是出口 B 新建的 `ai_clean_*` 动态表,中文名与既有模板「银行交易流水」完全相同。 - 用户从日志/治理树看到同名表,无法判断数据进了既有业务表还是 AI 表;建议 AI 表名加前缀/标记(如「AI-银行交易流水」),或在日志中明确「AI 动态表」。 **修复方式(2026-10-26 已实施)** - `AiCleanService.buildTableNameCn` 统一给 AI 动态表中文名加 `AI_` 前缀(幂等,sheet 名本身已带 `AI_` 时不重复叠加;仍受 200 字符列宽约束)。治理树、导入文件备注、处理日志都直接读该字段,所以一处生效。 - 处理日志改为点名目标:`已写入 N 行(AI 动态表 · ···)`;导入文件页备注区分为「AI 匹配既有模板:X」与「AI 新建动态表:Y」。 - 已存在的 AI 模板不改名(复用路径沿用存档名),只影响新建表。 - 单测:`AiCleanTableNamingTest`(前缀、幂等、长度上限)。 ### F-06(P2)出口 A 二次 askLlm 不校验 templateNo - BILL-09 的 `ai_clean_job.draft`: ```json {"templateNo":69, ...} ``` - 该路径只发送了 1 个候选(模板 48),模型却返回 69(越界);代码仍使用其 `binds` 做列映射。 - `filterBinds` 会过滤不存在的字段名,所以没有直接写脏列,但: - 模型输出已明显不符合“只准回候选序号”的约束; - 若 binds 恰好都能在候选模板中找到,仍会被采信,存在错绑风险。 - 建议:二次 `askLlm` 同样校验 `templateNo == 1`(唯一候选),不一致时丢弃 binds,转 NEED_REVIEW。 **修复方式(2026-10-26 已实施)** - 二次 `askLlm` 的候选先重编号为 `#1`(`AiTemplateMatcher.singleCandidate`),提示词序号与后端校验口径一致。 - 新增 `AiTemplateMatcher.candidateOf(candidates, draft)`:序号缺失、≤0、越界一律返回 null,两条模型调用路径共用。 - `AiCleanService.judge` 在二次调用后强制校验:`templateNo != 1` 时记录 ERROR 日志、保留原始草稿(`ai_clean_job.draft` 便于复盘)、丢弃 binds 并转 NEED_REVIEW。 - 单测:`AiTemplateMatcherTest#singleCandidateMustAnswerNumberOne`(含 `templateNo=69` 场景)。 ### F-07(P2/观察)同义词命中但出口 A 闸门不通过,最终建 AI 表 - BILL-02(查询卡号→银行卡号、借贷标志→借贷方向、交易金额→发生额、交易时间→交易时点)模型正确选中「银行交易流水」,置信 0.95,但确定性必填覆盖不通过,最终出口 B 新建 AI 表。 - CALL-02 同理(本机电话号码/呼叫类型/通话开始时间)。 - 这是“安全优先”的设计结果,不是错写;但从用户视角,模型已明确命中模板却仍得到一张新表,且日志写“命中模板……准备入库”,容易误解。 - 建议:UI/日志明确出口 A/B;评估是否在模型 binds 可靠时放宽必填覆盖,或提示“已命中模板但必填列需确认”。 **修复方式(2026-10-26 已实施,按“不改安全阈值、只把出口说清楚”处理)** - 出口 A 的处理日志:`命中既有模板「X」,走出口 A 写入既有业务表`。 - 出口 B 且候选是既有模板时:`模型命中既有模板「X」,但出口 A 闸门(档位/置信度、必填覆盖、确定性分数、文件列覆盖)未通过,改走出口 B 写入 AI 动态表`;候选是 AI 模板/按表头新建时也各自点名。 - 入库日志:`已写入 N 行(AI 动态表 · <来源>)`;导入文件页备注区分为「AI 匹配既有模板」/「AI 新建动态表」,配合 F-05 的 `AI_` 前缀可直接分辨。 - 确定性闸门本身**不放松**:出口 A 写的是用户既有业务表,错写不可逆;模型自评高不能单独决定出口 A。 - 单测:`AiSmartCleanServiceTest#exitBLogExplainsExistingTemplateDowngrade`。 ## 4. 执行结果(已跑部分) | 用例 | 文件 | 结果 | |---|---|---| | UI-01~07 | 案件 19 | 上传→智能清洗→进度/日志/结果页全链路可用;AI 阶段 0.95 判定;完成统计见 F-02 | | BILL-01 | 标准通用流水 | 既有直中;金额清洗见 F-03 | | BILL-02 | 同义通用流水 | 模型 0.95 选中「银行交易流水」;出口 B 10 行;跨案第二次 HEADER_CACHE 复用 | | BILL-03 | 相似词通用流水 | SCORE 出口 A,模板 2,置信 0.9320 | | BILL-04 | 乱序+增列+必填换词 | SCORE 出口 A,模板 2,置信 0.9275 | | BILL-09 | 交行同义 | **P0 错配模板 48,0 行落库** | | BILL-10 | 光大同义 | SCORE 出口 A,模板 35,置信 0.9320 | | BILL-11 | 工行同义 | SCORE 出口 A,模板 36,置信 0.9291 | | BILL-12 | 招行同义 | 主模型调用完成,因停止批次未收口(未计入结论) | | CALL-01/02/03/08/09/10/17/18/19 | 话单核心 | 标准话单直中模板 1/42;CALL-02/03/10/18 走 AI 出口 B(12/12/10/12 行);CALL-09/17/19 被模板 3 吞掉(F-01B) | | ALL-01 | 364 模板同表头 | 345 正确 / 16 错配 / 3 失败(详见 F-01C) | ## 5. 未执行/受限说明 - 本地 27B 模型单并发下单次约 60–120s;为覆盖更多场景,银行 13–16、部分账单独有场景、全量 364 模板的 AI 变体未全部跑完。已保留生成器与 manifest,可随时补跑。 - 全量模板同表头 364 文件已执行上传覆盖;全量 AI 变体(ALL-02)未执行,文件在 `full-template-variants/`,可按需补跑。 - UI 截图受内置浏览器可见性限制,关键结论均有 `ai_clean_job`、`clean_error_log`、服务日志与导入文件 API 数据佐证。 ## 6. 修复记录(2026-10-26) > 范围:仅 F-01、F-01B、F-03B、F-05、F-06、F-07。F-01C(模板元数据质量)、F-02(完成统计口径)、F-03(既有金额清洗)、F-04/F-04B(并发配置)按要求不修改。 > 验证方式:本轮改动全部有单测覆盖(`mvn -pl ai-server test` 的 aiclean / dm 相关用例全绿);未重跑端到端模型链路(本地模型单会话,见第 5 节)。 | 编号 | 代码位置 | 改动要点 | |---|---|---| | F-01 | `HeaderScorer`、`AiTemplateMatcher#exitOf` | 新增文件侧覆盖率并计入分数与出口 A 硬门槛(`FILE_COVERAGE_MIN=0.6`);收紧等长相似词规则(差异尾部必须共享至少 1 个字) | | F-01B | `PreDataListener#matchTemplate` | 部分匹配要求重叠列 ≥ `0.6×max(文件列数,模板列数)` 且 ≥3 列;不足则保持 `SHEET_TEMPLATE_MATCHED_FAIL` 交 AI | | F-03B | `AiRecordCleaner` | `direction_conf` 反向建表(别名/标准词 → 标准方向)+ 解析缓存 + 缺失字典时的收支/主被叫兜底 | | F-05 | `AiCleanService`、`AiSmartCleanService`、`DmService` | AI 动态表中文名统一 `AI_` 前缀(幂等、≤200 字);处理日志与导入备注点名「AI 动态表 / 既有业务表」 | | F-06 | `AiTemplateMatcher`、`AiCleanService` | 二次 `askLlm` 单候选重编号为 `#1`;`templateNo` 缺失/越界丢弃 binds 转 NEED_REVIEW,原始草稿仍落库供复盘 | | F-07 | `AiCleanService`、`AiSmartCleanService`、`DmService` | 出口 A/B 的日志与备注显式区分;模型命中既有模板却被闸门挡下时,日志写明原因;安全阈值不放松 | 新增/更新的单测: ```text module/aiclean/match/HeaderScorerTest # F-01 文件侧覆盖率 + 相似词收紧 module/aiclean/match/AiTemplateMatcherTest # F-06 candidateOf / singleCandidate module/aiclean/exec/AiRecordCleanerTest # F-03B 方向字典反向 + 兜底 module/aiclean/service/AiCleanTableNamingTest # F-05 AI_ 前缀 module/aiclean/service/AiSmartCleanServiceTest # F-07 出口 B 日志说明 module/dm/pre/PreDataListenerMatchTest # F-01B 上传匹配(真 xlsx/Fesod/DuckDB) ``` > 补充:全量 `mvn -pl ai-server test` 存在一个与本轮改动无关的既有失败 > (`PreDataListenerRawTest#previewTableFeedsAiLoader` 在整包运行时 `SpringUtil` 解析到真实的 > `CaseRoutingDataSource`,初始化预览表抛「请先打开案件后再操作数据」;用改动前的代码跑整包同样复现, > 该类单独运行通过),本轮未处理。 ## 7. 修复复测记录(2026-10-26 12:30–12:37) > 代码:`6396cac`;后端重启加载最新构建;本地模型单并发。 > 单测:`mvn -pl ai-server clean test -Dtest='HeaderScorerTest,AiTemplateMatcherTest,AiRecordCleanerTest,AiCleanTableNamingTest,AiSmartCleanServiceTest,PreDataListenerMatchTest'` > → **62 tests, 0 failures, 0 errors**(`logs/retest-unit.log`)。 > 复测样本/结果:`docs/testing/fixtures/ai-llm-match-20261026/retest/`(案件 27)。 | 编号 | 复测方式 | 结果 | |---|---|---| | F-01 | 新案件上传 BILL-09 同义表 + 智能清洗 | 上传阶段 `SHEET_TEMPLATE_MATCHED_FAIL`(不再直中模板 48);job `SELF_HEADER`、出口 B、8/8 行写入 `AI_交行流水`,业务表未写入。**PASS(修复的错配/丢数据路径已关闭)** | | F-01B | 新案件上传 CALL-09/CALL-17/CALL-19/IND-03 | 四者上传全部 `SHEET_TEMPLATE_MATCHED_FAIL`,不再命中模板 3 / 子模板 2084276161 / 596849484;CALL-09/17/19 各自建 AI 表并全列入库 10/10/12 行。**PASS(上传闸门修复生效)**;IND-03 因模型端点异常 `FAIL`,但未写错表。 | | F-03B | 复用修复前的 CALL-18 / BILL-02 AI 模板(HEADER_CACHE,零模型) | CALL-18 `CALL_DIRECTION` distinct = `[主叫, 被叫]`;BILL-02 `DEBIT_CREDIT_FLAG` distinct = `[支出, 收入]`。**PASS** | | F-05 | 新表命名 + 导入文件备注 | 复测新建表:`AI_交行流水`、`AI_导出话单`、`AI_通信记录日期时间`、`AI_通信记录`、`AI_银行流水`;导入备注均为「AI 新建动态表:AI_xxx」。**PASS** | | F-06 | 单测 `AiTemplateMatcherTest#singleCandidateMustAnswerNumberOne` | 通过;模型端点异常,未能在 E2E 中自然复现 `templateNo=69`。**单测 PASS** | | F-07 | 单测 `AiSmartCleanServiceTest#exitBLogExplainsExistingTemplateDowngrade` | 通过;模型端点异常,未能在 E2E 中触发“模型命中既有模板但闸门未过”。**单测 PASS** | ### 7.1 复测期间的外部阻塞 复测重启后,本地模型端点出现新异常: ```text 短提示(如“回复OK”、50 字以内)→ 200 正常流式返回 长提示(约 150–270 字以上)→ 连接被远端重置: HTTP transport error during streaming: SSE/NDJSON stream failed: java.io.IOException: HTTP/1.1 header parser received no bytes ``` - 复测批次 7 个文件:6 个因此走 `SELF_HEADER` 安全兜底,1 个(IND-03)`FAIL: 模型未返回可解析的规则草稿`。 - **这不是本轮修复引入的回归**:同一模型端点在修复前的长提示(6k tokens)可正常返回;本轮复测时短提示正常、长提示断连,属于外部模型服务状态异常。 - 因此 F-01/F-01B 的**上传闸门、安全兜底、零错表**已 E2E 验证;**模型正确选择模板的 E2E 分支**未能完成,需模型端点恢复后补跑。 - F-06/F-07 的安全行为已由单测覆盖;E2E 同样受该外部阻塞影响。 ### 7.2 复测结论 - 已修复项中,**F-03B、F-05 复测通过(含真实数据页核验)**;**F-01、F-01B 的故障路径(错表/吞文件)已关闭**;**F-06、F-07 单测通过**。 - 本轮未发现修复引入的新问题:新 AI 表命名、方向值、上传状态、导入备注均符合预期。 - 待模型端点恢复后,建议补跑 F-01/F-01B 的“模型命中正确模板 → 出口 A”分支,以及 F-07 的 E2E 日志展示。 - 复测证据: - 已入库:`retest/retest_evidence.json`、`retest/retest_fixes.py`、`retest/RT_*.xlsx`; - 本机日志(`.gitignore` 忽略,不随提交):`retest/retest_run.log`、`logs/backend-retest.log`、`logs/retest-unit.log`。 ### 7.3 复测中的残留观察(未列入本轮修复范围) - **SELF_HEADER 自建表的方向类型未推断**:模型端点异常时,`CALL-18`/`BILL-02` 的新建 AI 表把「呼叫类型/借贷方向」推断为 `TEXT`,数据里仍是 `主叫/被叫/呼入`、`借/贷/D`,没有归一成 `主叫/被叫`、`收入/支出`;而带 `CALL_DIRECTION`/`DEBIT_CREDIT_FLAG` 类型的 HEADER_CACHE 表已正确归一(F-03B 复测通过)。 - 这是「无模型兜底时按表头名推断类型」的粒度问题,不是 F-03B 字典反向修复本身;是否要扩展到自建表类型推断,建议单独评估。 - F-05 的 `AI_` 前缀只作用于新建模板;修复前已存在的 AI 模板(HEADER_CACHE 复用)保留原名,这是设计约定,复测中已确认。