Kaynağa Gözat

feat(aiclean): 表头同义词库与相似词匹配,修复模型中文绑定被全杀的问题

- 新表 ai_clean_synonym(星型 别名→标准词)+ /aiClean/synonym 管理接口:
  CRUD、同一归一化词全表唯一校验、事务提交后重建全局缓存,改词即时生效
- HeaderScorer:canonicalForScore 接同义词缓存;EXACT/SIMILAR 三档词匹配
  (后缀包含/等长编辑距离/同前缀距离2)全量参与计分与出口 A 直通;
  指纹链路不变;SCORE 命中输出逐列明细日志
- filterBinds:en∪cn 双域 + 同义词归一 + 模糊唯一命中改写、歧义丢弃
  (修复 2026-10-25 实测:模型回中文名被 en-only 清单全杀 → NEED_REVIEW)
- 提示词规则 2 放宽为可逐字复制中文名;sql 迁移脚本 ai_clean_synonym_init.sql
- 测试:aiclean 158 用例全绿(+6);E2E 两车辆文件回归不劣化
cc 4 gün önce
ebeveyn
işleme
70a0a9b176

+ 64 - 0
ai-server/src/main/java/com/zsjz/ai/common/cache/GlobalCache.java

@@ -17,6 +17,9 @@ import com.zsjz.ai.common.model.plat.entity.TableField;
 import com.zsjz.ai.common.model.plat.entity.TableInfo;
 import com.zsjz.ai.common.utils.Json;
 import com.zsjz.ai.common.utils.SystemConfManager;
+import com.zsjz.ai.module.aiclean.entity.AiCleanSynonym;
+import com.zsjz.ai.module.aiclean.mapper.AiCleanSynonymMapper;
+import com.zsjz.ai.module.aiclean.support.HeaderNormalizer;
 import com.zsjz.ai.module.plat.mapper.CardIssuerBankMapper;
 import com.zsjz.ai.module.plat.mapper.PhoneIspMapper;
 import com.zsjz.ai.module.plat.mapper.TableFieldMapper;
@@ -178,6 +181,20 @@ public class GlobalCache {
         return directionConfMap;
     }
 
+    /**
+     * 表头同义词映射:归一化别名 → 归一化标准词(来自 {@code ai_clean_synonym},status=1)。
+     *
+     * <p>消费方只有 AI 清洗的评分归一({@code HeaderScorer#canonicalForScore})与绑定域解析
+     * ({@code AiTemplateMatcher#filterBinds}),<b>绝不进指纹链路</b> —— 词条变更不影响
+     * 存量模板的 {@code header_md5}。归一化在装载时完成({@link HeaderNormalizer#normalize}),
+     * 运行期查表零成本。
+     */
+    private static volatile Map<String, String> synonymMap = Map.of();
+
+    public static Map<String, String> synonymMap() {
+        return synonymMap;
+    }
+
     /**
      * 仅供测试装配缓存快照(生产代码一律走 {@link #initCache()})。
      */
@@ -187,6 +204,15 @@ public class GlobalCache {
         fieldCnMap = Collections.unmodifiableMap(buildFieldCnMap(tableFieldCache, Map.of()));
     }
 
+    /**
+     * 仅供测试注入同义词快照(key = 归一化别名,value = 归一化标准词)。
+     * 生产代码一律走 {@link #initCache()}。
+     */
+    public static void setSynonymsForTesting(Map<String, String> aliasToStd) {
+        synonymMap = aliasToStd == null ? Map.of()
+                : Collections.unmodifiableMap(new HashMap<>(aliasToStd));
+    }
+
     /**
      * 仅供测试清空缓存快照(生产代码一律走 {@link #initCache()})。
      */
@@ -194,6 +220,7 @@ public class GlobalCache {
         templateTableMap = Map.of();
         tableFieldCache = Map.of();
         fieldCnMap = Map.of();
+        synonymMap = Map.of();
     }
 
     /**
@@ -224,6 +251,10 @@ public class GlobalCache {
                 newAllMatchedFieldsMap, newTableHead);
         Map<String, String> newFieldCnMap = buildFieldCnMap(newTableFieldCache, newTableHead);
 
+        // 同义词:独立加载、独立容错 —— 表不存在(存量库未跑迁移)或查询失败都保留旧快照,
+        // 不能连累模板主缓存
+        Map<String, String> newSynonymMap = loadSynonymMap();
+
         // 全部构建完成后一次性整体替换引用(volatile 写,读方立即可见且只见完整快照)
         tableInfoList = List.copyOf(newTableInfoList);
         idNameZhMap = Collections.unmodifiableMap(newIdNameZhMap);
@@ -234,9 +265,42 @@ public class GlobalCache {
         allMatchedFieldsMap = Collections.unmodifiableMap(newAllMatchedFieldsMap);
         tableHead = Collections.unmodifiableMap(newTableHead);
         fieldCnMap = Collections.unmodifiableMap(newFieldCnMap);
+        if (newSynonymMap != null) {
+            synonymMap = Collections.unmodifiableMap(newSynonymMap);
+        }
         log.info("initCache success");
     }
 
+    /**
+     * 读取启用的同义词并构建 归一化别名 → 归一化标准词 映射。
+     *
+     * @return 构建成功返回新映射;表为空 / 查询失败返回 null(调用方保留旧快照)
+     */
+    private static Map<String, String> loadSynonymMap() {
+        try {
+            AiCleanSynonymMapper mapper = SpringUtil.getBean(AiCleanSynonymMapper.class);
+            List<AiCleanSynonym> rows = mapper.selectList(Wrappers.<AiCleanSynonym>lambdaQuery()
+                    .eq(AiCleanSynonym::getStatus, 1));
+            if (rows == null || rows.isEmpty()) {
+                log.info("ai_clean_synonym 无启用词条,同义词缓存保留旧快照");
+                return null;
+            }
+            Map<String, String> out = new HashMap<>();
+            for (AiCleanSynonym row : rows) {
+                String alias = HeaderNormalizer.normalize(row.getAliasWord());
+                String std = HeaderNormalizer.normalize(row.getStdWord());
+                if (!alias.isEmpty() && !std.isEmpty()) {
+                    out.putIfAbsent(alias, std);
+                }
+            }
+            log.info("同义词缓存加载完成: {} 组", out.size());
+            return out;
+        } catch (Exception e) {
+            log.warn("加载同义词库失败(保留旧快照,评分按无同义词继续): {}", e.getMessage());
+            return null;
+        }
+    }
+
     /**
      * 字段英文名 → 中文名 的拉平映射:{@code table_field} 全量字段 + 派生表头
      * (表头里有 {@code table_field} 没有的统计列,如「交易总金额 totalMoney」)。

+ 70 - 0
ai-server/src/main/java/com/zsjz/ai/module/aiclean/api/AiCleanSynonymController.java

@@ -0,0 +1,70 @@
+package com.zsjz.ai.module.aiclean.api;
+
+import com.zsjz.ai.common.config.Result;
+import com.zsjz.ai.common.context.CaseContextHolder;
+import com.zsjz.ai.module.aiclean.entity.AiCleanSynonym;
+import com.zsjz.ai.module.aiclean.service.AiCleanSynonymService;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+
+/**
+ * 表头同义词库管理接口(AI 清洗评分归一与绑定域用的词对)。
+ *
+ * <p>词组模型与校验规则见 {@link com.zsjz.ai.module.aiclean.service.AiCleanSynonymService}:
+ * 星型「别名 → 标准词」、同一归一化词全表唯一、不做链式传递。
+ * 增删改在事务提交后自动重建全局缓存,改词即时生效,无需重启、不发版;
+ * 同义词不进指纹与向量索引,模板匹配的零模型直通链路不受影响。
+ *
+ * @author cc
+ * @since 2026/10/25
+ */
+@RestController
+@RequestMapping("/aiClean/synonym")
+public class AiCleanSynonymController {
+
+    private final AiCleanSynonymService service;
+
+    public AiCleanSynonymController(AiCleanSynonymService service) {
+        this.service = service;
+    }
+
+    /** 全部词条(含停用),按标准词排序 */
+    @GetMapping("/list")
+    public Result<List<AiCleanSynonym>> list() {
+        return Result.succeed(service.list());
+    }
+
+    /** 新增词对(JSON body:stdWord / aliasWord / remark,可带 status) */
+    @PostMapping("/create")
+    public Result<AiCleanSynonym> create(@RequestBody AiCleanSynonym row) {
+        if (row != null && row.getCreateBy() == null) {
+            row.setCreateBy(CaseContextHolder.currentUserId());
+        }
+        return Result.succeed(service.create(row));
+    }
+
+    /** 修改词对(JSON body:id 必填 + 要改的字段) */
+    @PostMapping("/update")
+    public Result<AiCleanSynonym> update(@RequestBody AiCleanSynonym row) {
+        return Result.succeed(service.update(row));
+    }
+
+    /** 删除词对(JSON body 形状 {@code {"id":1}}) */
+    @PostMapping("/delete")
+    public Result<Void> delete(@RequestBody AiCleanSynonym row) {
+        service.delete(row == null ? null : row.getId());
+        return Result.succeed();
+    }
+
+    /** 启用/停用(JSON body 形状 {@code {"id":1,"status":0}}) */
+    @PostMapping("/toggle")
+    public Result<Void> toggle(@RequestBody AiCleanSynonym row) {
+        service.toggle(row == null ? null : row.getId(), row == null ? null : row.getStatus());
+        return Result.succeed();
+    }
+}

+ 63 - 0
ai-server/src/main/java/com/zsjz/ai/module/aiclean/entity/AiCleanSynonym.java

@@ -0,0 +1,63 @@
+package com.zsjz.ai.module.aiclean.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.io.Serial;
+import java.io.Serializable;
+import java.time.LocalDateTime;
+
+/**
+ * 表头同义词({@code ai_clean_synonym})——星型词组:{@code alias_word → std_word}。
+ *
+ * <p>只参与 AI 清洗的<b>评分归一</b>({@code HeaderScorer#canonicalForScore})与
+ * <b>绑定域解析</b>({@code AiTemplateMatcher#filterBinds}),绝不进
+ * {@code HeaderFingerprint} 指纹 —— 指纹一旦被同义词折叠,存量模板的
+ * {@code header_md5} 会随词条配置漂移,指纹直通链路整体失效。
+ *
+ * <h3>词组纪律(由 {@code AiCleanSynonymService} 落库前校验)</h3>
+ * <ul>
+ *   <li>同一<b>归一化词</b>全表只允许出现一次(任一列),防止一个别名归两组的归一歧义;</li>
+ *   <li>不做链式传递:别名必须直接指向标准词,A=B、B=C 不会推出 A=C;</li>
+ *   <li>「一个词对应多个模板字段」的泛化场景不进表,交给打分层的模糊规则
+ *       (包含关系/编辑距离,见 {@code HeaderScorer})。</li>
+ * </ul>
+ *
+ * @author cc
+ * @since 2026/10/25
+ */
+@Data
+@TableName("ai_clean_synonym")
+public class AiCleanSynonym implements Serializable {
+
+    @Serial
+    private static final long serialVersionUID = 1L;
+
+    @TableId(type = IdType.ASSIGN_ID)
+    private Long id;
+
+    /** 标准词(组内规范形,如 交易金额) */
+    @TableField("std_word")
+    private String stdWord;
+
+    /** 别名(如 发生额),评分归一到标准词 */
+    @TableField("alias_word")
+    private String aliasWord;
+
+    private String remark;
+
+    /** 1 启用 / 0 停用 */
+    private Integer status;
+
+    @TableField("create_by")
+    private Long createBy;
+
+    @TableField("create_time")
+    private LocalDateTime createTime;
+
+    @TableField("update_time")
+    private LocalDateTime updateTime;
+}

+ 12 - 0
ai-server/src/main/java/com/zsjz/ai/module/aiclean/mapper/AiCleanSynonymMapper.java

@@ -0,0 +1,12 @@
+package com.zsjz.ai.module.aiclean.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.zsjz.ai.module.aiclean.entity.AiCleanSynonym;
+import org.apache.ibatis.annotations.Mapper;
+
+/**
+ * 表头同义词 Mapper(master PG)。
+ */
+@Mapper
+public interface AiCleanSynonymMapper extends BaseMapper<AiCleanSynonym> {
+}

+ 42 - 13
ai-server/src/main/java/com/zsjz/ai/module/aiclean/match/AiTemplateMatcher.java

@@ -306,26 +306,32 @@ public class AiTemplateMatcher {
      * 出口 A 里查不到的字段被 {@code AiRulePlan#forExisting} 跳过,该列悄悄变成空列;
      * 在这里拦掉,问题以 NEED_REVIEW 显式暴露,而不是静默丢列。
      *
-     * <p><b>合法域是中英文名的并集</b>:既有模板 cn={@code field_name_cn}、en={@code field_name_en};
-     * AI 模板 en 是物理列名 {@code c0..cN}。实测本地小模型面对 {@code 号牌号码 ⇒ c5} 时
-     * 大概率回中文名(2026-10-25 车辆登记信息实测:正确绑定被 en-only 清单全杀,NEED_REVIEW),
-     * 所以两个域都收;命中中文名时改写为英文名,出口 A 按 {@code field_name_en} 查 table_field
-     * 才解析得到。归一口径与 {@code AiRulePlan#indexCandidateFields} 一致。
+     * <p><b>合法域与解析顺序</b>(2026-10-25):
+     * <ol>
+     *   <li><b>EXACT</b>:候选字段 cn/en 经 {@code canonicalForScore}(同义词库归一)后与绑定名相等 ——
+     *       中英文名都收(本地小模型实测大概率回中文名);命中即改写为规范英文名,
+     *       出口 A 按 {@code field_name_en} 查 table_field 才解析得到;</li>
+     *   <li><b>SIMILAR</b>:精确未命中时按 {@code similarWords}(后缀包含/等长编辑距离)做相似解析,
+     *       <b>唯一命中</b>才改写;命中 0 个或 ≥2 个(如模板同时有 本方卡号/对方卡号,绑定名「卡号」
+     *       两个都像)一律丢弃 —— 该列在 forAi 按原样 TEXT 保留,语义绑定交给模型腿。</li>
+     * </ol>
+     * 归一口径与 {@code AiRulePlan#indexCandidateFields} 一致。
      */
     public static List<RuleBind> filterBinds(List<RuleBind> binds, TemplateCandidate cand, int colCount) {
         if (binds == null || cand == null) {
             return List.of();
         }
+        List<TemplateCandidate.FieldBrief> fields = cand.fields() == null ? List.of() : cand.fields();
         Map<String, String> enByNorm = new HashMap<>();
-        for (TemplateCandidate.FieldBrief f : cand.fields()) {
+        for (TemplateCandidate.FieldBrief f : fields) {
             if (f == null) {
                 continue;
             }
             if (StrUtil.isNotBlank(f.en())) {
-                enByNorm.putIfAbsent(normalizeName(f.en()), f.en());
+                enByNorm.putIfAbsent(HeaderScorer.canonicalForScore(f.en()), f.en());
             }
             if (StrUtil.isNotBlank(f.cn())) {
-                enByNorm.putIfAbsent(normalizeName(f.cn()), f.en());
+                enByNorm.putIfAbsent(HeaderScorer.canonicalForScore(f.cn()), f.en());
             }
         }
         List<RuleBind> out = new ArrayList<>();
@@ -333,9 +339,14 @@ public class AiTemplateMatcher {
             if (b == null || StrUtil.isBlank(b.getFieldNameEn())) {
                 continue;
             }
-            String en = enByNorm.get(normalizeName(b.getFieldNameEn()));
+            String canonical = HeaderScorer.canonicalForScore(b.getFieldNameEn());
+            String en = enByNorm.get(canonical);
             if (en == null) {
-                continue;
+                // 精确未命中:相似解析,只在唯一命中时改写,歧义宁可丢弃
+                en = resolveSimilarEn(fields, canonical);
+                if (en == null) {
+                    continue;
+                }
             }
             if (b.getFileColIndex() == null || b.getFileColIndex() < 0 || b.getFileColIndex() >= colCount) {
                 continue;
@@ -346,9 +357,27 @@ public class AiTemplateMatcher {
         return out;
     }
 
-    /** 字段名归一:trim + 小写,与 {@code AiRulePlan#normalizeKey} 同口径 */
-    private static String normalizeName(String name) {
-        return StrUtil.nullToEmpty(name).trim().toLowerCase(java.util.Locale.ROOT);
+    /**
+     * 相似解析:绑定名对候选字段 cn/en 做相似比对。返回唯一相似命中的字段英文名;
+     * 0 命中或 ≥2 命中返回 null(歧义不敢替模型做主)。
+     */
+    private static String resolveSimilarEn(List<TemplateCandidate.FieldBrief> fields, String bindCanonical) {
+        String en = null;
+        int hits = 0;
+        for (TemplateCandidate.FieldBrief f : fields) {
+            if (f == null || StrUtil.isBlank(f.en())) {
+                continue;
+            }
+            String cn = StrUtil.blankToDefault(f.cn(), "");
+            boolean sim =
+                    (!cn.isEmpty() && HeaderScorer.similarWords(bindCanonical, HeaderScorer.canonicalForScore(cn)))
+                            || HeaderScorer.similarWords(bindCanonical, HeaderScorer.canonicalForScore(f.en()));
+            if (sim) {
+                hits++;
+                en = f.en();
+            }
+        }
+        return hits == 1 ? en : null;
     }
 
     /**

+ 234 - 66
ai-server/src/main/java/com/zsjz/ai/module/aiclean/match/HeaderScorer.java

@@ -1,6 +1,7 @@
 package com.zsjz.ai.module.aiclean.match;
 
 import cn.hutool.core.util.StrUtil;
+import com.zsjz.ai.common.cache.GlobalCache;
 import com.zsjz.ai.module.aiclean.model.ColStat;
 import com.zsjz.ai.module.aiclean.model.TemplateCandidate;
 import com.zsjz.ai.module.aiclean.support.HeaderFingerprint;
@@ -8,7 +9,8 @@ import com.zsjz.ai.module.aiclean.support.HeaderNormalizer;
 
 import java.util.ArrayList;
 import java.util.HashMap;
-import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Locale;
 import java.util.Map;
@@ -20,16 +22,18 @@ import java.util.Set;
  * <p>与既有打分的区别:既有是「交集数 + 必填数」的<b>绝对值</b>(PreDataListener:386),
  * 模板字段越多越容易得高分,且没有阈值概念 —— 分不清「像」和「就是」。
  *
- * <p>2026-10-22 起不再只看表头集合:
+ * <p>2026-10-22 起不再只看表头集合:表头集合 / 字段名 / 列序 / 形状 / 必填覆盖五个组件。
+ * 2026-10-25 起词与词的比对统一走 {@link #matchWord} 三档:
  * <ul>
- *   <li>表头集合:覆盖率 + Jaccard(修复重复列把分母撑大的问题);</li>
- *   <li>字段名:候选的字段中文名/英文名是否出现在文件表头里(同义改写场景);</li>
- *   <li>列序:同序加分,乱序只给弱分(但不会因此丢掉候选);</li>
- *   <li>形状:{@link ColStat#shape()} 与候选字段类型是否矛盾;</li>
- *   <li>必填覆盖:候选的 required 字段是否都能在文件表头里找到。</li>
+ *   <li><b>EXACT</b>:同义词库({@code ai_clean_synonym},经 {@link #canonicalForScore} 归一)后相等;</li>
+ *   <li><b>SIMILAR</b>:代码内模糊规则(后缀包含 / 等长编辑距离,见 {@link #similarWords});</li>
+ *   <li><b>MISS</b>:不匹配。</li>
  * </ul>
- * 分数落在 0~1,出口 A 还必须额外通过「确定性分数 + 必填覆盖」闸门,
- * 模型自评置信度不能单独把候选升进出口 A。
+ * 按需求(2026-10-25)SIMILAR 与 EXACT <b>同权全量参与</b>所有组件(含必填覆盖与出口 A 直通判定);
+ * 出口 A 之后的抽样 verify、置信度放行线等运行时安全垫不变。
+ *
+ * <p><b>指纹纪律</b>:同义词与模糊规则只进本类,{@link HeaderFingerprint} 仍用纯
+ * {@link HeaderNormalizer#normalize} —— 不同表头被折叠成同一个 md5 会让指纹直通错配。
  *
  * @author cc
  * @since 2026/9/21
@@ -42,32 +46,6 @@ public final class HeaderScorer {
     /** 弱匹配线:低于它说明表头几乎没有共同点 */
     public static final double WEAK = 0.55;
 
-    /** 常见同义表头(只做评分归一,不参与指纹,避免指纹碰撞) */
-    private static final Map<String, String> SYNONYMS = Map.ofEntries(
-            Map.entry("账户名称", "户名"),
-            Map.entry("客户名称", "户名"),
-            Map.entry("户名", "户名"),
-            Map.entry("姓名", "户名"),
-            Map.entry("发生额", "交易金额"),
-            Map.entry("交易额", "交易金额"),
-            Map.entry("金额", "交易金额"),
-            Map.entry("交易日期", "日期"),
-            Map.entry("日期", "日期"),
-            Map.entry("交易时间", "时间"),
-            Map.entry("时间", "时间"),
-            Map.entry("卡号", "账号"),
-            Map.entry("银行账号", "账号"),
-            Map.entry("账号", "账号"),
-            Map.entry("账户", "账号"),
-            Map.entry("手机号", "电话"),
-            Map.entry("手机", "电话"),
-            Map.entry("联系电话", "电话"),
-            Map.entry("电话", "电话"),
-            Map.entry("身份证", "身份证号"),
-            Map.entry("身份证号码", "身份证号"),
-            Map.entry("证件号码", "身份证号"),
-            Map.entry("身份证号", "身份证号"));
-
     private HeaderScorer() {
     }
 
@@ -82,22 +60,29 @@ public final class HeaderScorer {
      * @return 0~1 的分数
      */
     public static double score(List<String> headers, TemplateCandidate cand, List<ColStat> colStats) {
-        List<String> fileNames = canonicalNames(headers);
+        ScoreContext file = ScoreContext.of(headers);
         List<String> tplNames = canonicalNames(cand.headers());
-        if (fileNames.isEmpty() || tplNames.isEmpty()) {
+        if (file.names.isEmpty() || tplNames.isEmpty()) {
             return 0d;
         }
-        Set<String> fileSet = new HashSet<>(fileNames);
-        Set<String> tplSet = new HashSet<>(tplNames);
-        long hit = fileSet.stream().filter(tplSet::contains).count();
+        Set<String> tplSet = new LinkedHashSet<>(tplNames);
+
+        // 逐模板列匹配:EXACT 与 SIMILAR 同权计入命中(全量参与)
+        int hit = 0;
+        for (String t : tplSet) {
+            if (file.match(t).type() != WordMatchType.MISS) {
+                hit++;
+            }
+        }
         double coverage = (double) hit / tplSet.size();
-        double jaccard = HeaderFingerprint.jaccard(fileSet, tplSet);
+        // 模糊感知的 Jaccard:命中数(含相似命中)充当交集,分母相应收缩;封顶防病态膨胀
+        double jaccard = Math.min(1d, (double) hit / Math.max(1, file.set.size() + tplSet.size() - hit));
         double header = 0.7 * Math.min(1d, coverage) + 0.3 * jaccard;
 
-        double field = fieldCoverage(fileSet, cand.fields());
-        double order = orderScore(fileNames, tplNames);
-        double shape = shapeScore(headers, cand.fields(), colStats);
-        double required = requiredCoverage(fileSet, cand.fields());
+        double field = fieldCoverage(file, cand.fields());
+        double order = orderScore(file, tplNames);
+        double shape = shapeScore(cand.fields(), colStats, file);
+        double required = requiredCoverage(file, cand.fields());
         return 0.55 * header + 0.15 * field + 0.10 * order + 0.10 * shape + 0.10 * required;
     }
 
@@ -119,30 +104,190 @@ public final class HeaderScorer {
 
     /** 形状与字段类型没有明显矛盾;没有样本/形状为 TEXT 时视为安全 */
     public static boolean shapeSafe(List<String> headers, TemplateCandidate cand, List<ColStat> colStats) {
-        return shapeScore(headers, cand.fields(), colStats) >= 0.5;
+        return shapeScore(cand.fields(), colStats, ScoreContext.of(headers)) >= 0.5;
     }
 
     /** 候选里所有 required 字段是否都能在文件表头中找到 */
     public static boolean requiredCovered(List<String> headers, TemplateCandidate cand) {
-        Set<String> fileSet = new HashSet<>(canonicalNames(headers));
-        return requiredCoverage(fileSet, cand.fields()) >= 1d;
+        ScoreContext file = ScoreContext.of(headers);
+        return requiredCoverage(file, cand.fields()) >= 1d;
     }
 
     /**
      * 模板里有多少列在文件表头中出现了(用于「必填全覆盖」判断)。
      */
     public static long matchedCount(List<String> headers, List<String> needCols) {
-        Set<String> fileSet = new HashSet<>(canonicalNames(headers));
-        return canonicalNames(needCols).stream().filter(fileSet::contains).count();
+        ScoreContext file = ScoreContext.of(headers);
+        return canonicalNames(needCols).stream().filter(w -> file.match(w).type() != WordMatchType.MISS).count();
+    }
+
+    /**
+     * 逐列匹配明细(排障用):形如 {@code #0 交易日期=EXACT | #1 通信类别=SIMILAR(≈通信类型) | #2 备注=MISS}。
+     * 每个待匹配 sheet 只应对<b>最终命中的候选</b>调用一次,不要在候选循环里调。
+     */
+    public static String matchDetail(List<String> headers, TemplateCandidate cand) {
+        ScoreContext file = ScoreContext.of(headers);
+        List<String> tplNames = canonicalNames(cand.headers());
+        if (file.names.isEmpty() || tplNames.isEmpty()) {
+            return "";
+        }
+        StringBuilder sb = new StringBuilder();
+        for (int i = 0; i < tplNames.size(); i++) {
+            WordHit hit = file.match(tplNames.get(i));
+            if (sb.length() > 0) {
+                sb.append(" | ");
+            }
+            sb.append('#').append(i).append(' ').append(tplNames.get(i)).append('=').append(hit.type());
+            if (hit.type() == WordMatchType.SIMILAR) {
+                sb.append("(≈").append(hit.fileWord()).append(')');
+            }
+        }
+        return sb.toString();
+    }
+
+    // ==================== 词匹配:EXACT / SIMILAR / MISS ====================
+
+    enum WordMatchType {EXACT, SIMILAR, MISS}
+
+    /**
+     * 一个模板词在文件表头里的匹配结果;{@code fileWord} 是命中的文件词(MISS 时为 null)。
+     */
+    record WordHit(WordMatchType type, String fileWord) {
+
+        static final WordHit MISS = new WordHit(WordMatchType.MISS, null);
     }
 
     /**
-     * 评分归一:去括号/空白/后缀 + 同义词映射。指纹仍用 {@link HeaderNormalizer#normalize},
+     * 相似词判定(双方均应已过 {@link #canonicalForScore} 且不相等)。三条规则:
+     * <ol>
+     *   <li><b>后缀包含</b>:短词(≥2 字)是长词的尾部。中文列名的业务限定词(本方/对方/车辆/通信…)
+     *       在前、单位等后缀(元/万元…)在后,所以只认尾部包含:
+     *       {@code 类型 ⊂ 车辆类型 ✓}、{@code 姓名 ⊂ 本方姓名 ✓}、
+     *       {@code 金额 ⊂ 金额元 ✗}(单位差异必须保持不同列)。</li>
+     *   <li><b>等长编辑距离 ≤1</b>(双方 ≥3 字):{@code 通信类型 ↔ 通信类别}。
+     *       只认等长替换,插入/删除会造成 金额元 ↔ 金额万元 这类单位混淆,不放行。</li>
+     *   <li><b>等长且共同前缀 ≥2、编辑距离 ≤2</b>(双方 ≥4 字):
+     *       {@code 车辆类型 ↔ 车辆种类}(两处尾部替换)。</li>
+     * </ol>
+     */
+    static boolean similarWords(String a, String b) {
+        int la = a.length();
+        int lb = b.length();
+        if (la == 0 || lb == 0 || a.equals(b)) {
+            return false;
+        }
+        String shorter = la <= lb ? a : b;
+        String longer = la <= lb ? b : a;
+        if (shorter.length() >= 2 && longer.endsWith(shorter)) {
+            return true;
+        }
+        if (la == lb && la >= 3 && editDistanceAtMost(a, b, 1)) {
+            return true;
+        }
+        return la == lb && la >= 4 && commonPrefixLen(a, b) >= 2 && editDistanceAtMost(a, b, 2);
+    }
+
+    /** 编辑距离是否 ≤ k(DP,表头词都很短,无需滚动数组优化) */
+    private static boolean editDistanceAtMost(String a, String b, int k) {
+        int la = a.length();
+        int lb = b.length();
+        if (Math.abs(la - lb) > k) {
+            return false;
+        }
+        int[][] dp = new int[la + 1][lb + 1];
+        for (int i = 0; i <= la; i++) {
+            dp[i][0] = i;
+        }
+        for (int j = 0; j <= lb; j++) {
+            dp[0][j] = j;
+        }
+        for (int i = 1; i <= la; i++) {
+            for (int j = 1; j <= lb; j++) {
+                int cost = a.charAt(i - 1) == b.charAt(j - 1) ? 0 : 1;
+                dp[i][j] = Math.min(Math.min(dp[i - 1][j] + 1, dp[i][j - 1] + 1), dp[i - 1][j - 1] + cost);
+            }
+        }
+        return dp[la][lb] <= k;
+    }
+
+    private static int commonPrefixLen(String a, String b) {
+        int n = Math.min(a.length(), b.length());
+        int i = 0;
+        while (i < n && a.charAt(i) == b.charAt(i)) {
+            i++;
+        }
+        return i;
+    }
+
+    /**
+     * 文件表头的匹配上下文:一次 score 调用构建一次,模板列逐个来问,memo 消除重复比对。
+     */
+    private static final class ScoreContext {
+
+        /** 归一化后的文件表头(保序,可能含重复) */
+        final List<String> names;
+        final Set<String> set;
+        /** 归一化词 → 首次出现的原始下标 */
+        final Map<String, Integer> firstIndex;
+        private final Map<String, WordHit> memo = new HashMap<>();
+
+        private ScoreContext(List<String> names, Map<String, Integer> firstIndex) {
+            this.names = names;
+            this.set = Set.copyOf(names);
+            this.firstIndex = Map.copyOf(firstIndex);
+        }
+
+        static ScoreContext of(List<String> rawHeaders) {
+            List<String> names = new ArrayList<>();
+            Map<String, Integer> firstIndex = new LinkedHashMap<>();
+            for (String h : rawHeaders == null ? List.<String>of() : rawHeaders) {
+                String n = canonicalForScore(h);
+                if (!n.isEmpty()) {
+                    names.add(n);
+                    firstIndex.putIfAbsent(n, names.size() - 1);
+                }
+            }
+            return new ScoreContext(names, firstIndex);
+        }
+
+        WordHit match(String tplWord) {
+            if (tplWord == null || tplWord.isEmpty() || set.isEmpty()) {
+                return WordHit.MISS;
+            }
+            return memo.computeIfAbsent(tplWord, w -> {
+                if (set.contains(w)) {
+                    return new WordHit(WordMatchType.EXACT, w);
+                }
+                for (String fw : names) {
+                    if (!fw.equals(w) && similarWords(w, fw)) {
+                        return new WordHit(WordMatchType.SIMILAR, fw);
+                    }
+                }
+                return WordHit.MISS;
+            });
+        }
+
+        /** 模板词对应的文件列原始下标:先精确,再按相似命中的文件词;找不到 -1 */
+        int indexOf(String tplWord) {
+            Integer exact = firstIndex.get(tplWord);
+            if (exact != null) {
+                return exact;
+            }
+            WordHit hit = match(tplWord);
+            return hit.type() == WordMatchType.MISS ? -1
+                    : firstIndex.getOrDefault(hit.fileWord(), -1);
+        }
+    }
+
+    // ==================== 评分归一 ====================
+
+    /**
+     * 评分归一:去括号/空白/后缀 + 同义词库映射。指纹仍用 {@link HeaderNormalizer#normalize},
      * 不受同义词影响,避免不同表头被折叠成同一个 md5。
      */
     static String canonicalForScore(String raw) {
         String n = HeaderNormalizer.normalize(raw);
-        return SYNONYMS.getOrDefault(n, n);
+        return GlobalCache.synonymMap().getOrDefault(n, n);
     }
 
     private static List<String> canonicalNames(List<String> headers) {
@@ -159,7 +304,7 @@ public final class HeaderScorer {
         return out;
     }
 
-    private static double fieldCoverage(Set<String> fileSet, List<TemplateCandidate.FieldBrief> fields) {
+    private static double fieldCoverage(ScoreContext file, List<TemplateCandidate.FieldBrief> fields) {
         if (fields == null || fields.isEmpty()) {
             // 历史无字段模板:不给正向加分,也不重罚
             return 0.5d;
@@ -171,19 +316,21 @@ public final class HeaderScorer {
             }
             String cn = canonicalForScore(f.cn());
             String en = canonicalForScore(f.en());
-            if ((!cn.isEmpty() && fileSet.contains(cn)) || (!en.isEmpty() && fileSet.contains(en))) {
+            boolean cnHit = !cn.isEmpty() && file.match(cn).type() != WordMatchType.MISS;
+            boolean enHit = !en.isEmpty() && file.match(en).type() != WordMatchType.MISS;
+            if (cnHit || enHit) {
                 hit++;
             }
         }
         return (double) hit / fields.size();
     }
 
-    private static double orderScore(List<String> fileName, List<String> tplNames) {
+    private static double orderScore(ScoreContext file, List<String> tplNames) {
         int last = -1;
         int found = 0;
         boolean ordered = true;
         for (String t : tplNames) {
-            int idx = fileName.indexOf(t);
+            int idx = file.indexOf(t);
             if (idx < 0) {
                 ordered = false;
                 continue;
@@ -200,8 +347,8 @@ public final class HeaderScorer {
         return ordered ? 1d : 0.4d;
     }
 
-    static double shapeScore(List<String> headers,
-                             List<TemplateCandidate.FieldBrief> fields, List<ColStat> colStats) {
+    static double shapeScore(List<TemplateCandidate.FieldBrief> fields, List<ColStat> colStats,
+                             ScoreContext file) {
         if (fields == null || fields.isEmpty() || colStats == null || colStats.isEmpty()) {
             return 0.5d;
         }
@@ -217,7 +364,7 @@ public final class HeaderScorer {
             if (f == null) {
                 continue;
             }
-            int pos = indexOfHeader(headers, f);
+            int pos = fieldPosition(file, f);
             if (pos < 0) {
                 continue;
             }
@@ -234,19 +381,38 @@ public final class HeaderScorer {
         return known == 0 ? 0.5d : (double) compatible / known;
     }
 
-    private static int indexOfHeader(List<String> headers, TemplateCandidate.FieldBrief field) {
+    /** 字段(cn/en 双名,先精确后相似)在文件表头中的原始下标;找不到 -1 */
+    private static int fieldPosition(ScoreContext file, TemplateCandidate.FieldBrief field) {
         String cn = canonicalForScore(field.cn());
         String en = canonicalForScore(field.en());
-        for (int i = 0; i < headers.size(); i++) {
-            String n = canonicalForScore(headers.get(i));
-            if (!n.isEmpty() && ((!cn.isEmpty() && n.equals(cn)) || (!en.isEmpty() && n.equals(en)))) {
-                return i;
+        if (!cn.isEmpty()) {
+            Integer p = file.firstIndex.get(cn);
+            if (p != null) {
+                return p;
+            }
+        }
+        if (!en.isEmpty()) {
+            Integer p = file.firstIndex.get(en);
+            if (p != null) {
+                return p;
+            }
+        }
+        if (!cn.isEmpty()) {
+            WordHit hit = file.match(cn);
+            if (hit.type() != WordMatchType.MISS) {
+                return file.firstIndex.getOrDefault(hit.fileWord(), -1);
+            }
+        }
+        if (!en.isEmpty()) {
+            WordHit hit = file.match(en);
+            if (hit.type() != WordMatchType.MISS) {
+                return file.firstIndex.getOrDefault(hit.fileWord(), -1);
             }
         }
         return -1;
     }
 
-    private static double requiredCoverage(Set<String> fileSet, List<TemplateCandidate.FieldBrief> fields) {
+    private static double requiredCoverage(ScoreContext file, List<TemplateCandidate.FieldBrief> fields) {
         if (fields == null || fields.isEmpty()) {
             return 1d;
         }
@@ -259,7 +425,9 @@ public final class HeaderScorer {
             required++;
             String cn = canonicalForScore(f.cn());
             String en = canonicalForScore(f.en());
-            if ((!cn.isEmpty() && fileSet.contains(cn)) || (!en.isEmpty() && fileSet.contains(en))) {
+            boolean cnHit = !cn.isEmpty() && file.match(cn).type() != WordMatchType.MISS;
+            boolean enHit = !en.isEmpty() && file.match(en).type() != WordMatchType.MISS;
+            if (cnHit || enHit) {
                 hit++;
             }
         }

+ 6 - 0
ai-server/src/main/java/com/zsjz/ai/module/aiclean/service/AiCleanService.java

@@ -29,6 +29,7 @@ import com.zsjz.ai.module.aiclean.mapper.AiCleanFieldMapper;
 import com.zsjz.ai.module.aiclean.mapper.AiCleanJobMapper;
 import com.zsjz.ai.module.aiclean.mapper.AiCleanTemplateMapper;
 import com.zsjz.ai.module.aiclean.match.AiTemplateMatcher;
+import com.zsjz.ai.module.aiclean.match.HeaderScorer;
 import com.zsjz.ai.module.aiclean.model.AiMatchOutcome;
 import com.zsjz.ai.module.aiclean.model.AiRowCleanResult;
 import com.zsjz.ai.module.aiclean.model.AiRuleDraft;
@@ -262,6 +263,11 @@ public class AiCleanService {
         job.setHeaderRow(ev.primaryBlock().headerRow());
         job.setMatchedBy(outcome.matchedBy());
         job.setMatchTier(outcome.tier().name());
+        // 打分腿的逐列命中明细(EXACT/SIMILAR/MISS):每 sheet 一行,排障同义词与模糊规则效果
+        if (AiMatchOutcome.BY_SCORE.equals(outcome.matchedBy()) && outcome.candidate() != null) {
+            log.info("AI 模板打分明细: {} ← {}", HeaderScorer.matchDetail(headers, outcome.candidate()),
+                    outcome.candidate().nameCn());
+        }
         // 用量遥测:模型名/token 随判定写回 job(finish 时落库)—— 成本看板的原料(评审 P1-5)
         int llmCalls = outcome.llmCalled() ? 1 : 0;
         LlmResult llm = outcome.llm();

+ 178 - 0
ai-server/src/main/java/com/zsjz/ai/module/aiclean/service/AiCleanSynonymService.java

@@ -0,0 +1,178 @@
+package com.zsjz.ai.module.aiclean.service;
+
+import cn.hutool.core.util.StrUtil;
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import com.zsjz.ai.common.cache.GlobalCache;
+import com.zsjz.ai.common.exception.ServerException;
+import com.zsjz.ai.module.aiclean.entity.AiCleanSynonym;
+import com.zsjz.ai.module.aiclean.mapper.AiCleanSynonymMapper;
+import com.zsjz.ai.module.aiclean.support.HeaderNormalizer;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.transaction.support.TransactionSynchronization;
+import org.springframework.transaction.support.TransactionSynchronizationManager;
+
+import java.time.LocalDateTime;
+import java.util.Comparator;
+import java.util.List;
+
+/**
+ * 表头同义词库管理({@code ai_clean_synonym})。
+ *
+ * <p>词组纪律(落库前逐条校验,违反直接报错):
+ * <ul>
+ *   <li><b>全局唯一</b>:同一归一化词全表只允许出现一次(任一列)——
+ *       一个别名归两个组会让 {@code alias→std} 归一映射产生歧义;</li>
+ *   <li><b>不做链式传递</b>:别名必须直接指向标准词,A=B、B=C 不会推出 A=C,
+ *       新词必须显式指到组的标准词;</li>
+ *   <li>标准词与别名相同、任一词归一化后为空都是无意义配置,拒绝。</li>
+ * </ul>
+ *
+ * <p>任何写操作在<b>事务提交后</b>重建 {@link GlobalCache}(照
+ * {@code TableInfoService#refreshGlobalCacheAfterCommit} 范本):缓存是「构建完整快照后
+ * 整体替换引用」,重建失败旧快照仍可用,所以吞异常只记日志。同义词不进指纹与向量索引,
+ * 无需发 {@code TemplateMetaChangedEvent}。
+ *
+ * @author cc
+ * @since 2026/10/25
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class AiCleanSynonymService {
+
+    private final AiCleanSynonymMapper mapper;
+
+    /** 全部词条(含停用),按标准词+别名排序,管理页直接展示 */
+    public List<AiCleanSynonym> list() {
+        return mapper.selectList(Wrappers.emptyWrapper()).stream()
+                .sorted(Comparator.comparing(AiCleanSynonym::getStdWord,
+                                Comparator.nullsLast(Comparator.naturalOrder()))
+                        .thenComparing(AiCleanSynonym::getAliasWord,
+                                Comparator.nullsLast(Comparator.naturalOrder())))
+                .toList();
+    }
+
+    @Transactional
+    public AiCleanSynonym create(AiCleanSynonym row) {
+        if (row == null) {
+            throw ServerException.spe("词对不能为空");
+        }
+        row.setId(null);
+        applyWords(row);
+        assertWordsFree(row.getStdWord(), row.getAliasWord(), null);
+        row.setStatus(row.getStatus() == null ? 1 : row.getStatus());
+        row.setCreateTime(LocalDateTime.now());
+        row.setUpdateTime(row.getCreateTime());
+        mapper.insert(row);
+        refreshAfterCommit();
+        return row;
+    }
+
+    @Transactional
+    public AiCleanSynonym update(AiCleanSynonym row) {
+        if (row == null || row.getId() == null) {
+            throw ServerException.spe("id 不能为空");
+        }
+        AiCleanSynonym old = mapper.selectById(row.getId());
+        if (old == null) {
+            throw ServerException.spe("词条不存在: " + row.getId());
+        }
+        applyWords(row);
+        assertWordsFree(row.getStdWord(), row.getAliasWord(), row.getId());
+        old.setStdWord(row.getStdWord());
+        old.setAliasWord(row.getAliasWord());
+        old.setRemark(row.getRemark());
+        if (row.getStatus() != null) {
+            old.setStatus(row.getStatus());
+        }
+        old.setUpdateTime(LocalDateTime.now());
+        mapper.updateById(old);
+        refreshAfterCommit();
+        return old;
+    }
+
+    @Transactional
+    public void delete(Long id) {
+        if (id == null || mapper.selectById(id) == null) {
+            return;
+        }
+        mapper.deleteById(id);
+        refreshAfterCommit();
+    }
+
+    /** 启用/停用(停用词条保留在表里,评分不再使用) */
+    @Transactional
+    public void toggle(Long id, Integer status) {
+        if (id == null) {
+            throw ServerException.spe("id 不能为空");
+        }
+        AiCleanSynonym old = mapper.selectById(id);
+        if (old == null) {
+            throw ServerException.spe("词条不存在: " + id);
+        }
+        old.setStatus(Integer.valueOf(1).equals(status) ? 1 : 0);
+        old.setUpdateTime(LocalDateTime.now());
+        mapper.updateById(old);
+        refreshAfterCommit();
+    }
+
+    private void applyWords(AiCleanSynonym row) {
+        row.setStdWord(StrUtil.trimToEmpty(row.getStdWord()));
+        row.setAliasWord(StrUtil.trimToEmpty(row.getAliasWord()));
+    }
+
+    /**
+     * 全局唯一校验:同一归一化词(标准词或别名任一列)不得出现第二次。
+     * 词条量级是几十~几百,全量读回在内存比对(归一化逻辑只在 Java 侧有),不做 SQL 模糊匹配。
+     */
+    private void assertWordsFree(String stdWord, String aliasWord, Long excludeId) {
+        String nStd = HeaderNormalizer.normalize(stdWord);
+        String nAlias = HeaderNormalizer.normalize(aliasWord);
+        if (nStd.isEmpty() || nAlias.isEmpty()) {
+            throw ServerException.spe("标准词与别名都不能为空");
+        }
+        if (nStd.equals(nAlias)) {
+            throw ServerException.spe("标准词与别名相同,无需配置");
+        }
+        for (AiCleanSynonym r : mapper.selectList(Wrappers.emptyWrapper())) {
+            if (excludeId != null && excludeId.equals(r.getId())) {
+                continue;
+            }
+            String nE = HeaderNormalizer.normalize(r.getAliasWord());
+            String nS = HeaderNormalizer.normalize(r.getStdWord());
+            if (nAlias.equals(nE) || nAlias.equals(nS) || nStd.equals(nE) || nStd.equals(nS)) {
+                throw ServerException.spe(StrUtil.format(
+                        "词「{}」已存在于同义词组({} ← {}),一个词只能属于一个组",
+                        nAlias.equals(nE) || nAlias.equals(nS) ? nAlias : nStd, r.getStdWord(), r.getAliasWord()));
+            }
+        }
+    }
+
+    /**
+     * 事务提交后重建全局缓存;无事务上下文(单测/非事务调用)时立即执行。
+     * 失败只记日志 —— 缓存是快照整体替换,旧快照仍然可用。
+     */
+    private void refreshAfterCommit() {
+        if (TransactionSynchronizationManager.isSynchronizationActive()) {
+            TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
+                @Override
+                public void afterCommit() {
+                    rebuildQuietly();
+                }
+            });
+        } else {
+            rebuildQuietly();
+        }
+    }
+
+    private void rebuildQuietly() {
+        try {
+            GlobalCache.initCache();
+        } catch (Exception e) {
+            log.error("重建全局缓存失败(同义词新值暂不生效,不影响主流程): {}", e.getMessage());
+        }
+    }
+}

+ 2 - 1
ai-server/src/test/java/com/zsjz/ai/module/aiclean/AiCleanSchemaTest.java

@@ -43,7 +43,7 @@ class AiCleanSchemaTest {
             Path.of("../../sql/ai_clean.sql"));
 
     @Test
-    @DisplayName("4 个实体的列名与建表语句完全一致")
+    @DisplayName("6 个实体的列名与建表语句完全一致")
     void entitiesMatchDdl() throws IOException {
         String ddl = readScript();
         assertColumns("ai_clean_template", AiCleanTemplate.class, ddl);
@@ -51,6 +51,7 @@ class AiCleanSchemaTest {
         assertColumns("ai_clean_rule", AiCleanRule.class, ddl);
         assertColumns("ai_clean_job", AiCleanJob.class, ddl);
         assertColumns("ai_clean_batch", AiCleanBatch.class, ddl);
+        assertColumns("ai_clean_synonym", com.zsjz.ai.module.aiclean.entity.AiCleanSynonym.class, ddl);
     }
 
     @Test

+ 32 - 0
ai-server/src/test/java/com/zsjz/ai/module/aiclean/match/AiTemplateMatcherTest.java

@@ -181,6 +181,38 @@ class AiTemplateMatcherTest {
         assertEquals("c5", kept.get(1).getFieldNameEn());
     }
 
+    @Test
+    @DisplayName("模糊解析:绑定名与模板字段名相似且唯一命中时改写(通信类别→通信类型)")
+    void filterBindsResolvesSimilarName() {
+        var cand = new TemplateCandidate(1, false, 77, null, 2,
+                "通信记录", "comm_record", headers,
+                List.of(new TemplateCandidate.FieldBrief(
+                        "通信类型", "commType", "TEXT", false, 0, null)), 0.9);
+        // 无词条:通信类别 canonical 后仍不等于 通信类型,走相似解析(等长距 1)
+        RuleBind similar = bind(0, "通信类别");
+        List<RuleBind> kept = AiTemplateMatcher.filterBinds(List.of(similar), cand, headers.size());
+        assertEquals(1, kept.size(), "相似唯一命中应放行");
+        assertEquals("commType", kept.getFirst().getFieldNameEn(), "必须改写为规范英文名");
+    }
+
+    @Test
+    @DisplayName("模糊解析歧义:绑定名同时像 本方卡号/对方卡号 两个字段时丢弃,不替模型做主")
+    void filterBindsDropsAmbiguousSimilarName() {
+        var cand = new TemplateCandidate(1, false, 77, null, 2,
+                "转账记录", "trans_record", headers,
+                List.of(new TemplateCandidate.FieldBrief("本方卡号", "srcCardNo", "TEXT", false, 0, null),
+                        new TemplateCandidate.FieldBrief("对方卡号", "dstCardNo", "TEXT", false, 0, null)), 0.9);
+        // 无词条时「卡号」对两个字段都是后缀包含命中 → 歧义丢弃
+        RuleBind ambiguous = bind(0, "卡号");
+        assertTrue(AiTemplateMatcher.filterBinds(List.of(ambiguous), cand, headers.size()).isEmpty(),
+                "两个字段都相似时必须丢弃,改错方向的卡号会把脏数据写进业务表");
+        // 精确命中不受影响:模型回本方卡号时正常改写
+        RuleBind exact = bind(1, "本方卡号");
+        List<RuleBind> kept = AiTemplateMatcher.filterBinds(List.of(exact), cand, headers.size());
+        assertEquals(1, kept.size());
+        assertEquals("srcCardNo", kept.getFirst().getFieldNameEn());
+    }
+
     @Test
     @DisplayName("模型自评低置信:判 WEAK,绝不允许走出口 A 写既有业务表")
     void lowConfidenceCannotReachExistingTable() {

+ 87 - 3
ai-server/src/test/java/com/zsjz/ai/module/aiclean/match/HeaderScorerTest.java

@@ -1,26 +1,42 @@
 package com.zsjz.ai.module.aiclean.match;
 
+import com.zsjz.ai.common.cache.GlobalCache;
 import com.zsjz.ai.module.aiclean.model.ColStat;
 import com.zsjz.ai.module.aiclean.model.TemplateCandidate;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
 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.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
 /**
- * 表头打分与出口 A 闸门:同义词、列序、单位、形状、必填覆盖。
+ * 表头打分与出口 A 闸门:同义词、模糊相似词、列序、单位、形状、必填覆盖。
  *
  * <p>这些是确定性判据,模型判错的代价(写进用户既有业务表)不可逆,
- * 所以阈值和组合条件必须用测试钉住。
+ * 所以阈值和组合条件必须用测试钉住。同义词走 {@code GlobalCache.setSynonymsForTesting}
+ * 注入(生产由 ai_clean_synonym 表经 initCache 装载),模糊规则是纯代码行为。
  *
  * @author cc
  * @since 2026/10/22
  */
 class HeaderScorerTest {
 
+    @BeforeEach
+    void setUp() {
+        GlobalCache.clearForTesting();
+    }
+
+    @AfterEach
+    void tearDown() {
+        GlobalCache.clearForTesting();
+    }
+
     private static TemplateCandidate.FieldBrief field(String cn, String en, String type, boolean required) {
         return new TemplateCandidate.FieldBrief(cn, en, type, required, 0, null);
     }
@@ -48,8 +64,13 @@ class HeaderScorerTest {
     }
 
     @Test
-    @DisplayName("同义表头:户名/账户名称、发生额/交易金额、交易日期/日期")
+    @DisplayName("同义表头(词条注入):户名/账户名称、发生额/交易金额、交易日期/日期")
     void synonyms() {
+        // 生产由 ai_clean_synonym 装载;这里注入同款词对钉住缓存链路
+        GlobalCache.setSynonymsForTesting(Map.of(
+                "账户名称", "户名",
+                "发生额", "交易金额",
+                "交易日期", "日期"));
         List<String> file = List.of("户名", "发生额", "交易日期");
         TemplateCandidate c = cand(List.of("账户名称", "交易金额", "日期"), List.of(
                 field("账户名称", "ownerName", "TEXT", false),
@@ -60,6 +81,58 @@ class HeaderScorerTest {
         assertTrue(HeaderScorer.passesStrong(file, c, score, List.of()));
     }
 
+    @Test
+    @DisplayName("无词条时同义表头不命中(回归锚:同义词只来自配置,代码内无内置词对)")
+    void synonymsRequireEntries() {
+        List<String> file = List.of("户名", "发生额", "交易日期");
+        TemplateCandidate c = cand(List.of("账户名称", "交易金额", "日期"), List.of(
+                field("账户名称", "ownerName", "TEXT", false),
+                field("交易金额", "transAmount", "MONEY", false),
+                field("日期", "transDate", "DATE", false)));
+        double score = HeaderScorer.score(file, c);
+        assertTrue(score < HeaderScorer.STRONG, "无词条时不应判 STRONG,实际 " + score);
+        assertFalse(HeaderScorer.passesStrong(file, c, score, List.of()));
+    }
+
+    @Test
+    @DisplayName("模糊规则(全量参与):等长编辑距离、同前缀双替换、后缀包含都算命中")
+    void fuzzySimilarRules() {
+        // 等长编辑距离 1:通信类型 ↔ 通信类别
+        assertFuzzyStrong(List.of("通信类别"), "通信类型");
+        // 等长 + 同前缀 + 距离 2:车辆类型 ↔ 车辆种类
+        assertFuzzyStrong(List.of("车辆种类"), "车辆类型");
+        // 后缀包含:本方姓名 ⊃ 姓名
+        assertFuzzyStrong(List.of("本方姓名"), "姓名");
+        // 后缀包含:类型 ⊂ 车辆类型(文件少限定词)
+        assertFuzzyStrong(List.of("类型"), "车辆类型");
+    }
+
+    private static void assertFuzzyStrong(List<String> file, String tplHeader) {
+        TemplateCandidate c = cand(List.of(tplHeader), List.of(field(tplHeader, "f_" + tplHeader, "TEXT", false)));
+        double score = HeaderScorer.score(file, c);
+        assertTrue(score >= 0.90, "相似词 " + file + "≈" + tplHeader + " 应过短模板 STRONG,实际 " + score);
+        assertTrue(HeaderScorer.passesStrong(file, c, score, List.of()),
+                "相似词 " + file + "≈" + tplHeader + " 应允许直通(全量参与)");
+    }
+
+    @Test
+    @DisplayName("模糊规则不许碰的边界:金额(元)/金额(万元) 单位差异不得相似")
+    void fuzzyNeverCollapsesUnits() {
+        // 文件 金额 vs 模板 金额(元):金额是前缀不是后缀,不许包含命中
+        assertFuzzyMiss(List.of("金额"), "金额(元)");
+        // 金额元 vs 金额万元:长度不同,插入距离不许放行
+        assertFuzzyMiss(List.of("金额元"), "金额(万元)");
+        // 姓名 vs 名称:2 字等长距 1,低于等长规则的 3 字下限
+        assertFuzzyMiss(List.of("名称"), "姓名");
+    }
+
+    private static void assertFuzzyMiss(List<String> file, String tplHeader) {
+        TemplateCandidate c = cand(List.of(tplHeader), List.of(field(tplHeader, "f_" + tplHeader, "TEXT", false)));
+        double score = HeaderScorer.score(file, c);
+        assertTrue(score < HeaderScorer.WEAK,
+                file + " vs " + tplHeader + " 不得判相似,实际 " + score);
+    }
+
     @Test
     @DisplayName("列序变化:仍能排进候选,但短模板不直接判 STRONG")
     void reordered() {
@@ -107,4 +180,15 @@ class HeaderScorerTest {
         assertFalse(HeaderScorer.requiredCovered(file, c));
         assertFalse(HeaderScorer.passesStrong(file, c, score, List.of()));
     }
+
+    @Test
+    @DisplayName("匹配明细:EXACT/SIMILAR 逐列可读,排障用")
+    void matchDetailReadable() {
+        GlobalCache.setSynonymsForTesting(Map.of("通信类别", "通信类型"));
+        List<String> file = List.of("初次登记日期", "通信类别", "号牌号码");
+        TemplateCandidate c = cand(List.of("初次登记日期", "通信类型", "号牌种类"), List.of());
+        String detail = HeaderScorer.matchDetail(file, c);
+        assertEquals("#0 初次登记日期=EXACT | #1 通信类型=EXACT | #2 号牌种类=SIMILAR(≈号牌号码)", detail,
+                "通信类别经词条归一后应记 EXACT;号牌种类与号牌号码同前缀距 2 判 SIMILAR");
+    }
 }

+ 44 - 0
sql/ai_clean.sql

@@ -168,3 +168,47 @@ create table if not exists ai_clean_batch
 );
 create unique index if not exists uk_ai_clean_batch on ai_clean_batch (case_id, batch_id);
 create index if not exists idx_ai_clean_batch_stage on ai_clean_batch (stage);
+
+-- ----------------------------------------------------------------------------
+-- 6. 表头同义词库(评分归一 + 绑定域,2026-10-25)
+--
+--    星型词组:alias_word → std_word(一个标准词挂任意多别名)。
+--    只进 HeaderScorer 评分与 filterBinds 绑定域,绝不进 HeaderFingerprint 指纹 ——
+--    同义词一旦折叠指纹,存量模板 header_md5 会随词条配置漂移。
+--    约束:同一归一化词全表只允许出现一次(Service 校验,防归一歧义);不做链式传递。
+--    命中类型:EXACT(表内映射)优先;SIMILAR(包含/编辑距离,见 HeaderScorer.matchWord)
+--    为代码内模糊规则,不落库。
+-- ----------------------------------------------------------------------------
+create table if not exists ai_clean_synonym
+(
+    id          bigint primary key,
+    std_word    varchar(100) not null,          -- 标准词(组内规范形,如 交易金额)
+    alias_word  varchar(100) not null,          -- 别名(如 发生额)
+    remark      varchar(200),
+    status      smallint default 1,             -- 1 启用 / 0 停用
+    create_by   bigint,
+    create_time timestamp default current_timestamp,
+    update_time timestamp
+);
+create unique index if not exists uk_ai_clean_synonym on ai_clean_synonym (std_word, alias_word);
+
+-- 内置词条(与 2026-10-22 起硬编码在 HeaderScorer.SYNONYMS 的 24 条一致,去掉 6 条自映射)
+insert into ai_clean_synonym (id, std_word, alias_word, remark) values
+    (1,  '户名',     '账户名称', '内置:户名系'),
+    (2,  '户名',     '客户名称', '内置:户名系'),
+    (3,  '户名',     '姓名',     '内置:户名系'),
+    (4,  '交易金额', '发生额',   '内置:金额系'),
+    (5,  '交易金额', '交易额',   '内置:金额系'),
+    (6,  '交易金额', '金额',     '内置:金额系'),
+    (7,  '日期',     '交易日期', '内置:日期系'),
+    (8,  '时间',     '交易时间', '内置:时间系'),
+    (9,  '账号',     '卡号',     '内置:账号系'),
+    (10, '账号',     '银行账号', '内置:账号系'),
+    (11, '账号',     '账户',     '内置:账号系'),
+    (12, '电话',     '手机号',   '内置:电话系'),
+    (13, '电话',     '手机',     '内置:电话系'),
+    (14, '电话',     '联系电话', '内置:电话系'),
+    (15, '身份证号', '身份证',   '内置:证件系'),
+    (16, '身份证号', '身份证号码', '内置:证件系'),
+    (17, '身份证号', '证件号码', '内置:证件系')
+on conflict (std_word, alias_word) do nothing;

+ 42 - 0
sql/ai_clean_synonym_init.sql

@@ -0,0 +1,42 @@
+-- ============================================================================
+-- ai_clean_synonym:表头同义词库(2026-10-25)
+--
+-- 星型词组:alias_word → std_word。只进 AI 清洗的评分(HeaderScorer)与绑定域
+-- (AiTemplateMatcher.filterBinds),绝不进 HeaderFingerprint 指纹。
+-- 本脚本用于**已存在的**环境(建表 + 迁移原硬编码词条);新环境直接按 ai_clean.sql 建表即可。
+-- 运行期增删改走 /aiClean/synonym 管理接口,改完自动刷新缓存,无需重启。
+-- ============================================================================
+
+create table if not exists ai_clean_synonym
+(
+    id          bigint primary key,
+    std_word    varchar(100) not null,          -- 标准词(组内规范形,如 交易金额)
+    alias_word  varchar(100) not null,          -- 别名(如 发生额)
+    remark      varchar(200),
+    status      smallint default 1,             -- 1 启用 / 0 停用
+    create_by   bigint,
+    create_time timestamp default current_timestamp,
+    update_time timestamp
+);
+create unique index if not exists uk_ai_clean_synonym on ai_clean_synonym (std_word, alias_word);
+
+-- 内置词条(与 2026-10-22 起硬编码在 HeaderScorer.SYNONYMS 的 24 条一致,去掉 6 条自映射)
+insert into ai_clean_synonym (id, std_word, alias_word, remark) values
+    (1,  '户名',     '账户名称', '内置:户名系'),
+    (2,  '户名',     '客户名称', '内置:户名系'),
+    (3,  '户名',     '姓名',     '内置:户名系'),
+    (4,  '交易金额', '发生额',   '内置:金额系'),
+    (5,  '交易金额', '交易额',   '内置:金额系'),
+    (6,  '交易金额', '金额',     '内置:金额系'),
+    (7,  '日期',     '交易日期', '内置:日期系'),
+    (8,  '时间',     '交易时间', '内置:时间系'),
+    (9,  '账号',     '卡号',     '内置:账号系'),
+    (10, '账号',     '银行账号', '内置:账号系'),
+    (11, '账号',     '账户',     '内置:账号系'),
+    (12, '电话',     '手机号',   '内置:电话系'),
+    (13, '电话',     '手机',     '内置:电话系'),
+    (14, '电话',     '联系电话', '内置:电话系'),
+    (15, '身份证号', '身份证',   '内置:证件系'),
+    (16, '身份证号', '身份证号码', '内置:证件系'),
+    (17, '身份证号', '证件号码', '内置:证件系')
+on conflict (std_word, alias_word) do nothing;