Răsfoiți Sursa

fix(aiclean): 非必填脏值不再卡抽样硬门,单 sheet 文件可直接看失败明细

- AiRecordCleaner.cleanRow 只回填阻塞错误(required 类):非必填脏值按约定置空、
  行照常入库,不再因为可选列 28% 脏值把整表打成待人工
- 入库统计仍用 cleanRowDetailed 的完整错误,warned/逐列 optional 计数不受影响
- 单 sheet 文件:把 sheet 的 AI 任务 id/清洗方式/备注带到文件行,
  文件行「导入失败」也能直接打开行级失败明细(多 sheet 保持空,避免选错 job)
- 校验测试同步:可选脏值出口 A 放行;必填列 1%~5% 错误率仍只够出口 B

测试:aiclean + dm 192 个用例通过(5 个 live 跳过)
cc 2 zile în urmă
părinte
comite
c4b167b793

+ 7 - 0
ai-server/src/main/java/com/zsjz/ai/module/aiclean/exec/AiRecordCleaner.java

@@ -53,6 +53,10 @@ public final class AiRecordCleaner {
 
     /**
      * 兼容旧调用:返回列名 → 错误说明;必填错误时整行返回 null。
+     *
+     * <p>只回填<b>阻塞错误</b>(required 类):非必填列的脏值按约定置空、行照常入库,
+     * 不能因为一个可选列错误率高就把整表判成需人工(抽样校验门只看阻塞错误)。
+     * 完整的非必填错误由 {@link #cleanRowDetailed} 提供给入库统计/失败明细。
      */
     public static Map<String, Object> cleanRow(Map<Integer, String> rowData,
                                                List<CompiledColumn> columns,
@@ -60,6 +64,9 @@ public final class AiRecordCleaner {
         AiRowCleanResult result = cleanRowDetailed(rowData, columns);
         if (errors != null) {
             for (ColumnError error : result.errors()) {
+                if (!error.blocking()) {
+                    continue;
+                }
                 errors.putIfAbsent(error.columnName(),
                         error.reason().name() + (error.valuePreview() == null ? "" : " 值=" + error.valuePreview()));
             }

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

@@ -1002,6 +1002,14 @@ public class DmService {
             fileInfo.setSuccessNum(successNum);
             // sheet 行展示「清洗方式 / 备注」:按 AI 任务记录回填瞬态字段
             fillSheetCleanInfo(childrens, latestJobs);
+            // 单 sheet 文件:把 sheet 的 AI 任务信息带到文件行,
+            // 「导入失败」在文件行点击时也能直接打开行级失败明细(多 sheet 文件保持空,避免选错 job)
+            if (childrens.size() == 1) {
+                FileInfo only = childrens.getFirst();
+                fileInfo.setCleanMode(only.getCleanMode());
+                fileInfo.setCleanNote(only.getCleanNote());
+                fileInfo.setAiJobId(only.getAiJobId());
+            }
         }
         return rootFiles
                 .stream()

+ 16 - 4
ai-server/src/test/java/com/zsjz/ai/module/aiclean/verify/AiRuleVerifierTest.java

@@ -58,11 +58,11 @@ class AiRuleVerifierTest {
     @Test
     @DisplayName("单列错误率超 5% 该列被拒绝绑定,而不是整体降分")
     void badColumnIsRejectedNotDampened() {
-        // 10 行里 4 行金额非法 → 40%,远超 5% 拒绝线
+        // 10 行里 4 行必填金额非法 → 40%,远超 5% 拒绝线
         List<Map<Integer, String>> data = new ArrayList<>(rows(6, "100.00", "张三"));
         data.addAll(rows(4, "约100元", "李四"));
         VerifyReport r = AiRuleVerifier.verify(data,
-                List.of(col(0, "amt", "MONEY", false), col(1, "name", "TEXT", false)), 0.9, 0.8, 10);
+                List.of(col(0, "amt", "MONEY", true), col(1, "name", "TEXT", false)), 0.9, 0.8, 10);
         assertTrue(r.rejectedCols().contains(0), r.perColErrorRate().toString());
         // 另一列不受牵连
         assertFalse(r.rejectedCols().contains(1));
@@ -71,15 +71,27 @@ class AiRuleVerifierTest {
     @Test
     @DisplayName("错误率在 1%~5% 之间:只够走出口 B,不够往既有业务表写")
     void midErrorRateOnlyPassesAiTable() {
-        // 200 行里 5 行坏 = 2.5%
+        // 200 行里 5 行必填金额坏 = 2.5%
         List<Map<Integer, String>> data = new ArrayList<>(rows(195, "100.00", "张三"));
         data.addAll(rows(5, "脏值", "张三"));
         VerifyReport r = AiRuleVerifier.verify(data,
-                List.of(col(0, "amt", "MONEY", false), col(1, "name", "TEXT", true)), 0.9, 0.8, 200);
+                List.of(col(0, "amt", "MONEY", true), col(1, "name", "TEXT", true)), 0.9, 0.8, 200);
         assertTrue(r.pass(AiCleanExitEnum.B));
         assertFalse(r.pass(AiCleanExitEnum.A));
     }
 
+    @Test
+    @DisplayName("非必填脏值只置空、不阻塞行:出口 A 也放行")
+    void optionalErrorsDoNotBlock() {
+        List<Map<Integer, String>> data = new ArrayList<>(rows(195, "100.00", "张三"));
+        data.addAll(rows(5, "脏值", "张三"));
+        VerifyReport r = AiRuleVerifier.verify(data,
+                List.of(col(0, "amt", "MONEY", false), col(1, "name", "TEXT", true)), 0.95, 0.9, 200);
+        assertEquals(0d, r.perColErrorRate().get(0), 0.0001,
+                "非必填脏值不应计入阻塞错误率");
+        assertTrue(r.pass(AiCleanExitEnum.A));
+    }
+
     @Test
     @DisplayName("有效行率过低:两个出口都不放行")
     void lowRowYieldBlocksBoth() {