Browse Source

fix(aiclean): 表头指纹保留括号语义,禁止列序变化复用旧绑定

- HeaderNormalizer 只去括号字符、保留限定词/单位:金额(元) 与 金额(万元)、
  账号(本方) 与 账号(对方) 不再碰撞
- HeaderFingerprint 按有效列位置拼接并忽略尾部 padding;新增 sameOrderedHeaders
- AI 模板复用必须同序同表头,否则不复用存档 colIndex,改走重新绑定/新建模板
- SheetProbe 增加 CSV 探测路径(AiRowReader 前 200 行),CSV 不再直接 Skip

测试:aiclean + dm 185 个用例通过(5 个 live 跳过)
cc 2 days ago
parent
commit
909090669a

+ 44 - 0
ai-server/src/main/java/com/zsjz/ai/module/aiclean/probe/SheetProbe.java

@@ -16,6 +16,7 @@ import org.apache.poi.ss.util.CellRangeAddress;
 import java.io.File;
 import java.util.ArrayList;
 import java.util.List;
+import java.util.Map;
 
 /**
  * S1 结构探测:重读原文件,把「行内容 + 合并区 + 表块」一次性取全。
@@ -54,6 +55,11 @@ public final class SheetProbe {
             log.info("AI 清洗探测:源文件不存在,跳过 fileId={}, path={}", fileId, file);
             return null;
         }
+        // CSV 不走 POI:WorkbookFactory 不支持 CSV,旧实现会把所有 CSV 直接 Skip。
+        // 用与入库完全相同的 AiRowReader 读前 200 行,口径一致。
+        if (fileName != null && fileName.toLowerCase().endsWith(".csv")) {
+            return probeCsv(caseId, fileId, sheetId, fileName, sheetName, file);
+        }
         if (file.length() > MAX_FILE_BYTES) {
             log.info("AI 清洗探测:文件过大跳过,fileId={}, size={}", fileId, file.length());
             return null;
@@ -97,6 +103,44 @@ public final class SheetProbe {
         return build(caseId, fileId, sheetId, fileName, sheetName, file, rows, merged, totalRows);
     }
 
+    /**
+     * CSV 探测:用与 AI 入库同一个 {@link AiRowReader} 读前 {@link #MAX_ROWS} 行,
+     * 再复用 {@link #build} 的表块/形状/指纹逻辑。CSV 没有合并单元格,merged 传空。
+     */
+    private static SheetEvidence probeCsv(Long caseId, Long fileId, Long sheetId, String fileName,
+                                          String sheetName, File file) {
+        try {
+            List<Map<Integer, String>> data = AiRowReader.read(file, null, 0, MAX_ROWS);
+            if (data.isEmpty()) {
+                return null;
+            }
+            int width = 0;
+            for (Map<Integer, String> row : data) {
+                for (Integer idx : row.keySet()) {
+                    if (idx != null) {
+                        width = Math.max(width, idx + 1);
+                    }
+                }
+            }
+            if (width <= 0) {
+                return null;
+            }
+            List<List<String>> rows = new ArrayList<>(data.size());
+            for (Map<Integer, String> source : data) {
+                List<String> row = new ArrayList<>(width);
+                for (int i = 0; i < width; i++) {
+                    String v = source.get(i);
+                    row.add(v == null ? "" : v);
+                }
+                rows.add(row);
+            }
+            return build(caseId, fileId, sheetId, fileName, sheetName, file, rows, List.of(), rows.size());
+        } catch (Exception e) {
+            log.error("AI 清洗探测:CSV 读取失败 fileId={}, err={}", fileId, e.getMessage());
+            return null;
+        }
+    }
+
     /**
      * 从行内容组装证据,抽出便于单测(不依赖 POI 与磁盘)。
      */

+ 32 - 22
ai-server/src/main/java/com/zsjz/ai/module/aiclean/service/AiCleanService.java

@@ -39,6 +39,7 @@ import com.zsjz.ai.module.aiclean.probe.SheetProbe;
 import com.zsjz.ai.module.aiclean.rule.AiRulePlan;
 import com.zsjz.ai.module.aiclean.rule.CompiledColumn;
 import com.zsjz.ai.module.aiclean.store.AiCleanStore;
+import com.zsjz.ai.module.aiclean.support.HeaderFingerprint;
 import com.zsjz.ai.module.aiclean.verify.AiRuleVerifier;
 import com.zsjz.ai.module.dm.mapper.FileInfoMapper;
 import com.zsjz.ai.module.agent.llm.LlmResult;
@@ -292,28 +293,34 @@ public class AiCleanService {
                         + ",需人工确认后再清洗");
             }
             if (tpl != null) {
-                List<RuleBind> stored = store.loadBinds(tpl.getId());
-                List<AiCleanField> storedFields = stored.isEmpty() ? List.of()
-                        : fieldMapper.selectList(Wrappers.<AiCleanField>lambdaQuery()
-                        .eq(AiCleanField::getAiTemplateId, tpl.getId())
-                        .orderByAsc(AiCleanField::getFieldSort));
-                if (!stored.isEmpty() && !storedFields.isEmpty()) {
-                    // 复用路径也要抽样校验:存档类型/必填可能已经不适配当前文件,
-                    // 不校验就直接写,会把错列/错类型的数据静默落库。
-                    mark(job, AiCleanStatusEnum.VERIFYING);
-                    List<CompiledColumn> columns = AiRulePlan.forStoredTemplate(storedFields, stored).columns();
-                    List<Map<Integer, String>> sample = loadSample(root, sheet, ev, job);
-                    VerifyReport report = verify(sample, columns, outcome.candidate(), outcome, ev);
-                    job.setConfidence(java.math.BigDecimal.valueOf(report.confidence().overall()));
-                    job.setDetail(detailJson(report));
-                    if (requiredRejected(columns, report)) {
-                        return new AiJudgeResult.NeedReview("复用模板的必填列抽样错误率过高,需人工确认");
+                // 复用存档绑定的前提是「同序同表头」:打分腿是集合相似度,列序变了仍可能高分,
+                // 直接套旧 col_index 会把整列数据写错位置。
+                if (!HeaderFingerprint.sameOrderedHeaders(headers, outcome.candidate().headers())) {
+                    log.info("AI 模板表头顺序/内容与当前文件不一致,不复用存档绑定: templateId={}", tpl.getId());
+                } else {
+                    List<RuleBind> stored = store.loadBinds(tpl.getId());
+                    List<AiCleanField> storedFields = stored.isEmpty() ? List.of()
+                            : fieldMapper.selectList(Wrappers.<AiCleanField>lambdaQuery()
+                            .eq(AiCleanField::getAiTemplateId, tpl.getId())
+                            .orderByAsc(AiCleanField::getFieldSort));
+                    if (!stored.isEmpty() && !storedFields.isEmpty()) {
+                        // 复用路径也要抽样校验:存档类型/必填可能已经不适配当前文件,
+                        // 不校验就直接写,会把错列/错类型的数据静默落库。
+                        mark(job, AiCleanStatusEnum.VERIFYING);
+                        List<CompiledColumn> columns = AiRulePlan.forStoredTemplate(storedFields, stored).columns();
+                        List<Map<Integer, String>> sample = loadSample(root, sheet, ev, job);
+                        VerifyReport report = verify(sample, columns, outcome.candidate(), outcome, ev);
+                        job.setConfidence(java.math.BigDecimal.valueOf(report.confidence().overall()));
+                        job.setDetail(detailJson(report));
+                        if (requiredRejected(columns, report)) {
+                            return new AiJudgeResult.NeedReview("复用模板的必填列抽样错误率过高,需人工确认");
+                        }
+                        if (!report.confidence().pass(AiCleanExitEnum.B)) {
+                            return new AiJudgeResult.NeedReview(StrUtil.format(
+                                    "复用模板置信度未达出口 B 的放行线: {}", report.confidence()));
+                        }
+                        return new AiJudgeResult.AiPlan(ev, stored, outcome.candidate(), null, headers);
                     }
-                    if (!report.confidence().pass(AiCleanExitEnum.B)) {
-                        return new AiJudgeResult.NeedReview(StrUtil.format(
-                                "复用模板置信度未达出口 B 的放行线: {}", report.confidence()));
-                    }
-                    return new AiJudgeResult.AiPlan(ev, stored, outcome.candidate(), null, headers);
                 }
             }
             // 存档规则/字段缺失(历史异常数据)→ 落回原路:按本次表头问模型补绑定
@@ -441,7 +448,10 @@ public class AiCleanService {
         boolean reuse = plan.candidate() != null && plan.candidate().aiTemplateId() != null;
         if (reuse) {
             tpl = templateMapper.selectById(plan.candidate().aiTemplateId());
-            reuse = tpl != null && "ONLINE".equals(tpl.getStatus());
+            // 必须「同序同表头」才复用:打分腿是集合相似度,列序变了仍可能命中;
+            // 复用存档 col_index 会把数据写错列,宁可新建模板也不冒这个险。
+            reuse = tpl != null && "ONLINE".equals(tpl.getStatus())
+                    && HeaderFingerprint.sameOrderedHeaders(headers, plan.candidate().headers());
             if (!reuse) {
                 tpl = null;
             }

+ 45 - 2
ai-server/src/main/java/com/zsjz/ai/module/aiclean/support/HeaderFingerprint.java

@@ -2,6 +2,7 @@ package com.zsjz.ai.module.aiclean.support;
 
 import cn.hutool.crypto.digest.DigestUtil;
 
+import java.util.ArrayList;
 import java.util.Collection;
 import java.util.HashSet;
 import java.util.List;
@@ -10,7 +11,13 @@ import java.util.Set;
 /**
  * 表头指纹与集合相似度。
  *
- * 指纹按位置拼接,顺序敏感是故意的:列序一变,规则里存的列序号就整体错位。
+ * <p>指纹按<b>有效列位置 + 归一化列名</b>拼接:
+ * <ul>
+ *   <li>尾部空列(SheetProbe 按最大行宽 padding 出来的)不参与,避免同表头因远端一个多出来的
+ *       单元格而换指纹;</li>
+ *   <li>中间空列保留 &lt;位置,空串&gt;,列序/占位变化仍会换指纹;</li>
+ *   <li>顺序敏感是故意的:列序一变,规则里存的列序号就整体错位。</li>
+ * </ul>
  *
  * @author cc
  * @since 2026/9/21
@@ -21,7 +28,43 @@ public final class HeaderFingerprint {
     }
 
     public static String of(List<String> headers) {
-        return DigestUtil.md5Hex(String.join("\u0001", HeaderNormalizer.normalizeAll(headers)));
+        List<String> canonical = canonical(headers);
+        StringBuilder sb = new StringBuilder();
+        for (int i = 0; i < canonical.size(); i++) {
+            String name = canonical.get(i);
+            if (name.isEmpty()) {
+                continue;
+            }
+            sb.append(i).append('=').append(name).append('\u0001');
+        }
+        return DigestUtil.md5Hex(sb.toString());
+    }
+
+    /**
+     * 两份表头是否「同序同义」:归一化后逐位相等,忽略尾部空列。
+     *
+     * <p>用于 AI 模板复用的安全闸门:只按打分(集合相似度)命中时,列序可能已经变了,
+     * 直接套存档的 colIndex 会把数据写错列。这里做一次顺序敏感的比较;
+     * 不相等就不复用,走重新绑定/新建模板。
+     */
+    public static boolean sameOrderedHeaders(List<String> a, List<String> b) {
+        return canonical(a).equals(canonical(b));
+    }
+
+    /** 归一化并去掉尾部空列(保留中间空列的位置) */
+    private static List<String> canonical(List<String> headers) {
+        List<String> out = new ArrayList<>();
+        if (headers == null) {
+            return out;
+        }
+        for (String h : headers) {
+            out.add(HeaderNormalizer.normalize(h));
+        }
+        int last = out.size() - 1;
+        while (last >= 0 && out.get(last).isEmpty()) {
+            last--;
+        }
+        return new ArrayList<>(out.subList(0, last + 1));
     }
 
     /** 交集大小 */

+ 6 - 9
ai-server/src/main/java/com/zsjz/ai/module/aiclean/support/HeaderNormalizer.java

@@ -9,16 +9,15 @@ import java.util.regex.Pattern;
 /**
  * 列名归一化。
  *
- * 既有匹配按中文列名精确比较(PreDataListener:340-395),「账户 号码」「(账户号码)」
+ * <p>既有匹配按中文列名精确比较(PreDataListener:340-395),「账户 号码」「(账户号码)」
  * 「账户号码(元)」都命中不了。AI 链路的打分与指纹直通都建立在归一化之上。
  *
- * @author cc
- * @since 2026/9/21
+ * <p><b>括号内容必须保留</b>:`金额(元)` 与 `金额(万元)`、`账号(本方)` 与 `账号(对方)`
+ * 只差括号里的限定词,删掉后指纹会碰撞,模板会被错误复用。这里只去掉括号字符本身,
+ * 内容作为语义 token 参与比较。
  */
 public final class HeaderNormalizer {
 
-    private static final Pattern BRACKETS = Pattern.compile("[((\\[【{][^(()\\[\\]【】{}]*[))\\]】}]");
-
     /** 整串被括号包住时的剥壳用 */
     private static final Pattern BRACKET_CHARS = Pattern.compile("[(()\\[\\]【】{}]");
 
@@ -37,10 +36,8 @@ public final class HeaderNormalizer {
             return "";
         }
         String s = HalfWidth.to(raw.trim());
-        // 去括号:「账户号码(元)」要去成「账户号码」,但「(账户号码)」整串被包住时
-        // 只能剥壳不能连内容一起删 —— 后者是会发生的真实写法(前端把列名整个括起来)
-        String unwrapped = BRACKETS.matcher(s).replaceAll("");
-        s = unwrapped.isBlank() ? BRACKET_CHARS.matcher(s).replaceAll("") : unwrapped;
+        // 只去括号字符、保留括号内容:限定词/单位是语义的一部分
+        s = BRACKET_CHARS.matcher(s).replaceAll("");
         for (char c : JUNK_CHARS.toCharArray()) {
             s = s.replace(String.valueOf(c), "");
         }

+ 30 - 4
ai-server/src/test/java/com/zsjz/ai/module/aiclean/AiCleanSupportTest.java

@@ -28,11 +28,14 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
 class AiCleanSupportTest {
 
     @Test
-    @DisplayName("列名归一化:空白、全角、括号、装饰后缀都要抹平")
+    @DisplayName("列名归一化:空白、全角、括号字符抹平,括号内容保留")
     void normalizeHeaders() {
         assertEquals(HeaderNormalizer.normalize("账户号码"), HeaderNormalizer.normalize("账户 号码"));
         assertEquals(HeaderNormalizer.normalize("账户号码"), HeaderNormalizer.normalize("(账户号码)"));
-        assertEquals(HeaderNormalizer.normalize("账户号码"), HeaderNormalizer.normalize("账户号码(元)"));
+        // 括号里的限定词/单位是语义:不能像旧实现那样整体删掉
+        assertEquals("账户号码元", HeaderNormalizer.normalize("账户号码(元)"));
+        assertNotEquals(HeaderNormalizer.normalize("金额(元)"), HeaderNormalizer.normalize("金额(万元)"));
+        assertNotEquals(HeaderNormalizer.normalize("账号(本方)"), HeaderNormalizer.normalize("账号(对方)"));
         assertEquals(HeaderNormalizer.normalize("交易流水"), HeaderNormalizer.normalize("交易流水明细表"));
         // 全角转半角:只在字符层面等价,不做语义翻译
         assertEquals(HeaderNormalizer.normalize("DATE"), HeaderNormalizer.normalize("DATE"));
@@ -41,13 +44,36 @@ class AiCleanSupportTest {
     }
 
     @Test
-    @DisplayName("表头指纹:同表头同指纹,列序变化必须换指纹")
+    @DisplayName("表头指纹:同表头同指纹,列序/单位变化必须换指纹")
     void fingerprintIsPositionSensitive() {
         List<String> a = List.of("交易日期", "金额", "账号");
-        List<String> b = List.of("交易日期 ", "金额(元)", "账号");
+        List<String> b = List.of("交易日期 ", "金额", "账号");
         assertEquals(HeaderFingerprint.of(a), HeaderFingerprint.of(b));
         assertNotEquals(HeaderFingerprint.of(a),
                 HeaderFingerprint.of(List.of("金额", "交易日期", "账号")));
+        assertNotEquals(HeaderFingerprint.of(List.of("金额")),
+                HeaderFingerprint.of(List.of("金额(元)")));
+        // 尾部 padding 空列不参与指纹
+        assertEquals(HeaderFingerprint.of(a),
+                HeaderFingerprint.of(List.of("交易日期", "金额", "账号", "", "")));
+    }
+
+    @Test
+    @DisplayName("复用安全:同序同义才允许复用存档列绑定")
+    void sameOrderedHeaders() {
+        List<String> file = List.of("交易日期", "金额", "账号", "");
+        assertTrue(HeaderFingerprint.sameOrderedHeaders(file,
+                List.of("交易日期", "金额", "账号")), "尾部 padding 不影响");
+        assertTrue(HeaderFingerprint.sameOrderedHeaders(
+                List.of("交易日期", "金额(元)", "账号"),
+                List.of("交易日期", "金额(元)", "账号")), "括号全半角形态不影响");
+        assertTrue(!HeaderFingerprint.sameOrderedHeaders(file,
+                List.of("金额", "交易日期", "账号")), "列序变化必须拒绝");
+        assertTrue(!HeaderFingerprint.sameOrderedHeaders(file,
+                List.of("交易日期", "金额(元)", "账号")), "表头不一致必须拒绝");
+        assertTrue(!HeaderFingerprint.sameOrderedHeaders(
+                List.of("交易日期", "金额(元)", "账号"),
+                List.of("交易日期", "金额(万元)", "账号")), "单位不同必须拒绝");
     }
 
     @Test

+ 14 - 0
ai-server/src/test/java/com/zsjz/ai/module/aiclean/probe/SheetProbeTest.java

@@ -108,4 +108,18 @@ class SheetProbeTest {
         assertEquals(2, rows.size());
         assertTrue(rows.get(1).containsKey(1));
     }
+
+    @Test
+    @DisplayName("CSV 走独立探测路径,不再被 POI 拒之门外")
+    void probeCsv() throws Exception {
+        File f = dir.resolve("simple.csv").toFile();
+        java.nio.file.Files.writeString(f.toPath(),
+                "姓名,金额,日期\n张三,100.00,2026-01-01\n李四,200.00,2026-01-02\n");
+        SheetEvidence ev = SheetProbe.probe(1L, 2L, 3L, f.getName(), null, f);
+        assertNotNull(ev);
+        assertEquals(3, ev.primaryBlock().headers().size());
+        assertEquals("姓名", ev.primaryBlock().headers().get(0));
+        assertEquals("MONEY", ev.colStats().get(1).shape());
+        assertEquals("DATE", ev.colStats().get(2).shape());
+    }
 }