silver-gorge-roach.md 11 KB

智能清洗融合:前端「智能」按钮 + 后端一个接口跑完双链路

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 直接入库)。拆成:

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(新)

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 分流必须与融合入口同批上线,否则数据"看不见"