cc 2 minggu lalu
induk
melakukan
11fba25033

+ 372 - 0
docs/design/glacial-glen-linnet.md

@@ -0,0 +1,372 @@
+# 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 只是"替用户提交一次清洗请求")
+
+```java
+// 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()` 与前端一行不改。
+
+```sql
+-- 复用 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("[数据治理]分类树统计完成!")` 之前**:
+
+```java
+// 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、事后不可还原,**必须自己重读**。
+```java
+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 票)。
+  ```java
+  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。
+- [Can LLMs Clean Up Your Mess? A Survey of Application-Ready LLM-based Data Cleaning](https://arxiv.org/html/2601.17058v1)
+- [Effortless spreadsheet normalisation with LLM](https://medium.com/data-science-collective/effortless-spreadsheet-normalisation-with-llm-c1b28669b729) / [TDS 版](https://towardsdatascience.com/effortless-spreadsheet-normalisation-with-llm/)
+- [SpreadsheetLLM: Encoding Spreadsheets for Large Language Models](https://arxiv.org/html/2407.09025v1)
+- [Best AI for Messy Spreadsheets — LlamaIndex](https://www.llamaindex.ai/insights/best-ai-for-messy-spreadsheets)(先定义目标结构再解析;合并/多行表头还原;不确定字段路由人审)
+
+## 十四、实现进度(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 自清):
+
+```bash
+# 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` 注释。
+4. **本项目 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,字符串快照测不出这类问题。
+5. 剔行条件应当用**闭集 LIKE 前后缀**而不是正则:既避开 DuckDB 正则方言差异,
+   也让「第%页」这种两头锚定的模式不会被「第三方支付」误命中。
+6. 结构 SQL 采用**一条组合语句**(`DROP` + `CREATE ... AS SELECT` 带全部 WHERE 条件),
+   不串临时表:连续 CTAS 在同连接上会触发上面第 4 条那种滞后报错,且多一倍建表开销。
+7. **写入失败不能伪装成"写了 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 条"同步治理断言"必须全绿。

+ 63 - 0
docs/design/graceful-prairie-perch.md

@@ -0,0 +1,63 @@
+# 智能清洗融合链路 · 端到端验证(真库 + 真模型 + 真浏览器)
+
+## Context
+
+融合入口(`POST /aiClean/smartClean`)与前端三处接入已落地,离线单测 69 条全绿,但**整条链路还没在真实环境里跑通过一次**:单测只能证明「doClean 只发一次、判定先行」这类时序,证明不了三件真问题——
+
+1. 真实解析出来的 `templateId`/`mainId`/`tableRuleList` 能不能原样喂给既有清洗(报文契约是否真的对得上);
+2. 真模型对一个列名完全不同的表块,判定是否收敛到 `DONE/PARTIAL`(而不是卡在 `JUDGING` 或一路 `NEED_REVIEW`);
+3. AI 自建表的数据能不能在治理树上长出负 id 节点、并在新加的 `id<0` 分流里真的取到数。
+
+当前环境已就绪:后端 `:8980/js/a` 已启动并就绪,前端 vite `:3100` 已起,浏览器已登录(`/case`,账号「系统管理员」),dev PG 里 5 张 `ai_clean_*` 表都在。**本计划需要写入真实数据**(dev PG 元数据 + 该案件 DuckDB + 一次真模型调用),所以先取批准。
+
+已核到一个**必须先修的造数错误**:既有解析的匹配源是 `table_field.field_name_cn`(`GlobalCache.ALL_FIELDS_MAP` / `ALL_MATCHED_FIELDS_MAP`,`GlobalCache.java:200-209`),硬条件是「表头必须含该模板全部 `matched=1` 列」(`PreDataListener.matchTemplate:340-395`,纯 `CollUtil.containsAll`,无阈值、无列数相等要求,且**不做全半角/括号归一**,唯一清洗是 `Str2NullConverter:21` 去 `\t\r\n'^` + trim)。我之前那份 `e2e_ok_trans.xlsx` 是照抄 `table_info.headers` 造的,用错了源,不保证命中 —— 必须按 `field_name_cn` 重造。
+
+## 一、准备(只读 + 我这边 Temp 目录里的临时文件)
+
+1. 只读 SQL(`192.168.0.103:5432/zsjz-ai`)选一个 matched 列少、且必填列不苛刻的模板:
+   `select t.id, t.main_id, count(*) filter (where f.matched=1) mc, array_agg(f.field_name_cn) filter (where f.matched=1) from table_info t join table_field f on f.table_id=t.id group by t.id having count(*) filter (where f.matched=1) between 4 and 12 order by mc limit 15`
+   取一个模板,把它**全部 matched 列名逐字**写进 `e2e_ok_trans.xlsx` 第 1 行(多放几列自己造的列没关系,只要保证「表头 ⊇ matched 列」就能命中;全字段 ⊇ 表头则算完全命中)。
+2. 保留 `e2e_messy_acct.xlsx`(表头 `账户号码/账户名称/交易发生日期/交易发生时刻/收入金额/支出金额/账户余额/对手户名/对方开户网点/资金用途备注`,第 1 行、无合并区,数据含 `¥`、全角逗号千分位、`2026年1月7日`、空列、末行「合计」)—— 它必须**不被任何模板命中**(跑之前先对全部模板的 matched 列做一次 `containsAll` 反向校验,避免白跑)。
+3. 记下起始基线(用于事后比对,全部只读):`ai_clean_job` / `ai_clean_batch` / `ai_clean_template` 的行数与最大 id。
+
+## 二、浏览器操作序列(browser-use,每步 take_snapshot 留证)
+
+1. `/case` → 点案件「**AI验证案件-20260915**」的「打开案件」(就是你为端到端建的那个案)。该案件里**已有旧上传/旧 AI 表,不清理**,所以下面所有比对都必须按「本次 batchId + 跑前基线 id 集合」过滤,不能对全表计数。
+2. 进上传页 → 依次上传 `e2e_ok_trans.xlsx`、`e2e_messy_acct.xlsx`(同一批次:第二个文件回传首个响应里的 `batchId`)。
+3. **断言入口修复**:即使 messy 那份解析为 `SHEET_TEMPLATE_MATCHED_FAIL`,「智能」按钮仍**可点**(`hasCleanableSheet`),且不再要求 `hasSuccessResult`。
+4. 点「智能」→ 期望:请求 `POST /js/a/aiClean/smartClean` 200,立即跳 `/data/cleanProgress?fused=1&batchId=...`;页面**不再**自动发出第二个 `POST /dm/doClean`(网络面板只应看到 smartClean)。
+5. 观察进度页:上半张卡仍由 SSE 推既有清洗进度;下半张「未匹配文件的 AI 识别」卡的计数从 `0/1` 往上涨,直到 `stage` 进 `DONE/PARTIAL/FAILED` 后停止轮询(2s 一跳、状态请求数应等于阶段数×若干,不应无限)。
+6. 进「数据治理」树:出现 AI 节点(节点 id 为负)→ 点开 → **断言走的是 `/aiClean/data/page`**(网络面板可见,`aiTemplateId` = -id),表头是中文列名、数据行有内容;再点一个既有正数节点,确认走的仍是 `/gt/treeTablePage`、行为与改动前一致。
+7. 若批次里出现「需人工确认」:卡片上的「去人工确认」→ 应跳 `/data/cleaning?focusKey=<sheetId>` 并定位到该 sheet。若本次没自然出现,这条改为**只做一次代码级确认**(读 `handleReviewManual` 与 `cleaning.vue` 对 `focusKey` 的消费),不改配置去强造。
+
+## 三、后端与库侧核对(只读)
+
+1. `/tmp/backend.log`:`智能清洗:合并 N 个 AI 命中的表与既有匹配的表,doClean 只调一次` 出现**恰好 1 次**;`未找到根节点文件,无需清洗` 不出现(说明没白发);SSE 关闭只发生在既有清洗收尾处一次。
+2. `ai_clean_batch`(按 batchId):`stage` 终态、`sheet_total=2`、`matched_count>=1`、`ai_judged_count=1`、`existing_hit_count / ai_load_count / need_review_count / failed_count` 与日志逐条对得上;`need_review_detail` 是合法 JSON 数组。
+3. `ai_clean_job`(按 `batch_id`):messy 那条的 `exit_code`(A/A2/A3/B)、`matched_by`、`tier`、`confidence`、`llm_calls`(**必须是 1**,用来验证「草稿复用不二次调模型」在真实链路上成立)、`row_total/row_loaded`、`status`。
+4. `ai_clean_template` 新增行(若走出口 B):`table_name_en = ai_t{id}`、`header_md5` 唯一未冲突、`status=ONLINE`、`category` 落在 `FileCategoryEnum` 值域内;对应案件 DuckDB 里 `ai_t{id}` 表存在且 `select count(*)` = `row_loaded`。
+5. 治理侧:`govern_tree` 中 `id < 0` 的节点数 = 该案件有数据的 AI 表数,且 `data_count` 与真 count 一致;正数节点数量与跑前一致(**既有链路零回归**)。
+
+## 四、通过标准与失败处理
+
+- **通过**:二、三两组断言全部成立,且 `stage ∈ {DONE, PARTIAL}`。`PARTIAL` 可接受(真模型对陌生列名判成待人工是设计内行为),但要留下 `need_review_detail` 的原因文本作为 P1 改进依据。
+- **失败即定位、不猜**:
+  - 卡在 `JUDGING` → 看是否 `Semaphore`/`CaseContextHolder` 问题(日志里 `AI 判定异常` 的堆栈);
+  - `doClean` 出现两次 → 说明 A 组合并逻辑漏了,回读 `dispatchExisting` 与前端实际提交的 `fileInfos`;
+  - AI 节点点开 500 → `id<0` 分支没生效或 `dataPage` 的 `head/pages` 结构与 `extractTablePayload` 不匹配;
+  - 真模型超时 → 记录耗时,把该批次的 `cost_ms/llm_calls` 写进文档「实现进度」,不当作功能失败。
+- 发现需要改代码的缺陷:先修 → 重跑离线单测(`mvn -o -pl ai-server test -Dtest='com.zsjz.ai.module.aiclean.**.*Test'`)→ 再走浏览器复验,不攒着。
+
+## 五、跑完的清理(都会先报备再动手)
+
+1. 上传落在仓库内 `ai-server/QingJian/workspace/{caseId}/upload/{batchId}/`:确认后**只删本次 batchId 目录**(不碰别的批次、不碰 `QingJian/{caseId}/data.db` 案件库)。
+2. `ai_clean_job/ai_clean_batch/ai_clean_template/ai_clean_field/ai_clean_rule` 里本次新行**保留**(是 P1 复放的样本),但把 id 列出来;如需回滚数据用现成 `POST /aiClean/revert?jobId=`,不手写 DELETE。
+3. `git status` 里那条 `AD ai-server/QingJian/uy76tkp8/data.db`(早前测试残留,已被 `git add` 进索引又在工作区删掉):**提请确认**是否 `git restore --staged` 摘掉;不擅自改索引。
+4. 文档 `docs/design/ai-clean-parallel.md`:把真实端到端结果(耗时、exit_code 分布、`llm_calls=1` 佐证、`S1→S7` 哪一步最先出问题)补进第十四节,并在「实现进度」里把「端到端未跑」这条划掉。
+
+## 关键文件
+
+- 后端:`ai-server/src/main/java/com/zsjz/ai/module/aiclean/service/AiSmartCleanService.java`、`.../api/AiSmartCleanController.java`、`.../service/AiCleanService.java`(`judge` / `loadViaAiTemplate` / `dataPage`)
+- 前端:`ai-frontend/src/case/views/data/upload.vue`、`cleanProgress.vue`、`index.vue`、`ai-frontend/src/case/api/govern/governApi.ts`
+- 只读参照(判据来源,不改):`ai-frontend/src/main/java/.../dm/pre/PreDataListener.java:260-281,340-395`、`common/cache/GlobalCache.java:200-209`
+- 造数脚本:`C:/Users/cc/AppData/Local/Temp/e2e/Gen.java`(需按第一节的 matched 列改 `OK_HEADERS` 后重新生成)

+ 372 - 0
docs/design/mossy-quarry-swan.md

@@ -0,0 +1,372 @@
+# 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 只是"替用户提交一次清洗请求")
+
+```java
+// 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()` 与前端一行不改。
+
+```sql
+-- 复用 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("[数据治理]分类树统计完成!")` 之前**:
+
+```java
+// 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、事后不可还原,**必须自己重读**。
+```java
+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 票)。
+  ```java
+  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。
+- [Can LLMs Clean Up Your Mess? A Survey of Application-Ready LLM-based Data Cleaning](https://arxiv.org/html/2601.17058v1)
+- [Effortless spreadsheet normalisation with LLM](https://medium.com/data-science-collective/effortless-spreadsheet-normalisation-with-llm-c1b28669b729) / [TDS 版](https://towardsdatascience.com/effortless-spreadsheet-normalisation-with-llm/)
+- [SpreadsheetLLM: Encoding Spreadsheets for Large Language Models](https://arxiv.org/html/2407.09025v1)
+- [Best AI for Messy Spreadsheets — LlamaIndex](https://www.llamaindex.ai/insights/best-ai-for-messy-spreadsheets)(先定义目标结构再解析;合并/多行表头还原;不确定字段路由人审)
+
+## 十四、实现进度(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 自清):
+
+```bash
+# 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` 注释。
+4. **本项目 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,字符串快照测不出这类问题。
+5. 剔行条件应当用**闭集 LIKE 前后缀**而不是正则:既避开 DuckDB 正则方言差异,
+   也让「第%页」这种两头锚定的模式不会被「第三方支付」误命中。
+6. 结构 SQL 采用**一条组合语句**(`DROP` + `CREATE ... AS SELECT` 带全部 WHERE 条件),
+   不串临时表:连续 CTAS 在同连接上会触发上面第 4 条那种滞后报错,且多一倍建表开销。
+7. **写入失败不能伪装成"写了 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 条"同步治理断言"必须全绿。

+ 119 - 0
docs/design/silver-gorge-roach.md

@@ -0,0 +1,119 @@
+# 智能清洗融合:前端「智能」按钮 + 后端一个接口跑完双链路
+
+## Context
+
+`module/aiclean`(AI 清洗)后端已落地并在真库/真模型上验证过,但目前**没有任何入口能触发它**:
+- 前端 `upload.vue:704-709` 的「智能」按钮只是 `go(BASE_CLEAN_PROGRESS)` 跳到进度页,让 `cleanProgress.vue:826` 在 `onMounted` 里自己调 `startClean()` → 只跑既有模板清洗;
+- 更关键的是 **只有未匹配文件的批次,「智能」按钮是灰的**:`hasSuccessResult`(`upload.vue:258-262`)依赖 `isSuccessStatus`,而 `SHEET_TEMPLATE_MATCHED_FAIL` 被归进 `FAIL_FILE_STATUSES`(`upload.vue:561-566`)→ AI 恰好在没有可用入口。
+
+目标(已确认三点取舍):新增 **一个** 后端接口 `POST /aiClean/smartClean` 一次做完「既有清洗 + AI 清洗」,前端**后台异步 + 轮询状态**,AI 判置信度不够的 sheet **标待人工并给跳手动清洗的入口**。
+
+约束沿用:不改既有清洗执行层与模板库(`GovernService` 那 1 行同步治理挂载是已完成的唯一例外)。
+
+## 一、融合后的完整流程
+
+```
+上传页 [智能] ──POST /aiClean/smartClean {fileInfos}──▶ 立即返回 {batchId, stage:RUNNING}
+                                                          │ 后台虚拟线程 + Semaphore(3)
+   ┌────────────────────────────────────────────────────┘
+   S1 拆分:sheet.templateId != null → A组(既有);== null → B组(交 AI 判定)
+   S2 B组判定:AiCleanService.judge(...)  S1探测→S2结构归一→S3匹配→S4规则→S5校验
+        ├ ExistingHit(templateId, mainId, rules)  → 并入 A组(带 tableRuleList)
+        ├ AiTemplatePlan(字段/规则/数据计划)        → 出口 B,待 S4 入库
+        └ NeedReview(reason)                        → 记录,不写任何表
+   S3 A组合并成【一次】DmService.doClean(dto)  ← 关键:只调一次,SSE 与 closeSee 都只发生一次
+   S4 出口 B 计划逐个入库 ai_t{n} + 写 table_info 之外的 AI 元数据 + calcAiTree()(负 id 节点)
+   S5 汇总写 ai_clean_batch(各计数 + needReview 明细),供前端轮询
+前端轮询 GET /aiClean/smartClean/status?batchId= → 阶段条 + 「N 个表需人工确认」→ 跳手动清洗
+```
+
+**为什么必须合并成一次 `doClean`**:`DmService.doClean`(`:650-676`)是 fire-and-forget 且完成回调里会 `sseService.closeSee()`。若 AI 命中既有模板时由 AI 自己再调一次 `ExistingTemplateDispatcher`(当前实现就是),同一批次会产生多次 doClean → 多个 SSE 会话先后关闭,进度页会中途断流;也就会出现"同一文件被洗两遍"。所以融合场景下 **A 路只出判定、不出手**,把模板与规则交给批次级那一次 `doClean`。
+
+## 二、后端改动(全在 `module/aiclean`,既有文件 0 改动)
+
+### 1. `service/AiCleanService` 拆分(模块内重构,不改行为)
+现在是 `run(jobId)` 一条龙(判定 + 出口 A 直接 dispatch + 出口 B 直接入库)。拆成:
+```java
+public AiJudgeResult judge(Long caseId, FileInfo root, FileInfo sheet) // S1→S5,只判定、不落任何库
+public long loadViaAiTemplate(AiJudgeResult plan, ...)                 // 出口 B:建表 + 写数 + 登记 + 刷树
+public AiCleanJob run(Long jobId);                                     // 保留:单 job 串行走完(供 /aiClean/run 与回归用)
+```
+`AiJudgeResult`(新 sealed 记录)三态:`ExistingHit(templateId, mainId, funcRegx, List<TableRuleDTO>)` / `AiTemplatePlan(...)` / `NeedReview(reason)`。
+出口 A 的 `TableRuleDTO` 直接复用已有 `AiRulePlan.forExisting(...)`(它产出的就是前端那份报文结构)。
+
+### 2. `service/AiSmartCleanService`(新)
+```java
+SmartCleanVO submit(Long caseId, Long userId, List<FileInfo> fileInfos);   // 落 ai_clean_batch + 立即返回
+void run(Long batchId);                                                     // 后台执行 S1→S5
+SmartCleanStatusVO status(Long batchId);
+```
+- 并发:虚拟线程 + `Semaphore(3)` 公平闸门,照 `FileRecognitionService:98-125` 既有范式;每个任务内 `CaseContextHolder.runWith(caseId, userId, ...)`(虚拟线程不继承 ThreadLocal,`DmService:671-675` 已踩过一次)。
+- 阶段字段:`JUDGING → CLEANING_EXISTING → LOADING_AI → DONE / PARTIAL`;判定阶段内部并发,阶段切换靠计数归零。
+- A 组为空时**跳过** `doClean`(`buildCleanJobs` 空会 `closeSee`,`DmService:653-658`,白关一次会话)。
+- 幂等:`ai_clean_job` 唯一键 `(case_id,file_id,sheet_id,block_no,ver)` 已能防重复判定;`ai_clean_batch` 按 `(case_id,batch_id)` 唯一,重复点按钮返回同一 batchId 而不重跑。
+- 不重叠:只处理 `templateId == null` 的 sheet,既有能匹配的绝不插手 → 与手动清洗链路天然互斥。
+
+### 3. `api/AiSmartCleanController`(新)
+```
+POST /aiClean/smartClean   body {fileInfos:[FileInfo 树]}  → {batchId, stage, fileCount, aiSheetCount, matchedSheetCount}
+GET  /aiClean/smartClean/status?batchId=                   → {stage, total, judged, cleaningExisting, loadedAi, needReview[], failed[]}
+```
+入参用 `@RequestBody`(既有约定:漏注解会退化成 `@ModelAttribute` 静默丢 JSON body,`DmController` 类注释已写明)。`needReview[]` 带 `sheetId + fileName + reason`,前端用它拼 focusKey 跳转。
+
+### 4. 新表 `sql/ai_clean.sql` 追加 `ai_clean_batch`
+`id, case_id, batch_id, file_count, sheet_total, matched_count, ai_judged_count, existing_hit_count, ai_load_count, need_review_count, failed_count, stage, need_review_json, last_error, create_time, update_time`;唯一键 `(case_id, batch_id)`。DDL 保持 `IF NOT EXISTS`,可重复执行(前 4 张表已在 dev PG 18.4 验过 12/12 语句)。
+
+### 5. 前端能看到 AI 数据的配套(否则融合结果是"半可见")
+治理树节点已是负 id(`AiGovernService`),但 `GovernTreeService.getTreeTablePage` 走 MyBatis-Plus 实体注册,点 AI 节点必 NPE —— 后端 `/aiClean/data/page` 已就绪,前端补一个 `id < 0` 分支即可(见第三节第 4 项)。
+
+## 三、前端改动
+
+### 1. `upload.vue`:让按钮可用 + 改为调新接口
+- 可用条件:`hasSuccessResult` → **`hasCleanableSheet`**(`upload.vue:270`,语义就是"解析出了 sheet"),否则未匹配批次永远点不动;
+- `handleSmartClean`:`persistFileInfos(allFileInfos)` → `await smartClean({fileInfos})` → 成功 `allowedToLeave=true; go({path: BASE_CLEAN_PROGRESS, query:{fused:'1', batchId}})`;接口失败留在本页并提示(不清批次)。
+- 文案:按钮副标题加一句"未匹配模板的文件将走 AI 自动识别"。
+
+### 2. `governApi.ts`:新增两个封装
+`smartClean(dto)`、`smartCleanStatus(batchId)`,与同文件既有 `doClean:148` 一致的 baseURL 与错误处理写法。
+
+### 3. `cleanProgress.vue`:fused 模式下不自己发起清洗
+`onMounted`(`:829`)里当 `route.query.fused === '1'` 时**跳过** `startClean()`(`:826`),只连接既有 SSE 收后端那一次 `doClean` 的进度;同时新增一个 AI 阶段卡片:轮询 `smartCleanStatus` 直到 `stage ∈ {DONE, PARTIAL}`,展示 `判定 x/y · 交既有清洗 n · AI 入库 m · 待人工 k`,`待人工` 可点 → 复用 `handleRowManualClean` 的 `focusKey` 跳 `BASE_CLEANING`。
+
+### 4. 治理树 AI 节点取数(`data/index.vue`)
+节点点击处分流:`node.id < 0` → 调 `/aiClean/data/page?aiTemplateId=-id`(表头用返回的 `head`),否则走现有 `treeTablePage`。约 10 行,独立分支,不动既有分页逻辑。
+
+## 四、实施顺序
+
+1. 后端:`AiJudgeResult` + `AiCleanService` 拆分(`judge` / `loadViaAiTemplate`),保持 `/aiClean/run` 行为不变 + 补单测。
+2. 后端:`ai_clean_batch` DDL + 实体 + mapper;`AiSmartCleanService`(闸门、阶段机、A 组合并一次 doClean、B 组入库);`AiSmartCleanController`。
+3. 前端:`governApi` 两个函数 → `upload.vue` 按钮条件与 handler → `cleanProgress.vue` fused 分支 + AI 阶段卡片轮询。
+4. 前端:治理树 `id<0` 分流。
+5. 文档:`docs/design/ai-clean-parallel.md` 升到 v7.6,把融合接口、批次状态机、前端接入点与这轮真模型跑出的四个修复(top-12 候选、`#n` 重编号、WEAK 不许走 A、草稿复用)写进「实现进度」。
+
+## 五、验证
+
+**离线单测(必做,不依赖模型/浏览器)**
+- `AiSmartCleanServiceTest`(mockito,不起 Spring):① 输入 6 个 sheet(3 有 templateId、3 没有)→ 断言 `doClean` **只被调用一次** 且 dto 里含 3 个既有 + AI 判为命中的那些 sheet,且每个都带 `tableRuleList`;② A 组为空 → 断言 `doClean` **零调用**(不白关 SSE);③ `judge` 返回 NeedReview → 断言不写任何库、`need_review_count` 正确;④ 重复 submit 同一 `(caseId,batchId)` → 返回同一 batchId 且不重跑。
+- 阶段机断言:只有当所有判定任务计数归零才切到 `CLEANING_EXISTING`(防止 doClean 早于判定,把 AI 命中的 sheet 漏掉)。
+
+**端到端(真 PG + 真模型 + 真浏览器)**
+1. 造两类文件:`messy.xlsx`(列名与模板不一致,POI 生成器已有)与一份能正常匹配模板的文件;
+2. 起 `mvn spring-boot:run` 与 `npm run dev`,走登录/开案,上传这两个文件;
+3. 断言:批次含未匹配文件时「智能」按钮**可点**(这是本轮修的入口问题);
+4. 点击后用 browser-use 观察:进度页不再重复发起清洗(后端日志里 `doClean` 只出现 1 次)、AI 阶段卡片计数推进到 `DONE`;
+5. `SELECT * FROM ai_clean_job WHERE batch_id=?` 看 `exit_code` 分布;`ai_clean_batch.stage` 终态正确;
+6. 治理树(`data/index.vue`)出现 AI 节点、点开能出数据与表头(`id<0` 分支生效),既有节点行为不变;
+7. 待人工场景:把 `LLM_MIN_CONFIDENCE` 临时调高触发 `NEED_REVIEW`,点「去人工确认」落到手动清洗页并定位到该 sheet;
+8. 回归:一个全部能匹配模板的批次,走「智能」后的行为与改动前逐行一致(`git diff` 里既有后端文件仍只有 `GovernService` 那 1 行)。
+
+## 六、风险
+
+| 风险 | 应对 |
+|---|---|
+| AI 判定慢(实测单文件几十秒起),批次里文件多时总时长不可控 | 后台异步 + 闸门并发;状态里给"排队中"计数;后续靠 `header_md5` 直通让重复表头零模型调用(P1) |
+| 判定与清洗的时序写错 → AI 命中的 sheet 被漏洗 | 阶段机:判定计数未归零不许进 `CLEANING_EXISTING`;配单测 ①② 钉死"只调一次" |
+| 多次 `doClean` 引发 SSE 提前关闭、进度页断流 | 融合路径上 A 路只出判定不出手;`ExistingTemplateDispatcher` 只保留给 `/aiClean/run` 单 job 场景 |
+| 与手动清洗重复入库 | 只处理 `templateId==null` 的 sheet;用户手动配过并跑过的 sheet 不再被 AI 接管(`AiCleanService` 已有 `SUPERSEDED` 语义) |
+| 前端 fused 标志丢失导致既不发清洗也不等清洗(卡住) | `cleanProgress` 无 `fused` 时行为完全不变;有 `fused` 时轮询超时给显式错误 + 「改为手动清洗」出口 |
+| 重复点「智能」 | batchId 幂等 + job 唯一键,返回同一批次 |
+| AI 表节点在前端点开 500 | 第四节第 5 步的 `id<0` 分流必须与融合入口同批上线,否则数据"看不见" |

+ 372 - 0
docs/design/snowy-vault-pintail.md

@@ -0,0 +1,372 @@
+# 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 只是"替用户提交一次清洗请求")
+
+```java
+// 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()` 与前端一行不改。
+
+```sql
+-- 复用 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("[数据治理]分类树统计完成!")` 之前**:
+
+```java
+// 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、事后不可还原,**必须自己重读**。
+```java
+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 票)。
+  ```java
+  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。
+- [Can LLMs Clean Up Your Mess? A Survey of Application-Ready LLM-based Data Cleaning](https://arxiv.org/html/2601.17058v1)
+- [Effortless spreadsheet normalisation with LLM](https://medium.com/data-science-collective/effortless-spreadsheet-normalisation-with-llm-c1b28669b729) / [TDS 版](https://towardsdatascience.com/effortless-spreadsheet-normalisation-with-llm/)
+- [SpreadsheetLLM: Encoding Spreadsheets for Large Language Models](https://arxiv.org/html/2407.09025v1)
+- [Best AI for Messy Spreadsheets — LlamaIndex](https://www.llamaindex.ai/insights/best-ai-for-messy-spreadsheets)(先定义目标结构再解析;合并/多行表头还原;不确定字段路由人审)
+
+## 十四、实现进度(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 自清):
+
+```bash
+# 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` 注释。
+4. **本项目 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,字符串快照测不出这类问题。
+5. 剔行条件应当用**闭集 LIKE 前后缀**而不是正则:既避开 DuckDB 正则方言差异,
+   也让「第%页」这种两头锚定的模式不会被「第三方支付」误命中。
+6. 结构 SQL 采用**一条组合语句**(`DROP` + `CREATE ... AS SELECT` 带全部 WHERE 条件),
+   不串临时表:连续 CTAS 在同连接上会触发上面第 4 条那种滞后报错,且多一倍建表开销。
+7. **写入失败不能伪装成"写了 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 条"同步治理断言"必须全绿。

+ 179 - 0
docs/design/soft-stone-chub.md

@@ -0,0 +1,179 @@
+# 把 Noobi.ai 的 Codex 封装移植进 zsjz-ai + AI 菜单下新增「插件」页
+
+## Context
+
+`ai-electron/` 已用 electron-egg(ee-core 5.0.1)把前端包成桌面端,现在要把桌面端升级成**能真正驱动 Codex agent** 的客户端。参考项目 `E:\workspace\ai\Noobi.ai`(Electron + React,基于 `@openai/codex` 的 app-server 协议)已经有一套很干净的 Codex 调用层,但它的产品逻辑(Godot/Three 游戏生成)和 React 前端我们都不用。
+
+要解决的问题:
+1. Noobi 的**文本模型硬依赖 ChatGPT 账号登录**(`src/main/main.ts:671` 有 `if(!status.account) throw '请先登录 ChatGPT'` 门禁),且全仓**没有任何** `model_provider` / `base_url` 写法 —— 免登录 + 自定义模型是**新增工作**,不是搬运。
+2. 需要一个「插件」页面管理 **Skill 与 MCP**,Codex app-server 原生只给了 MCP 全量 CRUD 和 Skill 的列表/启停,**安装/卸载 Skill 必须自建**。
+
+预期结果:桌面端里点「AI 数据分析 → 插件」即可配置本机 Codex 运行时用哪个模型(复用后端模型管理表)、增删 MCP server、安装/启停/卸载 Skill;全程不需要任何 Codex/OpenAI 账号登录。
+
+## 已实测事实(方案的地基,均已在实机核实)
+
+| 事实 | 证据 |
+|---|---|
+| 已装 `codex-cli 0.155.1`(Noobi 用 0.148) | `ai-electron/node_modules/@openai/codex-win32-x64/vendor/x86_64-pc-windows-msvc/bin/codex.exe --version` |
+| **`wire_api = "chat"` 已下线,只支持 `responses`** | 该 exe 二进制内含原文 `` `wire_api = "chat"` is no longer supported. `` |
+| 本地模型有原生通道 `--oss` / `--local-provider ollama\|lmstudio` | `codex.exe --help` |
+| `-c key=value` 可覆盖任意配置(含 `model_providers.*`),不传 `--strict-config` 时未知字段只告警 | `codex.exe --help`(`--strict-config` 释义:*Error out when config.toml contains fields that are not recognized*) |
+| Skill 目录规范:`$CODEX_HOME/skills/<kebab-case>/SKILL.md`,frontmatter 必填 `name` + `description`;内置在 `skills/.system/`;app-server 只有 `skills/list`、`skills/config/write`(启停)、`skills/extraRoots/set`、通知 `skills/changed`,**无安装/删除方法** | 0.155.1 内嵌 skill-creator 文档 + app-server 方法名取证 |
+| MCP 走 `mcp_servers.<id>` + `config/value/write` + `config/mcpServer/reload`,**热生效不用重启** | `E:\workspace\ai\Noobi.ai\src\main\mcpConfigManager.ts:84-93` |
+| ee-core 的 controller 自动注册成 IPC channel `controller/<文件名>/<方法>`,glob 含 `.ts`,esbuild bundle 成 CJS(target node20) | `ee-core/dist/cjs/socket/ipcServer.js:88-118`、`ee-bin/dist/cjs/plugins/bundle_registry_plugin.js:73` |
+| 渲染进程桥现状:`contextIsolation:false` + `nodeIntegration:true`,`webPreferences.preload` 是注释掉的 → `window.electron` **不存在**,只有 `require('electron')` 可用 | `ai-electron/electron/config/config.default.ts:20-24`;旁证:`frontend/src/core/components/PersonAvatar/src/PersonAvatar.vue:73-78` 调的 `controller/os/readBinaryFileRange` 在本仓根本没有对应 controller |
+| 前端实体只有 `ai-electron/frontend/`(仓库根 `ai-frontend/` 已被移走、不存在) | `ls E:/workspace/zsjz-ai` |
+
+**未获答复的三个提问,本计划按以下默认值编写(批准前可直接改)**:第三方 chat-only 端点 → 先做实测再决定是否上桥接代理(见 P3);「插件」页 → 只管 Skill + MCP;会话链路 → 建好 IPC 契约但不做对话 UI。
+
+## 移植清单(源 → 目标)
+
+`E:\workspace\ai\Noobi.ai\src\main\` → `E:\workspace\zsjz-ai\ai-electron\electron\service\codex\`
+
+| 源文件 | 处置 |
+|---|---|
+| `jsonRpcPeer.ts` | 原样搬(JSONL 分帧、出站 16MiB/入站单行 48MiB、30s 超时、`stop()` 的 endOutput→SIGTERM→SIGKILL 全部保留)+ `jsonRpcPeer.test.ts` |
+| `codexLocator.ts` | 搬 + 改写探测顺序:`ZSJZ_CODEX_BIN` → `@openai/codex-<平台>/vendor/<triple>/bin/codex(.exe)`(含 `app.asar→app.asar.unpacked` 替换)→ `which/where codex`;删掉 ChatGPT.app 分支 |
+| `codexAppServer.ts` | → `codexRuntime.ts`:**删** `account/login/start`、`account/logout`(`:342-355`)与 `account/read` 调用;`--strict-config` 不再传;`thread/start` 参数保持 camelCase(`modelProvider/approvalPolicy/sandbox/cwd`) |
+| `approvalBroker.ts` | 原样搬(`redact()` 已屏蔽 token/apiKey/secret)+ 单测。默认 `approvalPolicy:'never'`,保留 broker 以便后续接审批 UI |
+| `eventMapper.ts` | 搬,**整块删除** `stage` 推断(游戏流水线专用),+ 单测(删 imageGeneration/dynamicToolCall 用例) |
+| `eventLog.ts` | 原样搬,落盘改 `userData/zsjz-codex/events/yyyy-MM-dd.jsonl`(**不放 CODEX_HOME**,避免被 Codex 扫描)+ 单测 |
+| `mcpConfigManager.ts` | → `mcpService.ts`,**校验阈值全保留**(id 正则、args≤64/单条≤2000、command≤500、http 仅 https 或本机、URL 禁内嵌凭据) |
+| `contracts.ts` | 裁出 `AgentEvent` / `Approval*` / provider 子集 → `service/codex/types.ts`(前后端契约单一真源) |
+| `main.ts` | **只借范式**(`handle()` 包装、`broadcast`、事件绑定),落在 `controller/codexCtl.ts`;`:671` 登录门禁、20 个游戏 import 全部不移植 |
+| `src/generated/codex/`(751 文件、1.3MB) | **不移植**。手写代码零引用、`tsconfig.main.json` 也不 include,纯协议参考文档 |
+| 其余 21 个(gameHarness / godot / three / asset / media / previewServer / workspaceTemplate …) | 不移植 |
+| `scripts/codex-smoke.ts` | 重写为离线冒烟 `ai-electron/scripts/codex-smoke.mjs`(见验证章节) |
+
+TS 书写约束:相对 import **一律省略扩展名**(ee-bin 的 `copy` 分支会 `bundle:false` 输出字面 `require('./x.js')`,带 `.js` 后缀在 CJS 下会炸);禁用 `import.meta.url`,用 `__dirname` / `getBaseDir()`。
+
+## 分层与 IPC 契约
+
+```
+controller/codexCtl.ts        ← 唯一 IPC 入口 + 唯一 try/catch 层
+  service/codex/providerService.ts   后端模型 → Codex provider(新增,非移植)
+  service/codex/mcpService.ts        MCP CRUD(移植)
+  service/codex/skillService.ts      Skill 安装/卸载(新增,原生无此能力)
+  service/codex/codexRuntime.ts      1 方法 ↔ 1 RPC
+  service/codex/{jsonRpcPeer,codexLocator,codexHome,approvalBroker,eventMapper,eventLog}.ts
+```
+
+`ipcMain.handle` 抛错会被 Electron 包成 "Error occurred in handler for…"(`ipcServer.js:113`),所以 controller 层统一返回:
+
+```ts
+type Rpc<T> = { ok: true; data: T } | { ok: false; message: string };
+```
+
+channel 前缀 `controller/codexCtl/`,方法清单:
+
+- 运行时:`ping`(只探测二进制,不 spawn)→ `{available,version,binaryPath,codexHome,reason?}`;`status` → `{running,defaultModel,providerId,degraded}`;`start` / `stop` / `restart`
+- 模型:`listModels`、`providerCapabilities`、`applyProvider{modelId}`、`clearProvider`
+- MCP:`mcpList` / `mcpSave(McpServerInput)` / `mcpRemove{id}`
+- Skill:`skillList{forceReload?}` / `skillRead{path}` / `skillInstallFolder{srcPath}` / `skillInstallZip{zipPath}` / `skillRemove{path}` / `skillSetEnabled{path,enabled}` / `skillOpenFolder{path?}`
+- 会话(本期只建契约,UI 由你写):`threadStart{cwd?,model?,approvalPolicy?='never',sandbox?='workspace-write'}` / `turnRun{threadId,input}` / `turnInterrupt` / `threadUnsubscribe`
+- 运维:`logsTail{limit?}`
+
+## 免登录 + 模型配置(复用后端 `agent_model`)
+
+真源仍是后端 `agent_model` / `agent_model_provider`(`ai-server/.../controller/AgentModelController.java:32-74`,前端已有「模型管理」页 `frontend/src/ai/views/aiModel/index.vue`)。插件页只做「选一条后端模型 → 应用到本机 Codex 运行时」。
+
+写入方式**优先用启动参数而非改文件**,把密钥彻底留在内存:
+
+1. spawn 时追加 `-c model_provider="zsjz"`、`-c model="<modelId>"`、`-c 'model_providers.zsjz={ name="…", base_url="…", env_key="ZSJZ_CODEX_API_KEY", wire_api="responses", requires_openai_auth=false }'`;
+2. 同一次 spawn 的 `env.ZSJZ_CODEX_API_KEY = 后端返回的 apiKey` → **key 不落任何盘**(后端库里是明文,属既有现状,见风险 3);无 key 的本地服务注入占位值;
+3. 需要持久化的只有非敏感项,走原生 `config/value/write`(与 MCP 同一通道,Codex 自己也持有该文件写权,所以**绝不整文件覆盖**)。
+4. 换 key 必须重启子进程(env 不热更新)→ `applyProvider` 内部固定 `stop()+start()`。
+
+字段映射:`baseUrl → base_url`(规范化补 `/v1`,不含 `/v1` 给提示)、`headersJson → http_headers`、`config` JSONB 里只放行 `request_max_retries/stream_max_retries/stream_idle_timeout_ms/query_params`,白名单外键**丢弃并 warn**;`type==='OLLAMA'` → 走 `--oss --local-provider ollama` 原生路径。
+
+三处必须兜底:不调 `account/read`(返回 null 视为正常态);`model/list` 空/失败时回落顺序「后端 defaultModel → 上次应用的 model → 常量」并在 `status` 标 `degraded:true`;不传 `--strict-config`,改为写入前 `config/read` 回读比对。
+
+## Skill 安装/卸载(自建)
+
+- 目标目录 `<codexHome>/skills/<name>/`,`name` 归一化为 lowercase hyphen-case,正则 `^[a-z0-9]+(-[a-z0-9]+){0,9}$` 且 ≤64;
+- 校验 `SKILL.md` frontmatter 必含非空 `name` + `description`,否则拒绝安装;
+- 写入走「临时目录 `skills/.staging-<uuid>` → 原子 `rename`」,目标已存在需显式 `overwrite`;
+- zip 安全:逐 entry `path.resolve` 后必须以目标目录为前缀,拒绝绝对路径/`..`/symlink/`__MACOSX`;限额:单文件 ≤2MiB、解压总量 ≤25MiB、条目 ≤300、`SKILL.md` ≤64KiB、扩展名白名单(md/txt/json/yaml/py/js/ts/sh/ps1/png/jpg/svg/csv);
+- 启停仍用原生 `skills/config/write`;安装/删除后 `skills/list` 带 `forceReload`;
+- 删除边界:路径必须落在 `<codexHome>/skills` 内、首段目录名不以 `.` 开头(排除 `.system`/`.curated`/`.experimental` 内置项)、且非 system/admin scope;只删该 skill 目录,另外把 `skills/config/write` 的残留叶子写 `null` 清掉。
+
+## P3 决策门:chat-only 端点怎么跑通(最大风险,先实测再决定)
+
+0.155.1 只剩 `responses`,而 DashScope/DeepSeek/Kimi/GLM 的 OpenAI 兼容模式多数只有 `/v1/chat/completions`。实施到 P2 结束时安排一次**不超过半天的实测**:拿库里 2-3 个真实 baseUrl 探 `/v1/responses` 是否可达,并测本机 Ollama 版本是否已支持 Responses。结果三选一:
+
+- **A. 内置 Responses→Chat 转换代理**(覆盖面最广,工作量最大的新增件):主进程起 `127.0.0.1:<随机端口>`,codex 的 `base_url` 指向它,代理把 `/v1/responses`(含 SSE 事件流、工具调用、多轮)翻译成 `/v1/chat/completions`。落点 `electron/service/codex/responsesBridge.ts`,`applyProvider` 检测到 chat-only 时自动把 base_url 改指向代理。
+- **B. 只支持 Responses 端点 + Ollama 原生路径**,页面探测到 chat-only 直接给不兼容提示(最省,但大部分现有模型不可用)。
+- **C. 交给外部网关**(new-api/LiteLLM 等做 responses→chat 转发),我方只写校验与部署说明。
+
+默认倾向 A(你的诉求原文是「要配置第三方模型。或者本地模型。」),但要等实测数据——若多数端点已支持 Responses,选 B 省下一整个组件。
+
+### P2b 实测结论(2026-09-22,已回填)→ 采用 B + 端点探测,A 降级为后续增量
+
+无凭据探测各厂商 `/v1/responses`,并用「故意不存在的路径」做对照组排除网关统一 401 的干扰:
+
+| 厂商 | 假路径 | `/v1/responses` | 结论 |
+| --- | --- | --- | --- |
+| 阿里 DashScope(compatible-mode) | 404 | **401** | ✅ 已实现 Responses |
+| Moonshot / Kimi | 404 | **401** | ✅ 已实现 |
+| SiliconFlow | 404 | 404 | ❌ 未实现 |
+| DeepSeek | 401 | 401 | 网关对任意路径都 401,**无法判定** |
+| 智谱 GLM(paas/v4) | 401 | 401 | 同上,**无法判定** |
+| OpenAI / OpenRouter | 000 | 000 | 本机网络不通,未测 |
+| 本机 Ollama | — | — | 未安装;Codex 有内置 `model_provider="ollama"`,走内置通道 |
+
+决策:**本期按 B 实现**——provider 一律 `wire_api="responses"`,`applyProvider` 前先探测 `base_url + /responses`,404/405 就在页面上给出明确的「该端点未实现 Responses API」提示并阻止应用;本地模型走 Codex 内置 provider。**A(内置 Responses→Chat 转换代理)作为后续独立增量**,等确实要接 SiliconFlow/DeepSeek 这类 chat-only 端点时再做——它是一个大且易随 codex 升级漂移的组件,不该在证据不足时先建。
+
+补充实测:`model/list` 在**未登录**时也会返回 Codex 内置模型目录(如 `gpt-6-astra`),与自定义 provider 无关 → `defaultModel` 必须优先取 provider 配置的模型,否则页面会显示一个根本用不了的模型。`skills/list` 未登录可用,且 Codex 会把内置 skill 实体化到 `<codexHome>/skills/.system/`(imagegen / plugin-creator / review-agent / skill-creator / **skill-installer**),P5 的删除边界必须排除 `.system`。
+
+## 前端登记(4 处,均为既有约定)
+
+1. `frontend/src/core/store/modules/menu.json`:在 `id 10263`(「AI 数据分析」)的 `children` 末尾(现结构结束于 `:657`)追加一条,字段形状对齐 `10265`:
+   `{"path":"/aiPlugin/index","component":"/aiPlugin/index","children":[],"meta":{"icon":"i-mdi:puzzle-outline","hideMenu":false,"color":"","title":"插件"},"name":"ViewsAiPluginIndex","id":"10266","leaf":false,"url":"/aiPlugin/index","target":""}`
+2. `frontend/src/core/router/helper/routeHelper.ts:53-61` 的 `explicitDynamicViewMap` 加 `'/aiPlugin/index': () => import('@/ai/views/aiPlugin/index.vue')`(`AI_AGENT.md:372-374` 要求显式映射,否则 `:74` 的 `import.meta.glob` 会命中 node_modules 同名 views)。
+3. `ai-electron/frontend/uno.config.ts:21-46` safelist 补 `i-mdi:puzzle-outline` 等图标(menu.json 不在 UnoCSS 扫描范围)。
+4. 新增 `frontend/src/ai/api/codexApi.ts`:`ipcInvoke<T>(method, params)` 封装 + 在 `src/ai/api/index.ts` 登记出口。**不走 `src/ai/api/http.ts`**(那是 `/js/a` HTTP 壳,IPC 无前缀无 token);只有「选后端模型」的下达拉取复用既有 HTTP。
+
+页面 `frontend/src/ai/views/aiPlugin/index.vue`:`a-tabs`(`destroy-inactive-tab-pane`)分 MCP / Skill / Codex 运行时三块。表格与弹窗复用既有范式:列表写法照 `src/ai/views/aiModel/index.vue:32-140`(原生 `a-table` + 手写 columns,全量不分页),表单照 `src/ai/components/ModelFormModal.vue`。非 Electron 环境用 `require?.('electron')` 探测,取不到 ipcRenderer 就整页渲染一条 `a-alert`「本功能仅在桌面客户端可用」且**不发任何调用**;错误统一 `message.error(res.message)`,provider 不兼容用 `notification.warning` 常驻。
+
+无 i18n(菜单 title 与 AI 页文案都是中文字面量)、无菜单 SQL(全仓不建 `js_sys_menu`,菜单是前端静态 JSON)。
+
+## 生命周期与打包
+
+- **懒启动**:`ping` 不 spawn;首次 `applyProvider`/`mcp*`/`skill*`/`threadStart` 才拉起。`#starting` Promise 去重握手。
+- **崩溃**:peer `close` → `running=false`,进行中的 turn 以 `interrupted` 返回;**不自动重启**(自动重启会吞掉 provider 配置错误),由用户点「重启运行时」。
+- **回收**:在 `ai-electron/electron/preload/lifecycle.ts` 的 `beforeClose` 里 `await codexService.dispose()`(3s 兜底强杀)——不改 `main.ts`。
+- **CODEX_HOME**:固定 `userData/codex-home`(`mkdir mode 0700`),启动时补建空 `skills/`。
+- **打包必改** `ai-electron/cmd/builder.json`:加 `"asarUnpack": ["node_modules/@openai/codex-*/vendor/**"]`,否则 asar 里的 exe spawn 不到;`files` 已含 `node_modules/**`。Mac/Linux 的 builder-*.json 同步加对应平台包 glob。
+
+## 实施顺序与每步验收
+
+| # | 内容 | 验收 |
+|---|---|---|
+| P0 | **先打通渲染→主进程通道**(当前 `window.electron` 根本不存在):空 `codexCtl.ping` + 探针,确定用 `require('electron').ipcRenderer` 还是启用 `preload/bridge.js`+`contextIsolation` | 页面能拿到 `{ok:true,data:{version:'codex-cli 0.155.1'}}` |
+| P1 | 搬 `jsonRpcPeer`/`codexLocator`/`codexHome` + 3 个纯单测 | `codex-smoke` 之外的单测全绿;locator 在本机返回真实 exe 路径 |
+| P2 | `codexRuntime`(spawn/握手/stop/`model/list`),不传 `--strict-config` | `status().running===true`;`listModels()` 非空或明确 `degraded` |
+| P2b | **P3 决策门实测**(探后端真实端点 + Ollama 的 `/v1/responses`) | 产出 A/B/C 结论并回填本计划 |
+| P3 | provider:`applyProvider` + env 注入 + 重启;(选 A 则加 `responsesBridge.ts`) | 跑一次真实 `turnRun` 产出文件;**在 `config.toml` 里 grep 不到 api_key** |
+| P4 | `mcpService` 移植 | 存一个 stdio server → `mcpList` 出现 `connected:true,toolCount>0`,无需重启 |
+| P5 | `skillService` + 上传安全单测(越界 zip/超大 zip/缺 frontmatter 各一例) | 装入样例 skill → `skills/list` 可见且能启停;三类恶意包全被拒 |
+| P6 | 会话契约 `threadStart/turnRun/turnInterrupt` + `eventMapper`/`eventLog`/`webContents.send` 事件推送 | `logsTail` 有内容,turn 期间前端能收进度 |
+| P7 | 菜单/路由/safelist + `codexApi.ts` + 三 Tab 页面 | 三块 CRUD 全可用;浏览器 Web 模式显示降级提示 |
+| P8 | `asarUnpack` + prod 打包冒烟 | `ee-bin build --cmds=electron && ee-bin build --cmds=win64` 后装包里 `ping` 成功 |
+
+P1/P2 与 P4/P5 可并行(都只依赖 P0);P7 自 P0 起即可并行推进。
+
+## 端到端验证
+
+1. **离线冒烟** `ai-electron/scripts/codex-smoke.mjs`(不需要任何登录):起临时 HTTP 服务实现 `POST /v1/responses`(返回含一次 `write_file` 的固定流)→ 建临时 `CODEX_HOME` → spawn `app-server --listen stdio://`(带 `RUST_LOG=warn LOG_FORMAT=json`、`ZSJZ_SMOKE_KEY` 占位)→ `initialize`/`initialized` → `thread/start{approvalPolicy:'never',sandbox:'workspace-write',cwd:tmpdir}` → `turn/start` → 断言文件产出 → `stop()`,失败退出码非 0。
+2. **桌面端手工链路**:`ai-electron` 根目录 `npm run dev`(前端 vite 固定 3100,与 `cmd/bin.js` 端口一致)→ 插件页选一条后端模型 → 应用 → 装一个样例 skill → 加一个 stdio MCP(如 `npx -y @modelcontextprotocol/server-everything`)→ 看 `connected` 与 `toolCount` → 用 P6 的 `turnRun` 跑一句让它调用该 MCP 工具的话。
+3. **回归**:`frontend` 侧 `pnpm type:check` 与 `eslint --max-warnings 0` 不新增错误;浏览器 Web 模式下新菜单可打开且只降级提示,其余页面不受影响。
+
+## 风险清单
+
+1. **chat-only 端点不可用**(已实测 `wire_api="chat"` 下线)——产品级阻塞,P2b 用实测数据定 A/B/C。
+2. 0.148→0.155 协议漂移:Noobi 用到的 19 个 method 在 0.155.1 中已确认存在,但 `thread/start` 参数、`skills/*` 返回结构仍需逐个 smoke;升级 codex 前必须重跑 `codex-smoke`。
+3. `agent_model.api_key` 后端明文入库且明文下发,桌面端必然拿到它。本期只做「内存 + 子进程 env + 日志 redact」,长期要后端脱敏下发或按需申请。
+4. `sql/` 缺 `agent_model`/`agent_model_provider` 建表脚本(`QUICK_START.md:115` 已记为已知缺口)→ 新环境无表时 `applyProvider` 会 500。
+5. ee-core 的 `contextIsolation:false`+`nodeIntegration:true` 是脚手架默认值,本方案会在渲染进程直连主进程能力(能读写 `CODEX_HOME`、起子进程)。P0 若决定启用 preload + `contextIsolation:true`,则 IPC 契约不变但要改 `config.default.ts` —— 需你确认是否顺带收紧。
+6. Codex 自身也持有 `config.toml` 写权,我方「只写自己那段」靠 `config/value/write` 局部写保证,禁止整文件覆盖。
+7. 与后端既有的 `module/agent/mcp/*`(我方作为 MCP **Server** 对外暴露)和 `module/agent/config/SkillRepositorySupport.java`(未接线的服务端 skill 仓库)是**两套独立体系**,本期不打通,页面文案要避免误导。

+ 372 - 0
docs/design/tough-delta-roach.md

@@ -0,0 +1,372 @@
+# 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 只是"替用户提交一次清洗请求")
+
+```java
+// 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()` 与前端一行不改。
+
+```sql
+-- 复用 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("[数据治理]分类树统计完成!")` 之前**:
+
+```java
+// 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、事后不可还原,**必须自己重读**。
+```java
+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 票)。
+  ```java
+  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。
+- [Can LLMs Clean Up Your Mess? A Survey of Application-Ready LLM-based Data Cleaning](https://arxiv.org/html/2601.17058v1)
+- [Effortless spreadsheet normalisation with LLM](https://medium.com/data-science-collective/effortless-spreadsheet-normalisation-with-llm-c1b28669b729) / [TDS 版](https://towardsdatascience.com/effortless-spreadsheet-normalisation-with-llm/)
+- [SpreadsheetLLM: Encoding Spreadsheets for Large Language Models](https://arxiv.org/html/2407.09025v1)
+- [Best AI for Messy Spreadsheets — LlamaIndex](https://www.llamaindex.ai/insights/best-ai-for-messy-spreadsheets)(先定义目标结构再解析;合并/多行表头还原;不确定字段路由人审)
+
+## 十四、实现进度(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 自清):
+
+```bash
+# 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` 注释。
+4. **本项目 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,字符串快照测不出这类问题。
+5. 剔行条件应当用**闭集 LIKE 前后缀**而不是正则:既避开 DuckDB 正则方言差异,
+   也让「第%页」这种两头锚定的模式不会被「第三方支付」误命中。
+6. 结构 SQL 采用**一条组合语句**(`DROP` + `CREATE ... AS SELECT` 带全部 WHERE 条件),
+   不串临时表:连续 CTAS 在同连接上会触发上面第 4 条那种滞后报错,且多一倍建表开销。
+7. **写入失败不能伪装成"写了 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 条"同步治理断言"必须全绿。