|
|
@@ -0,0 +1,541 @@
|
|
|
+# 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 表 | **已修复**(字典反向 + 兜底归一) |
|
|
|
+| 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_ 前缀 + 日志点名 AI 动态表) |
|
|
|
+| F-06 | P2 | 出口 A 的二次 `askLlm` 只送 1 个候选,但模型返回 `templateNo=69` 越界序号;该路径不校验序号,仍使用模型 binds | 案件 24 draft 见 `results/bill_20261026_112312.json` | **已修复**(单候选重编号 + 序号校验) |
|
|
|
+| F-07 | P2 | 同义/相似词改写后模型能语义选对模板,但出口 A 的确定性闸门(必填覆盖)不通过,最终建 AI 表而非写既有业务表;用户看到“命中模板”却得到新表 | 案件 19 `BILL-02`/`CALL-02` | **已修复**(日志/备注明确出口 A/B) |
|
|
|
+
|
|
|
+## 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`,初始化预览表抛「请先打开案件后再操作数据」;用改动前的代码跑整包同样复现,
|
|
|
+> 该类单独运行通过),本轮未处理。
|