Browse Source

fix(aiclean): 修复 AI 模板匹配错配与出口判定问题(F-01/F-01B/F-03B/F-05/F-06/F-07)

- F-01 HeaderScorer 增加文件侧覆盖率:文件列按「一列对一个模板词」匹配,计入
  表头分数并作为出口 A 硬门槛(FILE_COVERAGE_MIN=0.6);收紧等长相似词规则
  (差异尾部至少共享 1 个字),交易方向/交易金额等跨字段误配不再算相似。
  8 列模板对 11 列交行流水只有 6/11 有归宿,不再直通农商行模板
- F-01B PreDataListener 部分匹配增加覆盖率阈值:重叠列 ≥ 0.6×max(文件列数,
  模板列数) 且 ≥3 列,不足时保持 SHEET_TEMPLATE_MATCHED_FAIL 交 AI 裁决,
  导出话单/宽表不再被子模板(通信登记信息等)吞掉
- F-03B AiRecordCleaner 方向字典按「标准方向→别名」反向建表并按 JSON 缓存;
  缺失/坏字典时兜底归一为 收入/支出、主叫/被叫,不再把整串别名写进单元格
- F-05 AI 动态表中文名统一加 AI_ 前缀(幂等、≤200 字),处理日志与导入备注
  点名「AI 动态表 / 既有业务表」,治理树可直接区分
- F-06 二次 askLlm 的单候选重编号为 #1,并校验 templateNo:缺失/越界丢弃
  binds 转 NEED_REVIEW,原始草稿仍落库供复盘
- F-07 出口 A/B 的日志与备注显式区分;模型命中既有模板却被闸门挡下时写明
  原因(安全阈值不放松)
- GlobalCache 测试快照补齐 idNameZh/allFields/allMatchedFields,支持上传阶段
  模板匹配的真 xlsx 回归测试
- 补充 HeaderScorer/AiTemplateMatcher/AiRecordCleaner/表命名/智能清洗/
  PreDataListener 单测;bug 报告回填修复记录

F-01C、F-02、F-03、F-04、F-04B 按要求未修改。
cc 5 days ago
parent
commit
6396cac3c6

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

@@ -197,11 +197,34 @@ public class GlobalCache {
 
     /**
      * 仅供测试装配缓存快照(生产代码一律走 {@link #initCache()})。
+     *
+     * <p>除模板/字段主缓存外,也装配上传阶段模板匹配要用的三张派生表
+     * ({@code idNameZhMap / allFieldsMap / allMatchedFieldsMap})——
+     * 否则 {@code PreDataListener.matchTemplate} 在测试里永远看不到任何模板。
      */
     public static void loadSnapshotForTesting(Map<Integer, TableInfo> templates, Map<Integer, List<TableField>> fields) {
         templateTableMap = Collections.unmodifiableMap(new HashMap<>(templates));
         tableFieldCache = Collections.unmodifiableMap(new HashMap<>(fields));
         fieldCnMap = Collections.unmodifiableMap(buildFieldCnMap(tableFieldCache, Map.of()));
+        Map<Integer, String> idNameZh = new HashMap<>();
+        for (TableInfo t : templates.values()) {
+            if (t.getId() != null) {
+                idNameZh.put(t.getId(), StrUtil.nullToEmpty(t.getTableNameCn()));
+            }
+        }
+        idNameZhMap = Collections.unmodifiableMap(idNameZh);
+        Map<Integer, List<String>> allFields = new HashMap<>();
+        Map<Integer, List<String>> allMatched = new HashMap<>();
+        fields.forEach((tableId, list) -> {
+            List<TableField> tableFields = list == null ? List.of() : list;
+            allFields.put(tableId, tableFields.stream().map(TableField::getFieldNameCn)
+                    .filter(StrUtil::isNotBlank).toList());
+            allMatched.put(tableId, tableFields.stream()
+                    .filter(f -> Integer.valueOf(1).equals(f.getMatched()))
+                    .map(TableField::getFieldNameCn).filter(StrUtil::isNotBlank).toList());
+        });
+        allFieldsMap = Collections.unmodifiableMap(allFields);
+        allMatchedFieldsMap = Collections.unmodifiableMap(allMatched);
     }
 
     /**
@@ -221,6 +244,9 @@ public class GlobalCache {
         tableFieldCache = Map.of();
         fieldCnMap = Map.of();
         synonymMap = Map.of();
+        idNameZhMap = Map.of();
+        allFieldsMap = Map.of();
+        allMatchedFieldsMap = Map.of();
     }
 
     /**

+ 93 - 16
ai-server/src/main/java/com/zsjz/ai/module/aiclean/exec/AiRecordCleaner.java

@@ -12,9 +12,11 @@ import com.zsjz.ai.module.dm.clean.func.Fun;
 import lombok.extern.slf4j.Slf4j;
 
 import java.util.ArrayList;
+import java.util.HashMap;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
 import java.util.regex.Pattern;
 
 /**
@@ -44,6 +46,24 @@ public final class AiRecordCleaner {
     /** 失败记录里的值预览长度:只做超长截断,不做脱敏 */
     private static final int PREVIEW_LEN = 80;
 
+    /** 借贷标志的兜底方向:借/D/付 = 支出,贷/C/收 = 收入(与 {@code FunDebitCredit} 的标准一致) */
+    private static final Map<String, String> DEBIT_CREDIT_FALLBACK = Map.ofEntries(
+            Map.entry("借", "支出"), Map.entry("D", "支出"), Map.entry("d", "支出"),
+            Map.entry("借记", "支出"), Map.entry("付", "支出"), Map.entry("付款", "支出"),
+            Map.entry("支出", "支出"),
+            Map.entry("贷", "收入"), Map.entry("C", "收入"), Map.entry("c", "收入"),
+            Map.entry("贷记", "收入"), Map.entry("收", "收入"), Map.entry("收款", "收入"),
+            Map.entry("收入", "收入"));
+
+    /** 通话方向的兜底方向:呼出/主叫话单 = 主叫,呼入/被叫话单 = 被叫 */
+    private static final Map<String, String> CALL_DIRECTION_FALLBACK = Map.ofEntries(
+            Map.entry("主叫", "主叫"), Map.entry("呼出", "主叫"), Map.entry("主叫话单", "主叫"),
+            Map.entry("被叫", "被叫"), Map.entry("呼入", "被叫"), Map.entry("被叫话单", "被叫"));
+
+    /** 方向字典反向缓存:key 是 directionConf 原文(模板级有限集合),超限整体清空 */
+    private static final Map<String, Map<String, String>> DIRECTION_DICT_CACHE = new ConcurrentHashMap<>();
+    private static final int DIRECTION_DICT_CACHE_MAX = 256;
+
     private AiRecordCleaner() {
     }
 
@@ -121,7 +141,7 @@ public final class AiRecordCleaner {
             }
 
             if (isDictionary(type)) {
-                values.put(col.columnName(), translateDirection(col, content));
+                values.put(col.columnName(), translateDirection(col, type, content));
                 continue;
             }
 
@@ -150,7 +170,7 @@ public final class AiRecordCleaner {
         }
         String type = normalizeType(col.fieldType());
         if (isDictionary(type)) {
-            return translateDirection(col, v);
+            return translateDirection(col, type, v);
         }
         AiValueCoercer.Result coerced = AiValueCoercer.coerce(type, v);
         if (!coerced.isOk()) {
@@ -210,26 +230,83 @@ public final class AiRecordCleaner {
     }
 
     /**
-     * 借贷方向字典翻译。
+     * 方向字典翻译:把文件里各种别名(呼出/01/D/付…)归一成标准方向。
+     *
+     * <p><b>directionConf 的格式是「标准方向 → 逗号分隔别名」</b>,与
+     * {@code GlobalCache.directionConfMap} 的 {@code tableId_别名 → key} 同构:
+     * <pre>{"主叫":"主叫,短信发送话单,呼出,01,主叫话单","被叫":"被叫,短信接收话单,呼入,02,被叫话单"}</pre>
+     * 所以必须<b>反向建表</b>。旧实现直接 {@code dict.getOrDefault(v, v)},输入「主叫」会命中 key
+     * 并返回整串别名(F-03B:AI 表单元格里出现「主叫,短信发送话单,呼出,01,主叫话单」)。
+     *
+     * <p>字典缺失或没命中时走 {@link #fallbackDirection}:借贷/通话方向是有限词表,
+     * 归一成「收入/支出」「主叫/被叫」比原值落库更有价值;不认识的值仍然原样保留。
+     */
+    private static String translateDirection(CompiledColumn col, String type, String v) {
+        String translated = reverseDict(col.directionConf()).get(v);
+        if (translated != null) {
+            return translated;
+        }
+        return fallbackDirection(type, v);
+    }
+
+    /**
+     * 解析 {@code directionConf} 并反向成「别名/标准词 → 标准方向」。
      *
-     * <p><b>不查 {@code GlobalCache.directionConfMap()}</b>:那张表的 key 是
-     * {@code table_info.id + "_" + 原值}(TransStdDataCleaner:90),AI 模板不在 table_info 里,
-     * 拿不到也不会命中。改成读列自带的 {@code directionConf} JSON —— 出口 B 的字典自持,
-     * 不依赖也不污染既有缓存。
+     * <p>逐格解析 JSON 太贵(一行×一列一次),按 JSON 原文缓存:模板是有限集合,
+     * 缓存超过上限说明来源异常,整体丢弃重建即可。
      */
-    private static String translateDirection(CompiledColumn col, String v) {
-        if (StrUtil.isBlank(col.directionConf())) {
-            return v;
+    private static Map<String, String> reverseDict(String conf) {
+        if (StrUtil.isBlank(conf)) {
+            return Map.of();
         }
+        Map<String, String> cached = DIRECTION_DICT_CACHE.get(conf);
+        if (cached != null) {
+            return cached;
+        }
+        Map<String, String> parsed = parseReverseDict(conf);
+        if (DIRECTION_DICT_CACHE.size() >= DIRECTION_DICT_CACHE_MAX) {
+            DIRECTION_DICT_CACHE.clear();
+        }
+        DIRECTION_DICT_CACHE.putIfAbsent(conf, parsed);
+        return DIRECTION_DICT_CACHE.getOrDefault(conf, parsed);
+    }
+
+    private static Map<String, String> parseReverseDict(String conf) {
         try {
-            Map<String, String> dict = com.zsjz.ai.common.utils.Json.toCls(
-                    col.directionConf(), new com.fasterxml.jackson.core.type.TypeReference<Map<String, String>>() {
+            Map<String, String> standardToAliases = com.zsjz.ai.common.utils.Json.toCls(
+                    conf, new com.fasterxml.jackson.core.type.TypeReference<Map<String, String>>() {
                     });
-            return dict == null ? v : dict.getOrDefault(v, v);
+            if (standardToAliases == null || standardToAliases.isEmpty()) {
+                return Map.of();
+            }
+            Map<String, String> out = new HashMap<>();
+            standardToAliases.forEach((standard, aliases) -> {
+                String std = StrUtil.trimToNull(standard);
+                if (std == null) {
+                    return;
+                }
+                // 标准词本身也必须可命中:文件/模型经常直接写标准词
+                out.putIfAbsent(std, std);
+                for (String alias : StrUtil.splitTrim(StrUtil.nullToEmpty(aliases), ',')) {
+                    if (!alias.isEmpty()) {
+                        out.putIfAbsent(alias, std);
+                    }
+                }
+            });
+            return Map.copyOf(out);
         } catch (Exception e) {
-            // 字典格式坏了就原样入库,不要因为翻译把整行判成无效
-            log.error("AI 借贷字典解析失败: {}", e.getMessage());
-            return v;
+            // 字典格式坏了不能让整行判无效:记日志后交给兜底字典
+            log.error("AI 方向字典解析失败(按兜底方向翻译): {}", e.getMessage());
+            return Map.of();
         }
     }
+
+    private static String fallbackDirection(String type, String v) {
+        Map<String, String> fallback = switch (type) {
+            case "DEBIT_CREDIT_FLAG" -> DEBIT_CREDIT_FALLBACK;
+            case "CALL_DIRECTION" -> CALL_DIRECTION_FALLBACK;
+            default -> null;
+        };
+        return fallback == null ? v : fallback.getOrDefault(v, v);
+    }
 }

+ 33 - 5
ai-server/src/main/java/com/zsjz/ai/module/aiclean/match/AiTemplateMatcher.java

@@ -93,6 +93,34 @@ public class AiTemplateMatcher {
         return out;
     }
 
+    /**
+     * 单候选重编号为 {@code #1},用于「模板已定、只补列绑定」的第二次 {@code askLlm}。
+     *
+     * <p>提示词里的序号必须与后端校验口径一致:若沿用候选池里的原始序号(例如 #6),
+     * 模型回 6 看起来合法,但列表里只有 1 个元素;统一成 #1 后「只准回 1」才是可校验的约束。
+     */
+    public static List<TemplateCandidate> singleCandidate(TemplateCandidate c) {
+        return c == null ? List.of() : renumber(List.of(c));
+    }
+
+    /**
+     * 模型回的候选序号 → 候选。<b>序号缺失、≤0、越界一律返回 null</b>(调用方按未命中处理)。
+     *
+     * <p>这条规则对两次模型调用同等生效:第二次只送 1 个候选时,模型仍可能回一个越界序号
+     * (F-06 实测只送 1 个候选却回了 {@code templateNo=69});不能因为「只有一个候选」
+     * 就默认采信它的 binds,否则错绑会静默写进 AI 表。
+     */
+    public static TemplateCandidate candidateOf(List<TemplateCandidate> candidates, AiRuleDraft draft) {
+        if (draft == null || candidates == null || candidates.isEmpty() || draft.getTemplateNo() == null) {
+            return null;
+        }
+        int no = draft.getTemplateNo();
+        if (no <= 0 || no > candidates.size()) {
+            return null;
+        }
+        return candidates.get(no - 1);
+    }
+
     /**
      * @param ev         结构证据
      * @param aiOnline   AI 模板库里 ONLINE 的模板(含字段),用于腿 1 与腿 2
@@ -160,14 +188,13 @@ public class AiTemplateMatcher {
         if (draft == null || draft.getTemplateNo() == null || draft.getTemplateNo() <= 0) {
             return AiMatchOutcome.none(md5, ranked, true);
         }
-        int idx = draft.getTemplateNo() - 1;
-        if (idx >= shortList.size()) {
+        TemplateCandidate picked = candidateOf(shortList, draft);
+        if (picked == null) {
             // 越界即幻觉,按未命中处理,绝不落库
             log.info("AI 匹配返回了不存在的候选序号 {}(送模型 {} 个),按未命中处理",
                     draft.getTemplateNo(), shortList.size());
             return AiMatchOutcome.none(md5, ranked, true);
         }
-        TemplateCandidate picked = shortList.get(idx);
         double conf = draft.getConfidence() == null ? 0d : draft.getConfidence();
         // 模型自己承认在猜(低置信)时不许升级成 STRONG,也就进不了出口 A
         AiMatchTierEnum tier = conf >= LLM_MIN_CONFIDENCE ? AiMatchTierEnum.STRONG : AiMatchTierEnum.WEAK;
@@ -399,11 +426,12 @@ public class AiTemplateMatcher {
             return AiCleanExitEnum.B;
         }
         // 出口 A 会直接写用户既有业务表,必须同时满足:档位 STRONG、确定性分数过线、
-        // 必填覆盖、形状不矛盾。
+        // 必填覆盖、形状不矛盾、文件列大部分能落到模板上(否则写进去就是静默丢列)
         boolean strong = tier == AiMatchTierEnum.STRONG
                 && deterministicScore >= HeaderScorer.STRONG
                 && HeaderScorer.requiredCovered(headers, c)
-                && HeaderScorer.shapeSafe(headers, c, colStats);
+                && HeaderScorer.shapeSafe(headers, c, colStats)
+                && HeaderScorer.fileCoverage(headers, c) >= HeaderScorer.FILE_COVERAGE_MIN;
         return strong ? AiCleanExitEnum.A : AiCleanExitEnum.B;
     }
 

+ 108 - 5
ai-server/src/main/java/com/zsjz/ai/module/aiclean/match/HeaderScorer.java

@@ -9,6 +9,7 @@ import com.zsjz.ai.module.aiclean.support.HeaderNormalizer;
 
 import java.util.ArrayList;
 import java.util.HashMap;
+import java.util.Iterator;
 import java.util.LinkedHashMap;
 import java.util.LinkedHashSet;
 import java.util.List;
@@ -32,6 +33,12 @@ import java.util.Set;
  * 按需求(2026-10-25)SIMILAR 与 EXACT <b>同权全量参与</b>所有组件(含必填覆盖与出口 A 直通判定);
  * 出口 A 之后的抽样 verify、置信度放行线等运行时安全垫不变。
  *
+ * <p><b>2026-10-26 文件侧覆盖率</b>:旧的覆盖率只算「模板列被文件命中多少」,
+ * 文件多出来的列完全不扣分 —— 8 列模板对上 11 列表单也能拿满分,交行流水因此被判给
+ * 农商行模板、8 行数据因必填「本方卡号」缺失全部被拒(F-01)。现在表头组件加入
+ * {@link #fileCoverage}(文件列有多少能落到模板上),出口 A 另设
+ * {@link #FILE_COVERAGE_MIN} 硬门槛:模板再像,文件里大半列对不上也不能直通。
+ *
  * <p><b>指纹纪律</b>:同义词与模糊规则只进本类,{@link HeaderFingerprint} 仍用纯
  * {@link HeaderNormalizer#normalize} —— 不同表头被折叠成同一个 md5 会让指纹直通错配。
  *
@@ -46,6 +53,15 @@ public final class HeaderScorer {
     /** 弱匹配线:低于它说明表头几乎没有共同点 */
     public static final double WEAK = 0.55;
 
+    /**
+     * 出口 A 的文件侧覆盖率下限:文件列里至少这么大比例能落到模板上。
+     *
+     * <p>8 列模板对上 11 列表单时,即使模板列全部命中,也有 3 列数据无处可去;
+     * 写进既有业务表就是静默丢列(F-01 交行流水被判给 8 列农商行模板),
+     * 因此宁可降级给模型裁决。
+     */
+    public static final double FILE_COVERAGE_MIN = 0.6;
+
     private HeaderScorer() {
     }
 
@@ -77,7 +93,10 @@ public final class HeaderScorer {
         double coverage = (double) hit / tplSet.size();
         // 模糊感知的 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;
+        // 文件侧覆盖率:只算「模板列命中文件」会漏掉「文件列没有归宿」——模板越窄越占便宜。
+        // 两者一起进 header 组件,窄模板对上宽文件时分数会被明显拉低。
+        double fileCoverage = fileCoverage(file, cand);
+        double header = 0.60 * Math.min(1d, coverage) + 0.20 * jaccard + 0.20 * fileCoverage;
 
         double field = fieldCoverage(file, cand.fields());
         double order = orderScore(file, tplNames);
@@ -88,7 +107,8 @@ public final class HeaderScorer {
 
     /**
      * 出口 A 的确定性闸门:短模板要求更高分,且必填字段必须都能在文件表头里找到;
-     * 明显的类型矛盾(如 DATE 字段对上一列日期形状为金额)也不许出口 A。
+     * 明显的类型矛盾(如 DATE 字段对上一列日期形状为金额)也不许出口 A;
+     * 文件列大半落不到模板上也一样(见 {@link #FILE_COVERAGE_MIN})。
      */
     public static boolean passesStrong(List<String> headers, TemplateCandidate cand, double score,
                                        List<ColStat> colStats) {
@@ -98,10 +118,73 @@ public final class HeaderScorer {
         if (!shapeSafe(headers, cand, colStats)) {
             return false;
         }
+        if (fileCoverage(headers, cand) < FILE_COVERAGE_MIN) {
+            return false;
+        }
         int cols = canonicalNames(cand.headers()).size();
         return cols <= 3 ? score >= 0.90 : score >= STRONG;
     }
 
+    /**
+     * 文件侧覆盖率:文件里有多少列能在候选模板上找到<b>各自不同</b>的归宿(表头/字段名,精确或相似)。
+     *
+     * <p>与「模板列命中率」互为镜像 —— 只看后者时,模板列越少越容易拿满分,
+     * 一个 2 列的通用模板会把整张 30 列流水表全部吸走。
+     *
+     * <p>按「一列对一个模板词」做贪心匹配(先精确后相似):否则一个通用字段名
+     * (如「卡号」)会把 银行卡号/对方卡号/付款卡号… 全部算成有归宿,
+     * 而真正写库时只有一个文件列能落到那一列上。
+     */
+    public static double fileCoverage(List<String> headers, TemplateCandidate cand) {
+        return fileCoverage(ScoreContext.of(headers), cand);
+    }
+
+    private static double fileCoverage(ScoreContext file, TemplateCandidate cand) {
+        if (file.set.isEmpty() || cand == null) {
+            return 0d;
+        }
+        Set<String> tplWords = new LinkedHashSet<>(canonicalNames(cand.headers()));
+        if (cand.fields() != null) {
+            for (TemplateCandidate.FieldBrief f : cand.fields()) {
+                if (f == null) {
+                    continue;
+                }
+                String cn = canonicalForScore(f.cn());
+                String en = canonicalForScore(f.en());
+                if (!cn.isEmpty()) {
+                    tplWords.add(cn);
+                }
+                if (!en.isEmpty()) {
+                    tplWords.add(en);
+                }
+            }
+        }
+        if (tplWords.isEmpty()) {
+            return 0d;
+        }
+        Set<String> unused = new LinkedHashSet<>(tplWords);
+        List<String> pending = new ArrayList<>();
+        int covered = 0;
+        // 先精确匹配:避免模糊命中抢走另一个文件列的精确归宿
+        for (String fileWord : file.set) {
+            if (unused.remove(fileWord)) {
+                covered++;
+            } else {
+                pending.add(fileWord);
+            }
+        }
+        for (String fileWord : pending) {
+            for (Iterator<String> it = unused.iterator(); it.hasNext(); ) {
+                if (similarWords(it.next(), fileWord)) {
+                    it.remove();
+                    covered++;
+                    break;
+                }
+            }
+        }
+        return (double) covered / file.set.size();
+    }
+
     /** 形状与字段类型没有明显矛盾;没有样本/形状为 TEXT 时视为安全 */
     public static boolean shapeSafe(List<String> headers, TemplateCandidate cand, List<ColStat> colStats) {
         return shapeScore(cand.fields(), colStats, ScoreContext.of(headers)) >= 0.5;
@@ -166,8 +249,11 @@ public final class HeaderScorer {
      *       {@code 金额 ⊂ 金额元 ✗}(单位差异必须保持不同列)。</li>
      *   <li><b>等长编辑距离 ≤1</b>(双方 ≥3 字):{@code 通信类型 ↔ 通信类别}。
      *       只认等长替换,插入/删除会造成 金额元 ↔ 金额万元 这类单位混淆,不放行。</li>
-     *   <li><b>等长且共同前缀 ≥2、编辑距离 ≤2</b>(双方 ≥4 字):
-     *       {@code 车辆类型 ↔ 车辆种类}(两处尾部替换)。</li>
+     *   <li><b>等长且共同前缀 ≥2、编辑距离 ≤2、差异尾部至少共享 1 个字</b>(双方 ≥4 字):
+     *       {@code 车辆类型 ↔ 车辆种类}(尾部 类型/种类 共享「类」)。
+     *       共享尾字这条限制是 2026-10-26 为 F-01 加的:{@code 交易方向 ↔ 交易金额}、
+     *       {@code 交易网点 ↔ 交易金额} 同样「共同前缀 2 + 替换 2」,却是完全不同的列,
+     *       放行会把交行流水的「借贷方向」判成「交易金额」所在的模板。</li>
      * </ol>
      */
     static boolean similarWords(String a, String b) {
@@ -184,7 +270,24 @@ public final class HeaderScorer {
         if (la == lb && la >= 3 && editDistanceAtMost(a, b, 1)) {
             return true;
         }
-        return la == lb && la >= 4 && commonPrefixLen(a, b) >= 2 && editDistanceAtMost(a, b, 2);
+        if (la != lb || la < 4) {
+            return false;
+        }
+        int common = commonPrefixLen(a, b);
+        return common >= 2
+                && editDistanceAtMost(a, b, 2)
+                && tailsShareChar(a, b, common);
+    }
+
+    /** 等长词在共同前缀之后的部分是否至少共享一个字(防「交易方向↔交易金额」这类跨字段误配) */
+    private static boolean tailsShareChar(String a, String b, int commonPrefix) {
+        String tailB = b.substring(commonPrefix);
+        for (int i = commonPrefix; i < a.length(); i++) {
+            if (tailB.indexOf(a.charAt(i)) >= 0) {
+                return true;
+            }
+        }
+        return false;
     }
 
     /** 编辑距离是否 ≤ k(DP,表头词都很短,无需滚动数组优化) */

+ 48 - 12
ai-server/src/main/java/com/zsjz/ai/module/aiclean/service/AiCleanService.java

@@ -335,16 +335,19 @@ public class AiCleanService {
         }
         // 选模板那次调用已经把列绑定一起返回了,复用它:单次模型调用实测就要几十秒,
         // 为了拿 binds 再打一次等于把延迟和 token 直接翻倍
-        AiTemplateMatcher.LlmAnswer answer = outcome.draft() != null
+        boolean bindOnlyCall = outcome.draft() == null;
+        // 二次调用只送唯一候选,且必须重编号成 #1 —— 否则模型回原始序号(如 #6)时后端没法判断合法性
+        List<TemplateCandidate> asked = AiTemplateMatcher.singleCandidate(outcome.candidate());
+        AiTemplateMatcher.LlmAnswer answer = !bindOnlyCall
                 ? new AiTemplateMatcher.LlmAnswer(outcome.draft(), outcome.llm())
-                : matcher.askLlm(ev, headers, List.of(outcome.candidate()));
+                : matcher.askLlm(ev, headers, asked);
         AiRuleDraft draft = answer == null ? null : answer.draft();
         if (draft == null) {
             // 调用发生但没拿到草稿(含重试耗尽):这次尝试也计入调用数
             job.setLlmCalls(llmCalls + 1);
             return new AiJudgeResult.Failed("模型未返回可解析的规则草稿");
         }
-        if (outcome.draft() == null) {
+        if (bindOnlyCall) {
             // 第二次模型调用(腿 1/2 确定性命中但没有 binds 的场景):次数与用量都要补记
             llmCalls++;
             if (answer.usage() != null) {
@@ -354,8 +357,18 @@ public class AiCleanService {
             }
         }
         job.setLlmCalls(llmCalls);
-        // 模型原始草稿落库:复盘「哪一版 prompt 判的」以及给后续提示词回归集当语料(评审 P1-5)
+        // 模型原始草稿落库:复盘「哪一版 prompt 判的」以及给后续提示词回归集当语料(评审 P1-5)。
+        // 放在序号校验之前:越界序号这类异常现场必须能回查到
         job.setDraft(StrUtil.maxLength(Json.toStr(draft), 4000));
+        // F-06:只送了 1 个候选,模型却回了越界/缺失序号(实测回过 69),说明它没按
+        // 「只准回候选序号」的约束作答;此时 binds 同样不可信,宁可转人工也不采信
+        if (bindOnlyCall && AiTemplateMatcher.candidateOf(asked, draft) == null) {
+            log.error("AI 匹配二次调用只送 1 个候选,模型却返回 templateNo={},丢弃 binds 转人工: jobId={}",
+                    draft.getTemplateNo(), job.getId());
+            return new AiJudgeResult.NeedReview(StrUtil.format(
+                    "模型返回的候选序号 {} 与唯一候选不一致,已丢弃其列绑定",
+                    draft.getTemplateNo()));
+        }
         List<RuleBind> binds = AiTemplateMatcher.filterBinds(draft.getBinds(), outcome.candidate(),
                 headers.size());
         if (binds.isEmpty()) {
@@ -367,7 +380,13 @@ public class AiCleanService {
             // 不再用置信度/必填覆盖率拦截(2026-10-26 需求)。
             job.setConfidence(draft.getConfidence() == null ? null
                     : java.math.BigDecimal.valueOf(draft.getConfidence()));
-            log.info("AI 动态表:按模型给出的模板与列绑定入库: templateId={}, binds={}",
+            if (outcome.candidate() != null && !outcome.candidate().fromAiLib()) {
+                // F-07:模型已命中既有模板却落到出口 B,必须留痕说明是哪道闸门没过,
+                // 否则用户看到「命中模板」却在结果里得到一张新表
+                log.info("出口 B:既有模板「{}」未过出口 A 闸门(档位/置信度、确定性分数、必填覆盖、"
+                        + "形状、文件列覆盖),改写入 AI 动态表: jobId={}", outcome.candidate().nameCn(), job.getId());
+            }
+            log.info("AI 动态表:按模型给出的模板与列绑定入库(出口 B): templateId={}, binds={}",
                     outcome.candidate().aiTemplateId(), binds.size());
             return new AiJudgeResult.AiPlan(ev, binds, outcome.candidate(), draft, headers);
         }
@@ -393,6 +412,8 @@ public class AiCleanService {
         // main_id / func_regex / line_no 都是既有链路要用的字段(瞬态或上传时未落库),
         // 少带一个就会在 StreamingEtlListener 里 NPE 或 fromCode(null) 抛异常
         TableInfo hit = GlobalCache.templateTableMap().get(outcome.candidate().templateId());
+        log.info("出口 A:写入既有业务表: jobId={}, templateId={}, template={}",
+                job.getId(), outcome.candidate().templateId(), outcome.candidate().nameCn());
         return new AiJudgeResult.ExistingHit(job.getSheetId(), outcome.candidate().templateId(),
                 hit == null ? outcome.candidate().mainId() : hit.getMainId(),
                 hit == null ? null : hit.getFuncRegx(),
@@ -837,6 +858,10 @@ public class AiCleanService {
      *   <li>sheet 名称缺失 → 「文件名称 + sheetN」,N 为该 sheet 在文件内的序号(从 1 开始);</li>
      *   <li>CSV → 整份文件就是一张表,用文件名称。</li>
      * </ul>
+     *
+     * <p>2026-10-26(F-05):所有 AI 动态表统一加 {@value #AI_TABLE_NAME_PREFIX} 前缀。
+     * 出口 B 的表名此前直接取 sheet 名,常与既有业务表同名(「银行交易流水」「通信记录」),
+     * 用户从处理日志/治理树分不清数据进了既有表还是 AI 表。
      */
     private String resolveAiTableNameCn(FileInfo root, FileInfo sheet) {
         String fileType = root == null ? null : root.getFileType();
@@ -851,20 +876,31 @@ public class AiCleanService {
         return !"CSV".equalsIgnoreCase(StrUtil.trim(fileType)) && StrUtil.trimToNull(sheetName) == null;
     }
 
+    /** AI 动态表中文名的统一前缀:与用户既有业务表一眼区分(治理树、导入文件备注、处理日志共用) */
+    static final String AI_TABLE_NAME_PREFIX = "AI_";
+
     /**
      * 纯命名规则(抽出静态方法便于单测):
-     * CSV 用文件名称;有 sheet 名用 sheet 名;否则「文件名称 + sheetN」。
+     * CSV 用文件名称;有 sheet 名用 sheet 名;否则「文件名称 + sheetN」;最后统一加 {@code AI_} 前缀。
      */
     static String buildTableNameCn(String fileType, String fileName, String sheetName, int sheetOrdinal) {
         String displayName = displayFileName(fileName);
+        String base;
         if ("CSV".equalsIgnoreCase(StrUtil.trim(fileType))) {
-            return fitNameCn(displayName);
-        }
-        String trimmedSheetName = StrUtil.trimToNull(sheetName);
-        if (trimmedSheetName != null) {
-            return fitNameCn(trimmedSheetName);
+            base = displayName;
+        } else {
+            String trimmedSheetName = StrUtil.trimToNull(sheetName);
+            base = trimmedSheetName != null ? trimmedSheetName
+                    : displayName + "sheet" + Math.max(1, sheetOrdinal);
         }
-        return fitNameCn(displayName + "sheet" + Math.max(1, sheetOrdinal));
+        String named = StrUtil.blankToDefault(StrUtil.trimToEmpty(base), "未命名表");
+        return fitNameCn(withAiPrefix(named));
+    }
+
+    /** 幂等加 AI 表前缀:sheet 名本身已带 {@code AI_} 时不重复叠加 */
+    static String withAiPrefix(String name) {
+        String value = StrUtil.trimToEmpty(name);
+        return value.startsWith(AI_TABLE_NAME_PREFIX) ? value : AI_TABLE_NAME_PREFIX + value;
     }
 
     /** 文件显示名:去掉落盘时的雪花 ID 前缀(如 {@code 2109826345177022464_})与扩展名 */

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

@@ -500,13 +500,11 @@ public class AiSmartCleanService {
                 p.job().setExitCode(com.zsjz.ai.module.aiclean.enums.AiCleanExitEnum.A.name());
                 aiCleanService.finish(p.job(), AiCleanStatusEnum.DISPATCHED_A, null, System.currentTimeMillis());
                 bump(batchId, "existingHit");
-                pushLog(sheetLabel(p) + " → 命中既有模板「" + hit.nameCn() + "」,转入既有清洗");
+                pushLog(sheetLabel(p) + " → 命中既有模板「" + hit.nameCn() + "」,走出口 A 写入既有业务表");
             }
             case AiJudgeResult.AiPlan plan -> {
                 plans.put(p.job().getId(), plan);
-                String target = plan.candidate() == null ? "未匹配到模板,按表头新建表"
-                        : "命中模板「" + plan.candidate().nameCn() + "」" + matchReason(p.job()) + ",准备入库";
-                pushLog(sheetLabel(p) + " → " + target);
+                pushLog(sheetLabel(p) + " → " + aiPlanLabel(plan) + matchReason(p.job()));
             }
             case AiJudgeResult.NeedReview r -> {
                 aiCleanService.finish(p.job(), AiCleanStatusEnum.NEED_REVIEW, r.reason(),
@@ -637,9 +635,29 @@ public class AiSmartCleanService {
         }
     }
 
+    /**
+     * 出口 B 的目标说明(F-07):模型明明选中了既有模板却最终建了 AI 表时,
+     * 必须把「闸门没过」的原因写进处理日志 —— 否则用户只看到「命中模板」,
+     * 却在结果页发现数据进了一张新表。
+     */
+    private static String aiPlanLabel(AiJudgeResult.AiPlan plan) {
+        var cand = plan.candidate();
+        if (cand == null) {
+            return "未匹配到模板,走出口 B 按表头新建 AI 动态表";
+        }
+        if (cand.fromAiLib()) {
+            return "命中 AI 模板「" + cand.nameCn() + "」,走出口 B 写入 AI 动态表";
+        }
+        return "模型命中既有模板「" + cand.nameCn()
+                + "」,但出口 A 闸门(档位/置信度、必填覆盖、确定性分数、文件列覆盖)未通过,"
+                + "改走出口 B 写入 AI 动态表";
+    }
+
     private void loadPlan(Long batchId, PendingAi p, AiJudgeResult.AiPlan plan, Stats st) {
         long start = System.currentTimeMillis();
-        String target = plan.candidate() == null ? "新建表" : "表「" + plan.candidate().nameCn() + "」";
+        String source = plan.candidate() == null ? "按表头新建"
+                : plan.candidate().fromAiLib() ? "AI 模板「" + plan.candidate().nameCn() + "」"
+                : "既有模板「" + plan.candidate().nameCn() + "」";
         try {
             long written = aiCleanService.loadViaAiTemplate(p.job(), p.root(), p.sheet(), plan);
             if (written < 0) {
@@ -647,7 +665,7 @@ public class AiSmartCleanService {
                 st.failed.incrementAndGet();
                 bump(batchId, "failed");
                 bumpRows(batchId, 0, nz(p.job().getRowRejected()), nz(p.job().getRowUnprocessed()));
-                pushLog(sheetLabel(p) + " → 入库失败:" + target);
+                pushLog(sheetLabel(p) + " → 入库失败(AI 动态表 · " + source + ")");
                 return;
             }
             aiCleanService.finish(p.job(), AiCleanStatusEnum.SUCCESS, null, start);
@@ -657,7 +675,7 @@ public class AiSmartCleanService {
                 bump(batchId, "selfBuilt");
             }
             bumpRows(batchId, nz(p.job().getRowLoaded()), nz(p.job().getRowRejected()), 0);
-            pushLog(sheetLabel(p) + " → 已写入 " + written + " 行(" + target + ")");
+            pushLog(sheetLabel(p) + " → 已写入 " + written + " 行(AI 动态表 · " + source + ")");
         } catch (Exception e) {
             log.error("AI 入库异常: jobId={}", p.job().getId(), e);
             aiCleanService.finish(p.job(), AiCleanStatusEnum.FAIL, StrUtil.maxLength(e.getMessage(), 900), start);

+ 42 - 1
ai-server/src/main/java/com/zsjz/ai/module/dm/pre/PreDataListener.java

@@ -44,6 +44,17 @@ import java.util.stream.Collectors;
 @Slf4j
 public class PreDataListener extends AnalysisEventListener<Map<Integer, String>> {
 
+    /**
+     * 上传阶段「部分匹配」的最低覆盖率:重叠列至少要覆盖文件/模板中较大一方的这个比例。
+     *
+     * <p>完全匹配(表头逐字相同/模板包含全部表头)不受这条限制;只有「文件比模板宽或模板有缺失列」
+     * 的部分匹配才需要它,防止 matched 字段极少的子模板先到先得。
+     */
+    private static final double PARTIAL_MATCH_MIN_RATIO = 0.6;
+
+    /** 重叠列的绝对下限:再小的模板(1~2 列)也不允许只靠 1 列就直通 */
+    private static final int PARTIAL_MATCH_MIN_COLUMNS = 3;
+
     /**
      * 文件信息列表,存储当前文件及其工作表的元数据
      */
@@ -389,8 +400,19 @@ public class PreDataListener extends AnalysisEventListener<Map<Integer, String>>
                     int mustSize = headVals.size() - tempHeadVal.size();
                     // 计算剩余字段的交集
                     Collection<String> intersection = CollUtil.intersection(tempFields, tempHeadVal);
+                    int overlap = intersection.size() + mustSize;
+                    // 部分匹配必须有覆盖率下限:否则「matched 字段只有 2 个」的通用模板
+                    // 会把整张话单/开户表吞掉(F-01B:导出话单 11 列直中「通信登记信息」模板 3,
+                    // 9 列数据静默丢失;IND-03 20 列直中 6 列子模板)。覆盖率不足时不下结论,
+                    // 让 sheet 保持 SHEET_TEMPLATE_MATCHED_FAIL,交给智能清洗的 AI 模板匹配裁决。
+                    int templateCols = tempFields.size() + tempMatchedFields.size();
+                    if (!partialMatchCovers(overlap, headVals.size(), templateCols)) {
+                        log.debug("模板{}部分匹配覆盖率不足(重叠 {},文件 {} 列,模板 {} 列),交由 AI 判定",
+                                templateId, overlap, headVals.size(), templateCols);
+                        continue;
+                    }
                     // 记录模板 ID 和匹配度(交集数量 + 必填字段数量)
-                    matchTemplateMap.put(templateId, intersection.size() + mustSize);
+                    matchTemplateMap.put(templateId, overlap);
                 }
             }
         }
@@ -401,6 +423,25 @@ public class PreDataListener extends AnalysisEventListener<Map<Integer, String>>
         return templateIds.isEmpty() ? null : templateIds.getFirst();
     }
 
+    /**
+     * 部分匹配的覆盖率是否足够直通。
+     *
+     * <p>分母取「文件列数 / 模板列数」中较大的一方:只看其中一边时,另一边的巨大缺口会被藏住
+     * (文件 20 列对 6 列子模板、或 11 列宽文件对 8 列模板,都会拿满分)。
+     *
+     * @param overlap      与模板的重叠列数(含已命中的必填字段)
+     * @param fileCols     文件表头列数
+     * @param templateCols 模板全部字段数
+     */
+    static boolean partialMatchCovers(int overlap, int fileCols, int templateCols) {
+        if (overlap <= 0 || fileCols <= 0 || templateCols <= 0) {
+            return false;
+        }
+        int floor = Math.min(PARTIAL_MATCH_MIN_COLUMNS, Math.min(fileCols, templateCols));
+        int needed = (int) Math.ceil(Math.max(fileCols, templateCols) * PARTIAL_MATCH_MIN_RATIO);
+        return overlap >= Math.max(floor, needed);
+    }
+
     /**
      * 表格规则列表构建工具方法
      */

+ 4 - 2
ai-server/src/main/java/com/zsjz/ai/module/dm/service/DmService.java

@@ -1082,14 +1082,16 @@ public class DmService {
             case "NEED_REVIEW", "FAIL" -> "清洗失败:" + StrUtil.blankToDefault(reason, "原因见处理日志");
             case "SKIP" -> "已跳过:" + StrUtil.blankToDefault(reason, "源文件不可用");
             default -> {
+                // 出口 A/B 的措辞要能一眼分辨(F-07):A 写用户既有业务表,B 新建 AI 动态表,
+                // 两者表名可能相同(旧版本甚至完全重名),所以备注里必须点名目标
                 if ("A".equals(job.getExitCode())) {
                     String name = job.getRefTemplateId() == null ? null
                             : GlobalCache.idNameZhMap().get(job.getRefTemplateId());
-                    yield "AI 匹配模板:" + StrUtil.blankToDefault(name,
+                    yield "AI 匹配既有模板:" + StrUtil.blankToDefault(name,
                             job.getRefTemplateId() == null ? "未知模板" : String.valueOf(job.getRefTemplateId()));
                 }
                 if ("B".equals(job.getExitCode())) {
-                    yield "AI 新建表:" + StrUtil.blankToDefault(
+                    yield "AI 新建动态表:" + StrUtil.blankToDefault(
                             aiTableName(job.getAiTemplateId(), aiTableNames),
                             job.getAiTemplateId() == null
                                     ? "AI 表"

+ 27 - 7
ai-server/src/test/java/com/zsjz/ai/module/aiclean/exec/AiRecordCleanerTest.java

@@ -106,16 +106,36 @@ class AiRecordCleanerTest {
     }
 
     @Test
-    @DisplayName("借贷字典取列自带的 directionConf,坏 JSON 不抛异常")
+    @DisplayName("方向字典按「标准 → 别名」反向翻译;缺失/坏字典走兜底方向,不整串落库")
     void directionDict() {
-        CompiledColumn ok = new CompiledColumn(0, "flag", "flag", "DEBIT_CREDIT_FLAG", false, 0,
-                List.of(), "{\"借\":\"D\",\"贷\":\"C\"}");
-        assertEquals("C", AiRecordCleaner.toTypedValue(ok, "贷", null));
+        // table_field.direction_conf 的真实格式:标准方向 → 逗号分隔别名
+        CompiledColumn call = new CompiledColumn(0, "dir", "dir", "CALL_DIRECTION", false, 0,
+                List.of(), "{\"主叫\":\"主叫,短信发送话单,呼出,01,主叫话单\","
+                + "\"被叫\":\"被叫,短信接收话单,呼入,02,被叫话单\"}");
+        // F-03B:输入等于标准词时必须返回标准词,不能返回整串别名
+        assertEquals("主叫", AiRecordCleaner.toTypedValue(call, "主叫", null));
+        assertEquals("主叫", AiRecordCleaner.toTypedValue(call, "呼出", null));
+        assertEquals("被叫", AiRecordCleaner.toTypedValue(call, "02", null));
+
+        CompiledColumn debit = new CompiledColumn(1, "flag", "flag", "DEBIT_CREDIT_FLAG", false, 0,
+                List.of(), "{\"支出\":\"借,D\",\"收入\":\"贷,C\"}");
+        assertEquals("支出", AiRecordCleaner.toTypedValue(debit, "D", null));
+        assertEquals("收入", AiRecordCleaner.toTypedValue(debit, "贷", null));
         // 字典里没有的值原样保留,不判错
-        assertEquals("其他", AiRecordCleaner.toTypedValue(ok, "其他", null));
-        CompiledColumn broken = new CompiledColumn(0, "flag", "flag", "DEBIT_CREDIT_FLAG", false, 0,
+        assertEquals("其他", AiRecordCleaner.toTypedValue(debit, "其他", null));
+
+        // 无字典(账单 AI 表):兜底归一成 收入/支出
+        CompiledColumn noDict = new CompiledColumn(2, "flag", "flag", "DEBIT_CREDIT_FLAG", false, 0,
+                List.of(), null);
+        assertEquals("支出", AiRecordCleaner.toTypedValue(noDict, "借", null));
+        assertEquals("收入", AiRecordCleaner.toTypedValue(noDict, "C", null));
+        assertEquals("收入", AiRecordCleaner.toTypedValue(noDict, "收", null));
+        assertEquals("支出", AiRecordCleaner.toTypedValue(noDict, "付款", null));
+
+        // 坏 JSON 不抛异常:仍按兜底方向翻译
+        CompiledColumn broken = new CompiledColumn(3, "flag", "flag", "DEBIT_CREDIT_FLAG", false, 0,
                 List.of(), "{不是 json");
-        assertEquals("贷", AiRecordCleaner.toTypedValue(broken, "贷", null));
+        assertEquals("收入", AiRecordCleaner.toTypedValue(broken, "贷", null));
     }
 
     @Test

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

@@ -265,6 +265,29 @@ class AiTemplateMatcherTest {
                 "模型自评高不能单独决定出口 A;必须确定性分数 + 必填覆盖都过线");
     }
 
+    @Test
+    @DisplayName("F-06 单候选重编号为 #1;越界/缺失序号一律映射为 null")
+    void singleCandidateMustAnswerNumberOne() {
+        var cand = new TemplateCandidate(6, false, 77, null, 2, "交易流水", "trans_record", headers,
+                List.of(), 0.9);
+        List<TemplateCandidate> asked = AiTemplateMatcher.singleCandidate(cand);
+        assertEquals(1, asked.getFirst().no(), "二次调用只送 1 个候选时必须重编号为 #1");
+        assertEquals(77, asked.getFirst().templateId());
+
+        AiRuleDraft ok = new AiRuleDraft();
+        ok.setTemplateNo(1);
+        assertEquals(77, AiTemplateMatcher.candidateOf(asked, ok).templateId());
+
+        AiRuleDraft outOfRange = new AiRuleDraft();
+        outOfRange.setTemplateNo(69);
+        assertNull(AiTemplateMatcher.candidateOf(asked, outOfRange),
+                "只送 1 个候选却回 69:模型没按约束作答,binds 不可采信");
+
+        AiRuleDraft missing = new AiRuleDraft();
+        assertNull(AiTemplateMatcher.candidateOf(asked, missing));
+        assertNull(AiTemplateMatcher.candidateOf(List.of(), ok));
+    }
+
     @Test
     @DisplayName("向量召回:把 header 分挤不进 top-K 的 AI 模板带进 shortList,并按 #1 对齐")
     void recallBringsSemanticTemplateIntoShortList() {

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

@@ -185,10 +185,66 @@ class HeaderScorerTest {
     @DisplayName("匹配明细:EXACT/SIMILAR 逐列可读,排障用")
     void matchDetailReadable() {
         GlobalCache.setSynonymsForTesting(Map.of("通信类别", "通信类型"));
-        List<String> file = List.of("初次登记日期", "通信类别", "号牌号码");
+        // 号牌类别 vs 号牌种类:同前缀距 2,差异尾部共享「类」→ 仍判 SIMILAR;
+        // 号牌号码差在「号码」与「种类」毫无共同字,2026-10-26 起按跨字段误配拦截(F-01)
+        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");
+        assertEquals("#0 初次登记日期=EXACT | #1 通信类型=EXACT | #2 号牌种类=SIMILAR(≈号牌类别)", detail,
+                "通信类别经词条归一后应记 EXACT;号牌种类与号牌类别同前缀距 2 判 SIMILAR");
+    }
+
+    @Test
+    @DisplayName("F-01 相似词收紧:交易方向/交易金额、交易网点/交易金额 不得判相似")
+    void fuzzyNeverCollapsesDifferentBusinessTokens() {
+        // 都是「等长 + 共同前缀 2 + 替换 2」,但差异尾部(方向/金额/网点)毫无共同字,
+        // 旧规则把它们判成 SIMILAR,交行流水的「借贷方向」因此被算进农商行模板
+        assertFuzzyMiss(List.of("交易金额"), "交易方向");
+        assertFuzzyMiss(List.of("交易金额"), "交易网点");
+        assertFuzzyMiss(List.of("交易日期"), "交易时间");
+        // 既有业务词对仍放行:尾部 类型/种类 共享「类」
+        assertFuzzyStrong(List.of("车辆种类"), "车辆类型");
+    }
+
+    @Test
+    @DisplayName("F-01 窄模板对宽文件:文件列覆盖率不足时禁止出口 A")
+    void narrowTemplateCannotHijackWideFile() {
+        GlobalCache.setSynonymsForTesting(Map.of(
+                "借贷方向", "交易方向",
+                "发生额", "交易金额",
+                "交易日期", "日期"));
+        List<String> file = List.of("银行卡号", "借贷方向", "发生额", "交易日期", "交易时间", "姓名",
+                "对方户名", "对方帐号", "余额", "对方金融机构网点名称", "备注");
+        // 农商行交易流水:8 列模板
+        TemplateCandidate narrow = cand(
+                List.of("交易方向", "交易金额", "日期", "交易网点", "对方户名", "对方账号", "交易名称", "余额"),
+                List.of(field("交易方向", "transDirection", "DEBIT_CREDIT_FLAG", true),
+                        field("交易金额", "transAmount", "MONEY", true),
+                        field("日期", "transDate", "DATE", true),
+                        field("交易网点", "transOrg", "TEXT", false),
+                        field("对方户名", "otherName", "TEXT", false),
+                        field("对方账号", "otherAccount", "TEXT", false),
+                        field("交易名称", "transName", "TEXT", false),
+                        field("余额", "balance", "MONEY", false)));
+        // 只有 6/11 列有归宿:银行卡号/姓名/备注等 5 列在模板里无处可放
+        assertEquals(6d / 11d, HeaderScorer.fileCoverage(file, narrow), 1e-9);
+        assertTrue(HeaderScorer.fileCoverage(file, narrow) < HeaderScorer.FILE_COVERAGE_MIN);
+        assertFalse(HeaderScorer.passesStrong(file, narrow, HeaderScorer.score(file, narrow), List.of()),
+                "8 列模板对上 11 列交行流水不能直通出口 A(否则 8 行全被必填拒绝)");
+    }
+
+    @Test
+    @DisplayName("F-01 宽模板覆盖整份文件:文件列覆盖率满,出口 A 正常放行")
+    void wideTemplateCoversWholeFile() {
+        List<String> file = List.of("银行卡号", "借贷方向", "发生额", "交易日期",
+                "对方户名", "对方帐号", "余额");
+        TemplateCandidate wide = cand(file, List.of(
+                field("银行卡号", "transCardNo", "ACCT_CARD_NO", true),
+                field("借贷方向", "transDirection", "DEBIT_CREDIT_FLAG", true),
+                field("发生额", "transAmount", "MONEY", false),
+                field("交易日期", "transDate", "DATE", true)));
+        assertEquals(1d, HeaderScorer.fileCoverage(file, wide), 1e-9);
+        double score = HeaderScorer.score(file, wide);
+        assertTrue(HeaderScorer.passesStrong(file, wide, score, List.of()), "宽模板应正常直通,实际 " + score);
     }
 }

+ 26 - 15
ai-server/src/test/java/com/zsjz/ai/module/aiclean/service/AiCleanTableNamingTest.java

@@ -9,8 +9,9 @@ import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
 /**
- * AI 动态表命名规则(需求 2026-10-18)的纯函数测试:
- * 中文名 —— Excel 用 sheet 名;sheet 名缺失用「文件名称 + sheetN」;CSV 用文件名称。
+ * AI 动态表命名规则(需求 2026-10-18 / 2026-10-26)的纯函数测试:
+ * 中文名 —— Excel 用 sheet 名;sheet 名缺失用「文件名称 + sheetN」;CSV 用文件名称;
+ * 最后统一加 {@code AI_} 前缀(F-05:与既有业务表区分)。
  * 物理表名 —— {@code ai_clean_} + 模板雪花 id。
  *
  * @author cc
@@ -28,38 +29,48 @@ class AiCleanTableNamingTest {
     }
 
     @Test
-    @DisplayName("Excel:中文名直接用 sheet 名称(文件名的 ID 前缀/扩展名不参与)")
+    @DisplayName("Excel:中文名 = AI_ + sheet 名称(文件名的 ID 前缀/扩展名不参与)")
     void excelUsesSheetName() {
-        assertEquals("机动车信息", AiCleanService.buildTableNameCn("XLS", FILE_NAME, "机动车信息", 1));
-        assertEquals("星际航运", AiCleanService.buildTableNameCn("XLSX", FILE_NAME, " 星际航运 ", 2));
+        assertEquals("AI_机动车信息", AiCleanService.buildTableNameCn("XLS", FILE_NAME, "机动车信息", 1));
+        assertEquals("AI_星际航运", AiCleanService.buildTableNameCn("XLSX", FILE_NAME, " 星际航运 ", 2));
     }
 
     @Test
-    @DisplayName("sheet 名称缺失:中文名 = 文件名称(去雪花ID前缀+扩展名)+ sheetN")
+    @DisplayName("sheet 名称缺失:中文名 = AI_ + 文件名称(去雪花ID前缀+扩展名)+ sheetN")
     void blankSheetNameFallsBackToFileNamePlusOrdinal() {
-        assertEquals("周海--车辆登记信息sheet2", AiCleanService.buildTableNameCn("XLS", FILE_NAME, null, 2));
-        assertEquals("周海--车辆登记信息sheet3", AiCleanService.buildTableNameCn("XLSX", FILE_NAME, "  ", 3));
-        assertEquals("无前缀文件sheet1",
+        assertEquals("AI_周海--车辆登记信息sheet2", AiCleanService.buildTableNameCn("XLS", FILE_NAME, null, 2));
+        assertEquals("AI_周海--车辆登记信息sheet3", AiCleanService.buildTableNameCn("XLSX", FILE_NAME, "  ", 3));
+        assertEquals("AI_无前缀文件sheet1",
                 AiCleanService.buildTableNameCn("XLS", "无前缀文件.xls", null, 0), "序号兜底 1 基");
     }
 
     @Test
-    @DisplayName("CSV:整份文件就是一张表,中文名 = 文件名称(sheet 名不参与)")
+    @DisplayName("CSV:整份文件就是一张表,中文名 = AI_ + 文件名称(sheet 名不参与)")
     void csvUsesFileName() {
         String csv = "2111759372622880768_周海--通信18905810269.csv";
-        assertEquals("周海--通信18905810269", AiCleanService.buildTableNameCn("CSV", csv, null, 1));
-        assertEquals("周海--通信18905810269", AiCleanService.buildTableNameCn("csv", csv, "Sheet1", 1));
+        assertEquals("AI_周海--通信18905810269", AiCleanService.buildTableNameCn("CSV", csv, null, 1));
+        assertEquals("AI_周海--通信18905810269", AiCleanService.buildTableNameCn("csv", csv, "Sheet1", 1));
+    }
+
+    @Test
+    @DisplayName("前缀幂等:sheet 名本身已带 AI_ 时不重复叠加")
+    void prefixIsIdempotent() {
+        assertEquals("AI_银行流水", AiCleanService.buildTableNameCn("XLSX", FILE_NAME, "AI_银行流水", 1));
+        assertEquals("AI_银行流水", AiCleanService.withAiPrefix("AI_银行流水"));
+        assertEquals("AI_银行流水", AiCleanService.withAiPrefix(" 银行流水 "));
     }
 
     @Test
     @DisplayName("文件名/表名全缺失:中文名不能为空(DDL 非空约束),且长度不超过列宽 200")
     void alwaysProducesNonBlankBoundedName() {
-        assertEquals("未命名文件sheet1", AiCleanService.buildTableNameCn("XLS", null, null, 1));
-        assertEquals("未命名文件", AiCleanService.buildTableNameCn("CSV", "  ", null, 1));
+        assertEquals("AI_未命名文件sheet1", AiCleanService.buildTableNameCn("XLS", null, null, 1));
+        assertEquals("AI_未命名文件", AiCleanService.buildTableNameCn("CSV", "  ", null, 1));
 
         String tooLong = "表".repeat(260);
         assertEquals(200, AiCleanService.fitNameCn(tooLong).length());
-        assertEquals(200, AiCleanService.buildTableNameCn("XLS", FILE_NAME, tooLong, 1).length());
+        String bounded = AiCleanService.buildTableNameCn("XLS", FILE_NAME, tooLong, 1);
+        assertEquals(200, bounded.length());
+        assertTrue(bounded.startsWith(AiCleanService.AI_TABLE_NAME_PREFIX), bounded);
     }
 
     @Test

+ 26 - 0
ai-server/src/test/java/com/zsjz/ai/module/aiclean/service/AiSmartCleanServiceTest.java

@@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.core.MybatisConfiguration;
 import com.baomidou.mybatisplus.core.conditions.AbstractWrapper;
 import com.baomidou.mybatisplus.core.conditions.Wrapper;
 import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.zsjz.ai.common.context.CaseContextHolder;
 import com.zsjz.ai.common.model.dm.dto.DoCleanDTO;
 import com.zsjz.ai.common.model.dm.dto.TableRuleDTO;
 import com.zsjz.ai.common.model.dm.entity.FileInfo;
@@ -13,6 +14,7 @@ import com.zsjz.ai.module.aiclean.enums.AiCleanStageEnum;
 import com.zsjz.ai.module.aiclean.enums.AiCleanStatusEnum;
 import com.zsjz.ai.module.aiclean.mapper.AiCleanBatchMapper;
 import com.zsjz.ai.module.aiclean.mapper.AiCleanJobMapper;
+import com.zsjz.ai.module.aiclean.model.TemplateCandidate;
 import com.zsjz.ai.module.dm.mapper.FileInfoMapper;
 import com.zsjz.ai.module.dm.service.DmService;
 import com.zsjz.ai.module.govern.serivce.GovernConfService;
@@ -326,6 +328,30 @@ class AiSmartCleanServiceTest {
         assertEquals(AiCleanStageEnum.PARTIAL, stages().get(stages().size() - 1));
     }
 
+    @Test
+    @DisplayName("F-07 出口 B 命中既有模板却建 AI 表:处理日志写明原因,入库日志标明 AI 动态表")
+    void exitBLogExplainsExistingTemplateDowngrade() {
+        FileInfo s1 = sheet(1, "银行流水", null);
+        TemplateCandidate existing = new TemplateCandidate(1, false, 21, null, 2, "银行流水", "trans_record",
+                List.of("查询卡号"), List.of(), 0.9);
+        given(Map.of(1L, new AiJudgeResult.AiPlan(null, List.of(), existing, null, List.of("查询卡号"))));
+
+        CaseContextHolder.runWith(1L, 2L, () -> service.run(9L, 1L, List.of(file(100L, 9L, s1))));
+
+        ArgumentCaptor<com.zsjz.ai.common.model.plat.dto.SseDTO> cap =
+                ArgumentCaptor.forClass(com.zsjz.ai.common.model.plat.dto.SseDTO.class);
+        verify(sseService, atLeastOnce()).sendSee(anyLong(), cap.capture());
+        List<String> logs = cap.getAllValues().stream()
+                .filter(dto -> dto.cmd() == 104)
+                .map(com.zsjz.ai.common.model.plat.dto.SseDTO::message)
+                .toList();
+        assertTrue(logs.stream().anyMatch(m -> m.contains("模型命中既有模板「银行流水」")
+                        && m.contains("出口 A 闸门") && m.contains("AI 动态表")),
+                "用户看到命中模板却得到新表时,日志必须解释原因:" + logs);
+        assertTrue(logs.stream().anyMatch(m -> m.contains("已写入 5 行(AI 动态表")),
+                "入库日志要明确目标是 AI 动态表:" + logs);
+    }
+
     // ==================== ④ 重复受理 ====================
 
     /** 把「起后台线程」换成「只记不跑」,受理阶段才能被断言 */

+ 222 - 0
ai-server/src/test/java/com/zsjz/ai/module/dm/pre/PreDataListenerMatchTest.java

@@ -0,0 +1,222 @@
+package com.zsjz.ai.module.dm.pre;
+
+import cn.hutool.extra.spring.SpringUtil;
+import com.baomidou.dynamic.datasource.DynamicRoutingDataSource;
+import com.zsjz.ai.common.cache.GlobalCache;
+import com.zsjz.ai.common.config.DuckdbUnpooledDataSource;
+import com.zsjz.ai.common.enums.FieldTypeEnum;
+import com.zsjz.ai.common.enums.FileSheetStateEnum;
+import com.zsjz.ai.common.model.dm.entity.FileInfo;
+import com.zsjz.ai.common.model.plat.entity.TableField;
+import com.zsjz.ai.common.model.plat.entity.TableInfo;
+import org.apache.fesod.sheet.ExcelReader;
+import org.apache.fesod.sheet.FesodSheet;
+import org.apache.fesod.sheet.support.ExcelTypeEnum;
+import org.apache.poi.ss.usermodel.Row;
+import org.apache.poi.ss.usermodel.Sheet;
+import org.apache.poi.xssf.usermodel.XSSFWorkbook;
+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 org.junit.jupiter.api.io.TempDir;
+import org.springframework.context.ApplicationContext;
+import org.springframework.context.support.StaticApplicationContext;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.InputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+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.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * 上传阶段模板匹配的严格回归(F-01B)。
+ *
+ * <p>既有匹配器的「部分匹配」分支只要文件表头包含模板的全部 {@code matched=1} 字段就会直通。
+ * 「通信登记信息」模板只有 使用人姓名/本机号码 两个 matched 字段,于是导出话单变体(11 列)
+ * 被它吞掉,9 列通话信息静默丢弃、AI 匹配根本没被触发。
+ *
+ * <p>这里用真 xlsx + 真 Fesod + 真 DuckDB 预览表跑完整上传监听器:既验证覆盖率阈值本身,
+ * 也钉住「低覆盖变体落到 SHEET_TEMPLATE_MATCHED_FAIL(交给 AI),完全同表头仍然直中」。
+ *
+ * @author cc
+ * @since 2026/10/26
+ */
+class PreDataListenerMatchTest {
+
+    /** F-01B 复现文件 CALL-09 的表头(11 列) */
+    private static final List<String> CALL_EXPORT_HEADERS = List.of(
+            "本机号码", "对方号码", "通话类型", "通话时间", "使用人姓名", "通话时长",
+            "本方基站大区号", "本方基站小区号", "本方证件号码", "本方号码归属地", "对方号码归属地");
+
+    @TempDir
+    Path dir;
+
+    private DuckdbUnpooledDataSource ds;
+    private ApplicationContext previousContext;
+
+    @BeforeEach
+    void setUp() {
+        ds = new DuckdbUnpooledDataSource("jdbc:duckdb:" + dir.resolve("case.duckdb"),
+                "org.duckdb.DuckDBDriver");
+        com.zsjz.ai.module.aiclean.exec.AiDuckDb.useDataSource(ds);
+        DynamicRoutingDataSource routing = mock(DynamicRoutingDataSource.class);
+        when(routing.getDataSource("slave")).thenReturn(ds);
+        previousContext = SpringUtil.getApplicationContext();
+        StaticApplicationContext ctx = new StaticApplicationContext();
+        ctx.refresh();
+        ctx.getBeanFactory().registerSingleton("dataSource", routing);
+        new SpringUtil().setApplicationContext(ctx);
+        GlobalCache.clearForTesting();
+    }
+
+    @AfterEach
+    void tearDown() {
+        GlobalCache.clearForTesting();
+        new SpringUtil().setApplicationContext(previousContext);
+        com.zsjz.ai.module.aiclean.exec.AiDuckDb.useDataSource(null);
+        try {
+            ds.close();
+        } catch (Exception ignored) {
+            // 临时库释放失败不影响断言
+        }
+    }
+
+    @Test
+    @DisplayName("部分匹配覆盖率:文件/模板较大一方至少覆盖 60%,且至少 3 列重叠")
+    void partialMatchNeedsBothSidesCoverage() {
+        // CALL-09(11 列)对通信登记模板(6 字段,2 个 matched 字段)只重叠 2 列 → 不直通
+        assertFalse(PreDataListener.partialMatchCovers(2, 11, 6));
+        // IND-03(20 列)对 6 列子模板即使全中也不够:20 的 60% = 12 列
+        assertFalse(PreDataListener.partialMatchCovers(6, 20, 6));
+        // BILL-04:30 列表头 + 3 个额外列 → 30/33 覆盖,允许直通
+        assertTrue(PreDataListener.partialMatchCovers(30, 33, 30));
+        // BILL-06:30 列模板 + 8 个额外列 → 30/38 = 78.9%,仍允许直通
+        assertTrue(PreDataListener.partialMatchCovers(30, 38, 30));
+        // 小模板 2 列全中:绝对下限压到 2,允许直通
+        assertTrue(PreDataListener.partialMatchCovers(2, 2, 2));
+        assertFalse(PreDataListener.partialMatchCovers(0, 10, 10));
+    }
+
+    @Test
+    @DisplayName("F-01B:导出话单变体(11 列)不再被「通信登记信息」的部分匹配吞掉,交给 AI")
+    void lowCoverageVariantFallsThroughToAi() throws Exception {
+        // 「通信登记信息」:6 个字段,matched=1 只有 使用人姓名/本机号码
+        stubTemplate(3, "通信登记信息",
+                List.of(field(3, "使用人姓名", "personName", true),
+                        field(3, "本机号码", "personPhone", true),
+                        field(3, "运营商", "operatorId", false),
+                        field(3, "开户时间", "activationDateTime", false),
+                        field(3, "号码类型", "phoneType", false),
+                        field(3, "号码状态", "phoneStatus", false)));
+
+        FileInfo sheet = upload(workbook("导出话单", CALL_EXPORT_HEADERS));
+
+        assertNotNull(sheet);
+        assertNull(sheet.getTemplateId(), "低覆盖变体不能直中「通信登记信息」,否则通话方向/时长全部丢失");
+        assertNull(sheet.getLineNo(), "未命中模板时不应写表头行号");
+        assertEquals(FileSheetStateEnum.SHEET_TEMPLATE_MATCHED_FAIL, sheet.getFileStatus(),
+                "必须保持匹配失败,智能清洗的 AI 模板匹配才会接管");
+    }
+
+    @Test
+    @DisplayName("完全同表头(11/11)仍然在上传阶段直中模板,不受阈值影响")
+    void exactHeaderStillMatchesAtUpload() throws Exception {
+        List<TableField> fields = new ArrayList<>();
+        for (int i = 0; i < CALL_EXPORT_HEADERS.size(); i++) {
+            fields.add(field(42, CALL_EXPORT_HEADERS.get(i), "f" + i, true, i));
+        }
+        stubTemplate(42, "导出话单", fields);
+
+        FileInfo sheet = upload(workbook("导出话单", CALL_EXPORT_HEADERS));
+
+        assertNotNull(sheet);
+        assertEquals(42, sheet.getTemplateId(), "逐字同表头必须继续直中(完全匹配不受部分匹配阈值限制)");
+        assertEquals(FileSheetStateEnum.SHEET_TEMPLATE_MATCHED_SUCCESS, sheet.getFileStatus());
+        assertEquals(1, sheet.getLineNo());
+    }
+
+    // ==================== 造数 ====================
+
+    private void stubTemplate(int id, String nameCn, List<TableField> fields) {
+        TableInfo info = new TableInfo();
+        info.setId(id);
+        info.setMainId(id);
+        info.setTableNameCn(nameCn);
+        info.setTableNameEn("t" + id);
+        info.setHeaders(String.join(",", fields.stream().map(TableField::getFieldNameCn).toList()));
+        Map<Integer, TableInfo> templates = new LinkedHashMap<>();
+        templates.put(id, info);
+        Map<Integer, List<TableField>> fieldMap = new LinkedHashMap<>();
+        fieldMap.put(id, fields);
+        GlobalCache.loadSnapshotForTesting(templates, fieldMap);
+    }
+
+    private static TableField field(int tableId, String cn, String en, boolean matched) {
+        return field(tableId, cn, en, matched, 0);
+    }
+
+    private static TableField field(int tableId, String cn, String en, boolean matched, int sort) {
+        TableField f = new TableField();
+        f.setTableId(tableId);
+        f.setTableNameEn("t" + tableId);
+        f.setFieldNameCn(cn);
+        f.setFieldNameEn(en);
+        f.setFieldType(FieldTypeEnum.TEXT);
+        f.setMatched(matched ? 1 : 0);
+        f.setRequired(0);
+        f.setFieldSort(sort);
+        return f;
+    }
+
+    private File workbook(String sheetName, List<String> headers) throws Exception {
+        File f = dir.resolve("upload-" + System.nanoTime() + ".xlsx").toFile();
+        try (XSSFWorkbook wb = new XSSFWorkbook()) {
+            Sheet sheet = wb.createSheet(sheetName);
+            Row header = sheet.createRow(0);
+            for (int i = 0; i < headers.size(); i++) {
+                header.createCell(i).setCellValue(headers.get(i));
+            }
+            Row data = sheet.createRow(1);
+            for (int i = 0; i < headers.size(); i++) {
+                data.createCell(i).setCellValue("值" + i);
+            }
+            try (FileOutputStream out = new FileOutputStream(f)) {
+                wb.write(out);
+            }
+        }
+        return f;
+    }
+
+    private FileInfo upload(File f) throws Exception {
+        List<FileInfo> infos = new ArrayList<>();
+        FileInfo root = new FileInfo();
+        root.setId(1000L);
+        root.setPid(0L);
+        root.setFileName(f.getName());
+        root.setFileType("XLSX");
+        infos.add(root);
+
+        PreDataListener listener = new PreDataListener(infos, f.getName(), 1000L, 1L);
+        try (InputStream in = Files.newInputStream(f.toPath());
+             ExcelReader reader = FesodSheet.read(in, listener).headRowNumber(0)
+                     .excelType(ExcelTypeEnum.XLSX).autoTrim(true)
+                     .registerConverter(new Str2NullConverter())
+                     .registerConverter(new DateNumberConverter()).build()) {
+            reader.readAll();
+        }
+        return infos.stream().filter(i -> i.getPid() != null && i.getPid() == 1000L).findFirst().orElse(null);
+    }
+}

+ 541 - 0
docs/testing/ai-llm-template-match-bug-report-20261026.md

@@ -0,0 +1,541 @@
+# AI 大模型模板匹配 · 严格测试问题报告(2026-10-26)
+
+> 对应用例文档:[ai-llm-template-match-strict-20261026.md](ai-llm-template-match-strict-20261026.md)
+> 结论先行:本轮在「AI 大模型模板匹配」链路发现 **2 个 P0(AI 错配模板导致 0 行;既有模糊匹配把话单误判成通信登记信息并写错表)**、**3 个 P1(统计口径/金额清洗/本地模型并发)**、**3 个 P2(可观测性与模型输出校验)**。
+> 2026-10-26 已按确认范围修复 **F-01、F-01B、F-03B、F-05、F-06、F-07**(见第 6 节「修复记录」);**F-01C、F-02、F-03、F-04、F-04B 按要求暂不处理**。
+> 复现文件全部保留在 `docs/testing/fixtures/ai-llm-match-20261026/`;执行结果 JSON 在 `results/`。
+
+## 1. 环境
+
+| 项 | 值 |
+|---|---|
+| 后端 | `ai-server` @ `http://127.0.0.1:8980/js/a`,从仓库根启动 |
+| 前端 | `ai-electron/frontend` @ `http://localhost:3100` |
+| 平台库 | PostgreSQL `192.168.0.106:5432/zsjz-ai` |
+| 模型 | Ollama `qwen`(Qwen3.8-27B)@ `http://10.66.66.66:8080/v1`;**本地模型只支持 1 个并发会话**(用户确认) |
+| AI 判定并发 | 测试后期统一 `-Dzsjz.ai-clean.max-concurrent=1`;默认值 3 的结果见 F-04 |
+| 嵌入 | 本地 bge-m3;AI 模板向量索引已就绪 |
+| 工作目录 | `E:\workspace\zsjz-ai`(`PathConst` 依赖) |
+| 账号 | `admin / admin123` |
+
+## 2. 问题汇总
+
+| 编号 | 级别 | 问题 | 复现文件 / 任务 | 状态 |
+|---|---|---|---|---|
+| F-01 | **P0** | 交通银行同义表被错误判成「农商行交易流水」模板 48,出口 A 写业务表;因模板缺少必填「本方卡号」,8 行数据全部被拒,业务表 0 行 | `bills/BILL-09_交通银行_同义.xlsx`;案件 24,job `2114558438624731136` | **已修复**(文件侧覆盖率) |
+| F-01B | **P0** | 导出话单/通信记录变体在上传阶段被既有模糊匹配器误判为「通信登记信息」模板 3;话单数据被写进 `call_opening_info`,只保留姓名/号码,通话方向/时间/时长等全部丢失;AI 链路根本没被触发 | `calls/CALL-09_导出话单_同义.xlsx`;案件 25,批次 `2114559120123633664` | **已修复**(部分匹配覆盖率) |
+| F-01C | P2 | table_info 全量同表头覆盖:364 个模板中 345 正确、16 错配(子集模板先到先得)、3 失败(headers 与 table_field 不一致);12 个模板存在表头字段在 table_field 中不存在 | `full-template/`;`results/full-exact_20261026_113820.json` | 不修复(数据质量) |
+| F-02 | **P1** | 清洗完成统计把非数据行(标题/表头)算成“导入成功”:文件行显示成功 2、失败 8,但业务表 0 行;另一批次 UI 显示 32 条、实际 30 行 | 案件 24 同上;案件 19 批次 `1792983591254364400` | 不修复 |
+| F-03B | **P1** | AI 动态表的 `CALL_DIRECTION` 列把方向字典的整串别名写进单元格(如“主叫,短信发送话单,呼出,01,主叫话单”),不是“主叫/被叫”;账单 AI 表的借贷方向也保留 借/贷/D/C/收/付 原值未归一 | `calls/CALL-02/03/10/18`;`bills/BILL-02` AI 表 | **已修复**(字典反向 + 兜底归一) |
+| F-03 | **P1** | 既有清洗对带千分位/货币符号/负号的金额未识别,直接写 `0.00`(1,234.50、-100.00、¥2,300.50、¥88.88、5,000.00 全部变 0.00) | `bills/BILL-01_通用流水_标准.xlsx`;案件 19 `trans_record_all` | 不修复 |
+| F-04 | **P1** | 默认 `max-concurrent=3` 时,本地单会话模型 2/3 调用超过 180s 硬超时并重试,批次卡在 JUDGING;单并发后恢复正常 | 案件 20/21;日志 `logs/backend-test2.log`、`backend-test3.log` | 不修复(环境/配置) |
+| F-04B | **P1** | 文件 AI 识别调度器仍是 `file-ai.max-concurrent=3`;含 txt 的批次会与 AI 清洗争用同一单会话模型,导致 `HTTP header parser received no bytes`、AI 清洗 FAIL/重试 | 案件 26,`EDGE-04_说明.txt` + `EDGE-06`;日志 `logs/backend-test5.log` | 不修复 |
+| F-05 | P2 | 出口 B 新建 AI 表的中文名与既有模板同名(“银行交易流水”/“通信记录”),处理日志只写“已写入 N 行(表 xxx)”,无法区分是既有业务表还是 AI 动态表 | 案件 19 批次 `1792983591254364400` | **已修复**(AI_ 前缀 + 日志点名 AI 动态表) |
+| F-06 | P2 | 出口 A 的二次 `askLlm` 只送 1 个候选,但模型返回 `templateNo=69` 越界序号;该路径不校验序号,仍使用模型 binds | 案件 24 draft 见 `results/bill_20261026_112312.json` | **已修复**(单候选重编号 + 序号校验) |
+| F-07 | P2 | 同义/相似词改写后模型能语义选对模板,但出口 A 的确定性闸门(必填覆盖)不通过,最终建 AI 表而非写既有业务表;用户看到“命中模板”却得到新表 | 案件 19 `BILL-02`/`CALL-02` | **已修复**(日志/备注明确出口 A/B) |
+
+## 3. 详细复现
+
+### F-01(P0)交通银行同义表错配为「农商行交易流水」,数据全部被拒
+
+**复现文件**
+
+`docs/testing/fixtures/ai-llm-match-20261026/bills/BILL-09_交通银行_同义.xlsx`
+
+表头(11 列):
+
+```text
+银行卡号,借贷方向,发生额,交易日期,交易时间,姓名,对方户名,对方帐号,余额,对方金融机构网点名称,备注
+```
+
+相对模板 34「交通银行」的改写:
+
+```text
+交易卡号→银行卡号;借贷方标志→借贷方向;金额→发生额
+```
+
+**复现步骤**
+
+1. 新建案件(复现使用案件 id=24,名称 `AI大模型匹配话单行业-1026`)。
+2. 上传 `BILL-09_交通银行_同义.xlsx`(工作表「交行流水」,表头在第 2 行)。
+3. 点击「智能清洗」,等待批次结束(单并发约 1 分钟)。
+4. 查看 `ai_clean_job` / 导入文件页 / `trans_record_all`。
+
+**实际结果**
+
+```json
+{
+  "job_id": 2114558438624731136,
+  "status": "DISPATCHED_A",
+  "exit_code": "A",
+  "matched_by": "SCORE",
+  "match_tier": "STRONG",
+  "ref_template_id": 48,
+  "llm_calls": 1,
+  "confidence": 0.9185,
+  "file_status": "FILE_CLEAN_COMPLETE",
+  "data_num": 10,
+  "success_num": 2,
+  "fail_num": 8
+}
+```
+
+- `ai_clean_job.ref_template_id = 48`,模板 48 是「农商行交易流水」,不是期望的 34「交通银行」。
+- 打分明细(`logs/backend-test5.log`,job 2114558438624731136):
+
+```text
+AI 模板打分明细: #0 交易方向=SIMILAR(≈交易金额) | #1 交易金额=EXACT | #2 日期=EXACT |
+#3 交易网点=SIMILAR(≈交易金额) | #4 对方户名=EXACT | #5 对方账号=SIMILAR(≈对方户名) |
+#6 交易名称=SIMILAR(≈交易金额) | #7 余额=EXACT ← 农商行交易流水
+```
+
+- 8 条 `clean_error_log` 全部是:
+
+```text
+第2行 ~ 第9行,[本方卡号] 数据为空
+```
+
+- `trans_record_all` 实际 `total = 0`;文件行却显示 `successNum=2, failNum=8`。
+- 导入文件页备注:`AI 匹配模板:农商行交易流水`。
+
+**界面复核**
+
+- 案件 24 → 导入文件页,文件行显示:`导入成功 2 / 导入失败 8`,清洗方式/备注在 sheet 行(API 返回 `cleanNote=AI 匹配模板:农商行交易流水`)。
+- 同页看不到「业务表 0 行」的事实,用户只会看到“2 条成功”。
+
+**期望结果**
+
+- 应命中模板 34「交通银行」,或至少由模型在候选中裁决后走出口 B;绝不能把交行数据交给「农商行」模板清洗。
+- 若无法确定,应 `NEED_REVIEW`,不写任何业务表。
+
+**根因线索(代码走读)**
+
+1. `HeaderScorer.score` 的覆盖率为「模板列被文件命中多少」:`coverage = hit / tplSet.size()`;**文件侧多出来的列完全不扣分**(`HeaderScorer.java` 的 `score`)。模板 48 只有 8 列,交行文件 11 列,7 个模板列命中即可拿高覆盖。
+2. 相似词规则把 `交易方向` 与 `交易金额` 判为 SIMILAR(等长、共同前缀“交易”、编辑距离 ≤2),掩盖了“借贷方向/发生额”并不是同一个字段。
+3. `passesStrong` 只校验候选模板的 `required` 是否覆盖 + 形状安全;候选模板 48 的必填(交易方向/交易金额/交易日期)都“覆盖”了,于是直接出口 A,**没有进入模型候选裁决**。
+4. 模板 48 的 `main_id=2`,既有清洗主字段包含必填「本方卡号」;该字段不在文件表头中,导致 8 行全部拒绝,业务表 0 行。
+
+**修复方式(2026-10-26 已实施)**
+
+- `HeaderScorer` 新增**文件侧覆盖率**(文件列有多少能在模板上找到归宿),并作为 `score` 的 header 组件之一(`0.60×模板列命中率 + 0.20×Jaccard + 0.20×文件侧覆盖率`);出口 A 另设硬门槛 `FILE_COVERAGE_MIN = 0.6`,`passesStrong` 与 `AiTemplateMatcher.exitOf` 都校验。
+- 收紧 `similarWords` 的「等长 + 共同前缀 2 + 编辑距离 2」规则:差异尾部必须至少共享 1 个字,`交易方向/交易金额`、`交易网点/交易金额`、`交易日期/交易时间` 不再判 SIMILAR;`车辆类型/车辆种类`(共享「类」)等既有业务词对保持不变。
+- 交行 11 列文件对 8 列模板的文件侧覆盖率为 6/11 ≈ 0.545,低于门槛 → 不再直通,转模型在候选里裁决(模型选交行模板且闸门通过则出口 A,否则出口 B)。
+- 单测:`HeaderScorerTest#narrowTemplateCannotHijackWideFile`、`#wideTemplateCoversWholeFile`、`#fuzzyNeverCollapsesDifferentBusinessTokens`。
+
+### F-01B(P0)话单变体被既有模糊匹配误判为「通信登记信息」,写错表且字段丢失
+
+**复现文件**
+
+`docs/testing/fixtures/ai-llm-match-20261026/calls/CALL-09_导出话单_同义.xlsx`
+
+表头(11 列,按导出话单 42 的同义改写):
+
+```text
+本机号码,对方号码,通话类型,通话时间,使用人姓名,通话时长,
+本方基站大区号,本方基站小区号,本方证件号码,本方号码归属地,对方号码归属地
+```
+
+改写:`本方号码→本机号码`、`主被叫标识→通话类型`、`本方姓名→使用人姓名`;期望模板 42「导出话单」。
+
+**复现步骤**
+
+1. 新建案件(复现使用案件 id=25)。
+2. 上传 `CALL-09_导出话单_同义.xlsx`。
+3. 上传结果直接显示 `SHEET_TEMPLATE_MATCHED_SUCCESS`、`templateId=3`(「通信登记信息」),**没有进入 AI 判定**。
+4. 智能清洗后查看 `call_opening_info`。
+
+**实际结果**
+
+```json
+{
+  "file": "CALL-09_导出话单_同义.xlsx",
+  "sheet": "导出话单",
+  "file_status": "FILE_CLEAN_COMPLETE",
+  "template_id": 3,
+  "data_num": 11,
+  "success_num": 11,
+  "fail_num": 0,
+  "expected_template_id": 42
+}
+```
+
+- `call_opening_info` 实际写入 10 行,只有 `personName` / `personPhone` 有值;`operatorId / activationDateTime / phoneType / phoneStatus` 全为空。
+- 通话方向、通话时间、通话时长、对方号码、基站等列全部丢失;文件行仍显示成功 11、失败 0。
+- 同一批次内 `CALL-08`(导出话单标准表头)被正确直中模板 42,说明模板本身可用;问题出在变体表头的匹配决策。
+
+**根因线索**
+
+`PreDataListener.matchTemplate` 的“部分匹配”分支:
+
+```java
+if (CollUtil.containsAll(tempFields, headVals)) { ... } // 完全匹配
+else if (CollUtil.containsAll(headVals, tempMatchedFields)) {
+    // 只要文件表头包含模板所有 matched=1 字段,就进入部分匹配
+    matchTemplateMap.put(templateId, intersection.size() + mustSize);
+}
+```
+
+模板 3 的 `matched=1` 字段只有两个:
+
+```text
+使用人姓名, 本机号码
+```
+
+任何同时包含这两个表头的话单/导出话单,即使还有 9 个完全不同的列,也会成为模板 3 的候选;如果没有更强的完全匹配,就直接返回模板 3 并标记 `SHEET_TEMPLATE_MATCHED_SUCCESS`。AI 清洗只接管 `SHEET_TEMPLATE_MATCHED_FAIL`,所以这类文件永远不会进入 AI 匹配。
+
+同一问题也影响:
+
+- `CALL-17_通信日期时间_同义.xlsx`:期望模板 64,实际上传直中模板 3;
+- `CALL-19_时长格式.xlsx`:期望模板 1,实际上传直中模板 3;
+- `IND-03_银行开户信息.xlsx`(跨行业组):期望模板 4「银行开户信息」(20 列),实际上传直中模板 2084276161「建设银行开户记录」(6 列),14 列数据不会被映射;
+- `IND-13_出入境记录.xlsx`(跨行业组):期望模板 52「出入境记录信息」(16 列),实际上传直中模板 596849484「出入境信息查询」(5 列),其余 11 列不会进入清洗。
+
+跨行业例子的模板情况:
+
+```text
+模板 4「银行开户信息」matched=1 字段:客户名称, 卡号
+模板 2084276161「建设银行开户记录」headers:户名,账号,证件号码,开户日期,余额,销户日期
+文件 IND-03 表头:户名,证件号码,卡号,账号,开户日期,余额,可用余额,币种,...(20 列)
+```
+
+文件把 `客户名称` 改成了 `户名`,模板 4 的必填 `客户名称` 不再命中;子模板 2084276161 的 6 个列全部能在文件里找到,于是部分匹配直接返回子模板,20 列里其余 14 列不会进入清洗。
+
+**期望**
+
+- 上传阶段的模糊匹配应设置最低覆盖率/列数比例,或只允许“完全匹配”直通;部分匹配应保留为 `SHEET_TEMPLATE_MATCHED_FAIL`,交由 AI 模板匹配裁决。
+- 至少不能把「通信记录/导出话单」写进「通信登记信息」表;写错时应显式 NEED_REVIEW,而不是成功。
+
+**修复方式(2026-10-26 已实施)**
+
+- `PreDataListener.matchTemplate` 的「部分匹配」分支增加覆盖率阈值:重叠列数(交集 + 必填命中)必须 ≥ `0.6 × max(文件列数, 模板列数)`,且不少于 3 列(小模板按 `min(3, 文件列, 模板列)` 兜底)。
+- 覆盖率不足时不再把模板放进候选,sheet 保持 `SHEET_TEMPLATE_MATCHED_FAIL`,由智能清洗的 AI 模板匹配裁决;完全匹配(表头逐字相同 / 模板包含全部表头)不受影响。
+- CALL-09(11 列)对模板 3(6 字段、2 个 matched 字段)只重叠 2 列 → 拒绝直通;IND-03(20 列)对 6 列子模板即使全中也只有 6 列 → 拒绝直通;BILL-04(30 列表头 + 3 个额外列,重叠 30)仍正常直中。
+- 单测:`PreDataListenerMatchTest#lowCoverageVariantFallsThroughToAi`(真 xlsx + 真 Fesod + 真 DuckDB)、`#exactHeaderStillMatchesAtUpload`、`#partialMatchNeedsBothSidesCoverage`。
+
+### F-01C(P2)table_info 全量同表头覆盖:345/364 正确,16 错配 + 3 失败
+
+**执行方式**
+
+- 按 `table_info` 每个模板的 `headers` 生成 3 行生产风格数据,表头放在模板声明的 `line_no`,共 364 个 xlsx。
+- 仅上传、不发起智能清洗(`run_tests.py full-exact --upload-only`),验证上传阶段既有匹配器能否正确直中;文件在 `full-template/`,结果在 `results/full-exact_20261026_113820.json`。
+
+**结果**
+
+```text
+files=364  sheet_success=361  sheet_fail=3
+命中模板 id 与期望一致:345
+命中模板 id 不一致:16
+未命中(SHEET_TEMPLATE_MATCHED_FAIL):3
+```
+
+**16 个错配(全部是子集模板先到先得)**
+
+| 期望模板 | 实际命中 |
+|---|---|
+| 57 同住址 | 56 同户人 |
+| 88496327 成都银行交易流水纪委调单 | 1746257711 |
+| 155064567 成都银行流水 | 1746257711 |
+| 687030857 农村商业银行成都开户信息 | 1106262359 |
+| 759819185 成都银行开户信息(DQXX) | 1720897654 |
+| 772432199 电信通联记录 | 1940093260 |
+| 798524514 语音清单 | 1940093260 |
+| 816952604 平安银行交易流水纪委调单 | 1699503043 |
+| 1018443797 四川银行 | 5 |
+| 1020340504 单位工行开户信息 | 847945890 |
+| 1149206312 成都银行交易(JYLS) | 95695751 |
+| 1318768623 短信明细记录 | 584544982 |
+| 1450844531 浦发银行调取账单流水 | 1302533924 |
+| 1517022216 中国银行信用卡交易记录 | 294976394 |
+| 1678225864 通信登记信息央地协查 | 23 电信通信登记信息 |
+| 1820032851 工商银行收付款明细 | 11548586 |
+
+**3 个失败**
+
+| 模板 | 原因 |
+|---|---|
+| 63 在逃同案撤销人员 | `table_info.headers` 有「在逃人员DNA编号」,`table_field` 只有「在逃人员人员DNA编号」,差一个字,完全匹配失败 |
+| 466958473(名称乱码) | 表头/字段中文均为乱码,且 headers 中有字段名不存在 |
+| 1652978732(名称乱码) | 同上,多个表头字段在 table_field 中不存在 |
+
+**12 个模板存在 headers 字段不在 table_field 中**
+
+```text
+6 转入转出账号(日期时间): 日志号, 传票号
+7 主账号与收付款账号组合(日期时间): 户名, 可用余额
+10 主叫被叫: cell_id, BILLING_VISIT_AREA_CODE通话地
+11 借贷方发生额(日期时间): 明细号
+17 同航班服务: 乘机时间, 到达日期, 到达时间
+19 铁路订票信息查询: 发车时间
+24 快递收发子模版: 物品名称
+58 同车违章: 车牌号码
+63 在逃同案撤销人员: 在逃人员DNA编号
+466958473 / 1652978732: 名称/字段乱码 + 多个字段缺失
+543445511 郴州农商银行: 本方卡号
+```
+
+**结论**
+
+- 这不是 AI 模板匹配代码引入的问题,而是 `table_info.headers` 与 `table_field` 元数据不一致,以及既有匹配器「子集模板先到先得」的既有行为。
+- 但它直接影响用户看到的结果:同表头文件会命中另一个模板,或在 AI 兜底前就被错误路由;修复时应优先统一元数据,再考虑子集模板的歧义消解。
+
+### F-02(P1)完成统计把标题/表头算成导入成功
+
+**复现 A(业务表 0 行但显示成功 2)**
+
+- 文件/案件/job 同 F-01。
+- 导入文件页:`数据量 10 / 导入成功 2 / 导入失败 8`;`trans_record_all total=0`。
+- 8 条失败日志对应 8 个数据行,成功 2 很可能是标题行与表头行,但它们并没有落入业务表。
+
+**复现 B(UI 32 条,实际 30 行)**
+
+- 案件 19,批次 `1792983591254364400`:`BILL-01_通用流水_标准.xlsx`(标题行 + 表头 + 8 数据行,既有直中)、`BILL-02`(AI 10 行)、`CALL-02`(AI 12 行)。
+- UI 完成页显示「导入成功 32 条」,处理日志「成功 32 行」。
+- 实际:既有表 `trans_record_all` 8 行 + AI 表 10 行 + AI 表 12 行 = 30 行。
+- `ai_clean_batch.loaded_rows = 22`(只统计 AI 写入),既有链路按 `file_info.dataNum - failNum = 10 - 0 = 10` 计入,合计 32。
+
+**根因线索**
+
+- `DmService` 完成统计:
+
+```java
+int dataNum = 0, failNum = 0;
+for (FileInfo child : fileInfoList) {
+    dataNum += child.getDataNum();   // 上传预览的物理行数(含标题/表头)
+    failNum += child.getFailNum();
+}
+int completedCount = dataNum - failNum;
+```
+
+- `AiSmartCleanService` 终态统计同样用 `dataNum - failNum` 作为既有 sheet 的“成功行数”。
+- 因此只要文件有标题行/表头行,`dataNum` 就比真实数据行多,完成页会多报;而 AI 动态表用的是 `rowLoaded`,两种口径混在同一张卡里。
+
+**期望**
+
+- 「导入成功(条)」必须等于实际落库数据行;标题/表头/空行不计入成功,也不应与 AI 的 `rowLoaded` 混算。
+
+### F-03(P1)既有清洗金额格式化值写成 0.00
+
+**复现文件**:`bills/BILL-01_通用流水_标准.xlsx`(直接命中模板 2,走既有清洗)。
+
+**实际**
+
+| 源值 | `trans_record_all.transAmount` |
+|---|---|
+| `1,234.50` | `0.00` |
+| `-100.00` | `0.00` |
+| `200.50` | `200.50` |
+| `¥2,300.50` | `0.00` |
+| `100.00` | `100.00` |
+| `¥88.88` | `0.00` |
+| `5,000.00` | `0.00` |
+| `45.60` | `45.60` |
+
+**根因线索**
+
+`TransStdDataCleaner`(main_id=2 使用)对 MONEY 字段:
+
+```java
+if (ReUtil.isMatch(PatternPool.MONEY, copyContent)) {
+    ...
+} else {
+    copyContent = "0.00";   // 不匹配就静默写 0,不报错
+}
+```
+
+带千分位、货币符号、负号的金额不匹配 `PatternPool.MONEY`,被静默改写为 0.00;行仍然算成功。该问题在既有链路,不是 AI 模板匹配新增,但会被 AI 出口 A 复用,影响生产账单。
+
+**期望**
+
+- 金额解析应支持千分位、¥/¥、括号负数、正负号;无法解析时应置空并计入 warning/失败,而不是静默 0.00。
+
+### F-03B(P1)AI 动态表方向字典写成整串别名
+
+**复现**
+
+- `CALL-18_主被叫方向.xlsx`(案件 25,AI 模板 `2114559632961183744`)源值只有 `主叫/被叫/呼入/呼出`。
+- `/aiClean/data/page?aiTemplateId=2114559632961183744` 实际:
+
+```text
+c3 = "主叫,短信发送话单,呼出,01,主叫话单"
+c3 = "被叫,短信接收话单,呼入,02,被叫话单"
+c3 = "呼入"
+```
+
+- `CALL-02`、`CALL-03`、`CALL-10` 的 AI 表同样出现整串别名。
+- 账单 AI 表 `BILL-02` 的 `借贷方向` 则保留 `借/贷/D/C/收/付` 原值,没有归一成统一方向。
+
+**根因线索**
+
+- `table_field.direction_conf` 的格式是「标准方向 → 逗号分隔别名」:
+
+```json
+{"主叫":"主叫,短信发送话单,呼出,01,主叫话单",
+ "被叫":"被叫,短信接收话单,呼入,02,被叫话单"}
+```
+
+- `AiRecordCleaner.translateDirection` 直接 `dict.getOrDefault(v, v)`:
+  - 当输入 `主叫` 时返回整串 `主叫,短信发送话单,...`;
+  - 正确做法应是**反向建表**:把每个别名映射回标准方向(与 `GlobalCache.directionConfMap` 的 `tableId_value → key` 逻辑一致),或至少当输入等于 key 时返回 key。
+- 该函数对所有 `DEBIT_CREDIT_FLAG` / `CALL_DIRECTION` 列生效;账单因为对应字段没有 `direction_conf`,才侥幸保留原值。
+
+**期望**
+
+- AI 动态表方向列应写入标准方向(主叫/被叫;收入/支出),不能把别名词典整串落库。
+
+**修复方式(2026-10-26 已实施)**
+
+- `AiRecordCleaner` 的方向翻译改为**反向建表**:按「标准方向 → 逗号分隔别名」解析后,建立 `别名/标准词 → 标准方向` 映射(与 `GlobalCache.directionConfMap` 的 `tableId_别名 → key` 同构);输入 `主叫` 返回 `主叫`,输入 `呼出/01/主叫话单` 同样返回 `主叫`。
+- 字典按 JSON 原文缓存,避免逐格重复解析;坏 JSON 只记日志,不把整行判无效。
+- 字典缺失或未命中时走兜底方向:`DEBIT_CREDIT_FLAG` 归一为 `收入/支出`(借=D=付 → 支出,贷=C=收 → 收入),`CALL_DIRECTION` 归一为 `主叫/被叫`(呼出 → 主叫,呼入 → 被叫);不认识的值仍原样保留。
+- 单测:`AiRecordCleanerTest#directionDict`(生产字典格式、标准词命中、别名命中、无字典兜底、坏 JSON)。
+
+### F-04(P1)默认 3 并发与本地单会话模型不匹配
+
+**复现**
+
+- 默认 `zsjz.ai-clean.max-concurrent=3`,一次批次 6 个 AI sheet。
+- 日志(`logs/backend-test2.log`):
+
+```text
+11:06:42 ERROR LlmRetry: AI 模板匹配 第 1 次调用失败,1051ms 后重试:
+模型调用超时(总时长 180s): Ollama:qwen
+11:06:42 ERROR ... 第 1 次调用失败 ...
+11:07:12 ERROR ... 第 1 次调用失败 ...
+```
+
+- 批次长时间停在 `JUDGING`,`ai_judged_count` 不增长;单并发后同一模型单次 30–120s 正常完成。
+
+**结论**
+
+- 这是环境/默认配置不匹配,不是匹配逻辑错误;用户已确认本地模型只支持一个并发会话。
+- 建议:本地模型默认 `max-concurrent=1`,或按模型提供方的并发能力配置;同时在 README/启动参数中写明。
+
+### F-04B(P1)文件 AI 识别与 AI 清洗争用单会话模型
+
+**复现**
+
+- 同一批次同时上传 `EDGE-04_说明.txt`(非表格,触发文件 AI 识别)和一个待 AI 判定的表格 `EDGE-06_表头第10行.xlsx`。
+- `logs/backend-test5.log`:
+
+```text
+11:34:18 ERROR [file-ai-0] 文件 AI 识别 第 1 次调用失败: 模型调用失败[Ollama:qwen]:
+HTTP transport error during streaming: SSE/NDJSON stream failed:
+java.io.IOException: HTTP/1.1 header parser received no bytes
+11:35:04 ERROR [ai-clean-14] 直连模型调用失败: model=Ollama:qwen,
+error=HTTP transport error during streaming: ... header parser received no bytes
+11:35:04 INFO  AI 清洗任务结束: jobId=..., status=FAIL, 原因=模型未返回可解析的规则草稿
+11:34:59 ERROR 文件 AI 识别失败: fileName=EDGE-04_说明.txt, 耗时=59939ms
+```
+
+- 同一时间段 `zsjz.file-ai.max-concurrent` 仍是 3(启动日志 `并发=3`),而 AI 清洗已经降到 1;两个调度器各自持有并发,底层模型只有一个会话。
+
+**结论**
+
+- 单并发约束必须全局生效:`ai-clean`、`file-ai`(以及其它直调 `LlmService` 的链路)需要共享同一个模型信号量,或至少把 `file-ai.max-concurrent` 也设为 1。
+- 这类争用表现为“模型返回不可解析/HTTP 断流”,容易被误判为模型能力问题;本轮 `EDGE-06` 的 FAIL 就是由此触发。
+
+### F-05(P2)AI 动态表与既有模板同名,日志无法区分
+
+案件 19 完成页:
+
+```text
+「BILL-02_通用流水_同义词.xlsx」·银行流水 → 已写入 10 行(表「银行交易流水」)
+```
+
+- 该表是出口 B 新建的 `ai_clean_*` 动态表,中文名与既有模板「银行交易流水」完全相同。
+- 用户从日志/治理树看到同名表,无法判断数据进了既有业务表还是 AI 表;建议 AI 表名加前缀/标记(如「AI-银行交易流水」),或在日志中明确「AI 动态表」。
+
+**修复方式(2026-10-26 已实施)**
+
+- `AiCleanService.buildTableNameCn` 统一给 AI 动态表中文名加 `AI_` 前缀(幂等,sheet 名本身已带 `AI_` 时不重复叠加;仍受 200 字符列宽约束)。治理树、导入文件备注、处理日志都直接读该字段,所以一处生效。
+- 处理日志改为点名目标:`已写入 N 行(AI 动态表 · ···)`;导入文件页备注区分为「AI 匹配既有模板:X」与「AI 新建动态表:Y」。
+- 已存在的 AI 模板不改名(复用路径沿用存档名),只影响新建表。
+- 单测:`AiCleanTableNamingTest`(前缀、幂等、长度上限)。
+
+### F-06(P2)出口 A 二次 askLlm 不校验 templateNo
+
+- BILL-09 的 `ai_clean_job.draft`:
+
+```json
+{"templateNo":69, ...}
+```
+
+- 该路径只发送了 1 个候选(模板 48),模型却返回 69(越界);代码仍使用其 `binds` 做列映射。
+- `filterBinds` 会过滤不存在的字段名,所以没有直接写脏列,但:
+  - 模型输出已明显不符合“只准回候选序号”的约束;
+  - 若 binds 恰好都能在候选模板中找到,仍会被采信,存在错绑风险。
+- 建议:二次 `askLlm` 同样校验 `templateNo == 1`(唯一候选),不一致时丢弃 binds,转 NEED_REVIEW。
+
+**修复方式(2026-10-26 已实施)**
+
+- 二次 `askLlm` 的候选先重编号为 `#1`(`AiTemplateMatcher.singleCandidate`),提示词序号与后端校验口径一致。
+- 新增 `AiTemplateMatcher.candidateOf(candidates, draft)`:序号缺失、≤0、越界一律返回 null,两条模型调用路径共用。
+- `AiCleanService.judge` 在二次调用后强制校验:`templateNo != 1` 时记录 ERROR 日志、保留原始草稿(`ai_clean_job.draft` 便于复盘)、丢弃 binds 并转 NEED_REVIEW。
+- 单测:`AiTemplateMatcherTest#singleCandidateMustAnswerNumberOne`(含 `templateNo=69` 场景)。
+
+### F-07(P2/观察)同义词命中但出口 A 闸门不通过,最终建 AI 表
+
+- BILL-02(查询卡号→银行卡号、借贷标志→借贷方向、交易金额→发生额、交易时间→交易时点)模型正确选中「银行交易流水」,置信 0.95,但确定性必填覆盖不通过,最终出口 B 新建 AI 表。
+- CALL-02 同理(本机电话号码/呼叫类型/通话开始时间)。
+- 这是“安全优先”的设计结果,不是错写;但从用户视角,模型已明确命中模板却仍得到一张新表,且日志写“命中模板……准备入库”,容易误解。
+- 建议:UI/日志明确出口 A/B;评估是否在模型 binds 可靠时放宽必填覆盖,或提示“已命中模板但必填列需确认”。
+
+**修复方式(2026-10-26 已实施,按“不改安全阈值、只把出口说清楚”处理)**
+
+- 出口 A 的处理日志:`命中既有模板「X」,走出口 A 写入既有业务表`。
+- 出口 B 且候选是既有模板时:`模型命中既有模板「X」,但出口 A 闸门(档位/置信度、必填覆盖、确定性分数、文件列覆盖)未通过,改走出口 B 写入 AI 动态表`;候选是 AI 模板/按表头新建时也各自点名。
+- 入库日志:`已写入 N 行(AI 动态表 · <来源>)`;导入文件页备注区分为「AI 匹配既有模板」/「AI 新建动态表」,配合 F-05 的 `AI_` 前缀可直接分辨。
+- 确定性闸门本身**不放松**:出口 A 写的是用户既有业务表,错写不可逆;模型自评高不能单独决定出口 A。
+- 单测:`AiSmartCleanServiceTest#exitBLogExplainsExistingTemplateDowngrade`。
+
+## 4. 执行结果(已跑部分)
+
+| 用例 | 文件 | 结果 |
+|---|---|---|
+| UI-01~07 | 案件 19 | 上传→智能清洗→进度/日志/结果页全链路可用;AI 阶段 0.95 判定;完成统计见 F-02 |
+| BILL-01 | 标准通用流水 | 既有直中;金额清洗见 F-03 |
+| BILL-02 | 同义通用流水 | 模型 0.95 选中「银行交易流水」;出口 B 10 行;跨案第二次 HEADER_CACHE 复用 |
+| BILL-03 | 相似词通用流水 | SCORE 出口 A,模板 2,置信 0.9320 |
+| BILL-04 | 乱序+增列+必填换词 | SCORE 出口 A,模板 2,置信 0.9275 |
+| BILL-09 | 交行同义 | **P0 错配模板 48,0 行落库** |
+| BILL-10 | 光大同义 | SCORE 出口 A,模板 35,置信 0.9320 |
+| BILL-11 | 工行同义 | SCORE 出口 A,模板 36,置信 0.9291 |
+| BILL-12 | 招行同义 | 主模型调用完成,因停止批次未收口(未计入结论) |
+| CALL-01/02/03/08/09/10/17/18/19 | 话单核心 | 标准话单直中模板 1/42;CALL-02/03/10/18 走 AI 出口 B(12/12/10/12 行);CALL-09/17/19 被模板 3 吞掉(F-01B) |
+| ALL-01 | 364 模板同表头 | 345 正确 / 16 错配 / 3 失败(详见 F-01C) |
+
+## 5. 未执行/受限说明
+
+- 本地 27B 模型单并发下单次约 60–120s;为覆盖更多场景,银行 13–16、部分账单独有场景、全量 364 模板的 AI 变体未全部跑完。已保留生成器与 manifest,可随时补跑。
+- 全量模板同表头 364 文件已执行上传覆盖;全量 AI 变体(ALL-02)未执行,文件在 `full-template-variants/`,可按需补跑。
+- UI 截图受内置浏览器可见性限制,关键结论均有 `ai_clean_job`、`clean_error_log`、服务日志与导入文件 API 数据佐证。
+
+## 6. 修复记录(2026-10-26)
+
+> 范围:仅 F-01、F-01B、F-03B、F-05、F-06、F-07。F-01C(模板元数据质量)、F-02(完成统计口径)、F-03(既有金额清洗)、F-04/F-04B(并发配置)按要求不修改。
+> 验证方式:本轮改动全部有单测覆盖(`mvn -pl ai-server test` 的 aiclean / dm 相关用例全绿);未重跑端到端模型链路(本地模型单会话,见第 5 节)。
+
+| 编号 | 代码位置 | 改动要点 |
+|---|---|---|
+| F-01 | `HeaderScorer`、`AiTemplateMatcher#exitOf` | 新增文件侧覆盖率并计入分数与出口 A 硬门槛(`FILE_COVERAGE_MIN=0.6`);收紧等长相似词规则(差异尾部必须共享至少 1 个字) |
+| F-01B | `PreDataListener#matchTemplate` | 部分匹配要求重叠列 ≥ `0.6×max(文件列数,模板列数)` 且 ≥3 列;不足则保持 `SHEET_TEMPLATE_MATCHED_FAIL` 交 AI |
+| F-03B | `AiRecordCleaner` | `direction_conf` 反向建表(别名/标准词 → 标准方向)+ 解析缓存 + 缺失字典时的收支/主被叫兜底 |
+| F-05 | `AiCleanService`、`AiSmartCleanService`、`DmService` | AI 动态表中文名统一 `AI_` 前缀(幂等、≤200 字);处理日志与导入备注点名「AI 动态表 / 既有业务表」 |
+| F-06 | `AiTemplateMatcher`、`AiCleanService` | 二次 `askLlm` 单候选重编号为 `#1`;`templateNo` 缺失/越界丢弃 binds 转 NEED_REVIEW,原始草稿仍落库供复盘 |
+| F-07 | `AiCleanService`、`AiSmartCleanService`、`DmService` | 出口 A/B 的日志与备注显式区分;模型命中既有模板却被闸门挡下时,日志写明原因;安全阈值不放松 |
+
+新增/更新的单测:
+
+```text
+module/aiclean/match/HeaderScorerTest                    # F-01 文件侧覆盖率 + 相似词收紧
+module/aiclean/match/AiTemplateMatcherTest               # F-06 candidateOf / singleCandidate
+module/aiclean/exec/AiRecordCleanerTest                  # F-03B 方向字典反向 + 兜底
+module/aiclean/service/AiCleanTableNamingTest            # F-05 AI_ 前缀
+module/aiclean/service/AiSmartCleanServiceTest           # F-07 出口 B 日志说明
+module/dm/pre/PreDataListenerMatchTest                   # F-01B 上传匹配(真 xlsx/Fesod/DuckDB)
+```
+
+> 补充:全量 `mvn -pl ai-server test` 存在一个与本轮改动无关的既有失败
+> (`PreDataListenerRawTest#previewTableFeedsAiLoader` 在整包运行时 `SpringUtil` 解析到真实的
+> `CaseRoutingDataSource`,初始化预览表抛「请先打开案件后再操作数据」;用改动前的代码跑整包同样复现,
+> 该类单独运行通过),本轮未处理。

+ 299 - 0
docs/testing/ai-llm-template-match-strict-20261026.md

@@ -0,0 +1,299 @@
+# AI 大模型 · 模板匹配严格测试用例(2026-10-26)
+
+> 被测对象:上传清洗流程中的 **AI 大模型模板匹配**(智能清洗 → AI 判定 → 模型裁决模板 + 列绑定 → 出口 A/B 入库)。
+> 目标:以生产环境思路造多行业、多方言、多表头变体文件,重点覆盖**账单(银行流水)**与**话单(通信记录)**,并对 `table_info` 全量模板做覆盖。
+> 本轮只记录问题,不修改代码;发现项留给使用方确认后再决定是否修复。
+
+## 1. 环境与入口
+
+| 项 | 值 |
+|---|---|
+| 后端 | `ai-server`,`http://127.0.0.1:8980/js/a`,仓库根为工作目录 |
+| 前端 | `ai-electron/frontend`,`http://localhost:3100` |
+| 数据库 | PostgreSQL `192.168.0.106:5432/zsjz-ai`(平台库) |
+| 案件库 | DuckDB,按案件落在 `QingJian/workspace/{caseId}/...` |
+| 模型 | `agent_model` 默认对话模型:Ollama `qwen` @ `http://10.66.66.66:8080/v1`(Qwen3.8-27B);`AI判定并发=3`,单次匹配超时 180s,失败重试 3 次封顶 |
+| 嵌入 | 本地 bge-m3(`E:/workspace/zsjz-ai/bge-m3`),向量索引已就绪 |
+| 测试案件 | `AI大模型匹配严格测试-1026`(案件 id=19,owner=admin) |
+| 账号 | `admin / admin123` |
+| 入口 | 登录 → 案件管理 → 打开案件 → 案件数据 → **清洗数据** → 文件上传 → **智能清洗** → 进度页 → 结果/导入文件 |
+
+### 1.1 界面可观测点
+
+- 上传页:解析结果树、工作表状态(模板匹配成功/失败)、「智能清洗」按钮。
+- 进度页:读取数据 → 清洗入库 → 数据治理;处理日志中 AI 阶段里程碑、逐表无法清洗原因。
+- 导入文件页:sheet 行「清洗方式」(系统清洗/AI清洗)、「备注」(AI 匹配模板 / AI 新建表 / 失败原因)、导入成功/失败数。
+- 导入数据页:既有业务表 / AI 动态表的数据与列。
+- 后端证据:`ai_clean_job`(`status/exit_code/matched_by/match_tier/ref_template_id/ai_template_id/llm_calls/token/row_*`)、`ai_clean_template/ai_clean_field/ai_clean_rule`、服务日志中的「AI 匹配第三腿」「可列打分明细」「出口 A 交既有链路」。
+
+### 1.2 判定口径
+
+| 途径 | 含义 | 预期 |
+|---|---|---|
+| `HEADER_CACHE` | 表头指纹直通 AI 模板库,零模型 | 复用存档绑定;同表头 |
+| `SCORE` | 归一化打分命中既有模板且强壮 | 直接出口 A;零模型 |
+| `LLM` | 模型在 top-12 候选中裁决 | 模型返回序号 + 列绑定;按置信度分档 |
+| `SELF_HEADER` | 无候选,按文件表头自建表 | 出口 B,全 TEXT |
+| `A` | 写既有业务表 | 必须具备 `ref_template_id`,抽样 verify 通过 |
+| `B` | 写 AI 动态表 `ai_t*` | 具备 `ai_template_id`,数据落 AI 表 |
+| `NEED_REVIEW` | 结构归一/必填错误率/置信度不过线/无绑定 | 不写业务表,需人工 |
+
+## 2. 测试数据设计
+
+### 2.1 目录与保留策略
+
+所有可复现文件保留在 `docs/testing/fixtures/ai-llm-match-20261026/`:
+
+```text
+fixtures/ai-llm-match-20261026/
+├── bills/                 # 账单/银行流水(xlsx/csv/xls)
+├── calls/                 # 话单/短信(xlsx/csv/xls)
+├── cross-industry/        # 车辆/开户/企业/轨迹/酒店/航班等
+├── edge/                  # 异常与边界
+├── full-template/         # table_info 全量模板同表头文件
+├── full-template-variants/# table_info 全量模板改写文件(模型判定)
+├── manifest.json          # 测试编号、文件、预期模板、变异方式
+└── results/               # 执行结果 JSON、关键日志、截图
+```
+
+### 2.2 文件生成原则
+
+- **生产风格数据**:姓名、身份证、手机号、银行卡、开户行、商户、摘要、基站、通话时长均按真实形态生成;金额/日期/时间故意混入常见脏值。
+- **表头变异**:
+  - 同义词(同义词典命中):交易金额↔发生额、借贷标志↔借贷方向、查询卡号↔查询账号、本机号码↔本机电话号码。
+  - 相似词/换词:号码↔号、帐号↔账号、类型↔类别、标识↔标志、时间↔时点/日期。
+  - 列多/列少:额外业务列、删除可选列、只保留必填列、超宽表。
+  - 列序:完全倒序/随机打乱(既有匹配器对列序不敏感,需配合改词才进入 AI)。
+  - 噪音:标题行、说明行、空行、合并表头、全角空格/括号、重复列名、空列。
+  - 格式:xlsx / xls / csv(UTF-8、UTF-8 BOM、GBK)、双 sheet、仅表头、损坏文件。
+- **数据唯一性**:每个文件的数据/行数/姓名/卡号不同,避免上传 MD5 去重把用例吞掉。
+- **强制进入 AI 途径**:对模板的 `matched=1` 字段做同义/换词改写,使既有匹配器在**上传阶段**无法精确命中,从而触发智能清洗的 AI 判定。
+
+## 3. 用例清单
+
+> 结果列在每轮执行后回填。状态:`PASS` / `FAIL` / `BLOCKED` / `N/A`。
+> 「预期模板」为 `table_info.id`;「出口」为 A=既有业务表、B=AI 动态表。
+
+### A. UI 主链路
+
+| 编号 | 场景 | 步骤 | 预期 | 结果 |
+|---|---|---|---|---|
+| UI-01 | 新建案件并进入上传页 | 案件管理→新建→打开→清洗数据 | 进入文件上传,上传/智能清洗按钮可用状态正确 | 待执行 |
+| UI-02 | 上传 1 个标准账单 | 选择文件→上传→解析 | 工作表「模板匹配成功」;智能清洗可用 | 待执行 |
+| UI-03 | 上传 1 个账单变体(同义) | 选择文件→上传→智能清洗 | 进入进度页;AI 判定推进;日志出现 AI 阶段;完成后统计正确 | 待执行 |
+| UI-04 | 上传 1 个话单变体(同义) | 同上 | 同上,且落库行列正确 | 待执行 |
+| UI-05 | 进度页/结果页 | 观察进度环、步骤条、处理日志、完成态 | 无卡死;失败/无法清洗口径正确;可查看结果 | 待执行 |
+| UI-06 | 导入文件页回显 | 查看 sheet 行 | 清洗方式=AI清洗;备注=匹配模板/新建表;成功失败数正确 | 待执行 |
+| UI-07 | 导入数据页核验 | 打开 AI/既有表 | 行数、列名、日期/金额类型展示正确 | 待执行 |
+| UI-08 | 手动清洗回归 | 上传页→手动清洗 | 进入清洗页,模板/列映射可操作(不受 AI 链路影响) | 待执行 |
+
+### B. 账单/银行流水矩阵(重点)
+
+| 编号 | 文件 | 场景 | 预期模板/行为 | 结果 |
+|---|---|---|---|---|
+| BILL-01 | BILL-01_通用流水_标准.xlsx | 与模板 2 逐字同表头(30 列) | 上传阶段既有直中 → 系统清洗;不产生 AI job | 待执行 |
+| BILL-02 | BILL-02_通用流水_同义词.xlsx | 查询卡号→查询账号、借贷标志→借贷方向、交易金额→发生额、交易时间→交易时点 | 既有失败→LLM;预期 2 或同族;出口 A | 待执行 |
+| BILL-03 | BILL-03_通用流水_相似词.xlsx | 查询卡号码、借贷标识、交易金额(元)、交易日期时间 | LLM;预期 2/26;置信度与列映射正确 | 待执行 |
+| BILL-04 | BILL-04_通用流水_乱序增列.xlsx | 列序倒置 + 额外 3 列(网点电话/经办人/复核人) | LLM;模板 2 族;多余列不丢数据 | 待执行 |
+| BILL-05 | BILL-05_通用流水_缺必填.xlsx | 删除「借贷标志」;其余同表头 | 不得写既有业务表;出口 B 或 NEED_REVIEW | 待执行 |
+| BILL-06 | BILL-06_通用流水_表头多.xlsx | 表头多于模板(+8 列) | LLM;模板 2 族;额外列按原样保留 | 待执行 |
+| BILL-07 | BILL-07_通用流水_表头少.xlsx | 仅保留必填 + 4 个可选列 | LLM;模板 2 族;出口 A | 待执行 |
+| BILL-08 | BILL-08_交通银行_标准.xlsx | 模板 34 逐字表头 | 既有直中;不产生 AI job | 待执行 |
+| BILL-09 | BILL-09_交通银行_同义.xlsx | 交易卡号→银行卡号、借贷方标志→借贷方向、金额→发生额 | LLM;预期 34;出口 A | 待执行 |
+| BILL-10 | BILL-10_光大银行_同义乱序.xlsx | 本方卡号→本方账号、借贷标志→借贷方向、业务摘要→摘要;列序打乱 | LLM;预期 35;出口 A | 待执行 |
+| BILL-11 | BILL-11_工商银行_同义.xlsx | 发生额→交易金额、记账时间→交易时间、卡号→银行卡号 | LLM;预期 36;借贷方向/金额列不串 | 待执行 |
+| BILL-12 | BILL-12_招商银行_同义.xlsx | 交易方向→借贷标志、对手帐号→对方账号、联机余额→余额 | LLM;预期 38;出口 A | 待执行 |
+| BILL-13 | BILL-13_长沙银行_同义.xlsx | 卡(帐)号→账号、调查户名→户名、资金用途→摘要 | LLM;预期 39;出口 A | 待执行 |
+| BILL-14 | BILL-14_DC银行_同义.xlsx | D/C→借贷标志、交易对手名称→对方户名、可用余额→余额 | LLM;预期 40;出口 A | 待执行 |
+| BILL-15 | BILL-15_邮储银行_同义.xlsx | 借贷区分→借贷标志、交易对手名称→对方户名、交易名称→交易类型 | LLM;预期 41;出口 A | 待执行 |
+| BILL-16 | BILL-16_渤海银行_同义.xlsx | 客户名称→户名、账号余额→余额、交易摘要→摘要 | LLM;预期 43;出口 A | 待执行 |
+| BILL-17 | BILL-17_微信支付账单.csv | 微信风格:交易时间/交易类型/交易对方/商品/收/支/金额(元)/支付方式/当前状态/交易单号/商户单号/备注 | LLM 或自建表;不得错写既有表;金额符号正确 | 待执行 |
+| BILL-18 | BILL-18_支付宝账单.csv | 支付宝风格:交易号/商家订单号/交易创建时间/付款时间/最近修改时间/交易来源地/类型/交易对方/商品名称/金额(元)/收/支/交易状态/服务费(元)/成功退款(元)/备注/资金状态 | LLM 或自建表;收/支方向正确 | 待执行 |
+| BILL-19 | BILL-19_银行流水_英文表头.xlsx | 全英文:Bank Name/Customer Name/Card No/Debit Credit/Amount/Transaction Time/Balance | LLM 不误判;无法匹配则自建表 | 待执行 |
+| BILL-20 | BILL-20_银行流水_标题说明行.xlsx | 第 1 行标题、第 2 行说明、第 3 行空行、第 4 行表头 | 表头定位正确;行号从表头后开始 | 待执行 |
+| BILL-21 | BILL-21_金额脏值.xlsx | 1,234.50 / -100.00 / (200.50) / ¥2,300.5 / 100元 / 1.2万 | 金额列解析正确;坏值置空或拒绝符合字段约束 | 待执行 |
+| BILL-22 | BILL-22_日期脏值.xlsx | 2026/1/6、2026年1月7日、2026-02-30、45000(Excel 序列号)、20260108 | 合法日期入库、非法置空;不得错日 | 待执行 |
+| BILL-23 | BILL-23_流水_UTF8BOM.csv | UTF-8 BOM,逗号分隔 | 探测/解析正常,表头无 BOM 残留 | 待执行 |
+| BILL-24 | BILL-24_流水_GBK.csv | GBK 编码 | 中文表头不乱码 | 待执行 |
+| BILL-25 | BILL-25_流水_双sheet.xlsx | Sheet1 流水、Sheet2 汇总 | 各自判定;互不污染;统计正确 | 待执行 |
+| BILL-26 | BILL-26_流水_合并表头.xlsx | 两行合并表头(借方/贷方金额) | 结构归一不可用 → NEED_REVIEW,不硬洗 | 待执行 |
+| BILL-27 | BILL-27_流水_空表头.xlsx | 表头全空/仅一列 | 不崩溃;自建表或 NEED_REVIEW | 待执行 |
+| BILL-28 | BILL-28_流水_超宽表.xlsx | 40 列,含 10 个额外列 | 不崩溃;模型列绑定不越界 | 待执行 |
+| BILL-29 | BILL-29_流水_重复列名.xlsx | 「交易金额」出现两次 | 不串列;重复列处理有明确行为 | 待执行 |
+| BILL-30 | BILL-30_流水_同名重复上传.xlsx | 与 BILL-02 内容相同的副本 | 按 MD5 去重或重新清洗;不得产生重复数据 | 待执行 |
+
+### C. 话单/通信记录矩阵(重点)
+
+| 编号 | 文件 | 场景 | 预期模板/行为 | 结果 |
+|---|---|---|---|---|
+| CALL-01 | CALL-01_通信记录_标准.xlsx | 模板 1 逐字表头(17 列) | 既有直中;不产生 AI job | 待执行 |
+| CALL-02 | CALL-02_通信记录_同义词.xlsx | 本机号码→本机电话号码、话单类型→通话类型、呼叫开始时间→通话开始时间 | LLM;预期 1;出口 A | 待执行 |
+| CALL-03 | CALL-03_通信记录_相似词.xlsx | 被叫号码、主叫号码、通话起始时点 | LLM;预期 1/42;方向列不串 | 待执行 |
+| CALL-04 | CALL-04_通信记录_乱序.xlsx | 列序完全倒置 | LLM;预期 1;出口 A | 待执行 |
+| CALL-05 | CALL-05_通信记录_缺必填.xlsx | 删除「话单类型」 | 不写既有业务表;B 或 NEED_REVIEW | 待执行 |
+| CALL-06 | CALL-06_通信记录_多列.xlsx | +基站名称/经纬度/IMEI/备注 | LLM;预期 1;额外列保留 | 待执行 |
+| CALL-07 | CALL-07_通信记录_少列.xlsx | 仅本机号码/对方号码/话单类型/呼叫开始时间 | LLM;预期 1;出口 A | 待执行 |
+| CALL-08 | CALL-08_导出话单_标准.xlsx | 模板 42 逐字表头 | 既有直中 | 待执行 |
+| CALL-09 | CALL-09_导出话单_同义.xlsx | 本方号码→本机号码、主被叫标识→通话类型、本方姓名→使用人姓名 | LLM;预期 42;出口 A | 待执行 |
+| CALL-10 | CALL-10_移动语音话单.xlsx | 模板 1557528396 表头 + 基站号/小区代码 | LLM;预期 1557528396;出口 A | 待执行 |
+| CALL-11 | CALL-11_移动调单.xlsx | 模板 2022049432:呼叫类型/通话起始时间/基站号/小区号 | LLM;预期 2022049432;出口 A | 待执行 |
+| CALL-12 | CALL-12_联通语音.xlsx | 模板 401794643:billing_nbr计费号码 等中英混合 | LLM;预期 401794643;出口 A | 待执行 |
+| CALL-13 | CALL-13_联通短信.xlsx | 模板 584544982:收费用户号码/开始时间/通话类型/对端号码 | LLM;预期 584544982/1318768623;出口 A | 待执行 |
+| CALL-14 | CALL-14_央地协查短信.xlsx | 模板 2041091612:源地址/目的地址/短信分类/发送时间 | LLM;预期 2041091612;短信内容保留 | 待执行 |
+| CALL-15 | CALL-15_电信话单.xlsx | 模板 830256311 中英混合表头 | LLM;预期 830256311;出口 A | 待执行 |
+| CALL-16 | CALL-16_电信通联话单.xlsx | 模板 1940093260:计费号码/主叫/开始时间/通话时长/被叫 | LLM;预期 1940093260;出口 A | 待执行 |
+| CALL-17 | CALL-17_通信记录_日期时间分列.xlsx | 模板 64:通话日期 + 通话时间 | LLM;预期 64;日期/时间均入库 | 待执行 |
+| CALL-18 | CALL-18_主被叫方向.xlsx | 话单类型列值为「主叫/被叫/呼入/呼出」 | 方向映射正确;不得反转 | 待执行 |
+| CALL-19 | CALL-19_时长格式.xlsx | 1:23、83秒、0分45秒、2分 | 时长数值正确;坏值置空 | 待执行 |
+| CALL-20 | CALL-20_基站信息.xlsx | LAC/CellID/小区名/经纬度混合 | 基站列保留;不误绑对方归属地 | 待执行 |
+| CALL-21 | CALL-21_话单_CSV标题行.csv | 标题行 + 空行 + 表头 + 数据 | 表头定位正确;CSV 不 Skip | 待执行 |
+| CALL-22 | CALL-22_话单_损坏.xlsx | 伪造 xlsx 内容 | 解析失败记录,不拖垮批次 | 待执行 |
+| CALL-23 | CALL-23_话单_双sheet.xlsx | 移动 + 联通两个 sheet | 各自命中;统计合计正确 | 待执行 |
+| CALL-24 | CALL-24_话单_重复上传.xlsx | CALL-02 副本 | 去重/重清洗不重复 | 待执行 |
+
+### D. 跨行业矩阵
+
+| 编号 | 文件 | 场景 | 预期 | 结果 |
+|---|---|---|---|---|
+| IND-01 | IND-01_车辆登记_标准.xlsx | 已有 AI 模板「机动车信息」逐字表头 | HEADER_CACHE 直通,零模型,出口 B | 待执行 |
+| IND-02 | IND-02_车辆登记_同义词.xlsx | 号牌号码→牌照号码、车辆类型→车辆种类、车身颜色→颜色 | LLM/SCORE;复用车辆 AI 模板;出口 B | 待执行 |
+| IND-03 | IND-03_银行开户信息.xlsx | 模板 4 同义改写(客户名称→户名、证照号码→证件号码) | LLM;预期 4;出口 A/B 合理 | 待执行 |
+| IND-04 | IND-04_通信登记信息.xlsx | 模板 3 同义改写(本机号码→号码) | LLM;预期 3/23;不误判话单 | 待执行 |
+| IND-05 | IND-05_企业基本信息.xlsx | 复用 AI 模板「企业-基本信息查询」 | HEADER_CACHE/LLM;出口 B | 待执行 |
+| IND-06 | IND-06_企业主要人员.xlsx | 复用 AI 模板「企业-主要人员信息」 | 出口 B | 待执行 |
+| IND-07 | IND-07_个体经营者.xlsx | 复用 AI 模板「个体-经营者信息」 | 出口 B | 待执行 |
+| IND-08 | IND-08_航班记录.xlsx | 航班号/出发地/到达地/起飞时间/座位 | LLM 或自建;不误判账单 | 待执行 |
+| IND-09 | IND-09_酒店入住.xlsx | 入住时间/离店时间/房号/酒店名称 | LLM 或自建;日期正确 | 待执行 |
+| IND-10 | IND-10_快递物流.xlsx | 运单号/寄件人/收件人/揽收时间/轨迹 | LLM 或自建;长数字不丢精度 | 待执行 |
+| IND-11 | IND-11_社保公积金.xlsx | 单位名称/缴费基数/缴费月份/个人账户 | LLM 或自建;月份/金额正确 | 待执行 |
+| IND-12 | IND-12_不动产登记.xlsx | 不动产权证号/坐落/面积/权利人 | LLM 或自建;面积数值正确 | 待执行 |
+| IND-13 | IND-13_出入境记录.xlsx | 证件号码/出入境日期/口岸/航班 | LLM 或自建;日期正确 | 待执行 |
+| IND-14 | IND-14_车辆轨迹.xlsx | 车牌/过车时间/卡口/方向 | LLM 或自建;时间/车牌正确 | 待执行 |
+| IND-15 | IND-15_人员轨迹.xlsx | 身份证号/时间/地点/经度/纬度 | LLM 或自建;经纬度不丢精度 | 待执行 |
+| IND-16 | IND-16_铁路购票.xlsx | 订单号/车次/乘车日期/出发到达站/席别 | LLM 或自建;订单号保留 | 待执行 |
+| IND-17 | IND-17_电商订单.xlsx | 订单号/下单时间/商品/金额/收货地址 | LLM 或自建;金额正确 | 待执行 |
+| IND-18 | IND-18_保险保单.xlsx | 保单号/投保人/被保险人/保费/生效日期 | LLM 或自建;金额/日期正确 | 待执行 |
+| IND-19 | IND-19_证券交易.xlsx | 股东账号/证券代码/买卖方向/成交价格/成交数量 | LLM 或自建;方向/金额正确 | 待执行 |
+| IND-20 | IND-20_支付账户.xlsx | 支付账号/绑定卡/交易时间/金额/对手 | LLM 或自建;卡号保留 | 待执行 |
+
+### E. 异常与边界
+
+| 编号 | 场景 | 预期 | 结果 |
+|---|---|---|---|
+| EDGE-01 | 仅表头无数据行 | 任务结束,0 行,不报错 | 待执行 |
+| EDGE-02 | 空 xlsx(无 sheet 内容) | 解析失败/跳过,不拖垮批次 | 待执行 |
+| EDGE-03 | 伪造 xlsx | SKIP/FAIL,llm=0 | 待执行 |
+| EDGE-04 | 非表格 txt | 文件登记/不支持解析,不进入模板匹配 | 待执行 |
+| EDGE-05 | 5000 行流水 | 流式入库,进度/统计正确,耗时可控 | 待执行 |
+| EDGE-06 | 合并单元格表头 | NEED_REVIEW,不硬洗 | 待执行 |
+| EDGE-07 | 三 sheet 混合(账单/话单/无关) | 各自结论正确,互不污染 | 待执行 |
+| EDGE-08 | 表头在第 10 行 | 表头定位正确 | 待执行 |
+| EDGE-09 | 含空列/重复列/全角空格 | 归一化正确,不串列 | 待执行 |
+| EDGE-10 | 同名文件重复上传 | MD5 去重或重清洗,不重复写 | 待执行 |
+| EDGE-11 | 跨案件同文件 | 数据隔离;既有模板可命中;AI 模板复用符合设计 | 待执行 |
+| EDGE-12 | 模型不可用(临时切断) | fail-open:按未命中/自建表,不挂起批次 | 待执行 |
+| EDGE-13 | 判定中途重启后端 | 批次被标记 FAILED,可重新发起 | 待执行 |
+| EDGE-14 | 两批次并发提交 | 案件数据互不覆盖;job 不串 | 待执行 |
+| EDGE-15 | 表头含提示词注入/SQL 注入 | 模型不执行指令;不影响匹配;SQL 不执行 | 待执行 |
+
+### F. table_info 全量模板覆盖
+
+| 编号 | 场景 | 预期 | 结果 |
+|---|---|---|---|
+| ALL-01 | 364 个模板按各自表头生成同构文件,逐个上传 | 每个 sheet 均被既有匹配器命中;命中模板 id 正确;无解析异常 | 待执行 |
+| ALL-02 | 364 个模板各生成 1 个「改一个匹配字段」变体,走 AI | 正确模板在候选 top-12;模型命中/合理自建;无错写业务表 | 待执行 |
+| ALL-03 | 10 组重复表头模板族 | 记录先到先得/歧义;不崩溃 | 待执行 |
+| ALL-04 | AI 模板库同表头二次上传 | HEADER_CACHE 零模型复用;数据并入/可撤销 | 待执行 |
+
+### G. 安全与一致性
+
+| 编号 | 场景 | 预期 | 结果 |
+|---|---|---|---|
+| SEC-01 | 模型返回不存在的候选序号 | 按未命中处理,不越界、不落库 | 待执行 |
+| SEC-02 | 模型编造字段名 | 被 filterBinds 丢弃;不静默吞列 | 待执行 |
+| SEC-03 | 表头含「忽略以上指令」 | 不改变模型行为;匹配仍按结构 | 待执行 |
+| SEC-04 | 跨案件读取 job/数据 | 拒绝或只返回当前案件 | 待执行 |
+| SEC-05 | 出口 A 必填列错误率高 | verify 拦截 → NEED_REVIEW,不写业务表 | 待执行 |
+
+## 4. 记录规则
+
+每个用例记录:
+
+1. 测试编号、文件、上传时间、批次号;
+2. 上传阶段状态:`SHEET_TEMPLATE_MATCHED_SUCCESS/FAIL`、命中的既有模板;
+3. AI job:`status / exit_code / matched_by / match_tier / ref_template_id / ai_template_id / llm_calls / token_in / token_out / confidence / row_total / row_loaded / row_rejected / fail_reason`;
+4. 数据核验:落库表名、行数、列映射(重点检查金额/日期/方向/本方对方是否串列);
+5. 界面证据:进度页、导入文件页、导入数据页截图;
+6. 若失败:复现步骤、实际现象、服务日志片段、期望结果、严重级别(P0 错写业务表 / P1 无法入库 / P2 可用性问题)。
+
+## 5. 预期风险点(用于定向观察)
+
+- 模型把 A 银行流水判成 B 银行模板,且因 `passesStrong` 通过而写入错误业务表(P0)。
+- 模型置信度 ≥0.6 但列绑定错误,抽样 verify 未覆盖到脏列,导致金额/借贷方向错误入库(P0)。
+- 同义词/相似词改写过强,正确模板未进 top-12 候选,AI 自建表而非命中模板(P1)。
+- 缺必填列时模型硬凑模板,出口 A verify 拦截转 NEED_REVIEW 而非合理自建(P2)。
+- 账单/话单方向字段(借贷、主被叫)映射反转(P0)。
+- 金额单位(元/万元)、日期格式、Excel 序列号处理错误(P1)。
+- 表头重复/空列导致列索引偏移(P1)。
+- 多 sheet / 大文件导致 SSE 进度卡死或统计口径不一致(P2)。
+- 重复上传/跨案件复用产生重复行或串案(P1)。
+
+## 6. 执行结果(本轮已跑部分)
+
+> 详细问题、复现文件与日志见 [ai-llm-template-match-bug-report-20261026.md](ai-llm-template-match-bug-report-20261026.md)。
+> 本地 27B 模型只支持单并发,单次判定约 40–120s;为保证覆盖优先执行了高价值场景,未跑条目见 §7。
+
+| 组 | 用例 | 结果 |
+|---|---|---|
+| UI | UI-01~UI-07(案件 19) | 上传→智能清洗→进度页→结果页全链路可用;标准账单直中,账单/话单同义变体模型判定 0.95;完成统计存在多报(见 Bug F-02) |
+| 账单 | BILL-01(标准) | 既有直中模板 2;金额格式化值被清洗为 0.00(Bug F-03) |
+| 账单 | BILL-02(同义) | LLM 0.95 选中模板 2,出口 B 10 行;第二次跨案 HEADER_CACHE 零模型复用 |
+| 账单 | BILL-03(相似词) | SCORE 出口 A,模板 2,置信 0.9320 |
+| 账单 | BILL-04(乱序+增列+换词) | SCORE 出口 A,模板 2,置信 0.9275 |
+| 账单 | BILL-05(缺必填) | 批次被停止前处于 MATCHING;未收口 |
+| 账单 | BILL-08(交行标准) | 既有直中模板 34 |
+| 账单 | BILL-09(交行同义) | **P0 错配模板 48「农商行交易流水」;8 行数据全部被拒,业务表 0 行**(Bug F-01) |
+| 账单 | BILL-10/11(光大/工行同义) | SCORE 出口 A,模板 35/36,置信 0.9320/0.9291 |
+| 账单 | BILL-12(招行同义) | 主模型完成,批次停止前未收口 |
+| 话单 | CALL-01(标准)、CALL-08(导出话单标准) | 既有直中模板 1/42,零模型 |
+| 话单 | CALL-02(同义) | HEADER_CACHE 出口 B 12 行,零模型(跨案 AI 模板复用) |
+| 话单 | CALL-03(相似词) | LLM 出口 B 12 行,置信 0.95 |
+| 话单 | CALL-10(移动话单同义) | LLM 出口 B 10 行,置信 0.95 |
+| 话单 | CALL-18(主被叫方向) | LLM 出口 B 12 行;方向列落库为整串别名(Bug F-03B) |
+| 话单 | CALL-09/17/19 | **上传阶段被既有模糊匹配误判为模板 3「通信登记信息」,未进入 AI;CALL-09 数据写入 call_opening_info 仅剩姓名/号码**(Bug F-01B) |
+| 跨行业 | IND-01(车辆标准) | HEADER_CACHE 零模型,出口 B 8 行 |
+| 跨行业 | IND-02(车辆同义) | LLM 0.97,出口 B 8 行 |
+| 跨行业 | IND-04(通信登记同义) | LLM 出口 B 8 行 |
+| 跨行业 | IND-05/06/07(企业类标准) | HEADER_CACHE 零模型,出口 B 6 行/6 行/6 行 |
+| 跨行业 | IND-08(民航订票同义) | 模型返回 templateNo=0 → SELF_HEADER 自建表 7 行 |
+| 跨行业 | IND-20(支付账户同义) | 模型返回 templateNo=0 → SELF_HEADER 自建表 7 行 |
+| 跨行业 | IND-03(银行开户同义) | 上传直中被 6 列子模板 2084276161 吞掉;14 列不映射(Bug F-01B) |
+| 跨行业 | IND-13(出入境同义) | 上传直中被 5 列子模板 596849484 吞掉;姓名写入 certType,11 列丢失(Bug F-01B) |
+| 异常 | EDGE-01(仅表头) | 既有直中,0 行,批次 DONE |
+| 异常 | EDGE-02/EDGE-04(空 workbook / txt) | 不产生可清洗 sheet;txt 触发文件 AI 识别,与单会话模型争用导致同批 AI 清洗失败(Bug F-04B) |
+| 异常 | EDGE-03(损坏 xlsx) | job=SKIP「结构探测不可用」,不拖垮批次 |
+| 异常 | EDGE-06(表头第 10 行) | 模型调用受争用断流 → FAIL「模型未返回可解析的规则草稿」 |
+| 异常 | EDGE-07(合并单元格) | NEED_REVIEW「需要结构归一」,不硬洗 |
+| 异常 | EDGE-08(三 sheet) | 银行流水直中;通信记录被模板 3 吞掉;通讯录 SELF_HEADER 5 行 |
+| 异常 | EDGE-09(空列/重复列) | 上传直中模板 2(AI 被绕过);列处理未验证到 AI 列映射 |
+| 异常 | EDGE-10(表头注入) | 模型未选模板 → SELF_HEADER 自建表 6 行,无注入执行 |
+| 全量 | ALL-01(364 模板同表头上传) | 361/364 上传直中;345 命中正确模板、16 错配、3 失败(元数据不一致),详见 Bug 报告 F-01C |
+| 全量 | ALL-02(364 模板 AI 变体) | 未全量执行;生成器与文件已保留,可补跑 |
+
+## 7. 未执行/受限说明
+
+- 本地模型单并发下单次 40–120s;为在合理时间内覆盖账单/话单/跨行业/异常,以下条目未执行或未收口:
+  - BILL-06/07/12~16、BILL-17~30 大部;
+  - CALL-04~07、CALL-11~16、CALL-20~24;
+  - IND-09~12、IND-14~19;
+  - ALL-02 全量变体、EDGE-05(5000 行)。
+- 相关样本、生成器和 manifest 均在 `docs/testing/fixtures/ai-llm-match-20261026/`,可按 `run_tests.py --ids ...` 补跑。
+- 内置浏览器截图受可见性限制;关键证据均保留为 API 返回 JSON、`ai_clean_job`/`clean_error_log` 记录与服务日志。
+
+## 8. 执行记录
+
+| 日期 | 范围 | 结果 | 备注 |
+|---|---|---|---|
+| 2026-10-26 | 用例文档 | 已编写 | 按本文件执行 |
+| 2026-10-26 | 账单/话单/跨行业/异常精选 | 已执行 | 发现 2 个 P0、4 个 P1、3 个 P2,详见 Bug 报告 |
+| 2026-10-26 | table_info 364 模板同表头 | 执行中 | `results/` 与报告回填 |