版本记录(每轮修订标题版本号递增并在此表补一行;正文有实质变更才升位)
版本 变更 v1 三方案对比;推荐"AI 决策一次 + 确定性算子批量执行",含结构归一层与全自动安全网;要求给 table_field加fun_list列v2 改为完全并行独立链路:不碰既有执行层与模板库结构;清洗函数按库共用;接管点只读 SHEET_TEMPLATE_MATCHED_FAILv3 尝试让 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/aiclean44 个类 + 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_enNOT NULL 但值依赖只有插完才有的 id,只能走AiCleanStore.register(先补 id 再补表名),手插必挂 —— 测试因此改成走生产入口而不是自己拼字段v7.6 接前端 + 融合入口:新增 POST /aiClean/smartClean(一次请求跑完「既有模板清洗 + 未匹配文件的 AI 清洗」)与GET /aiClean/smartClean/status(轮询),第 5 张表ai_clean_batch已在 dev PG 建好(15/15 语句)。关键取舍:DmService.doClean是 fire-and-forget 且完成回调会closeSee(),所以融合路径上 A 路只出判定不出手 —— AI 命中的表由AiSmartCleanService攒齐后并入同一次doClean;A 组为空时干脆不发(buildCleanJobs只看有没有源文件与 children,不看templateId,发了会起一轮空清洗并白关 SSE)。前端:上传页「智能」可用条件从hasSuccessResult改成hasCleanableSheet(原逻辑恰好在最需要它的「模板全没匹配上」批次把入口锁死),进度页fused=1时不再自行发起清洗、只连 SSE + 轮询 AI 阶段,治理树按id<0分流到/aiClean/data/page(返回结构对齐treeTablePage,表格组件无需特例)。另记真模型跑出来的 4 条修正(候选截断、序号重编号、WEAK 不许走 A、草稿复用),见第十四节
断点:PreDataListener.java:340-395 纯中文列名集合打分匹配模板库,未命中置 SHEET_TEMPLATE_MATCHED_FAIL(:304),清洗阶段跳过(DmService.java:749-751、CleanTask.java:59-76)→ 模板库覆盖不到的文件只能人工配规则,否则数据丢失。
定稿原则(经五轮确认):
CleanFactory/StrategyType/StreamingEtlListener/34 个 cleaner/loader/AbstractData*)、治理取数与建树逻辑(GovernTreeService)、模板库(table_info/table_field 的结构与数据行)一律不动。module/dm 平级的独立模块,只读既有状态接管。doClean、数据落该模板对应的既有业务表;确实没有合适模板 → 才建 AI 模板、数据落 AI 自己的动态表。ai_clean_template/ai_clean_field,不写 table_info;但分类树共用既有 govern_tree —— 由 AI 侧独立治理把自己的节点写进去(负 id),取树的前后端逻辑零改动。Fun* 实例(不复制实现、不改签名),AiRuleCompiler 是唯一新增映射逻辑。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 → 不需要任何"结构性跳过"的技巧。隔离从"调参数"变成"换存储",这是本方案最硬的一处简化。
// 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 装配方式。reClean 只能按文件维度物理删除 DmService:860-907):perColErrorRate≤1% 且 必填列全覆盖 且 双采样逐列一致 且 rowYield≥0.9;任一不满足 → NEED_REVIEW,不降级到 B(宁可人工配,也别把脏数据写进你熟悉的表)。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 同输入同输出"的一致性单测。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 的根本差别。
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_ruleid、ai_template_id(可空:出口 A 的规则快照挂 ai_clean_job)、col_index、field_name_en、fun_hints_json(扁平枚举 hint,不含 @class)、matched(AI 内部打分/证据用,与既有 table_field.matched 无关)
ai_clean_jobid、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
ai_clean_batch(v7.6,融合入口的状态机与幂等锚点)id、case_id、batch_id(唯一键 (case_id,batch_id))、stage、file_count、sheet_total、matched_count(原本就匹配上模板的)、ai_judged_count(每种判定结论都 +1,含异常,所以它就是「AI 这一段跑了多少」)、existing_hit_count、ai_load_count、need_review_count、failed_count、need_review_detail([{sheetId,fileName,sheetName,reason}],进程重启后前端仍能看到该人工的表)、last_error
阶段机顺序不可调:JUDGING → CLEANING_EXISTING → LOADING_AI → DONE / PARTIAL(整体异常才 FAILED)。AI 命中既有模板的那些 sheet 必须等所有判定归零后才并入那一次 doClean,早发就把它们漏在车下;PARTIAL 只看本轮本地计数,不回读 DB 的 failed_count —— 那是并发累加的,回读到的可能含上一轮的残渣。
分类入库:category_l1/l2 用受控枚举(取值域来自既有 table_info.classify 实际值 + 兜底「其他-待归类」),不接受自由文本;错分可按 ai_clean_template 批量订正,不触碰你的模板库。
| 项 | 设计 |
|---|---|
| 表名 | 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 表开只读视图并登记 |
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 表注册 MPTableInfo,或通用实体 + 动态表名拦截器)——需赌 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):
header_md5 → ai_clean_template:零 LLM 零成本直通(沉淀收口,唯一有效降本手段);score=0.7*|交集|/|字段数| + 0.3*jaccard(...)(归一化:去空白、全半角、括号内容、「表|明细|清单|汇总」后缀);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。
项目两条路都现成:直调 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 工具循环、不挂自主迭代。
chatAs。模型只做一次语义判定,函数链/SQL/入库全是编译器和既有算子。这是业界综述的一致结论(模型出模式、确定性执行器跑批量)。chatAs",没有工具、没有自主决策,因此不具备 agent 的任何不可控性,但拿到了 detect–verify–repair 的补错收益。开关默认关,仅对 WEAK 档开(STRONG 不需要,NONE 交给出口 B 新建模板)。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 各自模板),不引入"一表多模板"的贯穿式新概念 |
doClean 后 sheet 被既有链路推进到 FILE_CLEAN_COMPLETE、job 记 DISPATCHED_A 不再重跑;B 的元数据不在 table_info 里,既有匹配器永远看不见 → 同一 sheet 不可能被两条链路重复写。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(物理删除、无事务、按文件维度)。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% |
AiRuleCompiler hint→Fun 逐条断言内存对象与参数;StructureSqlCompiler SQL 快照 + 白名单;DuckTypeMapper 全类型;AiTemplateMatcher 归一化与阈值分档;govern_tree 负 id 与 table_info.id 不重叠的边界断言、calcAiTree 连跑两次的幂等断言(DELETE WHERE id<0 后节点数不增不减)。DoCleanDTO 与人工在前端点"清洗"提交的报文逐字段 diff(除规则内容外结构必须一致);跑完断言数据进了该模板既有业务表、sheet 状态为 FILE_CLEAN_COMPLETE、ai_clean_job=DISPATCHED_A。git diff 断言既有文件改动只有 GovernService.java 的 1 行调用(且该行不改变任何既有变量/流程);AI 链路全量跑完后,govern_tree 中 id>0 的既有节点集合逐行 diff 与关闭 AI 时完全一致(正数节点不许增删改),既有树接口 JSON 对正数节点部分一致;table_info/table_field 行数与内容不变(count(*) + 校验和)。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(*) 抛错时治理任务仍正常完成(只跳过该节点)。chatAs 调用次数 0、结果与第一次逐行一致。| 风险 | 应对 |
|---|---|
| 出口 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。
包 com.zsjz.ai.module.aiclean,与 module/dm 平级(55 个类/接口 + 1 个 prompt + 1 个 SQL);全案对既有后端的改动只有 GovernService.java +5 行(注入字段 + 同步治理那一行调用),已核对为纯新增。
前端四处接入点(都不改既有请求与组件行为,只加分支):
| 位置 | 改动 |
|---|---|
case/api/govern/governApi.ts |
smartClean / smartCleanStatus / aiCleanDataPage 三个封装 + types/dto.ts 里 3 个响应类型 |
case/views/data/upload.vue |
「智能」= hasCleanableSheet 时可点 → 调 smartClean → 带 fused=1&batchId 跳进度页;失败留在本页不清缓存 |
case/views/data/cleanProgress.vue |
fused=1 时不再自行 startClean()(那一次 doClean 在后端),只连 SSE 收进度 + 轮询 AI 阶段卡片(含「去人工确认」跳手动清洗、超时出口) |
case/views/data/index.vue |
取数漏斗 fetchTableData 里按 activeNode.id < 0 分流到 /aiClean/data/page,翻页/排序/搜索共用同一分支 |
| 已落地 | 类 |
|---|---|
| 元数据 | sql/ai_clean.sql(5 张 PG 表,第 5 张 ai_clean_batch 为融合入口的批次状态机)+ 5 实体 + 5 Mapper |
| 状态线 | AiCleanStatusEnum、AiCleanExitEnum、AiMatchTierEnum、AiCleanStageEnum(批次阶段机)、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(已拆成 judge / apply / loadViaAiTemplate / prepareJob / finish,另保留 submit/run/revert/dataPage)、AiCleanController(/aiClean/*)、AiRowReader(Fesod,注册同样的两个转换器) |
| 融合入口 | AiJudgeResult(sealed:ExistingHit/AiPlan/NeedReview/Skip/Failed,只出结论不落库)、AiSmartCleanService(判定并发 → 一次 doClean → 出口 B 入库 → 批次终态)、AiSmartCleanController(POST /aiClean/smartClean + GET /aiClean/smartClean/status) |
| 测试 | 本模块 69 条(64 条离线全绿 + 5 条需开关:真 PG 往返 4、真模型契约 1)。归一化/指纹/切块/形状/列消毒(AiCleanSupportTest 7)、SQL 与规则编译(AiCleanCompileTest 7)、真实 xlsx 探测 + 探测与读表器对表头行理解一致(SheetProbeTest 3)、编译 SQL 在真 DuckDB 上执行(StructureSqlCompilerRunTest 3)、匹配三腿含直通与幻觉兜底与 WEAK 不许走 A(AiTemplateMatcherTest 6)、清洗器类型转换(AiRecordCleanerTest 7)、校验门(AiRuleVerifierTest 5)、动态表建表/写入/翻页/按 job 撤销在真 DuckDB 上(AiDuckDbTest 6)、负 id 治理节点与缺表/失败兜底(AiGovernServiceTest 6)、实体注解 ↔ DDL 列名对齐(AiCleanSchemaTest 3,离线)、融合时序(AiSmartCleanServiceTest 11,见下) |
真库验证怎么跑(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 探测外部解析器的既有噪声,与本模块无关。
实现中新查到的事实与当场修正(原文未记):
templateId:StreamingEtlListener:117 直接读 currentSheet.getMainId(),而 FileInfo.mainId/funcRegx/tableRuleList/tableNameEn 全是 @TableField(exist=false) 的瞬态字段,前端报文本来就带着它们 → dispatcher 必须一起填,否则 fromCode(null) 抛异常。FunMultiplier 只在输入含小数点时生效(无小数点时内部 Convert.toInt("") 得 null 抛异常、被 onException 吞掉后返回原值)。所以 SCALE hint 只对小数金额有效,整数列的单位归一必须走结构层 UNIT_SCALE。已写进 RuleHintEnum 注释。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,字符串快照测不出这类问题。DROP + CREATE ... AS SELECT 带全部 WHERE 条件),
不串临时表:连续 CTAS 在同连接上会触发上面第 4 条那种滞后报错,且多一倍建表开销。insertBatch 原先 catch 后照常返回已写计数,
全自动链路上层会把失败当成功(还去刷治理节点)。现返回 -1,
AiCleanService 对 written <= 0 判 FAIL、AiCleanStore 失败时不刷树。
这条是真跑 DuckDB(往 DATE 列塞"不是日期")才暴露的 —— mock 测不出来。真模型 + 真库这一轮另外顶出来的 4 条(都是「不连真模型就看不见」的问题,已落进代码与单测):
table_info 全量 362 个候选一次性进 prompt,740 秒返回一次、还挑中第 306 个并自报置信度 0.3、绑定 0 列。现在按打分截断 LLM_TOP_K = 12,低质候选根本不进上下文。table_info.id 当 #n 写进 prompt,模型回 #306 这类越界值,兜底就成了「一律判无匹配」。现在重编号成 1..K 并在 renumber() 里保证 prompt 与解析用同一套序号,越界仍然只能落 NONE。exitOf(tier, cand) 现在要求 STRONG 且非 AI 库才返回 A,否则 B;AiTemplateMatcherTest 两条用例分别钉死「低置信 → B」「高置信 → A」。AiMatchOutcome 直接把 AiRuleDraft 带出来给出口 B 复用(命名/分类),llm_calls 才如实反映成本。融合入口钉住的时序(AiSmartCleanServiceTest 11 条,mockito、不起 Spring、不连模型):
doClean 恰好一次,且 InOrder 断言三条判定都在它之前;doClean 零调用(不白关 SSE),出口 B 照旧入库;NeedReview → 不建表不写数(never().loadViaAiTemplate),批次只加 need_review_count;PARTIAL;batchId、不再起后台线程、不重跑判定;JUDGING → CLEANING_EXISTING → LOADING_AI → DONE/PARTIAL,断言枚举序列逐位相等。测试侧一个坑:MP 的 lambda 列名解析依赖「实体已被 MyBatis 扫过并注册
TableInfo」,不起 Spring 时LambdaUpdateWrapper.set(AiCleanBatch::getStage, …)会抛can not find lambda cache for this entity,被run()的兜底 catch 吞掉后整批伪装成FAILED。单测在静态块里TableInfoHelper.initTableInfo(...)注册一次;生产路径由 mapper 扫描天然完成(AiCleanPgRoundTripTest已验)。
P0 主动收窄的一处:结构归一指令只记录不执行。凡被判为「必须结构归一才看得懂」的表块(有合并区,或表头不在第 1 行)直接标 NEED_REVIEW 交回人工,不硬洗 —— 既有读法拿到的行列本身就是错位的,硬洗会把错位数据写进表里,比不洗更糟。执行能力在 P1 补(S2 已备好编译产物与 runStatements 通道,缺的是"归一后行集喂给清洗器"这一步)。
还没做的:结构归一执行(把归一行集喂进清洗器,解除 NEED_REVIEW 收窄)、repair 二次直调、真实案件 + 真实模型下端到端一把跑通(5 张表已建、元数据往返已验、前端三个入口已接,缺的是开一个案件 + 拿一个未匹配文件走完 S1→S7 并在浏览器里看两路进度)、header_md5 直通后的零模型重放闭环验证、AI 侧独立向量表。
docs/design/ai-clean-parallel.md。后续每轮修订把标题版本号递增,并在开头版本记录表补一行(正文有实质变更才升位)。DoCleanDTO 报文(决定 children 与 fileInfos 装配细节,出口 A 的前提);② doClean 在 AI 异步线程下的 CaseContextHolder/SSE 上下文绑定(参考 GovernService.calcTask:147 的 runWith 范式);③ table_info.classify 实际值域(决定 AI 分类枚举);④ 前端治理树节点点击的取数代码位置(只加一个 id<0 分支,取树接口与树构建不变);⑤ GovernService.executeTreeCalculationTasks() 的 :320 之后、:321 进度播报之前(这是全案唯一既有改动)。GovernService.java 的 1 行同步治理调用;第十一节第 4 条"侵入面回归"(id>0 节点逐行不变)与第 5 条"同步治理断言"必须全绿。