ソースを参照

fix(aiclean): 绑定校验兼容字段中文名并归一为英文名

cc 3 日 前
親
コミット
4f05bb14cd

+ 154 - 0
.zcode/plans/plan-session_f942efbb046ee26c1fb0a8640651590d.md

@@ -0,0 +1,154 @@
+# 知识库模块设计与实施方案(修订版 2)
+
+## 调研结论(方案依据)
+
+项目已有可复用的完整基础设施,本方案**最大化复用现有模式**:
+- **隔离模式**:`agent` 表已有 `user_id + is_builtin` 口径(内置=全局可见只读、自建=仅创建者可见,后端过滤),`AgentExpertController` 的 CRUD 模式直接照搬。
+- **RAG 组件**:`EmbeddingProvider`(本地 ONNX bge-m3 / 在线模型双模)、`AttachmentVectorMapper`(pgvector 动态表原生 SQL)、`AttachmentDocIndexer`(分块+并发闸门+预算超时)全部现成。
+- **异步范式**:项目无 @Async/MQ,用 Spring 事件 + 后台线程(仿 `TemplateMetaChangedEvent` → `@EventListener` 异步索引)。
+- **Agentic 工具范式**:`RagSchemaSearchTool`(@Tool 注解注册进 Toolkit,模型自主调用)+ `appendRagPrompt` 系统提示词注入。
+- **文件下载范式**:后端 `LocalFileStorageUtil`(`/file-download/{path}`)+ 前端 `artifactApi.downloadArtifactFile`(手动带 token fetch blob → `downloadByData` 落盘)。
+- **存储**:主库 PostgreSQL(pgvector 已装);文件落盘 `LocalFileStorageUtil`;SQL 脚本手工执行(无 Flyway)。
+
+## 已确认的决策与要求
+1. Agent 绑定 **1 个**知识库(agent 表加 `knowledge_base_id`)
+2. RAG 检索:**Agentic 工具式**;**工具默认注册到绑定了知识库的 agent 上**——作为基础设施工具(不进工具组白名单、不受 tools_allow_json 限制、无需手动勾选),绑定即自动生效
+3. 前后端一起做;**前端样式布局参考专家页 `aiExpert/index.vue`** 卡片版式
+4. 内置知识库由**管理员(userId=1)**维护
+5. 已上传文档支持**下载**,**新建下载记录表**存下载行为
+6. **文档列表按上传者隔离:用户只能看到/下载/管理自己上传的文档**(本次新增)
+
+## 两层隔离模型(核心口径)
+
+| 层级 | 隔离规则 |
+|---|---|
+| 知识库本体 | 内置(is_builtin=1)全局可见可绑定;自建仅创建者可见可绑定 |
+| 库内文档 | **列表/详情/下载/删除/重试 只对上传者本人开放**(user_id 过滤)。内置库里管理员上传的文档清单对普通用户不可见 |
+| RAG 检索 | `search_knowledge` 检索**绑定知识库的全量文档**(与文档列表隔离解耦)——内置库对普通用户「知识可用、清单保密」,这正是共享库的价值 |
+| 绑定校验 | agent 绑定时校验知识库对当前用户可见(内置 or 自己的) |
+
+## 需求补充(我补充的遗漏项)
+
+| 补充项 | 设计 |
+|---|---|
+| 文档解析落盘 | Tika 抽取全文存 sidecar .txt(重索引无需重新解析原件),落 `knowledge/{kbId}/doc/` |
+| 异步向量化 | 上传后事件驱动异步索引,状态机 PENDING→INDEXING→READY/FAILED + fail_reason |
+| 重新索引 | 手动 reindex 接口;`tableTag()` 按模型隔离向量表,换模型不污染旧向量 |
+| 删除级联 | 删知识库 → 删全库文档文件+sidecar+向量行+下载记录 + 解绑引用该 KB 的 agent;删文档 → 三清 + 下载记录 |
+| 内置库初始化 | SQL 插入内置知识库(固定 id=1,is_builtin=1,user_id=1) |
+| 限制配置 | `KnowledgeProperties`:max-docs-per-user-per-kb、max-file-size、chunk-size/overlap、index-concurrency |
+| 我的文档数 | KB 卡片展示「我的文档 N 篇」(按 kb_id+user_id group by 统计,不冗余全库计数) |
+
+## 一、数据库(新建 `sql/knowledge_base.sql`,幂等)
+
+```sql
+-- 知识库
+create table if not exists knowledge_base (
+    id          bigint primary key,              -- 雪花ID;内置库固定 id=1
+    user_id     bigint       not null,           -- 创建者(内置=1 管理员)
+    is_builtin  smallint     not null default 0, -- 1=内置(全局可见只读)
+    name        varchar(100) not null,
+    description varchar(500),
+    create_time timestamp    default current_timestamp,
+    update_time timestamp
+);
+create index if not exists idx_kb_user on knowledge_base(user_id);
+
+-- 知识库文档(元数据 + 解析/索引状态;user_id 即上传者,列表隔离依据)
+create table if not exists knowledge_doc (
+    id           bigint primary key,
+    kb_id        bigint        not null,
+    user_id      bigint        not null,         -- 上传者(列表/下载隔离依据)
+    file_name    varchar(500)  not null,
+    file_path    varchar(1000) not null,         -- 原件(下载用)
+    sidecar_path varchar(1000),                  -- Tika 全文 .txt(向量化用)
+    mime         varchar(100),
+    size_bytes   bigint default 0,
+    char_count   integer default 0,
+    chunk_count  integer default 0,              -- 已向量化块数
+    index_status varchar(20) default 'PENDING',  -- PENDING/INDEXING/READY/FAILED
+    fail_reason  varchar(1000),
+    download_count integer default 0,           -- 冗余下载次数(我的文档列表展示)
+    create_time  timestamp default current_timestamp,
+    update_time  timestamp
+);
+create index if not exists idx_kd_kb_user on knowledge_doc(kb_id, user_id);
+
+-- 下载记录表(谁在何时下载了哪个文档)
+create table if not exists knowledge_doc_download_log (
+    id            bigint primary key,           -- 雪花ID
+    kb_id         bigint      not null,
+    doc_id        bigint      not null,
+    user_id       bigint      not null,         -- 下载人
+    file_name     varchar(500) not null,        -- 下载时文件名快照
+    download_time timestamp default current_timestamp
+);
+create index if not exists idx_kdl_doc on knowledge_doc_download_log(doc_id);
+create index if not exists idx_kdl_user on knowledge_doc_download_log(user_id);
+
+-- 内置知识库
+insert into knowledge_base (id, user_id, is_builtin, name, description)
+values (1, 1, 1, '系统通用知识库', '系统内置,所有用户可见(管理员维护)')
+on conflict (id) do nothing;
+
+-- agent 绑定知识库
+alter table agent add column if not exists knowledge_base_id bigint;
+```
+
+## 二、后端(ai-server 新增 `module/knowledge` 包)
+
+### 实体/Mapper
+- `KnowledgeBase`、`KnowledgeDoc`、`KnowledgeDocDownloadLog`(MyBatis-Plus,`IdType.ASSIGN_ID`,仿 `ChatAttachment`)
+- `KnowledgeVectorMapper`:pgvector 原生 SQL(动态表名建表/插入/cosine 检索/按 docId 删/按 kbId 删),仿 `AttachmentVectorMapper`;检索 WHERE `payload->>'kbId'` 双保险
+
+### Service
+- `KnowledgeBaseService`(隔离规则仿 `AgentService` 专家管理区,userId 显式传参便于单测):
+  - `list(userId)`:内置 + 自己的,后端过滤,内置在前;VO 带「我的文档数」(group by 统计)
+  - `create/update/delete`:内置拒绝编辑/删除;删除时级联(全库文档+sidecar+向量行+下载记录)+ `detachKnowledgeBase(kbId)` 解绑 agent 并 `evictAgentPool`
+  - `requireVisible(userId, kbId)`:内置 or 自己的(绑定校验用)
+- `KnowledgeDocService`(**文档操作全部按上传者隔离**):
+  - `listMine(userId, kbId)`:知识库可见性校验后,**只返回 user_id=当前用户 的文档**
+  - `upload(userId, kbId, file)`:权限(自建库=库主人;内置库=仅管理员 userId=1 可传)→ 类型白名单 → 落盘 → Tika 抽全文存 sidecar → 登记 doc(PENDING)→ 发布 `KnowledgeDocIndexEvent`
+  - `download(userId, docId)`:**requireOwnedDoc(只允许下载自己上传的)** → 写下载记录 + download_count+1 → 返回文件流
+  - `delete(userId, docId)`:requireOwnedDoc → DB 行 + 原件 + sidecar + 向量行三清 + 下载记录
+  - `reindex(userId, docId)`:requireOwnedDoc → 状态重置 PENDING 重发事件
+- `KnowledgeDocIndexer`(`@EventListener` 异步,仿 `AttachmentDocIndexer`):
+  - 读 sidecar → `chunk()` 分块(固定窗口+重叠+段落对齐)→ `EmbeddingProvider.embed` → 写 pgvector `kb_vec_{kbId}{tag}_d{dims}` → 更新 doc.chunk_count/READY;异常置 FAILED+fail_reason
+  - 成员级公平 Semaphore 全局嵌入并发闸门
+
+### 工具与 Agent 接入
+- `KnowledgeSearchTool`(仿 `RagSchemaSearchTool`):`@Tool(name="search_knowledge", readOnly=true)`,构造时绑定 kbId+kbName,参数 `query`/`top_k`,返回片段(content+来源文档名+相似度)
+- **默认注册语义**:agent 绑定了知识库 → `AgentService.buildAgent()` 自动把 `KnowledgeSearchTool` 注册为基础设施工具(与 `RagSchemaSearchTool` 同路径,不进任何工具组、不受 `tools_allow_json`/工具包白名单约束、`reset_equipped_tools` 不收起),并 `appendKbPrompt` 追加使用指引;未绑定知识库的 agent 不注册该工具(省 token)。EmbeddingProvider 不可用时跳过注册并 log.warn(fail-open)
+- `AgentEntity` 加 `knowledgeBaseId`;`SaveExpertDTO` 加可选 `knowledgeBaseId`;保存时校验存在性+可见性;`writeExpert` 显式 set 该列(null 照写,支持解绑)
+
+### Controller(`KnowledgeBaseController`,`/knowledge-bases`,仿 `AgentExpertController`)
+- `GET /` 列表;`POST /` 创建;`PUT /{id}`(内置拒绝);`DELETE /{id}`(内置拒绝)
+- `GET /{id}/docs` **我的文档列表**;`POST /{id}/docs`(multipart 上传);`DELETE /{id}/docs/{docId}`
+- `GET /{id}/docs/{docId}/download` 下载(校验归属+记下载记录+文件流)
+
+## 三、前端(样式布局对齐专家页 aiExpert/index.vue)
+
+1. **api/knowledgeApi.ts** + `types.ts` 类型:KB CRUD、我的文档列表/上传/删除/下载(下载仿 `artifactApi.downloadArtifactFile`:带 token fetch blob → `downloadByData`)
+2. **views/aiKnowledge/index.vue**:完全对齐专家页版式——
+   - 同款 panel 结构:卡头(标题「知识库」+ 摘要统计「内置 N · 我的 N」+ 名称搜索框 + 刷新/新建按钮)
+   - 同款卡片网格:卡片头图标+名称+「内置/自定义」chip 标签、描述、chips(我的文档数/索引状态摘要)、卡底操作区(主操作「管理文档」+ 图标按钮:详情/编辑/删除,内置卡片不渲染编辑删除,后端另有双重校验)
+   - 空态复用 `EmptyState`;详情弹框复用 `BasicModal`
+3. **文档管理弹窗**(卡片「管理文档」打开,同款 Modal 版式):**标题标注「我的上传文档」**,表格列:文件名/大小/块数/索引状态 tag/失败原因/下载次数/操作[下载|重试|删除];上传按钮;文档列表接口天然只返回当前用户上传的数据,无需前端过滤
+4. **aiWorkbench/index.vue**:`RAIL_ITEMS` 增加 `{ key: 'knowledge', icon: 'mdi:bookshelf', label: '知识库' }`
+5. **AgentFormModal.vue**:加「绑定知识库」下拉(数据源=知识库列表,可清空=不绑定),随 `SaveExpertParams` 提交
+
+## 四、实施顺序
+
+1. SQL 脚本 + 实体/Mapper(含下载记录表)
+2. KnowledgeBaseService / KnowledgeDocService(隔离、级联、下载记录)
+3. KnowledgeDocIndexer(异步索引)+ 事件
+4. KnowledgeSearchTool + AgentService/AgentEntity/SaveExpertDTO 接入
+5. KnowledgeBaseController(含下载接口)
+6. 前端:API → 知识库页(对齐专家页版式)→ 文档管理弹窗 → 工作台入口 → AgentFormModal 绑定选择
+7. 单测(隔离规则:内置只读/归属校验/文档按上传者隔离/下载权限/级联删除)
+
+## 五、明确不做
+- 不引入新向量库/MQ/@Async(与项目现状一致)
+- 不扩展权限系统(沿用 user_id + is_builtin + 管理员=1 口径)
+- 不做文档在线预览(仅列表管理 + 下载)
+- 对话链路(AgentChatServiceImpl)不改动——工具注册随 buildAgent 生效,池化驱逐已有机制

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

@@ -300,29 +300,57 @@ public class AiTemplateMatcher {
     }
 
     /**
-     * 只保留 {@code fieldNameEn} 确实存在于候选模板的绑定。
+     * 只保留字段名确实存在于候选模板的绑定,并把 {@code fieldNameEn} 归一成该字段的英文名。
      *
-     * <p>这道校验必须有:出口 A 场景下字段名会被反射写进实体,编出来的名字直接报错;
-     * 出口 B 场景下它会变成动态表的列名。
+     * <p><b>为什么这道校验存在</b>:模型偶发会编造模板里没有的字段名。放行的后果是脏绑定静默通过 ——
+     * 出口 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} 一致。
      */
     public static List<RuleBind> filterBinds(List<RuleBind> binds, TemplateCandidate cand, int colCount) {
         if (binds == null || cand == null) {
             return List.of();
         }
-        List<String> allowed = cand.fields().stream().map(TemplateCandidate.FieldBrief::en).toList();
+        Map<String, String> enByNorm = new HashMap<>();
+        for (TemplateCandidate.FieldBrief f : cand.fields()) {
+            if (f == null) {
+                continue;
+            }
+            if (StrUtil.isNotBlank(f.en())) {
+                enByNorm.putIfAbsent(normalizeName(f.en()), f.en());
+            }
+            if (StrUtil.isNotBlank(f.cn())) {
+                enByNorm.putIfAbsent(normalizeName(f.cn()), f.en());
+            }
+        }
         List<RuleBind> out = new ArrayList<>();
         for (RuleBind b : binds) {
-            if (b == null || StrUtil.isBlank(b.getFieldNameEn()) || !allowed.contains(b.getFieldNameEn())) {
+            if (b == null || StrUtil.isBlank(b.getFieldNameEn())) {
+                continue;
+            }
+            String en = enByNorm.get(normalizeName(b.getFieldNameEn()));
+            if (en == null) {
                 continue;
             }
             if (b.getFileColIndex() == null || b.getFileColIndex() < 0 || b.getFileColIndex() >= colCount) {
                 continue;
             }
+            b.setFieldNameEn(en);
             out.add(b);
         }
         return out;
     }
 
+    /** 字段名归一:trim + 小写,与 {@code AiRulePlan#normalizeKey} 同口径 */
+    private static String normalizeName(String name) {
+        return StrUtil.nullToEmpty(name).trim().toLowerCase(java.util.Locale.ROOT);
+    }
+
     /**
      * 出口选择。<b>WEAK 档一律不许走 A</b>:A 会把数据直接写进用户既有业务表,
      * 而既有清理只能按文件维度物理删除,判错一次的代价远高于让 AI 自己建表。

+ 4 - 3
ai-server/src/main/java/com/zsjz/ai/module/aiclean/model/RuleBind.java

@@ -24,9 +24,10 @@ public class RuleBind {
     private Integer fileColIndex;
 
     /**
-     * 目标字段英文名。必须落在候选模板的字段枚举内,否则该绑定被拒绝 ——
-     * 这是防「模型凭空造字段」的硬校验(出口 A 场景下它会被反射写进实体,
-     * 造出来的字段名会直接 {@code ReflectUtil.setFieldValue} 失败)。
+     * 目标字段名。模型允许回候选模板字段的中文名或英文名(逐字复制),
+     * {@code AiTemplateMatcher#filterBinds} 会把不在模板字段枚举内的绑定丢弃(防造字段),
+     * 并把命中中文名的改写成规范英文名 —— 出口 A 按 {@code field_name_en} 查 table_field、
+     * 出口 B 按 en/cn 双域查候选字段({@code AiRulePlan#indexCandidateFields})。
      */
     private String fieldNameEn;
 

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

@@ -9,8 +9,9 @@
 【硬性规则】
 1. templateNo 只能是候选列表里出现过的序号(# 后面的数字)。这些序号之外没有任何模板存在,
    不要猜 ID、不要编名字。都不合适时填 0。
-2. fieldNameEn 必须逐字来自所选模板的字段英文名清单。清单里没有的字段一律不许出现 ——
-   宁可留空该列(不写进 binds),也不要造一个新字段名。
+2. fieldNameEn 必须逐字复制所选模板字段清单里的名字:优先复制英文名(⇒ 右侧);
+   英文名是 c0/c1 这类物理列名时,直接复制中文名(⇒ 左侧)即可。
+   清单里没有的名字一律不许出现 —— 宁可留空该列(不写进 binds),也不要造一个新字段名。
 3. 拿不准对应哪个字段的列,直接不写进 binds。留空比绑错好:绑错会把脏数据写进业务表。
 4. hints 只能从下面这些取值里选(可以不带):
    - EXTRACT_NAMED:EMAIL / EXTRACT_NAMED:MOBILE_CN / EXTRACT_NAMED:CITIZEN_ID

+ 31 - 7
ai-server/src/test/java/com/zsjz/ai/module/aiclean/match/AiTemplateMatcherTest.java

@@ -142,19 +142,43 @@ class AiTemplateMatcherTest {
     }
 
     @Test
-    @DisplayName("列绑定过滤:字段名不在候选枚举内、列号越界的一律丢弃")
-    void filterBindsRejectsInventedFields() {
+    @DisplayName("列绑定过滤:en/cn 双域合法、命中中文名改写为英文名;编造名与越界列丢弃")
+    void filterBindsAcceptsBothDomainsAndRejectsInventedFields() {
+        // 既有模板风格:cn=金额,en=transAmount
         var cand = new TemplateCandidate(1, false, 77, null, 2,
                 "交易流水", "trans_record", headers,
                 List.of(new TemplateCandidate.FieldBrief(
                         "金额", "transAmount", "MONEY", true, 0, null)), 0.9);
-        RuleBind ok = bind(1, "transAmount");
-        RuleBind invented = bind(2, "不存在的字段");
+        RuleBind byEn = bind(1, "transAmount");
+        RuleBind byCn = bind(2, "金额");
+        RuleBind invented = bind(3, "不存在的字段");
         RuleBind outOfRange = bind(99, "transAmount");
         List<RuleBind> kept = AiTemplateMatcher.filterBinds(
-                List.of(ok, invented, outOfRange), cand, headers.size());
-        assertEquals(1, kept.size());
-        assertEquals(1, kept.getFirst().getFileColIndex());
+                List.of(byEn, byCn, invented, outOfRange), cand, headers.size());
+        assertEquals(2, kept.size());
+        // 英文名原样保留
+        assertEquals(1, kept.get(0).getFileColIndex());
+        assertEquals("transAmount", kept.get(0).getFieldNameEn());
+        // 模型回中文名也合法,且必须改写成英文名 —— 出口 A 按 field_name_en 查 table_field
+        assertEquals(2, kept.get(1).getFileColIndex());
+        assertEquals("transAmount", kept.get(1).getFieldNameEn());
+    }
+
+    @Test
+    @DisplayName("AI 模板风格候选(en=c0..cN):模型回中文名必须放行并改写为物理列名")
+    void filterBindsAcceptsCnAgainstAiStyleCandidate() {
+        // 复现 2026-10-25 车辆登记信息实测:模型对「号牌号码 ⇒ c5」回中文名,
+        // en-only 清单把整批正确绑定杀光 → 「模型未给出任何可用列绑定」
+        var cand = new TemplateCandidate(1, true, null, 9001L, null,
+                "车辆登记信息", "ai_clean_9001", headers,
+                List.of(new TemplateCandidate.FieldBrief(
+                        "号牌号码", "c5", "TEXT", false, 0, null)), 0.9);
+        RuleBind byCn = bind(0, "号牌号码");
+        RuleBind byEn = bind(1, "c5");
+        List<RuleBind> kept = AiTemplateMatcher.filterBinds(List.of(byCn, byEn), cand, headers.size());
+        assertEquals(2, kept.size());
+        assertEquals("c5", kept.get(0).getFieldNameEn());
+        assertEquals("c5", kept.get(1).getFieldNameEn());
     }
 
     @Test

BIN
周海--车辆登记信息 - 副本.xls