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 改动)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(...)(它产出的就是前端那份报文结构)。
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;判定阶段内部并发,阶段切换靠计数归零。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,既有能匹配的绝不插手 → 与手动清洗链路天然互斥。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 跳转。
sql/ai_clean.sql 追加 ai_clean_batchid, 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 语句)。
治理树节点已是负 id(AiGovernService),但 GovernTreeService.getTreeTablePage 走 MyBatis-Plus 实体注册,点 AI 节点必 NPE —— 后端 /aiClean/data/page 已就绪,前端补一个 id < 0 分支即可(见第三节第 4 项)。
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}});接口失败留在本页并提示(不清批次)。governApi.ts:新增两个封装smartClean(dto)、smartCleanStatus(batchId),与同文件既有 doClean:148 一致的 baseURL 与错误处理写法。
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。
data/index.vue)节点点击处分流:node.id < 0 → 调 /aiClean/data/page?aiTemplateId=-id(表头用返回的 head),否则走现有 treeTablePage。约 10 行,独立分支,不动既有分页逻辑。
AiJudgeResult + AiCleanService 拆分(judge / loadViaAiTemplate),保持 /aiClean/run 行为不变 + 补单测。ai_clean_batch DDL + 实体 + mapper;AiSmartCleanService(闸门、阶段机、A 组合并一次 doClean、B 组入库);AiSmartCleanController。governApi 两个函数 → upload.vue 按钮条件与 handler → cleanProgress.vue fused 分支 + AI 阶段卡片轮询。id<0 分流。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 + 真模型 + 真浏览器)
messy.xlsx(列名与模板不一致,POI 生成器已有)与一份能正常匹配模板的文件;mvn spring-boot:run 与 npm run dev,走登录/开案,上传这两个文件;doClean 只出现 1 次)、AI 阶段卡片计数推进到 DONE;SELECT * FROM ai_clean_job WHERE batch_id=? 看 exit_code 分布;ai_clean_batch.stage 终态正确;data/index.vue)出现 AI 节点、点开能出数据与表头(id<0 分支生效),既有节点行为不变;LLM_MIN_CONFIDENCE 临时调高触发 NEED_REVIEW,点「去人工确认」落到手动清洗页并定位到该 sheet;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 分流必须与融合入口同批上线,否则数据"看不见" |