ai-llm-template-match-bug-report-20261026.md 37 KB

AI 大模型模板匹配 · 严格测试问题报告(2026-10-26)

对应用例文档: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 列):

银行卡号,借贷方向,发生额,交易日期,交易时间,姓名,对方户名,对方帐号,余额,对方金融机构网点名称,备注

相对模板 34「交通银行」的改写:

交易卡号→银行卡号;借贷方标志→借贷方向;金额→发生额

复现步骤

  1. 新建案件(复现使用案件 id=24,名称 AI大模型匹配话单行业-1026)。
  2. 上传 BILL-09_交通银行_同义.xlsx(工作表「交行流水」,表头在第 2 行)。
  3. 点击「智能清洗」,等待批次结束(单并发约 1 分钟)。
  4. 查看 ai_clean_job / 导入文件页 / trans_record_all。

实际结果

{
  "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):

    AI 模板打分明细: #0 交易方向=SIMILAR(≈交易金额) | #1 交易金额=EXACT | #2 日期=EXACT |
    #3 交易网点=SIMILAR(≈交易金额) | #4 对方户名=EXACT | #5 对方账号=SIMILAR(≈对方户名) |
    #6 交易名称=SIMILAR(≈交易金额) | #7 余额=EXACT ← 农商行交易流水
    
  • 8 条 clean_error_log 全部是:

    第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 的同义改写):

本机号码,对方号码,通话类型,通话时间,使用人姓名,通话时长,
本方基站大区号,本方基站小区号,本方证件号码,本方号码归属地,对方号码归属地

改写:本方号码→本机号码、主被叫标识→通话类型、本方姓名→使用人姓名;期望模板 42「导出话单」。

复现步骤

  1. 新建案件(复现使用案件 id=25)。
  2. 上传 CALL-09_导出话单_同义.xlsx。
  3. 上传结果直接显示 SHEET_TEMPLATE_MATCHED_SUCCESS、templateId=3(「通信登记信息」),没有进入 AI 判定。
  4. 智能清洗后查看 call_opening_info。

实际结果

{
  "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 的“部分匹配”分支:

if (CollUtil.containsAll(tempFields, headVals)) { ... } // 完全匹配
else if (CollUtil.containsAll(headVals, tempMatchedFields)) {
    // 只要文件表头包含模板所有 matched=1 字段,就进入部分匹配
    matchTemplateMap.put(templateId, intersection.size() + mustSize);
}

模板 3 的 matched=1 字段只有两个:

使用人姓名, 本机号码

任何同时包含这两个表头的话单/导出话单,即使还有 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 列不会进入清洗。

跨行业例子的模板情况:

模板 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。

结果

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 中

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 完成统计:

    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 字段:

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 实际:

    c3 = "主叫,短信发送话单,呼出,01,主叫话单"
    c3 = "被叫,短信接收话单,呼入,02,被叫话单"
    c3 = "呼入"
    
  • CALL-02、CALL-03、CALL-10 的 AI 表同样出现整串别名。

  • 账单 AI 表 BILL-02 的 借贷方向 则保留 借/贷/D/C/收/付 原值,没有归一成统一方向。

根因线索

  • table_field.direction_conf 的格式是「标准方向 → 逗号分隔别名」:

    {"主叫":"主叫,短信发送话单,呼出,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):

    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:

    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 完成页:

「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:

    {"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 的日志与备注显式区分;模型命中既有模板却被闸门挡下时,日志写明原因;安全阈值不放松

新增/更新的单测:

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 复测期间的外部阻塞

复测重启后,本地模型端点出现新异常:

短提示(如“回复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 复用)保留原名,这是设计约定,复测中已确认。