Selaa lähdekoodia

feat(aiclean): AI 模板匹配交由模型判定并按模型列映射入库

- 表头指纹完全一致时保留零模型复用;指纹比较兼容旧归一化算出的过期 md5,
  避免同表头文件被挡在零模型路径之外
- 表头有差异(同义词/别名/列序)不再由打分直接判定 AI 模板命中,改由模型
  选模板并输出列绑定;落库按「文件列 → 字段」映射写入它选中的表,不再做
  抽样 verify/置信度拦截,模型判定未命中才新建表
- 匹配提示词为每个模板字段附上 ai_clean_synonym 中的同义词/别名,并补充
  按同义关系绑定的判断规则
- 出口 A(既有业务表)保留确定性闸门与抽样校验
- 新增/调整单测:过期 md5 指纹直通、按字段映射的列计划、提示词同义词
cc 5 päivää sitten
vanhempi
commit
1e5b9cf7ee

+ 21 - 14
ai-server/src/main/java/com/zsjz/ai/module/aiclean/match/AiTemplateMatcher.java

@@ -12,6 +12,7 @@ import com.zsjz.ai.module.aiclean.model.ColStat;
 import com.zsjz.ai.module.aiclean.model.RuleBind;
 import com.zsjz.ai.module.aiclean.model.SheetEvidence;
 import com.zsjz.ai.module.aiclean.model.TemplateCandidate;
+import com.zsjz.ai.module.aiclean.support.HeaderFingerprint;
 import com.zsjz.ai.module.agent.llm.LlmRequest;
 import com.zsjz.ai.module.agent.llm.LlmResult;
 import com.zsjz.ai.module.agent.llm.LlmService;
@@ -101,15 +102,25 @@ public class AiTemplateMatcher {
         List<String> headers = ev.primaryBlock().headers();
         String md5 = ev.headerMd5();
 
-        // 腿 1:指纹直通
+        // 指纹直通:归一化后逐字同表头,不存在语义判断,可以零模型复用存档绑定与动态表。
+        // 表头只要有一点差异(同义词/别名/列序变化),都不走这里,交给下面的模型裁决。
+        // 比较时同时按存档 md5 与「用当前归一化规则重算模板表头的 md5」:归一化规则演进后,
+        // 老模板的 header_md5 是旧规则算的,只看存档值会让同表头文件永远回不到零模型路径。
         for (AiTemplateWithFields awf : aiOnline) {
-            if (md5 != null && md5.equals(awf.template().getHeaderMd5())) {
+            if (md5 == null) {
+                break;
+            }
+            AiCleanTemplate t = awf.template();
+            String recomputed = HeaderFingerprint.of(
+                    StrUtil.splitTrim(StrUtil.nullToEmpty(t.getHeaders()), ','));
+            if (md5.equals(t.getHeaderMd5()) || md5.equals(recomputed)) {
                 return outcome(AiMatchTierEnum.STRONG, AiCleanExitEnum.B,
                         toCandidate(awf, 0, 1.0), List.of(), AiMatchOutcome.BY_CACHE, md5, false, null, null);
             }
         }
 
-        // 腿 2:归一化打分,既有库 + AI 库同池
+        // 候选池:既有库 + AI 库同池。打分只用于「把候选按相关度排给模型看」,
+        // 不再直接判定 AI 模板命中 —— 表头有差异时由模型选模板并给出列绑定,后端按模型结果入库。
         List<TemplateCandidate> candidates = new ArrayList<>(TemplateCandidateLoader.fromSettingLib(1));
         int no = candidates.size() + 1;
         for (AiTemplateWithFields awf : aiOnline) {
@@ -120,21 +131,17 @@ public class AiTemplateMatcher {
                 .map(c -> withScore(c, HeaderScorer.score(headers, c, ev.colStats())))
                 .sorted(Comparator.comparingDouble(TemplateCandidate::score).reversed()).toList();
         TemplateCandidate best = scored.isEmpty() ? null : scored.getFirst();
-        if (best != null && HeaderScorer.passesStrong(headers, best, best.score(), ev.colStats())) {
-            // 这一腿是确定性名字匹配,不掺模型猜测,所以允许直接出口 A
+        // 唯一保留的确定性直通:命中「既有业务模板」且确定性证据过硬 —— 出口 A 直接写用户
+        // 既有业务表且不可逆,不能只凭模型一句话。AI 动态表则一律由模型裁决(下面第三腿)。
+        if (best != null && !best.fromAiLib()
+                && HeaderScorer.passesStrong(headers, best, best.score(), ev.colStats())) {
             return outcome(AiMatchTierEnum.STRONG,
                     exitOf(AiMatchTierEnum.STRONG, best, best.score(), headers, ev.colStats()),
                     best, scored, AiMatchOutcome.BY_SCORE, md5, false, null, null);
         }
 
-        // 腿 3 之前:向量召回只「补候选 + 改排序」,不参与判档。
-        // 为什么只做召回、不做判档:向量分衡量的是「语义像不像」,它天然会把
-        // 「银行流水」和「第三方支付流水」判得很像;一旦让向量分参与 STRONG 判定,
-        // 就会直接走出口 A 把数据写进用户既有业务表 —— 既有清理只能按文件维度物理删除,
-        // 判错一次不可逆。所以档位只能由确定性证据(指纹 / 归一化表头打分)或模型裁决给出,
-        // 向量分最高也只能把候选往前排,让模型有机会看见它并自己下判断。
-        // 关掉模型(灰度/止损)时第三腿根本不走,召回没有消费方,这里连嵌入调用都省掉 ——
-        // 成本与改造前严格一致。
+        // 向量召回只「补候选 + 改排序」,不参与判档:它衡量的是语义像不像,
+        // 一旦参与出口 A 的判定就会把数据写进用户既有业务表 —— 判错不可逆。
         List<TemplateCandidate> ranking = llmEnabled ? mergeRecall(scored, ev, headers) : scored;
         // 按名次重编号:提示词里的 #n 必须正好对应下面列表的下标 n-1。
         // 直接用候选池原始编号是错的 —— 排序/截断之后 #7 就不再是第 7 个元素,
@@ -144,7 +151,7 @@ public class AiTemplateMatcher {
         // (每个模板还带整张字段清单),也会让模型在几百个候选里失焦
         List<TemplateCandidate> shortList = ranked.stream().limit(LLM_TOP_K).toList();
 
-        // 腿 3:模型裁决
+        // 模型裁决
         if (!llmEnabled || shortList.isEmpty()) {
             return AiMatchOutcome.none(md5, ranked, false);
         }

+ 65 - 1
ai-server/src/main/java/com/zsjz/ai/module/aiclean/match/MatchPromptBuilder.java

@@ -1,11 +1,15 @@
 package com.zsjz.ai.module.aiclean.match;
 
+import com.zsjz.ai.common.cache.GlobalCache;
 import com.zsjz.ai.common.enums.FileCategoryEnum;
 import com.zsjz.ai.module.aiclean.model.ColStat;
 import com.zsjz.ai.module.aiclean.model.SheetEvidence;
 import com.zsjz.ai.module.aiclean.model.TemplateCandidate;
 
+import java.util.ArrayList;
+import java.util.HashMap;
 import java.util.List;
+import java.util.Map;
 
 /**
  * 拼匹配提示词的 user 部分。
@@ -14,6 +18,10 @@ import java.util.List;
  * 一次清洗动辄几万行,全塞进去既超预算也没有信息增量,
  * 而形状与非空率这些结论已经在 Java 侧算好了。
  *
+ * <p><b>同义词要一并给模型</b>:文件表头常写标准词的别名(「类型」/「车辆类型」),
+ * 只给字段中英文名会让模型看不出二者是一回事,匹配率上不去。每个字段后面附上
+ * {@code ai_clean_synonym} 里归到该字段标准词上的别名(见 {@link #aliasIndex()})。
+ *
  * @author cc
  * @since 2026/9/21
  */
@@ -22,6 +30,9 @@ final class MatchPromptBuilder {
     /** 给模型看的样例行数 */
     private static final int SAMPLE_ROWS = 5;
 
+    /** 单个字段最多展示的别名词条数:再多只是噪声,还要控制提示词体积 */
+    private static final int MAX_ALIASES_PER_FIELD = 6;
+
     private MatchPromptBuilder() {
     }
 
@@ -57,10 +68,63 @@ final class MatchPromptBuilder {
         }
 
         sb.append("\n【候选模板】\n");
+        // 同义词按「标准词 → 别名」建一次索引,所有候选共用
+        Map<String, List<String>> aliases = aliasIndex();
         for (TemplateCandidate c : candidates) {
-            sb.append(c.promptBlock());
+            sb.append(candidateBlock(c, aliases));
         }
         sb.append("\n请按要求输出 JSON。\n");
         return sb.toString();
     }
+
+    /**
+     * 同义词索引:标准词 → 该标准词的别名列表。
+     *
+     * <p>{@code ai_clean_synonym} 存的是「别名 → 标准词」,给模型看的要反过来:
+     * 写在字段后面的是「这个字段在真实文件里还可能叫什么」。
+     * 键值在 {@code GlobalCache} 装载时已完成归一化,这里直接按归一化标准词取。
+     */
+    static Map<String, List<String>> aliasIndex() {
+        Map<String, List<String>> out = new HashMap<>();
+        for (Map.Entry<String, String> e : GlobalCache.synonymMap().entrySet()) {
+            if (e.getKey() == null || e.getValue() == null || e.getKey().isBlank()) {
+                continue;
+            }
+            out.computeIfAbsent(e.getValue(), k -> new ArrayList<>()).add(e.getKey());
+        }
+        return out;
+    }
+
+    /**
+     * 单个候选模板的提示词块:表名 + 列头 + 字段清单(含该字段的同义/别名)。
+     *
+     * <p>别名是做「同义词匹配」的依据 —— 文件写「类型」、模板字段是「车辆类型」时,
+     * 模型只有看到二者是同一标准词的别名,才能把它绑到正确字段上。
+     */
+    static String candidateBlock(TemplateCandidate c, Map<String, List<String>> aliases) {
+        StringBuilder sb = new StringBuilder();
+        sb.append('#').append(c.no()).append(' ').append(c.nameCn())
+                .append("(列头:").append(String.join("|", c.headers())).append(")\n");
+        for (TemplateCandidate.FieldBrief f : c.fields()) {
+            sb.append("    - ").append(f.cn()).append(" ⇒ ").append(f.en())
+                    .append('[').append(f.type()).append(']');
+            if (f.required()) {
+                sb.append(" 必填");
+            }
+            String canonical = HeaderScorer.canonicalForScore(f.cn());
+            List<String> fieldAliases = aliases.get(canonical);
+            if (fieldAliases != null && !fieldAliases.isEmpty()) {
+                List<String> shown = fieldAliases.stream()
+                        .filter(a -> !a.equals(canonical))
+                        .sorted()
+                        .limit(MAX_ALIASES_PER_FIELD)
+                        .toList();
+                if (!shown.isEmpty()) {
+                    sb.append("(同义:").append(String.join("|", shown)).append(')');
+                }
+            }
+            sb.append('\n');
+        }
+        return sb.toString();
+    }
 }

+ 0 - 17
ai-server/src/main/java/com/zsjz/ai/module/aiclean/model/TemplateCandidate.java

@@ -48,21 +48,4 @@ public record TemplateCandidate(int no,
     public record FieldBrief(String cn, String en, String type, boolean required,
                              Integer fieldLen, String directionConf) {
     }
-
-    /**
-     * 拼成提示词里的一段候选描述。
-     */
-    public String promptBlock() {
-        StringBuilder sb = new StringBuilder();
-        sb.append('#').append(no).append(" ")
-                .append(nameCn).append("(列头:").append(String.join("|", headers)).append(")\n");
-        for (FieldBrief f : fields) {
-            sb.append("    - ").append(f.cn())
-                    .append(" ⇒ ").append(f.en())
-                    .append('[').append(f.type()).append(']')
-                    .append(f.required() ? " 必填" : "")
-                    .append('\n');
-        }
-        return sb.toString();
-    }
 }

+ 51 - 2
ai-server/src/main/java/com/zsjz/ai/module/aiclean/rule/AiRulePlan.java

@@ -210,8 +210,8 @@ public final class AiRulePlan {
     }
 
     /**
-     * 复用已有 AI 模板时的执行计划:列名/类型/必填一律以存档的 {@code ai_clean_field} 为准,
-     * 物理表结构不会因为「这次 AI 多绑/少绑了列」而对不上。
+     * 复用已有 AI 模板时的执行计划(同表头零模型重放):列名/类型/必填一律以存档的
+     * {@code ai_clean_field} 为准,存档 {@code col_index} 就是文件列号。
      */
     public static Plan forStoredTemplate(List<AiCleanField> storedFields, List<RuleBind> binds) {
         Map<Integer, RuleBind> bindByIndex = new LinkedHashMap<>();
@@ -239,6 +239,55 @@ public final class AiRulePlan {
         return new Plan(columns, sorted);
     }
 
+    /**
+     * 按模型给出的「文件列 → 字段」映射复用已有 AI 模板:模型选中哪张表,就写进那张表。
+
+    /**
+     * 按模型给出的「文件列 → 字段」映射复用已有 AI 模板:表头与存档不同序/不同词时也能并入同一张表。
+     *
+     * <p>与 {@link #forStoredTemplate} 的区别:后者按存档 {@code col_index} 取<b>同一位置</b>的文件列,
+     * 要求逐列同序同词;这里按<b>字段名</b>找模型绑定里的文件列下标 ——
+     * 文件列序变化、多列少列都不影响,写入的仍是存档表的那些物理列。
+     *
+     * <p>没有映射到的存档列不参与本次写入(落 NULL),物理表结构保持不变;
+     * 调用方应把完整存档字段用于建表(表不存在时按完整列建),只把本 Plan 的列用于写数。
+     */
+    public static Plan forStoredTemplateByMapping(List<AiCleanField> storedFields, List<RuleBind> binds) {
+        Map<String, RuleBind> bindByField = new LinkedHashMap<>();
+        for (RuleBind b : binds) {
+            if (b != null && b.getFileColIndex() != null && StrUtil.isNotBlank(b.getFieldNameEn())) {
+                bindByField.putIfAbsent(fieldKey(b.getFieldNameEn()), b);
+            }
+        }
+        List<AiCleanField> sorted = new ArrayList<>(storedFields);
+        sorted.sort(Comparator.comparing(f -> f.getFieldSort() == null ? Integer.MAX_VALUE : f.getFieldSort()));
+        List<CompiledColumn> columns = new ArrayList<>();
+        List<AiCleanField> used = new ArrayList<>();
+        for (AiCleanField f : sorted) {
+            RuleBind bind = f.getFieldNameEn() == null ? null : bindByField.get(fieldKey(f.getFieldNameEn()));
+            if (bind == null) {
+                continue;
+            }
+            String type = StrUtil.blankToDefault(f.getFieldType(), "TEXT");
+            columns.add(new CompiledColumn(
+                    bind.getFileColIndex(),
+                    f.getColumnName(),
+                    f.getFieldNameEn(),
+                    type,
+                    Integer.valueOf(1).equals(f.getRequired()),
+                    f.getFieldLen(),
+                    AiRuleCompiler.compileFuns(bind),
+                    f.getDirectionConf()));
+            used.add(f);
+        }
+        return new Plan(columns, used);
+    }
+
+    /** 字段名比对键:绑定与存档字段的大小写/空白差异不应造成映射丢失 */
+    private static String fieldKey(String name) {
+        return StrUtil.trimToEmpty(name).toLowerCase(Locale.ROOT);
+    }
+
     /** 动态表应有的列数 = 非空表头列数(用于识别「历史模板只登记了部分列」) */
     public static int expandedColumnCount(List<String> headerNames) {
         if (headerNames == null) {

+ 63 - 37
ai-server/src/main/java/com/zsjz/ai/module/aiclean/service/AiCleanService.java

@@ -294,21 +294,17 @@ public class AiCleanService {
             job.setMatchedBy(AiMatchOutcome.BY_SELF_HEADER);
             return new AiJudgeResult.AiPlan(ev, selfBinds, null, null, headers);
         }
-        // 指纹/打分命中 AI 模板库的表:复用存档规则与已有动态表,零模型调用 ——
-        // header_md5 是唯一键,再建同指纹模板会撞 uk_ai_clean_tpl_md5(2026-10-11 实测)
+        // 同表头零模型:指纹直通时复用存档规则与已有动态表,一次模型都不调。
+        // 表头有任何差异(同义词/别名/列序)都不走这里,交给下面由模型判定并出映射。
         if (!outcome.exit().writesExistingTable() && outcome.candidate().aiTemplateId() != null) {
             AiCleanTemplate tpl = templateMapper.selectById(outcome.candidate().aiTemplateId());
             if (tpl != null && !"ONLINE".equals(tpl.getStatus())) {
-                // OFFLINE/DRAFT 模板仍占着 header_md5 唯一键,继续新建会撞键并留下幽灵表,
-                // 明确转人工比静默失败好;Phase B 再做重新激活/晋升。
                 return new AiJudgeResult.NeedReview("命中 AI 模板但状态为 " + tpl.getStatus()
                         + ",需人工确认后再清洗");
             }
             if (tpl != null) {
-                // 复用存档绑定的前提是「同序同表头」:打分腿是集合相似度,列序变了仍可能高分,
-                // 直接套旧 col_index 会把整列数据写错位置。
                 if (!HeaderFingerprint.sameOrderedHeaders(headers, outcome.candidate().headers())) {
-                    log.info("AI 模板表头顺序/内容与当前文件不一致,不复用存档绑定: templateId={}", tpl.getId());
+                    log.info("AI 模板表头与当前文件不一致,交模型判定模板与列映射: templateId={}", tpl.getId());
                 } else {
                     List<RuleBind> stored = store.loadBinds(tpl.getId());
                     List<AiCleanField> storedFields = stored.isEmpty() ? List.of()
@@ -365,7 +361,18 @@ public class AiCleanService {
         if (binds.isEmpty()) {
             return new AiJudgeResult.NeedReview("模型未给出任何可用列绑定");
         }
+        AiCleanExitEnum exit = outcome.exit();
+        if (!exit.writesExistingTable()) {
+            // 出口 B(AI 动态表):模型给了模板与列绑定就直接按它入库 —— 不再做抽样复核、
+            // 不再用置信度/必填覆盖率拦截(2026-10-26 需求)。
+            job.setConfidence(draft.getConfidence() == null ? null
+                    : java.math.BigDecimal.valueOf(draft.getConfidence()));
+            log.info("AI 动态表:按模型给出的模板与列绑定入库: templateId={}, binds={}",
+                    outcome.candidate().aiTemplateId(), binds.size());
+            return new AiJudgeResult.AiPlan(ev, binds, outcome.candidate(), draft, headers);
+        }
 
+        // 出口 A(用户既有业务表):写错不可逆,保留抽样复核这道安全垫
         mark(job, AiCleanStatusEnum.VERIFYING);
         List<Map<Integer, String>> sample = loadSample(root, sheet, ev, job);
         List<CompiledColumn> verifyColumns = resolveColumns(binds, ev, outcome.candidate(), outcome);
@@ -379,23 +386,19 @@ public class AiCleanService {
         if (report.rejectedCols() != null && !report.rejectedCols().isEmpty()) {
             binds = binds.stream().filter(b -> !report.rejectedCols().contains(b.getFileColIndex())).toList();
         }
-        AiCleanExitEnum exit = outcome.exit();
         if (!report.confidence().pass(exit)) {
             return new AiJudgeResult.NeedReview(
                     StrUtil.format("置信度未达出口 {} 的放行线: {}", exit, report.confidence()));
         }
-        if (exit.writesExistingTable()) {
-            // main_id / func_regex / line_no 都是既有链路要用的字段(瞬态或上传时未落库),
-            // 少带一个就会在 StreamingEtlListener 里 NPE 或 fromCode(null) 抛异常
-            TableInfo hit = GlobalCache.templateTableMap().get(outcome.candidate().templateId());
-            return new AiJudgeResult.ExistingHit(job.getSheetId(), outcome.candidate().templateId(),
-                    hit == null ? outcome.candidate().mainId() : hit.getMainId(),
-                    hit == null ? null : hit.getFuncRegx(),
-                    AiRulePlan.forExisting(binds, outcome.candidate(), headers),
-                    outcome.candidate().nameCn(),
-                    ev.primaryBlock().headerRow());
-        }
-        return new AiJudgeResult.AiPlan(ev, binds, outcome.candidate(), draft, headers);
+        // main_id / func_regex / line_no 都是既有链路要用的字段(瞬态或上传时未落库),
+        // 少带一个就会在 StreamingEtlListener 里 NPE 或 fromCode(null) 抛异常
+        TableInfo hit = GlobalCache.templateTableMap().get(outcome.candidate().templateId());
+        return new AiJudgeResult.ExistingHit(job.getSheetId(), outcome.candidate().templateId(),
+                hit == null ? outcome.candidate().mainId() : hit.getMainId(),
+                hit == null ? null : hit.getFuncRegx(),
+                AiRulePlan.forExisting(binds, outcome.candidate(), headers),
+                outcome.candidate().nameCn(),
+                ev.primaryBlock().headerRow());
     }
 
     /** 单 job 场景下把判定结论落地(出口 A 自己发清洗、出口 B 自己建表入库) */
@@ -454,24 +457,23 @@ public class AiCleanService {
         SheetEvidence ev = plan.ev();
         List<RuleBind> binds = plan.binds();
         List<String> headers = plan.headers();
-        // 同指纹复用:候选来自 AI 模板库时数据并入已有 AI 动态表,不再新建模板 ——
-        // header_md5 唯一键下重复登记会直接撞库(2026-10-11 E2 实测 FAIL)
         AiCleanTemplate tpl = null;
+        // 复用有两条口径:
+        //  ① 同表头零模型(judge 已判定指纹直通,plan.draft == null):存档 col_index 即文件列号;
+        //  ② 模型判定(plan.draft != null):按模型给的「文件列 → 字段」映射写入它选中的表。
+        boolean storedReplay = plan.draft() == null;
         boolean reuse = plan.candidate() != null && plan.candidate().aiTemplateId() != null;
         if (reuse) {
             tpl = templateMapper.selectById(plan.candidate().aiTemplateId());
-            // 必须「同序同表头」才复用:打分腿是集合相似度,列序变了仍可能命中;
-            // 复用存档 col_index 会把数据写错列,宁可新建模板也不冒这个险。
-            reuse = tpl != null && "ONLINE".equals(tpl.getStatus())
-                    && HeaderFingerprint.sameOrderedHeaders(headers, plan.candidate().headers());
+            reuse = tpl != null && "ONLINE".equals(tpl.getStatus()) && !binds.isEmpty();
             if (!reuse) {
                 tpl = null;
             }
         }
-        // 历史模板只登记了 AI 命中的少数列(如车辆表只有号牌/种类/所有人 3 列),
-        // 与「动态表要还原整张 sheet」的口径不一致:直接复用会让结果页/治理树永远只有那几个字段。
-        // 这里清掉旧模板与旧表,按完整表头重建;同表头的其他文件下次清洗会复用新表。
-        if (reuse) {
+        // 历史模板只登记了 AI 命中的少数列(如车辆表只有号牌/种类/所有人 3 列):
+        // 同表头零模型重放前按完整表头重建(原行为)。模型判定路径写的是存档字段本身,
+        // 与文件列数无关,不做删表重建。
+        if (reuse && storedReplay) {
             int expectedColumns = AiRulePlan.expandedColumnCount(headers);
             Long storedColumns = fieldMapper.selectCount(Wrappers.<AiCleanField>lambdaQuery()
                     .eq(AiCleanField::getAiTemplateId, tpl.getId()));
@@ -484,16 +486,37 @@ public class AiCleanService {
                 reuse = false;
             }
         }
-        AiRulePlan.Plan aiPlan;
+        AiRulePlan.Plan aiPlan = null;
         List<AiCleanRule> ruleRows = new ArrayList<>();
+        // 建表用的列定义:复用/新建都先按各自的完整列集建(幂等),写数只写映射到的列
+        List<AiCleanField> tableFields = null;
         if (reuse) {
             List<AiCleanField> storedFields = fieldMapper.selectList(Wrappers.<AiCleanField>lambdaQuery()
                     .eq(AiCleanField::getAiTemplateId, tpl.getId())
                     .orderByAsc(AiCleanField::getFieldSort));
-            aiPlan = AiRulePlan.forStoredTemplate(storedFields, binds);
-            log.info("AI 表指纹命中,复用已有动态表: table={}, templateId={}", tpl.getTableNameEn(), tpl.getId());
-        } else {
+            AiRulePlan.Plan planned = storedReplay
+                    ? AiRulePlan.forStoredTemplate(storedFields, binds)
+                    : AiRulePlan.forStoredTemplateByMapping(storedFields, binds);
+            if (planned.columns().isEmpty()) {
+                log.info("列绑定没有命中任何存档字段,按未匹配处理并新建表: templateId={}, binds={}",
+                        tpl.getId(), binds.size());
+                reuse = false;
+                tpl = null;
+            } else {
+                aiPlan = planned;
+                tableFields = storedFields;
+                if (storedReplay) {
+                    log.info("同表头零模型复用已有动态表: table={}, templateId={}, 列数={}",
+                            tpl.getTableNameEn(), tpl.getId(), storedFields.size());
+                } else {
+                    log.info("按模型列映射写入已有动态表: table={}, templateId={}, 写入列={}/{}",
+                            tpl.getTableNameEn(), tpl.getId(), planned.fields().size(), storedFields.size());
+                }
+            }
+        }
+        if (!reuse || tpl == null) {
             aiPlan = AiRulePlan.forAi(binds, headers, ev.colStats(), plan.candidate());
+            tableFields = aiPlan.fields();
             // 模板 id 先行:物理表名依赖它;元数据登记挪到写数成功之后
             tpl = new AiCleanTemplate();
             tpl.setId(IdUtil.getSnowflakeNextId());
@@ -531,8 +554,9 @@ public class AiCleanService {
             }
         }
         // 复用模板时物理表可能在当前案件不存在(跨案件共享/被手工删过):
-        // CREATE TABLE IF NOT EXISTS 是幂等的,复用也走一次,避免 openWriter 直接失败
-        String err = AiDuckDb.createAiTable(tpl.getTableNameEn(), aiPlan.fields());
+        // CREATE TABLE IF NOT EXISTS 是幂等的,复用也走一次,避免 openWriter 直接失败。
+        // 建表用完整存档列(映射复用只写命中的列,但表结构不能被这次文件裁窄)
+        String err = AiDuckDb.createAiTable(tpl.getTableNameEn(), tableFields);
         if (err != null) {
             log.error("AI 动态表建表失败,跳过写入: table={}, err={}", tpl.getTableNameEn(), err);
             return -1L;
@@ -553,6 +577,8 @@ public class AiCleanService {
                 rejectLogSample, rejectLogAll);
         // 与手动清洗共用 clean_error_log:导入文件页的「导入失败」弹框直接按批次/文件查这张表
         List<CleanErrorLog> errorLogs = new ArrayList<>();
+        // lambda 捕获需要事实最终变量:aiPlan 在上面按「复用/新建」二选一赋值
+        final AiRulePlan.Plan aiPlanF = aiPlan;
         long written = -1L;
         try (AiDuckDb.BatchWriter writer = AiDuckDb.openWriter(tpl.getTableNameEn(), cols)) {
             // 行数据优先取自上传预览表:不用为了入库再把整份源文件重新解析一遍。
@@ -560,7 +586,7 @@ public class AiCleanService {
             // 特殊字符剔除、函数链、类型规整都还没做,纯 SQL 的 CREATE TABLE AS 会把这些全部丢掉。
             AiDuckDb.RawRowHandler handler = (row, lineNo) -> {
                 rejects.countSource();
-                AiRowCleanResult result = AiRecordCleaner.cleanRowDetailed(row, aiPlan.columns());
+                AiRowCleanResult result = AiRecordCleaner.cleanRowDetailed(row, aiPlanF.columns());
                 rejects.record(lineNo, result);
                 if (result.rejected()) {
                     errorLogs.add(buildErrorLog(job, lineNo, result.errors(), columnNameCn));

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

@@ -34,5 +34,7 @@
 
 【判断提示】
 - 先看形状(PHONE/IDCARD/DATE/MONEY/…)再看列名:列名可能被改写过,形状不会说谎。
+- 模板字段后面的「同义」列出该字段在真实文件里的常见别名;文件表头写的是别名时,
+  按同义关系把它绑到这个字段,不要因为字面不同就判为不匹配。
 - 同一形状同时像「本方卡号」和「对方卡号」这类成对字段时,参考列名里的限定词(本方/对方/他行)。
 - 表头之上若有标题行、说明行,不要把它们当数据;需要时写进 structHints。

+ 28 - 10
ai-server/src/test/java/com/zsjz/ai/module/aiclean/match/AiTemplateMatcherTest.java

@@ -42,20 +42,19 @@ import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 
 /**
- * 三腿匹配 + 向量召回的测试,重点在三件事:
+ * 匹配与向量召回的测试,重点在三件事:
  *
- * <p>① <b>前两腿必须真的不调模型</b>。整条链路的降本全押在「表头指纹直通」上,
- * 如果它偷偷走到第三腿,功能照样正确、成本却翻倍,而且没有任何报错会提示 ——
- * 所以这里断言的是 {@code llmCalled=false},不只是结果对。
+ * <p>① <b>同表头指纹直通必须零模型</b>。整条链路的降本押在「表头完全一致直接复用」上,
+ * 如果它偷偷走到模型,功能照样正确、成本却翻倍,所以这里断言的是 {@code llmCalled=false}。
+ * 表头有差异(同义词/别名/列序)时不走指纹,交给模型裁决。
  *
  * <p>② <b>模型幻觉必须被兜住</b>。{@code chatAsDetail} 只是提示词约束 + 宽松解析,
  * 没有 schema 强校验,模型完全可能回一个不存在的候选序号;越界一律按未命中处理,
  * 绝不能拿它去落库。
  *
- * <p>③ <b>向量召回只做召回、不做判档</b>。它可以改变「模型看到哪些候选、按什么顺序看」,
- * 但绝不能把 header 分不够的候选抬成 STRONG 直通、更不能因此走出口 A ——
- * 那是不可逆地往用户既有业务表写数据。注意这里 mock 的是向量索引,纯 JUnit5 + Mockito,
- * 不启动 Spring 上下文。
+ * <p>③ <b>出口 A(既有业务表)保留确定性闸门</b>:写用户既有业务表不可逆,
+ * 模型自评高不能单独决定出口 A。向量召回只改「模型看到谁、按什么顺序看」,不参与判档。
+ * 注意这里 mock 的是向量索引,纯 JUnit5 + Mockito,不启动 Spring 上下文。
  *
  * @author cc
  * @since 2026/9/21
@@ -81,7 +80,7 @@ class AiTemplateMatcherTest {
     }
 
     @Test
-    @DisplayName("腿 1:AI 模板指纹命中,一次模型都不调")
+    @DisplayName("同表头指纹直通:AI 模板命中,一次模型都不调")
     void fingerprintShortCircuits() {
         AiCleanTemplate tpl = new AiCleanTemplate();
         tpl.setId(9001L);
@@ -92,11 +91,30 @@ class AiTemplateMatcherTest {
         var outcome = matcher.match(evidence,
                 List.of(new AiTemplateMatcher.AiTemplateWithFields(tpl, List.of())), true);
         assertEquals(AiMatchOutcome.BY_CACHE, outcome.matchedBy());
-        assertFalse(outcome.llmCalled(), "指纹命中却调了模型,降本就不成立了");
+        assertFalse(outcome.llmCalled(), "同表头必须零模型,调了模型降本就不成立了");
         assertEquals(AiCleanExitEnum.B, outcome.exit());
         assertEquals(9001L, outcome.candidate().aiTemplateId());
     }
 
+    @Test
+    @DisplayName("老模板存档 md5 是旧归一化算的:按当前规则重算表头后仍应指纹直通")
+    void staleStoredMd5StillHitsByRecomputedFingerprint() {
+        AiCleanTemplate tpl = new AiCleanTemplate();
+        tpl.setId(9002L);
+        tpl.setTableNameCn("银行流水");
+        tpl.setTableNameEn("ai_t9002");
+        // 模拟归一化规则演进前的存档值:与当前 md5 不同,但表头内容完全一致
+        tpl.setHeaderMd5("stale-md5-from-old-normalizer");
+        tpl.setHeaders(String.join(",", headers));
+        tpl.setStatus("ONLINE");
+        var outcome = matcher.match(evidence,
+                List.of(new AiTemplateMatcher.AiTemplateWithFields(tpl, List.of())), true);
+        assertEquals(AiMatchOutcome.BY_CACHE, outcome.matchedBy(),
+                "同表头文件必须回到零模型路径,不能被过期的存档 md5 挡住");
+        assertFalse(outcome.llmCalled());
+        assertEquals(9002L, outcome.candidate().aiTemplateId());
+    }
+
     @Test
     @DisplayName("腿 2:既有模板强命中 → 出口 A,仍然不调模型")
     void scoreHitsExistingTemplate() {

+ 61 - 0
ai-server/src/test/java/com/zsjz/ai/module/aiclean/match/MatchPromptBuilderTest.java

@@ -0,0 +1,61 @@
+package com.zsjz.ai.module.aiclean.match;
+
+import com.zsjz.ai.common.cache.GlobalCache;
+import com.zsjz.ai.module.aiclean.model.TemplateCandidate;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * 匹配提示词必须把「字段同义词」交给模型。
+ *
+ * <p>文件表头常写标准词的别名(「类型」/「车辆类型」),只给字段中英文名时模型看不出
+ * 二者是一回事,匹配率上不去。这里钉住:词条要按「标准词 → 别名」出现在字段后面,
+ * 没有词条时不要输出空的「同义」段。
+ *
+ * @author cc
+ * @since 2026/10/26
+ */
+class MatchPromptBuilderTest {
+
+    @AfterEach
+    void tearDown() {
+        GlobalCache.clearForTesting();
+    }
+
+    @Test
+    @DisplayName("候选块把字段同义词一并给模型:类型/车辆种类 归到 车辆类型")
+    void fieldAliasesInPrompt() {
+        GlobalCache.setSynonymsForTesting(Map.of(
+                "类型", "车辆类型",
+                "车辆种类", "车辆类型",
+                "牌照号码", "号牌号码"));
+        TemplateCandidate c = new TemplateCandidate(1, true, null, 9001L, null,
+                "机动车信息", "ai_clean_9001",
+                List.of("初次登记日期", "车辆类型", "号牌号码"),
+                List.of(new TemplateCandidate.FieldBrief("车辆类型", "c1", "TEXT", false, 0, null),
+                        new TemplateCandidate.FieldBrief("号牌号码", "c5", "TEXT", false, 0, null)),
+                1.0);
+        String block = MatchPromptBuilder.candidateBlock(c, MatchPromptBuilder.aliasIndex());
+        assertTrue(block.contains("车辆类型 ⇒ c1[TEXT](同义:类型|车辆种类)"), block);
+        assertTrue(block.contains("号牌号码 ⇒ c5[TEXT](同义:牌照号码)"), block);
+    }
+
+    @Test
+    @DisplayName("没有词条时不输出空的「同义」段")
+    void noAliasesNoNoise() {
+        GlobalCache.setSynonymsForTesting(Map.of());
+        TemplateCandidate c = new TemplateCandidate(1, true, null, 9001L, null,
+                "机动车信息", "ai_clean_9001", List.of("车辆类型"),
+                List.of(new TemplateCandidate.FieldBrief("车辆类型", "c0", "TEXT", false, 0, null)),
+                1.0);
+        String block = MatchPromptBuilder.candidateBlock(c, MatchPromptBuilder.aliasIndex());
+        assertFalse(block.contains("同义"), block);
+    }
+}

+ 81 - 0
ai-server/src/test/java/com/zsjz/ai/module/aiclean/rule/AiRulePlanMappingTest.java

@@ -0,0 +1,81 @@
+package com.zsjz.ai.module.aiclean.rule;
+
+import com.zsjz.ai.module.aiclean.entity.AiCleanField;
+import com.zsjz.ai.module.aiclean.model.RuleBind;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+/**
+ * 复用已有 AI 模板的「按模型列映射」计划。
+ *
+ * <p>复用动态表原本要求逐字同表头,模型给出的列绑定在落库阶段被丢弃 ——
+ * 只差一个同义词列的文件会另建一张同名表。这里钉住按字段名映射的行为:
+ * 取数下标来自绑定、物理列名与类型来自存档字段、未映射的列跳过。
+ *
+ * @author cc
+ * @since 2026/10/26
+ */
+class AiRulePlanMappingTest {
+
+    private static AiCleanField field(int sort, String column, String en, String type, Integer colIndex) {
+        AiCleanField f = new AiCleanField();
+        f.setFieldSort(sort);
+        f.setColumnName(column);
+        f.setFieldNameEn(en);
+        f.setFieldType(type);
+        f.setRequired(0);
+        f.setFieldLen(0);
+        f.setColIndex(colIndex);
+        return f;
+    }
+
+    private static RuleBind bind(int fileCol, String fieldEn) {
+        RuleBind b = new RuleBind();
+        b.setFileColIndex(fileCol);
+        b.setFieldNameEn(fieldEn);
+        return b;
+    }
+
+    @Test
+    @DisplayName("按字段名映射:文件列序与存档 col_index 不同也能对上")
+    void mapsByFieldNameNotPosition() {
+        List<AiCleanField> stored = List.of(
+                field(1, "c0", "c0", "DATE", 0),
+                field(2, "c1", "c1", "TEXT", 1),
+                field(3, "c2", "c2", "TEXT", 2));
+        // 模型给的映射:文件第 3 列 → 字段 c0,文件第 1 列 → 字段 c1
+        List<RuleBind> binds = List.of(bind(2, "c0"), bind(0, "c1"));
+        AiRulePlan.Plan plan = AiRulePlan.forStoredTemplateByMapping(stored, binds);
+        assertEquals(2, plan.columns().size(), "只有被映射到的存档列参与写入");
+        CompiledColumn first = plan.columns().get(0);
+        assertEquals("c0", first.columnName(), "物理列名必须取存档定义");
+        assertEquals(2, first.colIndex(), "取数下标来自模型绑定,而不是存档 col_index");
+        assertEquals("DATE", first.fieldType(), "类型取存档字段定义");
+        assertEquals("c1", plan.columns().get(1).columnName());
+        assertEquals(0, plan.columns().get(1).colIndex());
+        assertEquals(2, plan.fields().size());
+    }
+
+    @Test
+    @DisplayName("未映射的存档列被跳过:物理表该列保持 NULL,不会被写错")
+    void unmappedStoredColumnIsSkipped() {
+        List<AiCleanField> stored = List.of(
+                field(1, "c0", "c0", "TEXT", 0),
+                field(2, "c1", "c1", "TEXT", 1));
+        AiRulePlan.Plan plan = AiRulePlan.forStoredTemplateByMapping(stored, List.of(bind(0, "c0")));
+        assertEquals(List.of("c0"), plan.fields().stream().map(AiCleanField::getColumnName).toList());
+    }
+
+    @Test
+    @DisplayName("字段名大小写/空白差异不丢映射")
+    void fieldNameIsCaseInsensitive() {
+        List<AiCleanField> stored = List.of(field(1, "c0", "C0", "TEXT", 0));
+        AiRulePlan.Plan plan = AiRulePlan.forStoredTemplateByMapping(stored, List.of(bind(4, " c0 ")));
+        assertEquals(1, plan.columns().size());
+        assertEquals(4, plan.columns().getFirst().colIndex());
+    }
+}