# 智能清洗融合链路 · 端到端验证(真库 + 真模型 + 真浏览器) ## 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=` 并定位到该 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` 后重新生成)