|
|
@@ -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 条"同步治理断言"必须全绿。
|