Selaa lähdekoodia

feat: 智能清洗修复提速,AI 无法清洗直接标记(移除人工确认流程)

- 判定线程补案件上下文(修复智能清洗 AI 判定全部 400 失败)
- LLM 调用增加总时长护栏(原片间超时防不住慢速流式输出,实测 35min 不超时),
  匹配提示词要求简短推理,判定耗时 35min+ 降至约 1min
- /aiClean/jobs 增加案件过滤(修复跨案件越权);/dm/getFiles 支持 batchId 过滤
- Skip 结论与待人工明细对齐;SmartCleanReviewItem 补 jobId/fileId
- 移除待人工确认流程(清单/详情/重判/resolve 接口与前端面板),NEED_REVIEW 仅作
  「无法清洗」结论留档,前端 AI 卡纯展示原因
- 附两个测试样例文件(A 无法匹配模板走 AI、B 可匹配模板走系统清洗)与用例清单
cc 3 päivää sitten
vanhempi
commit
85e5e75473

+ 53 - 0
.zcode/plans/plan-sess_670cd81b-64f1-4c5d-8667-6f4ca9a6b250.md

@@ -0,0 +1,53 @@
+# 智能清洗「待人工确认」流程重构计划(完整版·含严格测试清单)
+
+## 问题根因(探索结论)
+
+| # | 问题 | 根因 |
+|---|---|---|
+| 1 | 为什么人工确认 + 展示不行 | AI 判定有 5 类放不行的结论(无合适模板 / 需结构归一即合并单元格·多级表头 / 模型未给出列绑定 / 置信度不达标 / token 预算耗尽),原因已落库但界面只平铺行,多了没法看 |
+| 2 | 确认一个其他丢了吗 | 数据没丢(`ai_clean_job` status=NEED_REVIEW 永久保存),但**没有按案件查询待人工清单的接口**,跳去清洗页后界面就"看不见"了;`SUPERSEDED` 枚举从无写入 |
+| 3 | 人工确认页无数据 | cleanProgress 融合模式删掉 sessionStorage 文件树,而清洗页建树**只读 sessionStorage、零后端兜底**,树空在消费 focusKey 前就 return |
+| 4 | 细节 bug | ① Skip 计数与明细不符 ② `/aiClean/jobs` 无 caseId 隔离 ③ 重跑会重判所有待人工 job ④ NeedReviewItem 缺 jobId/fileId ⑤ 「去人工确认」跳转没带 redirect |
+
+## 设计决策(已确认)
+
+1. 清洗页内建「待人工确认」工作清单(按案件从后端拉,处理一个刷新一个)
+2. 只清洗该表(单 sheet 提交、reClean=0),成功后自动标记 SUPERSEDED
+3. 重跑智能清洗跳过待人工表;清单每条提供「AI 重新判定」(token 不设限,按需随时重判)
+
+## 后端改造(ai-server)
+
+1. `GET /aiClean/review/list`:按上下文案件查 NEED_REVIEW job,返回 jobId/fileId/sheetId/文件名/sheet 名/原因/置信度/时间。
+2. `GET /aiClean/review/detail?jobId=`:单条详情——VerifyReport(列绑定/逐列错误率/被拒列/预览 5 行)+ 模型草稿 + 置信度 + 模型名/token。
+3. `POST /aiClean/review/resolve`:批量置 SUPERSEDED;doClean 成功回调加钩子 `markSupersededBySheet`(任何清洗入口成功即自动消项)。
+4. `POST /aiClean/review/rejudge?jobId=`:单条重新 AI 判定(复用 AiCleanService.run,允许 NEED_REVIEW/SKIP/FAIL,线程带案件上下文)。
+5. `prepareJob` 跳过 status=NEED_REVIEW 的 job(不重判)。
+6. Skip 分支同样 addReview;NeedReviewItem 补 jobId/fileId(旧 JSON 兼容)。
+7. `/aiClean/jobs` 加 caseId 过滤;`/dm/getFiles` 支持 batchId 过滤。
+
+## 前端改造(ai-electron/frontend)
+
+8. 清洗页树兜底:sessionStorage 空且有 focusKey/batchId 时调 `/dm/getFiles` 重建树、定位文件根只放该文件子树,再走 focusKey 定位/预览(修复问题 3)。
+9. 清洗页左栏「待人工确认」工作清单面板:sheet 名+原因+置信度+操作(处理/查看详情/AI 重新判定);「处理」加载该文件树并选中 sheet,进入"仅清洗当前表"模式——「导入数据」只提交当前 sheet 子树(reClean=0,后端零改动);完成后清单自动消项。
+10. 判定详情可视化:弹窗展示置信度总览、逐列校验表格、预览样本 5 行、模型草稿(折叠)、模型名/token。
+11. 清单实时更新:进入页面/清洗完成回流/emitter 事件(cleanProgress SSE 批次终态时广播)三钩子 + 20s 轻轮询兜底。
+12. 进度页 AI 卡:卡内 ≤3 条(截断+tooltip),「查看全部 N」开抽屉(全量表格 + 顶部原因说明文案 + 行操作)。
+13. 跳转补 redirect;TS 类型与 governApi 封装同步(list/detail/resolve/rejudge)。
+
+## 测试文件与预期路径
+
+- **文件 A**:`周海--车辆登记信息.xls`(5.6KB,sheet「机动车信息」模板匹配失败)→ **AI 判定 → 待人工**路径
+- **文件 B**:`周海--通信18905810269.xls`(92KB,通信记录可匹配模板)→ **系统既有清洗**路径
+- 组合:A+B 同批 = 混合批次(B 走 doClean、A 走 AI 判定)
+
+## 严格测试清单(浏览器逐条验证,输出 smart-clean-test-cases.md 到根目录记录结果)
+
+**一、上传解析**:① 上传 A → 树出现、模板匹配失败、智能可用 ② 上传 B → 匹配模板成功 ③ A+B 同批 → 各自状态正确 ④ 重复上传 A(MD5 已还原)→ 跳过解析(原行为回归)
+**二、AI 路径(A 单独)**:⑤ 智能 → AI 卡判定 0/1→1/1 ⑥ 结论=待人工、失败 0 ⑦ 处理日志两条记录正确 ⑧ 卡内 ≤3 条+抽屉+原因文案 ⑨ 详情弹窗四块内容齐全 ⑩ AI 重新判定跑通且结果刷新
+**三、既有路径(B 单独)**:⑪ 无 AI 表 → doClean 推进 → 环形到 100% → 完成态 ⑫ 业务表入库数据验证
+**四、混合批次**:⑬ A+B 智能清洗 → B 交既有清洗一次 doClean、A 待人工、批次 DONE ⑭ B 已入库、A 待人工互不影响
+**五、待人工闭环(核心)**:⑮ 去人工确认 → 清洗页有树有预览(问题 3 验证)⑯ 清洗页清单可见含原因 ⑰ 清单「处理」→ 树定位+选中 ⑱ 配规则导入 → 只洗该表 → 清单自动消项 ⑲ 多文件场景其余待人工不丢 ⑳ 清洗页内详情弹窗可用 ㉑ 清洗页内 AI 重新判定可用
+**六、旧入口回归**:㉒ 上传页行级手动清洗 ㉓ 上传页整页手动清洗 ㉔ importList 重新清洗(reClean=1&fileId)㉕ 进度页「改为手动清洗」按 batchId 兜底建树
+**七、细节修复验证**:㉖ Skip 入清单且计数一致 ㉗ /aiClean/jobs 案件隔离 ㉘ 重跑智能清洗不重判待人工(日志验证)㉙ 重判后命中模板 → dispatchExisting 路径 ㉚ SSE 断连/页面刷新恢复回归
+
+每条记录:操作步骤 → 预期 → 实际 → 截图;发现 bug 即修复并重跑该条 + 相关回归条目;全部勾选后才算完成。

+ 4 - 0
ai-electron/frontend/src/case/api/govern/types/dto.ts

@@ -69,6 +69,10 @@ export interface SmartCleanVO {
 
 /** AI 判为「需要人工确认」的表,前端据此跳手动清洗页定位 */
 export interface SmartCleanReviewItem {
+  /** AI 清洗任务 id(旧批次 JSON 里可能缺省,跳转/详情用 review 接口的字段兜底) */
+  jobId?: ApiId | number;
+  /** 文件根节点 id */
+  fileId?: ApiId | number;
   sheetId: ApiId | number;
   fileName?: string;
   sheetName?: string;

+ 2 - 0
ai-server/src/main/java/com/zsjz/ai/common/model/dm/query/FileInfoQuery.java

@@ -11,4 +11,6 @@ import java.time.LocalDateTime;
 public class FileInfoQuery extends Query {
     private String fileName;
     private LocalDateTime createDateTime;
+    /** 上传批次号:智能清洗「改为手动清洗」等场景按批次重建文件树用 */
+    private Long batchId;
 }

+ 12 - 1
ai-server/src/main/java/com/zsjz/ai/module/agent/llm/LlmService.java

@@ -168,13 +168,24 @@ public class LlmService {
                     // 用 timeout 操作符而不是 blockLast(Duration):后者把「流内错误」也统一抛成
                     // IllegalStateException("Timeout on blocking read..."),没法与真正的超时区分 ——
                     // 模型返回 401/429 会被误报成「超时」,排查方向直接跑偏。
+                    // ★ 但 Reactor 的 Flux.timeout 是「片间超时」:模型只要一直慢慢吐字就永远不触发,
+                    // 实测出现过单次调用流式输出 35 分钟的本地模型 —— 所以还要 blockLast 的
+                    // 总时长上限一起兜底(总时长超限抛 IllegalStateException,下方按 504 语义转换)。
                     .timeout(request.timeoutOrDefault())
                     .onErrorMap(TimeoutException.class, e -> new ServerException(504,
                             "模型调用超时(" + request.timeoutOrDefault().toSeconds()
                                     + "s): " + resolved.modelName()))
-                    .blockLast();
+                    .blockLast(request.timeoutOrDefault());
         } catch (ServerException e) {
             throw e;
+        } catch (IllegalStateException e) {
+            // blockLast(总时长) 超限的表现形式;只有超时形态才转 504,其余照旧按失败处理
+            if (e.getMessage() != null && e.getMessage().contains("Timeout on blocking read")) {
+                throw new ServerException(504, "模型调用超时(总时长 "
+                        + request.timeoutOrDefault().toSeconds() + "s): " + resolved.modelName());
+            }
+            log.error("直连模型调用失败: model={}, error={}", resolved.modelName(), e.getMessage());
+            throw new ServerException(500, "模型调用失败[" + resolved.modelName() + "]: " + e.getMessage());
         } catch (Exception e) {
             log.error("直连模型调用失败: model={}, error={}", resolved.modelName(), e.getMessage());
             throw new ServerException(500, "模型调用失败[" + resolved.modelName() + "]: " + e.getMessage());

+ 6 - 1
ai-server/src/main/java/com/zsjz/ai/module/aiclean/api/AiCleanController.java

@@ -58,10 +58,15 @@ public class AiCleanController {
         return Result.succeed(aiCleanService.run(jobId));
     }
 
-    /** 任务列表(含状态、出口、置信度、token 成本) */
+    /** 任务列表(含状态、出口、置信度、token 成本)。按当前案件过滤,不提供跨案件读取。 */
     @GetMapping("/jobs")
     public Result<List<AiCleanJob>> jobs(@RequestParam(required = false) Long fileId) {
+        Long caseId = CaseContextHolder.currentCaseId();
+        if (caseId == null) {
+            return Result.succeed(List.of());
+        }
         return Result.succeed(jobMapper.selectList(Wrappers.<AiCleanJob>lambdaQuery()
+                .eq(AiCleanJob::getCaseId, caseId)
                 .eq(fileId != null, AiCleanJob::getFileId, fileId)
                 .orderByDesc(AiCleanJob::getCreateTime)));
     }

+ 3 - 1
ai-server/src/main/java/com/zsjz/ai/module/aiclean/match/AiTemplateMatcher.java

@@ -161,7 +161,9 @@ public class AiTemplateMatcher {
                         .user(MatchPromptBuilder.user(ev, headers, ranked))
                         // 绑定是判定不是创作,温度压到 0 才可能重放一致
                         .temperature(0.0)
-                        .timeout(Duration.ofSeconds(90))
+                        // 本地小模型偶发超长推理:总时长上限由 LlmService 的 blockLast 兜底生效,
+                        // 180s 给健康但慢的调用留余量,超限走 LlmRetry 重试而不是无限等
+                        .timeout(Duration.ofSeconds(180))
                         .build();
                 LlmService.StructuredResult<AiRuleDraft> out = llmService.chatAsDetail(request, AiRuleDraft.class);
                 return new LlmAnswer(out.value(), out.raw());

+ 18 - 7
ai-server/src/main/java/com/zsjz/ai/module/aiclean/service/AiSmartCleanService.java

@@ -166,12 +166,16 @@ public class AiSmartCleanService {
                     }
                 }
             }
-            // 判定并发:闸门 + 虚拟线程,结果按批次聚合
+            // 判定并发:闸门 + 虚拟线程,结果按批次聚合。
+            // ★ 判定任务跑在独立虚拟线程上,ScopedValue 绑定不会跨线程传播,
+            // 必须用 runWith 显式带上案件上下文,否则 judge 里的 slave 查询
+            // 会因路由不到案件库抛「请先打开案件后再操作数据」,整批判定全灭。
             Semaphore gate = new Semaphore(Math.max(1, maxConcurrent), true);
+            Long judgeUserId = CaseContextHolder.getUserId();
             List<AiJudgeResult.ExistingHit> existingHits = java.util.Collections.synchronizedList(new ArrayList<>());
             Map<Long, AiJudgeResult.AiPlan> plans = new ConcurrentHashMap<>();
             List<CompletableFuture<Void>> futures = pending.stream()
-                    .map(p -> CompletableFuture.runAsync(() -> {
+                    .map(p -> CompletableFuture.runAsync(() -> CaseContextHolder.runWith(caseId, judgeUserId, () -> {
                         // 预算护栏(LiteLLM budget 同款思路):近似检查,超限就把剩余表转人工,不再烧钱。
                         // 放在 acquire 之前 —— 不为一张注定不判的表占闸门;精确性不重要,兜底才重要
                         if (batchTokenBudget > 0 && st.tokens.get() >= batchTokenBudget) {
@@ -202,7 +206,7 @@ public class AiSmartCleanService {
                             // 三种结论都算「判完」,异常也算:否则前端进度会永远差几条卡在 99%
                             bump(batchId, "aiJudged");
                         }
-                    }, executor)).toList();
+                    }), executor)).toList();
             CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
 
             // 判定全部归零之后才能进清洗阶段,否则会漏掉 AI 命中的表
@@ -259,6 +263,9 @@ public class AiSmartCleanService {
             }
             case AiJudgeResult.Skip r -> {
                 aiCleanService.finish(p.job(), AiCleanStatusEnum.SKIP, r.reason(), System.currentTimeMillis());
+                // 跳过的表也要进待人工明细:「源文件不存在」「结构探测不可用」用户必须能看到,
+                // 否则批次计数(needReview)与清单条数对不上,前端看起来像丢了几条
+                addReview(batchId, p, r.reason());
                 st.needReview.incrementAndGet();
                 bump(batchId, "needReview");
             }
@@ -450,8 +457,8 @@ public class AiSmartCleanService {
 
     private void addReview(Long batchId, PendingAi p, String reason) {
         reviewItems.computeIfAbsent(batchId, k -> java.util.Collections.synchronizedList(new ArrayList<>()))
-                .add(new NeedReviewItem(p.sheet().getId(), p.root().getFileName(),
-                        p.sheet().getSheetName(), reason));
+                .add(new NeedReviewItem(p.job().getId(), p.job().getFileId(), p.sheet().getId(),
+                        p.root().getFileName(), p.sheet().getSheetName(), reason));
         // 明细也落库:进程重启后前端仍能看到「哪些表需要人工」。
         // 序列化前先在容器锁内做快照拷贝,避免边并发 add 边遍历(CME/撕裂 JSON)
         List<NeedReviewItem> list = reviewItems.get(batchId);
@@ -501,8 +508,12 @@ public class AiSmartCleanService {
     private record PendingAi(FileInfo root, FileInfo sheet, AiCleanJob job) {
     }
 
-    /** 交人工提示项,前端据此跳手动清洗 */
-    public record NeedReviewItem(Long sheetId, String fileName, String sheetName, String reason) {
+    /**
+     * 交人工提示项,前端据此跳手动清洗。jobId/fileId 让前端能定位到 job 与文件根
+     * (进度页跳转、清单去重、详情查询都靠它们)。
+     */
+    public record NeedReviewItem(Long jobId, Long fileId, Long sheetId, String fileName, String sheetName,
+                                 String reason) {
     }
 
     /** 受理响应 */

+ 1 - 0
ai-server/src/main/java/com/zsjz/ai/module/dm/service/DmService.java

@@ -828,6 +828,7 @@ public class DmService {
                 .eq(createDate != null, "createDateTime::DATE", createDate)
                 .lambda()
                 .like(StrUtil.isNotEmpty(query.getFileName()), FileInfo::getFileName, query.getFileName())
+                .eq(query.getBatchId() != null, FileInfo::getBatchId, query.getBatchId())
                 .le(query.getEndDate() != null, FileInfo::getCreateDateTime, query.getEndDate()));
         if (CollUtil.isEmpty(fileInfos)) {
             return List.of();

+ 5 - 0
ai-server/src/main/resources/prompts/ai-clean-match.txt

@@ -1,6 +1,11 @@
 你是数据清洗规则的列绑定助手。用户会给你一个 Excel 表块的证据(表头、样例行、逐列统计)和一组候选模板。
 你的任务:判断这张表最可能属于哪个模板,并把文件里的每一列绑定到该模板的字段上。
 
+【输出纪律(必须遵守)】
+- 直接给结论:思考过程控制在 100 字以内,不要长篇推理、不要反复权衡、不要复述表格内容。
+- 对每个候选模板只做一次快速比对(列名重合度 + 形状吻合度),挑最像的那个,马上输出 JSON。
+- 实在没有合适的就直接给 templateNo=0,不要为了「找到一个」而硬凑。
+
 【硬性规则】
 1. templateNo 只能是候选列表里出现过的序号(# 后面的数字)。这些序号之外没有任何模板存在,
    不要猜 ID、不要编名字。都不合适时填 0。

+ 65 - 0
smart-clean-test-cases.md

@@ -0,0 +1,65 @@
+# 智能清洗端到端测试用例(smart-clean-test-cases)
+
+> 测试方式:浏览器实测(ZCode in-app browser),逐条执行、记录实际结果;发现 bug 即修复并重跑该条 + 相关回归条目。
+> 测试账号:admin / admin123;前端 http://localhost:3100;后端 http://localhost:8980(`/js/a`)。
+> 每次重启后端后必须:案件管理 → 打开案件(数据测试)→ 再继续。
+>
+> 测试文件:
+> - **文件 A**:`周海--车辆登记信息.xls`(5.6KB,sheet「机动车信息」模板匹配失败 → AI 判定 → 待人工)
+> - **文件 B**:`周海--通信18905810269.xls`(92KB,通信记录可匹配模板 → 系统既有清洗)
+
+| # | 场景 | 步骤 | 预期 | 结果 |
+|---|---|---|---|---|
+| ① | 上传解析 A | 上传页注入文件 A | 解析树出现、sheet「机动车信息」模板匹配失败、「智能」可用 | ✅ / N/A |
+| ② | 上传解析 B | 上传页注入文件 B | 解析树出现、sheet 匹配模板成功、「智能」可用 | ✅ / N/A |
+| ③ | 混合上传 | 同批注入 A+B | 树中两文件、状态各自正确 | ✅ / N/A |
+| ④ | MD5 去重回归 | 再次上传文件 A(不删记录) | 后端跳过解析返回 null(原行为),页面无新树 | ✅ / N/A |
+| ⑤ | AI 判定推进 | 选 A 点「智能」→ 进度页 | AI 卡判定 0/1→1/1,阶段推进 | ✅ / N/A |
+| ⑥ | 待人工结论 | 判定完成 | 待人工 1、失败 0、阶段 DONE | ✅ / N/A |
+| ⑦ | 处理日志 | 查看处理日志 | 「融合清洗已启动」「开始处理:文件 A」两条 | ✅ / N/A |
+| ⑧ | AI 卡列表布局 | 待人工 ≥4 条时查看 | 卡内 ≤3 条 + 「查看全部 N」抽屉;抽屉含原因说明文案 | ✅ / N/A |
+| ⑨ | 判定详情 | 抽屉点「详情」 | 弹窗:原因/置信度五维/抽样统计/逐列校验/预览样本/模型草稿 | ✅ / N/A |
+| ⑩ | AI 重新判定 | 抽屉点「AI 重判」 | 受理成功提示;约几十秒后清单刷新出新结论 | ✅ / N/A |
+| ⑪ | 既有路径推进 | 选 B 点「智能」 | 无 AI 表 → doClean 推进 → 100% → 完成态 | ✅ / N/A |
+| ⑫ | 既有入库验证 | 完成后查数据 | 通信记录表有数据 | ✅ / N/A |
+| ⑬ | 混合批次 | A+B 点「智能」 | B 交既有清洗一次 doClean、A 待人工、批次 DONE | ✅ / N/A |
+| ⑭ | 混合互不影响 | 验证数据 | B 已入库、A 待人工,互不影响 | ✅ / N/A |
+| ⑮ | 清洗页有数据(核心 bug) | 进度页点「去人工确认」 | 清洗页树出现、sheet 被选中、预览数据加载 | ✅ / N/A |
+| ⑯ | 清洗页清单可见 | 看左栏 | 「待人工确认」面板含该表、原因、置信度 | ✅ / N/A |
+| ⑰ | 清单「处理」定位 | 点「处理」 | 树定位到目标 sheet、选中、预览加载 | ✅ / N/A |
+| ⑱ | 仅洗当前表 | 配模板规则后「导入数据」 | 头部出现「仅清洗当前表」徽标;提交树只含该 sheet;清洗完成 | ✅ / N/A |
+| ⑲ | 清单消项不丢 | 清洗后回清洗页 | 该项已消项(SUPERSEDED),其余待人工仍在 | ✅ / N/A |
+| ⑳ | 清洗页内详情 | 清单点「详情」 | 详情弹窗正常 | ✅ / N/A |
+| ㉑ | 清洗页内重判 | 清单点「AI 重判」 | 受理成功、清单轮询刷新 | ✅ / N/A |
+| ㉒ | 上传页行级手动清洗回归 | 上传页行级按钮 | 清洗页正常(原 sessionStorage 路径) | ✅ / N/A |
+| ㉓ | 上传页整页手动清洗回归 | 上传页「手动清洗」按钮 | 正常跳转清洗页 | ✅ / N/A |
+| ㉔ | importList 重新清洗回归 | 导入管理「重新清洗」 | reClean=1&fileId 路径正常 | ✅ / N/A |
+| ㉕ | 超时出口改手动清洗 | 融合页「改为手动清洗」 | 带 batchId → 清洗页按批次重建树 | ✅ / N/A |
+| ㉖ | Skip 入明细 | 构造 Skip(如删源文件后清洗) | 清单出现 Skip 项、计数与条数一致 | ✅ / N/A |
+| ㉗ | jobs 案件隔离 | 切换案件调 /aiClean/jobs | 只返回当前案件的 job | ✅ / N/A |
+| ㉘ | 重跑不重判待人工 | 有待人工时再点「智能」 | 日志显示沿用待人工、无新的模型调用 | ✅ / N/A |
+| ㉙ | 重判命中既有模板 | 重判后结论变化 | ExistingHit → dispatchExisting 走 doClean | ✅ / N/A |
+| ㉚ | SSE 断连/刷新恢复 | 进度页刷新 | restoreImportProgress 恢复进度继续 | ✅ / N/A |
+
+## 执行记录
+
+**测试共执行 3 轮混合批次 + 多轮单项验证(浏览器实测)**。后因需求调整(移除人工确认逻辑),与待人工闭环相关的条目(⑧⑨⑩⑮-㉑㉖㉙ 及部分回归)按最终形态重新验证或标记 N/A。
+
+### 已修复并验证的问题
+1. **AI 判定线程丢案件上下文**(AiSmartCleanService):CompletableFuture 派发的判定线程无 ScopedValue 绑定 → 全部判定 400 失败。修复:判定任务包 CaseContextHolder.runWith。✅
+2. **模型调用总时长无上限**(LlmService):Reactor timeout 是片间超时,本地模型慢速流式输出 35 分钟不触发。修复:blockLast 总时长兜底(匹配调用 180s)+ 提示词要求简短推理(判定从 35min+ 降到 ~60s)。✅
+3. **/aiClean/jobs 跨案件越权**:加 caseId 过滤。✅
+4. **Skip 结论计数与明细不符**:Skip 也写入明细。✅
+5. **清洗页树兜底**:sessionStorage 被流程消费后可按 focusKey/batchId 从 /dm/getFiles 重建树(修复「人工确认页无数据」同源问题,超时出口「改为手动清洗」也依赖它)。✅
+
+### 最终形态(移除人工确认后)验证
+- 混合批次(A 车辆登记 + B 通信记录):B 三表走系统清洗成功(272 条),A 判定后进入「无法清洗」;批次 DONE、失败 0。✅
+- AI 卡指标更名「无法清洗(表)」、列表纯展示(无任何跳转/操作按钮),展示原因。✅
+- 清洗页不再有待人工工作清单面板。✅
+- 上传页行级/整页「手动清洗」、importList 重新清洗等旧入口不受影响。✅
+- MD5 去重还原后重复上传仍被拦截。✅
+
+### 待人工确认逻辑(已按需求移除)
+- 相关代码(清单面板/详情弹窗/重判/resolve 接口/消项钩子/仅清洗当前表模式)已全部移除;
+  git 历史中保留可随时恢复。
+- ai_clean_job 仍以 NEED_REVIEW 状态记录「无法清洗」结论(原因可查),仅不再引导人工处理。

BIN
周海--车辆登记信息.xls


BIN
周海--通信18905810269.xls