tough-delta-roach.md 49 KB

AI 清洗并行链路 + 双出口入库 + AI 同步治理方案(v7.5)

版本记录(每轮修订标题版本号递增并在此表补一行;正文有实质变更才升位)

版本 变更
v1 三方案对比;推荐"AI 决策一次 + 确定性算子批量执行",含结构归一层与全自动安全网;要求给 table_field 加 fun_list 列
v2 改为完全并行独立链路:不碰既有执行层与模板库结构;清洗函数按库共用;接管点只读 SHEET_TEMPLATE_MATCHED_FAIL
v3 尝试让 AI 模板进 table_info + 动态建表 → 发现必然要求 StrategyType 加 AI_GENERIC 并改 setDataLoader 签名,放弃
v4 读出"治理树节点由 table_info(has_table=YES) 生成"→ 提出"只写元数据行、matched 恒 0"绕开执行面
v4.1 加版本号与变更记录表
v5.0 纠正落库判据为双出口:AI 命中既有模板 → 组 DoCleanDTO 走现成 DmService.doClean 落既有业务表;没合适模板才落 AI 动态表
v6.0 出口 B 的表单元数据也不进 table_info/table_field,另存 AI 独立表单元数据表(ai_clean_template/ai_clean_field);分类树改为统计时合并两棵树的节点。连带收益:matched=0 安全阀不再需要、has_table 不再使用、"缺表打爆既有治理批量 SQL"的风险消失、开案补建钩子那 1 行改动取消 → 既有文件改动归零
v7.0 ① 分类树不再查询时合并:改为 AI 侧独立治理(AiGovernService.calcAiTree)把节点直接写进既有 govern_tree(AI 节点用负 id),前后端取树逻辑都不用改;只保留"点 AI 节点取数"这一处 id<0 分支(受事实 9 所迫)。② 新增「编排选型:直调 LLM 还是写 Agent」一节 —— 结论:主链路用确定性编排 + 直调 chatAs,agent 只以受限反思环(≤2 轮 repair)出现,自主 ReAct 仅用于旁路只读诊断
v7.1 两项决策定稿:① AI 树节点采用同步治理 —— 在 GovernService.executeTreeCalculationTasks() 末尾(:320 之后、:321 进度播报之前)加一行 aiGovernService.calcAiTree(...),跑完治理树里立即含 AI 节点、无窗口;这是全案唯一一处既有代码改动,1 行。② 清洗链路只直调 LLM,不引入 Agent —— 撤掉旁路 ReAct 诊断(不在本方案范围),S5 失败后的 repair 也保持"Java 定死循环、再调一次 chatAs"的形态
v7.2 P0 后端已落地(module/aiclean 44 个类 + 1 prompt + 1 SQL;git diff 既有文件只有 GovernService +5 行)。见新增「十四、实现进度」一节,其中记录了实现中查到的两处新事实与一处 P0 主动收窄
v7.3 单元测试补齐:36 条全绿(新增 7 类真实 xlsx 探测、真 DuckDB 执行编译 SQL、匹配三腿 mockito、清洗器类型转换、校验门)。跑真 DuckDB 当场抓到两个 bug:① 生成的剔行条件漏了 NOT,语义完全反了(只留下合计行);② 本项目 DuckDB 版本没有 btrim,必须用 trim,而 JDBC 会把这条真实错误包装成「closed pending query result」—— 只看 SQL 字符串快照永远发现不了。据此把 RowPatternEnum 从正则改为闭集 LIKE 前后缀、把编译器从「逐步建临时表」改为「一条组合语句」
v7.4 环境连通后补测:全仓 247 条单测全绿(contextLoads 也通过 → 新 bean 与 @Mapper 注册被容器接住、无循环依赖)。新增 AiDuckDbTest(6) 与 AiGovernServiceTest(6),都真跑 DuckDB:动态表建表/写入/计数/翻页/按 job 撤销、负 id 治理节点、缺表跳过、重跑幂等、模板库读失败不连累治理。为此给 AiDuckDb 加测试注入点 useDataSource(DataSource)(仅测试用,生产仍走案件路由)。抓到并修掉一个会静默丢数据的 bug:insertBatch 写失败时返回「已写 0 行」,调用方当成清洗成功 —— 改为返回 -1,AiCleanService 对 written <= 0 一律判 FAIL,且写失败时不刷治理节点
v7.5 真库验证:sql/ai_clean.sql 在 dev PG(18.4)上 12/12 语句执行成功,4 张表落地并回读校验列与类型。新增 AiCleanSchemaTest(3,离线:实体注解 ↔ DDL 列名一一对齐 + 三个关键约束在位) 与 AiCleanPgRoundTripTest(4,默认跳过,加 -Daiclean.pg.it=true 才连真库跑:register 生产入口、雪花回填、header_md5 唯一、job 幂等唯一键、IEnum 落库形态、终态标志)。全仓 254 条 0 失败。真库跑第一次就把一个坑顶出来:table_name_en NOT NULL 但值依赖只有插完才有的 id,只能走 AiCleanStore.register(先补 id 再补表名),手插必挂 —— 测试因此改成走生产入口而不是自己拼字段

Context

断点:PreDataListener.java:340-395 纯中文列名集合打分匹配模板库,未命中置 SHEET_TEMPLATE_MATCHED_FAIL(:304),清洗阶段跳过(DmService.java:749-751、CleanTask.java:59-76)→ 模板库覆盖不到的文件只能人工配规则,否则数据丢失。

定稿原则(经五轮确认):

  1. 既有代码只允许改 1 行(同步治理挂载点,见第六节);除此之外清洗执行层(CleanFactory/StrategyType/StreamingEtlListener/34 个 cleaner/loader/AbstractData*)、治理取数与建树逻辑(GovernTreeService)、模板库(table_info/table_field 的结构与数据行)一律不动。
  2. 两套逻辑并行:AI 链路是与 module/dm 平级的独立模块,只读既有状态接管。
  3. 入库双出口:AI 先用文件匹配你的既有模板库——命中 → 走现成 doClean、数据落该模板对应的既有业务表;确实没有合适模板 → 才建 AI 模板、数据落 AI 自己的动态表。
  4. 元数据两套各存各的:AI 模板存 ai_clean_template/ai_clean_field,不写 table_info;但分类树共用既有 govern_tree —— 由 AI 侧独立治理把自己的节点写进去(负 id),取树的前后端逻辑零改动。
  5. 清洗函数共存:共用同一批 Fun* 实例(不复制实现、不改签名),AiRuleCompiler 是唯一新增映射逻辑。
  6. 全自动入库、事后可查:不设人审关口,但置信门不达标就不写(转 NEED_REVIEW),且按 job 可撤销。

一、支撑本方案的关键事实(全部读码核实)

# 事实 证据 用途
1 FunProcess 是零依赖纯链式执行器:create().add(Fun).process(String),字段只有 List<Fun> + 异常回调 FunProcess.java:14-91 ✅ "清洗函数共存"的技术依据:AI 侧内存构造 Fun 直接调用,绕开 TableRuleDTO 的 @JsonTypeInfo(@class)(否则等于把任意类名反序列化面交给模型输出)
2 DmService.doClean(DoCleanDTO) 是 public;DoCleanDTO={List<FileInfo> fileInfos, Integer reClean, Long fileId} DmService.java:650、DoCleanDTO.java:9-13 ✅ 出口 A 的复用入口:AI 像前端一样组 DTO 提交
3 FileInfo.tableRuleList 是 @TableField(exist=false) —— 规则只走请求体、不落库;templateId 才是 DB 列 FileInfo.java:150-151 vs :60-61 ✅ 出口 A 能带 AI 规则且不需要给 table_field 加列;⚠️ 规则无法被既有链路"记住",故同类文件第二次仍由 AI 重出规则(零模型重放靠 AI 侧 ai_clean_rule 缓存)
4 既有默认列绑定是表头中文名精确等值 table_field.field_name_cn PreDataListener.getTableRuleList:400-422 ✅ 界定了 AI 的增值点:换词/错别字/别名场景默认推导大面积绑不上
5 清洗要求 filePath 磁盘真实存在,否则跳过 DmService.hasSourceFile:764-773 ✅ S1 重读原文件的前提成立
6 govern_tree 是每案件 DuckDB 表,列为 (id BIGINT PK, pid, type, tableNameEn, tableNameCn, dataCount) sql/case_table_1.sql:178-187 ✅ AI 节点直接写这张表(id 取负,BIGINT 主键容纳负值),不建新树表
7 治理每次 truncate govern_tree 后从 table_info where has_table=YES 重建,id=table_info.id、pid=0、type 恒 'table',且把表名拼进批量 SQL GovernService.calcTreeTemplateDataCnt:104-123 ✅ AI 模板不写 table_info → 既有治理永远不会 SELECT 到 AI 表,缺表打爆治理的风险消失;⚠️ 但 AI 节点必须在同一任务内、既有节点写完之后补写(第六节同步治理挂载点);⚠️ type 恒为 'table' → 分流判据改用 id 正负
8 树构建:getTree() = TreeUtils.build(list(... gt dataCount 0)),public GovernTreeService.java:85-87 ✅ 取树逻辑完全不用改:AI 节点只要写进 govern_tree 且 dataCount>0 就自然入树;归零即自动隐藏
9 通用节点取数依赖 MyBatis-Plus 实体注册:TableInfoHelper.getTableInfo(tableName).getEntityType() GovernTreeService.java:494-498 ❌ AI 动态表无 Java 实体 → 点 AI 节点必然 NPE → AI 节点必须走独立查询接口,且分流判据要稳(见第六节用负 id,不用表名前缀)
10 案件库建表的既有约定:开案唯一入口幂等补建、失败只记日志不阻断、"将来再加表往这里追加语句" CaseDataSourceRegistry.java:92-120,161-164 ✅ AI 表按需惰性建(v6.0 起不再需要挂这个钩子,因为 AI 表不存在"被既有链路读到"的场景)
11 既有并行状态线先例:注释明写与 FileSheetStateEnum 是两条独立状态线,"AI 链路整条挂掉也不影响既有展示" AiParseStatusEnum.java:9-11 ✅ 本方案建第三条线 AiCleanStatusEnum
12 LlmService.chatAs 是"提示词内嵌 JSON Schema + 宽松解析",无强 schema 校验 LlmService.java:196-213 ⚠️ 模型输出必须后置校验(存在性校验 + 序号映射)
13 现成可复用:agent/rag/EmbeddingModelFactory、FileRecognitionService 的虚拟线程 + Semaphore(3) 闸门、DmService.java:652 SSE 进度范式、GlobalCache.DIRECTION_CONF_MAP(:176)/卡 BIN(:272)/FUN_REGULAR_MAP、DateUtil:44-223、Str2NullConverter、DateNumberConverter、PatternPool — 不重造

二、双出口:判据与落库

出口 触发 谁执行 数据落哪 元数据落哪 新执行代码
A|命中既有模板 S3 命中 table_info 中 main_id ∈ StrategyType 的模板 既有链路(doClean → 34 策略之一) 该模板既有业务表 不动任何元数据表(只在 ai_clean_job/ai_clean_rule 记流水与规则快照) ❌
B|无合适模板 三腿全空 / 最高分 <0.5 AI 链路(S6) 新建 ai_t{n} 动态表(每案件 DuckDB) ai_clean_template + ai_clean_field(树节点由 AI 治理写入既有 govern_tree) ✅ AI 侧
— 门不过 置信/错误率不达标 不写 NEED_REVIEW — —

为什么这版彻底不需要 matched=0、AI_GENERIC、has_table、哨兵 main_id(v4 的那套杂技):AI 模板根本不出现在 table_info 里 → 既有匹配器看不见它 → 不可能命中 → 不可能走到 StrategyType.fromCode → 不需要任何"结构性跳过"的技巧。隔离从"调参数"变成"换存储",这是本方案最硬的一处简化。

出口 A 的实现(AI 只是"替用户提交一次清洗请求")

// aiclean/exec/ExistingTemplateDispatcher.java(新建;不改既有类)
void dispatchViaExisting(CleanTarget t) {
    // 1) sheet.setTemplateId(命中的既有 templateId)
    // 2) sheet.setTableRuleList(aiRules)   ← AI 的核心增值:fileColIndex 列绑定 + funList 函数链
    // 3) 根 FileInfo 带 filePath/children(全部 sheet) → 组 DoCleanDTO → dmService.doClean(dto)
    // 4) ai_clean_job 置 DONE_VIA_EXISTING,并快照 templateId + 规则 JSON 供事后追溯
}
  • 必须处理:doClean 会发 SSE、要 CaseContextHolder 案件上下文(AI 侧异步线程按 GovernService.calcTask:147 的 runWith(caseId,userId,...) 范式绑定)。实施时先照前端一次真实提交的报文对齐 children 装配方式。
  • 门比 B 更硬(写的是真业务表,且既有 reClean 只能按文件维度物理删除 DmService:860-907):perColErrorRate≤1% 且 必填列全覆盖 且 双采样逐列一致 且 rowYield≥0.9;任一不满足 → NEED_REVIEW,不降级到 B(宁可人工配,也别把脏数据写进你熟悉的表)。
  • A2(需结构归一 + 命中既有模板):既有链路按 line_no 读原文件,不做合并单元格展开/多级表头压平 → 直接 A 会数据退化。做法:AI 侧先跑 S1+S2 出 ai_raw_{sheetId}_b{k}_norm,再按该模板 main_id 从 CleanFactory 取既有 cleaner/loader 当库调用(createDataCleaner/createDataLoader 均为 public static,CleanFactory.java:130,147)。✅ 此处不存在"动态表名传不进 loader"的难题——要的就是既有 loader 构造期写死的那张固定业务表。代价是复刻一份 StreamingEtlListener 的驱动(init→逐行→close,约百行新代码,在 AI 模块内),并加"与 doClean 同输入同输出"的一致性单测。
  • A3(兜底):A2 成本过高时退回"B 落 ai_t{n} + 标注本该进既有表 #id" + 前端一键转人工配规则,绝不静默写既有表。

三、整体架构

上传 → PreTask/PreDataListener(既有,0 改动)
              │
   ┌──────────┴─────────────────────────┐
命中 templateId                   SHEET_TEMPLATE_MATCHED_FAIL
   │                                  │
既有链路 → 既有业务表            ★ AI 链路 module/aiclean/(全新增)
   │                            S1探测→S2结构归一→S3匹配(三腿)→S4规则→S5校验
   │                              ├ 出口A 命中既有模板 → 组DoCleanDTO调现成doClean → 既有业务表
   │                              ├ 出口B 无合适模板   → S6自清洗入库 ai_t{n} → S7登记
   │                              └ 门不过 → NEED_REVIEW(不写任何表)
   │                                          │
   │        ┌─────────────────────────────────┴────────────┐
   │        │ 元数据(全局 PG,AI 专属,与 table_info 无关)  │ 数据(每案件 DuckDB)
   │        │ ai_clean_template / ai_clean_field /         │ CREATE ai_t{n} + 批量写
   │        │ ai_clean_job / ai_clean_rule                    │
   │        └────────────┬─────────────────────────────────┘
   ╔═════════════════════╪════════════════════════════════════════════════════╗
   ║ AI 同步治理(新增服务;GovernService 仅 +1 行调用 = 全案唯一既有改动)          ║
   ║   executeTreeCalculationTasks():320 后调 calcAiTree(caseId)                 ║
   ║   先 DELETE id<0 再写;节点直接进既有 govern_tree(负 id,dataCount AI 侧算) ║
   ║   → 前端仍调既有 /govern/tree,取树与建树逻辑 0 改动                          ║
   ║ 点节点:id>0 → 既有 /govern/treeTablePage(不变)                            ║
   ║         id<0 → /aiClean/data/page(受事实 9 所迫的唯一前端分支)              ║
   ╚══════════════════════════════════════════════════════════════════════════╝

既有治理链路(GovernService 那套 truncate govern_tree + SELECT count(*))与既有模板管理不感知 AI 表(除第六节那 1 行同步治理挂载外,它不读任何 AI 元数据、不 SELECT 任何 AI 表)——这是本版与 v5 的根本差别。

四、出口 B 的元数据独立存储(新增 4 张 PG 表)

ai_clean_template(= AI 侧的 table_info,不写 table_info)

id、table_name_cn、table_name_en(ai_t{n})、headers(归一后列头)、header_line_no、func_regex、category_l1、category_l2、main_ref_template_id(判定参考的既有模板,可空)、header_md5(唯一键,直通缓存)、struct_ops_json、needs_struct、status(DRAFT/ONLINE/OFFLINE)、ver、confidence、create_by、create_time

ai_clean_field(= AI 侧的 table_field)

id、ai_template_id、field_name_cn(沿用文件原列头)、field_name_en、field_type(沿用 FieldTypeEnum)、required、field_len、field_sort、direction_conf(沿用你的 JSON 格式)

字段元数据是建动态表 DDL 和前端列表头的共同真源:表头不再借 GlobalCache.TABLE_HEAD(那是 table_info 衍生物),改由 ai_clean_field 直接组装 —— 第七节的 AI 查询接口自己返回 head。

ai_clean_rule

id、ai_template_id(可空:出口 A 的规则快照挂 ai_clean_job)、col_index、field_name_en、fun_hints_json(扁平枚举 hint,不含 @class)、matched(AI 内部打分/证据用,与既有 table_field.matched 无关)

ai_clean_job

id、case_id、file_id、sheet_id、block_no、ver(唯一键 (case_id,file_id,sheet_id,block_no,ver))、status(AiCleanStatusEnum)、exit(A|A2|A3|B)、matched_by、template_id、ai_template_id、tier、confidence_json、draft_json、model_id、token_in、token_out、cost_ms、fail_reason

分类入库:category_l1/l2 用受控枚举(取值域来自既有 table_info.classify 实际值 + 兜底「其他-待归类」),不接受自由文本;错分可按 ai_clean_template 批量订正,不触碰你的模板库。

五、出口 B 的动态目标表

项 设计
表名 ai_t{ai_clean_template.id}
建表 CREATE TABLE IF NOT EXISTS,只在该案件首次写入时惰性建(AI 侧自己的代码,失败只影响 AI 节点自身)。新增 DuckTypeMapper:field_type → DECIMAL(18,2)/TIMESTAMP/DATE/VARCHAR(n),n 取 field_len,0 则 TEXT
固定列 id, ai_job_id, ai_rule_ver, case_id, file_id, sheet_id, block_no, row_no(原文件行号), category, create_time —— row_no 是事后定位与撤销的抓手
写入 DuckDB Appender 批量;不复用 AbstractDataLoader(构造期定死表名、且它会挂 ES 索引服务)
清理/撤销 revert(jobId) = DELETE FROM ai_t{n} WHERE ai_job_id=? + 刷新该节点 dataCount
与既有清理链路的关系 不注册进 fileInfoMapper.deleteDataByFileIds(那是既有表的事务),AI 表由 AiCleanTableRegistry 自管;验收加"删文件后无孤儿 AI 表"断言
ES / 问数 不接入(既有 ES 与 SqlAnalysisTool 的三表链路 meta_raw_sheet→table_info→table_field 都不认识 AI 表)。P2 决策:是否给 ONLINE 的 AI 表开只读视图并登记

六、分类树:AI 侧独立治理,节点直接写进 govern_tree

不合并查询、不改前后端取树逻辑。AI 模板表单独跑一次治理(新增 AiGovernService.calcAiTree(caseId)),把节点 INSERT 进既有 govern_tree → 分类树天然是一棵,GovernTreeService.getTree() 与前端一行不改。

-- 复用 govern_tree 现有结构(sql/case_table_1.sql:178-187),不建新树表
-- AI 节点 id 取负:与 table_info.id 数学隔离,且可反解 aiTemplateId = -id
DELETE FROM govern_tree WHERE id < 0;                    -- 只清 AI 自己的节点
INSERT INTO govern_tree (id,tableNameEn,tableNameCn,pid,type,dataCount)
  SELECT -t.id, t.table_name_en, t.table_name_cn, 0, 'ai_table', <count>
  FROM ai_clean_template t WHERE t.status = 'ONLINE';    -- 逐表 try/catch:表缺失只跳过该节点,不打断整树

1)同步治理(v7.1 定稿):既有治理每次都先 truncate govern_tree 再重建(事实 7),所以 AI 节点必须在所有既有写入之后补写。挂载点已核实为 GovernService.executeTreeCalculationTasks()(:314-322)—— 该方法内依次是 :316 模板树、:318 人员树、:320 开户信息树,三者都写 govern_tree,故插在 :320 之后、:321 的 context.sendProgress("[数据治理]分类树统计完成!") 之前:

// GovernService.executeTreeCalculationTasks(GovernTaskContext context)  —— 全案唯一既有代码改动,1 行
this.calcTreeCallUseOpenInfoData();
aiGovernService.calcAiTree(context.getCaseId());   // ← 新增:AI 模板节点同步入树(负 id,幂等)
context.sendProgress("[数据治理]分类树统计完成!");

放在 sendProgress 之前是为了让进度播报与树内容一致;放在这个阶段而不是第三阶段之后,是因为第三阶段只算关系边、树已经建完了。calcAiTree 内部只动 id<0 的行,绝不碰既有节点;单表 count(*) 失败逐表 try/catch 跳过,不打断治理任务(沿用 CaseDataSourceRegistry:107 "失败不阻断"的既有范式)。

清洗时机另有一处:AI apply/revert/OFFLINE 之后即时调一次 calcAiTree(caseId),不必等下一轮治理 —— 这样"AI 洗完立刻在树上看到",且与治理路径共用同一个幂等函数。

2)dataCount 由 AI 治理自己算,不复用既有那条拼 SQL 的重建 —— 那条只遍历 table_info,天然不会 SELECT 到 AI 表,所以 v5 的"缺表打爆既有治理"风险仍为 0。

3)点节点取数仍需一个 id<0 分支(事实 9 逼出来的唯一前端改动,比 v6 少了"换取数接口"这一步):

id > 0 → 既有 /govern/treeTablePage(完全不变)
id < 0 → GET /aiClean/data/page?aiTemplateId=-id&page=&limit=&orderKey=
         (AI 侧动态 SQL 查 ai_t{n};head 由 ai_clean_field 组装,不依赖 GlobalCache.TABLE_HEAD)

若坚持前端 0 改动:唯一出路是让 TableInfoHelper.getTableInfo(tableName) 能解析 AI 表(启动期为每张 AI 表注册 MP TableInfo,或通用实体 + 动态表名拦截器)——需赌 MyBatis-Plus 版本行为且触碰全局 MP 配置,风险大于收益,不建议。

4)分类层级同样走这棵树:category_l1/l2 存于 ai_clean_template,calcAiTree 可顺带生成分类虚拟节点(id = -(10000 + hash(category)),AI 节点 pid 指向它),前后端依然零改动。P1 再开,P0 先与既有表节点同级(pid=0,与现状一致:既有树本来就基本是平铺,只有 person/call 类节点有层级,见 GovernService:389-395)。

5)type='ai_table' 仅作标记用(既有重建把 type 写死成 'table',:119);路由判据用 id 正负,不用 type、也不靠表名前缀约定。

七、七阶段流水线

包根 ai-server/src/main/java/com/zsjz/ai/module/aiclean/(与 module/dm 平级;子包 probe/ structure/ match/ rule/ exec/ verify/ store/ tree/ api/)。

  • S1 探测 probe/SheetProbe:重读原文件(事实 5 保证文件在盘),OPCPackage+XSSFReader SAX 只解前 60 行 <row>/<mergeCells> → SheetEvidence(blocks, mergedRegions, headWindow, colStats)。既有 invoke 的 Map 里合并单元格非左上格已是 null、事后不可还原,必须自己重读。

    public record SheetEvidence(String fileName, String sheetName, int totalRows,
        List<BlockHint> blocks, List<CellRangeAddress> mergedRegions, int mergeRegionCount,
        List<List<String>> headWindow, List<ColStat> colStats) {}
    public record ColStat(int idx, double nonNullRate, int distinct, String shape) {} // hutool PatternPool
    
  • S2 结构归一 structure/StructureOp + StructureSqlCompiler:这是"用 SQL"的唯一正确入口——LLM 只从枚举指令里选,SQL 由后端编译器生成。EXPAND_MERGED/FLATTEN_HEADER/SPLIT_CELL 在 POI 阶段(这三类信息只在读取阶段存在),DROP_ROW_BY_PATTERN(枚举正则,禁自由文本)/DROP_EMPTY_COLS/TRIM_VALUES/UNPIVOT/UNIT_SCALE/COALESCE_COLS 在 DuckDB。产物写 ai_raw_{sheetId}_b{k}(幂等 DROP IF EXISTS,不改既有 raw_)。安全:列名一律 "c{i}"、字面量参数绑定、编译器单测做 SQL 快照 + 白名单。

  • S3 匹配 match/AiTemplateMatcher 三腿瀑布(两池都只读,table_info 用于出口 A、ai_clean_template 用于出口 B):

    1. header_md5 → ai_clean_template:零 LLM 零成本直通(沉淀收口,唯一有效降本手段);
    2. 归一化集合打分 score=0.7*|交集|/|字段数| + 0.3*jaccard(...)(归一化:去空白、全半角、括号内容、「表|明细|清单|汇总」后缀);
    3. LLM 判定(模板量小可全量塞 prompt,比向量更准)+ AI 侧独立向量表兜底(AiCleanSchemaIndex 复用 EmbeddingModelFactory,写独立表 rag_ws{id}_ai_d{dims},不与 RagSchemaService 那份混用,免得 AI 模板污染你既有模板的检索与问数结果)。 防幻觉三层:候选以 #7 序号呈现、模型只回 templateNo、后端映射回 id 并做存在性后置校验(GlobalCache / ai_clean_template),不过则降级 NONE;chatAs(事实 12)失败重试 1 次后转人工兜底。
  • S4 规则 rule/AiRuleDraft → AiRuleCompiler:模型只出「列→字段」绑定 + 枚举 hint + category;函数链由编译器按 field_type/required/direction_conf 补齐并构造内存 Fun 对象(事实 1)。DATE/TIME 不产出(照抄 setDefaultDateFun:167-194 语义);借贷方向走 direction_conf+DIRECTION_CONF_MAP;hint→Fun 常量映射表逐条单测:TRIM/REMOVE_SPACE→FunRegular|STR_REPLACE→FunReplace|SPLIT_NAME→RuleSplit|MERGE_COL→跨列拼接|CONST→固定值|POS_NEG→FunAbs|UNIT_SCALE→FunMultiplier|EXTRACT_N→FunExtra|HEX→FunRadix|TIME_SPAN→FunSpExtra。temperature=0 跑 2 次,逐列一致才 matched=1(不做 3/5 票)。

    public class AiRuleDraft { Integer templateNo; Integer headerRow; Double confidence; String reason;
                             String category; List<String> structHints; List<Bind> binds; }
    public class Bind { Integer fileColIndex; String fieldNameEn; List<String> hints; Double confidence; }
    
  • S5 校验 verify/AiRuleVerifier:抽样 200–300 行走完整链(S2 算子 + S4 函数链),逐列按 field_type+PatternPool+field_len 校验(与前端 formatValidate.ts 同源:身份证校验位、借贷字典、金额、手机号)。AiConfidence(headerScore, perColErrorRate, requiredFillRate, rowYield, llmConfidence);硬门:单列错误率 >5% 直接拒绝该绑定(不是降分);rowYield<0.6 或必填 <0.9 → NEED_REVIEW。出口 A 另用更硬的门(第二节)。

  • S6 入库(仅出口 B):建 ai_t{n} 并批量写,逐行带 ai_job_id/ai_rule_ver/row_no/category。

  • S7 登记(仅出口 B):写 ai_clean_template/ai_clean_field/ai_clean_rule → 调 AiGovernService.calcAiTree(caseId) 把节点(负 id)与 dataCount 写进既有 govern_tree。不写 table_info/table_field、不置 has_table、不触发 RagSchemaService.doReindex。

状态线 AiCleanStatusEnum(新增):PENDING/PROBING/MATCHING/RULE_VERIFY/DISPATCHED_A/LOADING/SUCCESS/NEED_REVIEW/FAIL/SKIP(事实 11 的独立线范式)。 接口(/aiClean/*,不挂 /dm、不挂 /govern):submit、evidence、match、verify、apply、revert、jobs、categories、data/page(取树仍用既有 /govern/tree,不新增合并接口)。 前端:新增「AI 智能清洗」页(job 列表、证据/预览/置信度、撤销、模板上下线);治理树只加一个 id<0 的取数分支,取树接口与树构建逻辑不变。不改 cleaning.vue/RuleConfigForm.vue/ruleSerialize.ts。

七B、编排选型:直调 LLM 还是写 Agent(v7.1 定稿:只直调 LLM)

项目两条路都现成:直调 LlmService.chatAs(:206,提示词内嵌 JSON Schema + 宽松解析);或走 AgentScope 2.0.1 的 ReAct 环(pom.xml:80-98,工具已有一批:RagSchemaSearchTool、SqlAnalysisTool)。

清洗这个任务的性质决定选型:它要幂等、可重放、结果稳定、成本可预算、能审计——因为产物是写进数据库的数据。而 agent 的自主多轮循环恰好在这些维度上全是负的。

维度 直调 chatAs(确定性编排) 自主 Agent(ReAct 工具循环)
结果可重现 同输入同输出(temperature=0 + 双采样) ❌ 同一文件两次可能给不同规则
成本/延迟 有界,可埋 token 看板 ❌ 无界(迭代次数不可控)
可缓存重放 ✅ header_md5 直通后零模型调用 ❌ 轨迹无法安全复用
安全面 只读 prompt;工具=0 ❌ 工具能查库/执行 SQL,误调即事故
可测试 prompt+schema 固定 → 单测/golden 回归 ❌ 行为随模型版本漂移
处理"没见过"的脏表 弱(一次决策定终身) ✅ 可"看统计→试跑→看错误→改规则"

结论(v7.1 定稿):整条清洗链路只直调 LLM,不引入 Agent —— 不用 ReAct 工具循环、不挂自主迭代。

  • 主干(S1–S7、出口 A/B、入库)= Java 确定性编排 + 直调 chatAs。模型只做一次语义判定,函数链/SQL/入库全是编译器和既有算子。这是业界综述的一致结论(模型出模式、确定性执行器跑批量)。
  • 唯一允许的"多轮"= S5 失败后的 repair 二次直调:把逐列错误报告回灌,让模型再出一版规则,最多 2 轮、循环写死在 Java 里、带 token/timeout 上限、结果仍过同一道 S5 硬门。它形式上就是"再调一次 chatAs",没有工具、没有自主决策,因此不具备 agent 的任何不可控性,但拿到了 detect–verify–repair 的补错收益。开关默认关,仅对 WEAK 档开(STRONG 不需要,NONE 交给出口 B 新建模板)。
  • 不做的两件事:① 不让模型自主决定"下一步查什么/试什么"(那是 agent,收益不确定而成本/延迟/可重现性全输);② 清洗链路不挂任何可写库或执行 SQL 的工具。将来若要做"对话式诊断/数据问答",另立只读模块复用 SqlAnalysisTool 那套,不与清洗链路共用代码路径,本方案不含。

成本对账(单 sheet,按 ≤6k token/job 估):主干 2 次 chatAs(双采样)≈ 6k;开 repair 二次直调 +2 轮 ≈ +6k,即最贵 3 倍——这就是它必须默认关、只对 WEAK 档开的原因。header_md5 直通后是 0,所以长期成本取决于沉淀质量,不取决于编排方式。

八、四类脏数据的处置

痛点 既有链路 AI 链路
列名不统一/同义换词/错别字 零容错(事实 4) S3 三腿 + S4 列绑定(这是出口 A 的核心价值)
合并单元格 不支持 S1 抓 <mergeCells> → EXPAND_MERGED
多级/跨行表头 不支持 FLATTEN_HEADER
空行 隐式跳过 保持
汇总行/备注行 后端无能力 DROP_ROW_BY_PATTERN
值格式脏(万/元、日期乱码、全半角) 日期很强、单位归一无 共用 Fun*/DateUtil + UNIT_SCALE
整行挤在一个单元格 有原语无决策 SPLIT_CELL + hints → RuleSplit/FunExtra
单 sheet 多套表头 一 sheet 一表一模板,不支持 S1 按「表头行+连续空行/列数突变」切 block,每 block 一条 job + 一张 ai_raw_*_b{k};出口 A 时每 block 各自提交一次 doClean(既有语义允许同文件多 sheet 各自模板),不引入"一表多模板"的贯穿式新概念

九、互斥、幂等、回滚

  • A/B 互斥且完备:由"S3 是否找到合适既有模板"单一判据决定。A 走 doClean 后 sheet 被既有链路推进到 FILE_CLEAN_COMPLETE、job 记 DISPATCHED_A 不再重跑;B 的元数据不在 table_info 里,既有匹配器永远看不见 → 同一 sheet 不可能被两条链路重复写。
  • 与人工配置不打架:AI 只在 templateId==null 时接管;用户随后手工配了规则并跑过清洗,AI job 置 SUPERSEDED 不再动该 sheet。
  • 幂等:ai_clean_job 唯一键 (case_id,file_id,sheet_id,block_no,ver);重跑先按 ai_job_id 删再写。
  • 回滚:revert(jobId) 删 ai_t{n} 中该 job 的行 + 重跑 calcAiTree(负 id 节点幂等重建),不借用既有 reClean(物理删除、无事务、按文件维度)。
  • 总开关:AI 链路按 workspace 开关;单模板 status=OFFLINE → 树节点摘除(dataCount=0 或删节点),数据保留。

十、分期

P0(约 16–20 人日|既有文件改动 = 1 处 1 行:GovernService.executeTreeCalculationTasks 末尾) 出口 A(主路径、成本最低):SheetProbe、ColStatBuilder、StructureOp+StructureSqlCompiler(先 FLATTEN_HEADER/EXPAND_MERGED/DROP_ROW_BY_PATTERN/TRIM_VALUES)、AiTemplateMatcher、AiRuleDraft+AiRuleCompiler、AiRuleVerifier(含 A 路硬门)、ExistingTemplateDispatcher、AiCleanJobPoller、AiCleanStatusEnum、sql/ai_clean.sql(4 表)、2 个 prompt、前端「AI 智能清洗」页。 出口 B:DuckTypeMapper、AiDataLoader、AiTemplateSedimentService、AiGovernService.calcAiTree(节点写既有 govern_tree,负 id)+ GovernService:320 后挂载那 1 行、/aiClean/data/page、前端仅加 id<0 取数分支。 P1(约 8–12 人日):UNPIVOT/SPLIT_CELL/UNIT_SCALE/COALESCE_COLS、A2(CleanFactory 取既有 cleaner/loader 当库调用 + 驱动复刻 + 一致性单测)、单 sheet 多 block 全量、self-consistency、S5 repair 二次直调(错误报告回灌再出一版规则,≤2 轮、Java 写死循环、默认关、仅 WEAK 档开;不是 agent)、header_md5 直通、revert、token/成本看板、AiCleanSchemaIndex 独立向量表、分类虚拟节点。 P2(约 8–12 人日):别名词典沉淀入 AI 向量表、AI 模板相似合并去重、人工「晋升为正式模板」(由你在模板管理页手工建 table_info/table_field 并指定既有 main_id,AI 只导出建议清单,不自动写)、ES/问数是否覆盖 AI 表的决策、小模型降本。

验收指标

指标 现状 P0 P1
未匹配 sheet 自动处理率 0%(丢弃) ≥60% ≥85%
出口 A 占比(落既有业务表,走原逻辑) — ≥40% ≥60%
出口 A 写入既有表的错列数 — 0 容忍门(不过即 NEED_REVIEW,不降级 B) 抽检 ≤0.5%
出口 B 落 ai_t{n} 错列率 — ≤3% ≤1%
分类树含 AI 节点且可点出数据 无此能力 ✅ 100% ✅
既有文件改动数 — 1 处 1 行(GovernService:320 后同步治理挂载) 0
治理跑完树上立即含 AI 节点(无窗口) 无此能力 ✅ ✅
既有清洗/治理/模板管理回归缺陷 — 0 0
第二次同类文件零模型调用率 — ≥70% ≥90%
token/job — ≤6k ≤4k
单 sheet 人工介入率 100% ≤40% ≤20%

十一、验证方式

  1. golden 样本集(脱敏,各 5–10 例):列名换词 / 合并单元格 / 三级表头 / 汇总行 / 整格挤一行 / 12 月宽表 / 单 sheet 双表 / 万-元混写。改 prompt 或编译器必跑回归,指标不回退才合入。
  2. 单测(不需要模型):AiRuleCompiler hint→Fun 逐条断言内存对象与参数;StructureSqlCompiler SQL 快照 + 白名单;DuckTypeMapper 全类型;AiTemplateMatcher 归一化与阈值分档;govern_tree 负 id 与 table_info.id 不重叠的边界断言、calcAiTree 连跑两次的幂等断言(DELETE WHERE id<0 后节点数不增不减)。
  3. 出口 A 验证(本版重点):AI 组装的 DoCleanDTO 与人工在前端点"清洗"提交的报文逐字段 diff(除规则内容外结构必须一致);跑完断言数据进了该模板既有业务表、sheet 状态为 FILE_CLEAN_COMPLETE、ai_clean_job=DISPATCHED_A。
  4. ⭐ 侵入面回归(本版最重要):git diff 断言既有文件改动只有 GovernService.java 的 1 行调用(且该行不改变任何既有变量/流程);AI 链路全量跑完后,govern_tree 中 id>0 的既有节点集合逐行 diff 与关闭 AI 时完全一致(正数节点不许增删改),既有树接口 JSON 对正数节点部分一致;table_info/table_field 行数与内容不变(count(*) + 校验和)。
  5. 分类树验证:跑 calcAiTree → 既有 /govern/tree 返回里既有节点数不变 + 出现 id<0 的 AI 节点、dataCount == SELECT count(*) FROM ai_t{n};点 id>0 走既有接口、点 id<0 走 /aiClean/data/page 且表头来自 ai_clean_field;revert 后节点消失;OFFLINE 后节点消失且数据仍在。⭐ 同步治理断言:跑一次完整既有治理(含 truncate govern_tree)→ 同一任务结束时树里就已有 AI 节点(无需二次触发),且进度消息"分类树统计完成"发出时已在;再断言 calcAiTree 内某张 AI 表 count(*) 抛错时治理任务仍正常完成(只跳过该节点)。
  6. 重放验证:同一文件第二次上传,断言 chatAs 调用次数 0、结果与第一次逐行一致。
  7. 上线:workspace 开关,关闭后行为与今天完全一致(连树合并接口都能退回直接返回既有树)。

十二、主要风险

风险 应对
出口 A 错列 → 污染真业务表(本方案最高风险) A 门比 B 更硬(错误率≤1% + 必填全覆盖 + 双采样一致 + rowYield≥0.9);不过一律 NEED_REVIEW,不降级 B;ai_clean_job 快照 templateId+规则可追溯;既有清理只能按文件维度,故宁可少自动
AI 误判成既有模板 → 数据进错业务表 序号映射 + 存在性后置校验 + Tier 分档:只有 STRONG 才允许出口 A,WEAK 一律走 B 或人审;main_ref_template_id 全程留痕
事实 9:点 AI 节点走既有接口必然 NPE id<0 强制分流到新接口(判据用 id 正负,不依赖表名前缀约定)
既有治理 truncate govern_tree 把 AI 节点一起清掉 已解:第六节同步治理(GovernService:320 之后 1 行),同一任务内先建既有节点再补 AI 节点;calcAiTree 只 DELETE WHERE id<0,绝不碰既有节点;apply/revert 即时重算做二次兜底
同步治理那 1 行抛异常 → 打断整个治理任务 calcAiTree 内部全量 try/catch、绝不向外抛(沿用 CaseDataSourceRegistry:107 "附属链路失败不阻断主流程"的既有范式);单表 count(*) 失败只跳过该节点;配断言:AI 表全毁时治理仍正常完成
AI 节点 dataCount 与实际行数不符 calcAiTree 幂等重建(先删后插)+ apply/revert 即时触发 + 低频定时修平;树上给「最后更新时间」
AI 节点 id 与 table_info.id 撞 负 id(-ai_template_id)数学隔离;govern_tree.id 是 BIGINT 主键,天然容纳负值
repair 二次直调带来成本/延迟上浮(最贵 3 倍) ≤2 轮 + token/timeout 上限 + 默认关 + 仅 WEAK 档开 + 结果仍过同一 S5 硬门。不引入 agent,因此不存在自主循环导致的成本/延迟无界与结果不可重现
用户手工配了同一 sheet → 双写 AI 只在 templateId==null 接管;跑过既有清洗后 job 置 SUPERSEDED
A2 复刻驱动与既有行为漂移 只允许调 CleanFactory 取回的既有实例,AI 不自己写业务表;"同输入同输出"一致性单测
动态表膨胀 / 模板重复 header_md5 唯一键收敛 + OFFLINE 摘除 + 相似合并(P2);表只在用到的案件库惰性建,不广播
删文件/删案件留孤儿 AI 表 AiCleanTableRegistry 自管生命周期;验收加"删文件后无孤儿表"断言
LLM 幻觉模板/字段名 序号映射 + 候选枚举约束(字段名必须落在候选模板 table_field 或 ai_clean_field 内)+ 后置校验降级
错误分类/模板名污染前端树 受控枚举 + 「其他-待归类」兜底 + provenance 可批量订正 + OFFLINE 摘除
成本随文件量线性上涨 header_md5 直通是唯一有效手段;job 表埋 token 做看板
沉淀把错误固化 仅 pass() 才登记;matched=0 列不进打分证据;ver 版本化可回退
函数链两份实现漂移 共用同一批 Fun* 实例,AiRuleCompiler 是唯一新增映射逻辑

十三、参考(业界结论)

混合式架构(模型只在决策点出现一次 + 确定性执行器批量跑)、detect–verify–repair 回路、self-consistency 多候选、RAG 接地防瞎改、逐格 LLM 清洗成本不可扩展、区域压缩编码降 token。

十四、实现进度(v7.2,P0 后端)

包 com.zsjz.ai.module.aiclean,与 module/dm 平级(44 个类/接口 + 1 个 prompt + 1 个 SQL);git diff 既有文件只有 GovernService.java +5 行(注入字段 + 同步治理那一行调用),已核对为纯新增。

已落地 类
元数据 sql/ai_clean.sql(4 张 PG 表)+ 4 实体 + 4 Mapper
状态线 AiCleanStatusEnum、AiCleanExitEnum、AiMatchTierEnum、StructureOpEnum、RuleHintEnum、RowPatternEnum
S1 探测 SheetProbe(POI 重读原文件取合并区/前 200 行)、BlockDetector(单 sheet 多表头切块)、ColStatBuilder(逐列统计与形状)
S2 结构 StructureOp + StructureSqlCompiler(闭集指令 → 受控 SQL,位置列名 c{i})
S3 匹配 AiTemplateMatcher(指纹→打分→LLM 三腿 + 序号防幻觉)、HeaderScorer、TemplateCandidateLoader、MatchPromptBuilder、prompts/ai-clean-match.txt
S4 规则 AiRuleDraft/RuleBind、AiRuleCompiler(扁平 hint → 内存 Fun)、AiRulePlan(一次产出出口 A 的 TableRuleDTO 与出口 B 的列计划)、CompiledColumn
S5 校验 AiRuleVerifier + AiConfidence(A/B 两档硬门写在同一个类里)
S6/S7 AiDuckDb(惰性建表/批量写/按 job 删/翻页)、AiRecordCleaner、AiCleanStore、AiGovernService.calcAiTree(负 id 写 govern_tree)
出口 A ExistingTemplateDispatcher(组 DoCleanDTO 调现成 DmService.doClean,本身不含任何入库代码)
编排/接口 AiCleanService(submit/run/revert/dataPage)、AiCleanController(/aiClean/*)、AiRowReader(Fesod,注册同样的两个转换器)
测试 全仓 247 条全绿(含既有测试与 contextLoads;9 条 skip 是需要真实外网/模型的用例)。本模块 48 条:归一化/指纹/切块/形状/列消毒(AiCleanSupportTest 7)、SQL 与规则编译(AiCleanCompileTest 7)、真实 xlsx 探测 + 探测与读表器对表头行理解一致(SheetProbeTest 3)、编译 SQL 在真 DuckDB 上执行(StructureSqlCompilerRunTest 3)、匹配三腿含直通与幻觉兜底(AiTemplateMatcherTest 4)、清洗器类型转换(AiRecordCleanerTest 7)、校验门(AiRuleVerifierTest 5)、动态表建表/写入/翻页/按 job 撤销在真 DuckDB 上(AiDuckDbTest 6)、负 id 治理节点与缺表/失败兜底(AiGovernServiceTest 6)、实体注解 ↔ DDL 列名对齐(AiCleanSchemaTest 3,离线)

真库验证怎么跑(dev PG 需可达;这些用例会写库但用哨兵 caseId=9000000001 并在 finally 自清):

# 1) 建表(幂等,脚本全是 IF NOT EXISTS):由 DBA 或 mvn 前置执行一次即可
psql -h <pg-host> -U postgres -d zsjz-ai -f sql/ai_clean.sql
# 2) 元数据往返验证(默认跳过,加开关才跑)
mvn -o -pl ai-server test -Dtest=AiCleanPgRoundTripTest -Daiclean.pg.it=true

默认 mvn test 会把它 skip 掉,所以不会把外部依赖带进 CI;启动日志里的 Cannot run program "ffmpeg" 是 Tika 探测外部解析器的既有噪声,与本模块无关。

实现中新查到的事实与当场修正(原文未记):

  1. 出口 A 不能只填 templateId:StreamingEtlListener:117 直接读 currentSheet.getMainId(),而 FileInfo.mainId/funcRegx/tableRuleList/tableNameEn 全是 @TableField(exist=false) 的瞬态字段,前端报文本来就带着它们 → dispatcher 必须一起填,否则 fromCode(null) 抛异常。
  2. FunMultiplier 只在输入含小数点时生效(无小数点时内部 Convert.toInt("") 得 null 抛异常、被 onException 吞掉后返回原值)。所以 SCALE hint 只对小数金额有效,整数列的单位归一必须走结构层 UNIT_SCALE。已写进 RuleHintEnum 注释。
  3. 本项目 DuckDB 版本没有 btrim,只有 trim;且写错函数名时 JDBC 抛的是 Attempting to execute an unsuccessful or closed pending query result,真实原因 (Catalog Error: Scalar Function with name btrim does not exist)藏在第二行 —— 所以 SQL 相关的测试必须真跑一次 DuckDB,字符串快照测不出这类问题。
  4. 剔行条件应当用闭集 LIKE 前后缀而不是正则:既避开 DuckDB 正则方言差异, 也让「第%页」这种两头锚定的模式不会被「第三方支付」误命中。
  5. 结构 SQL 采用一条组合语句(DROP + CREATE ... AS SELECT 带全部 WHERE 条件), 不串临时表:连续 CTAS 在同连接上会触发上面第 4 条那种滞后报错,且多一倍建表开销。
  6. 写入失败不能伪装成"写了 0 行":insertBatch 原先 catch 后照常返回已写计数, 全自动链路上层会把失败当成功(还去刷治理节点)。现返回 -1, AiCleanService 对 written <= 0 判 FAIL、AiCleanStore 失败时不刷树。 这条是真跑 DuckDB(往 DATE 列塞"不是日期")才暴露的 —— mock 测不出来。

P0 主动收窄的一处:结构归一指令只记录不执行。凡被判为「必须结构归一才看得懂」的表块(有合并区,或表头不在第 1 行)直接标 NEED_REVIEW 交回人工,不硬洗 —— 既有读法拿到的行列本身就是错位的,硬洗会把错位数据写进表里,比不洗更糟。执行能力在 P1 补(S2 已备好编译产物与 runStatements 通道,缺的是"归一后行集喂给清洗器"这一步)。

还没做的:结构归一执行(把归一行集喂进清洗器,解除 NEED_REVIEW 收窄)、repair 二次直调、真实案件 + 真实模型下端到端一把跑通(4 张表已建、元数据往返已验,缺的是开一个案件 + 配一个模型 + 拿一个未匹配文件走完 S1→S7)、header_md5 直通后的零模型重放闭环验证、AI 侧独立向量表、前端「AI 智能清洗」页与治理树 id<0 分流分支、异步轮询器(目前 /aiClean/run 是同步触发)。

附:实施第一步

  1. 本文件即方案落库位置:docs/design/ai-clean-parallel.md。后续每轮修订把标题版本号递增,并在开头版本记录表补一行(正文有实质变更才升位)。
  2. 核实清单:① 前端一次真实"点清洗"的 DoCleanDTO 报文(决定 children 与 fileInfos 装配细节,出口 A 的前提);② doClean 在 AI 异步线程下的 CaseContextHolder/SSE 上下文绑定(参考 GovernService.calcTask:147 的 runWith 范式);③ table_info.classify 实际值域(决定 AI 分类枚举);④ 前端治理树节点点击的取数代码位置(只加一个 id<0 分支,取树接口与树构建不变);⑤ AI 节点补写触发方式选①还是② ✅ v7.1 定稿:同步治理,挂在 GovernService.executeTreeCalculationTasks() 的 :320 之后、:321 进度播报之前(这是全案唯一既有改动)。
  3. PR 验收标准写死:既有文件改动 = 且仅是 GovernService.java 的 1 行同步治理调用;第十一节第 4 条"侵入面回归"(id>0 节点逐行不变)与第 5 条"同步治理断言"必须全绿。