From e0aa8130af80ab6e123c12e7afd1f0ffec63cef6 Mon Sep 17 00:00:00 2001 From: caoqianming Date: Fri, 24 Jul 2026 15:31:50 +0800 Subject: [PATCH] =?UTF-8?q?fix(kb):=20=E9=9B=B6=E5=BA=93=E6=97=B6=E6=B3=A8?= =?UTF-8?q?=E5=85=A5=E5=86=B7=E5=90=AF=E5=8A=A8=E5=A5=91=E7=BA=A6,?= =?UTF-8?q?=E4=BF=AE=E3=80=8C=E6=94=BE=E8=BF=9B=E7=9F=A5=E8=AF=86=E5=BA=93?= =?UTF-8?q?=E3=80=8D=E8=A2=AB=E8=AF=AF=E5=86=99=E8=BF=9B=E8=AE=B0=E5=BF=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根因:kb_block 零库返回空串,agent 不知道 KB 机制存在,用户点名"知识库" 被就近理解成记忆写进 .memory/(真实用户事故)。与 memory 空契约常驻同一课。 - core/kb.py: 新增 _KB_COLD_START(建库步骤 + INDEX 行格式 + KB/记忆分工, ~百 token);kb_block 零库分支改注该契约,有库分支不变 - tests/test_kb_block.py: 锁两分支(冷启动文案/路径展示/全量 INDEX/空库) - DESIGN.md §3.8: "有库才注入"取舍更新为冷启动注入 + 事故记录 Co-Authored-By: Claude Fable 5 --- DESIGN.md | 2 +- core/agent_builder.py | 3 +- core/kb.py | 25 ++++++++++++-- tests/test_kb_block.py | 77 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 102 insertions(+), 5 deletions(-) create mode 100644 tests/test_kb_block.py diff --git a/DESIGN.md b/DESIGN.md index 8e5f153..5b9e4aa 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -95,7 +95,7 @@ Session = 消息列表,ORM 直写 PG `messages`(append-only,jsonb 存 LiteLLM - **做成机制而非 skill**(判据:有独立于会话的持久状态需用户管理 → 机制):落盘 `user_root/.kb/<库名>/`(INDEX.md + docs/ 转换后 md + sources/ 原件)。**"已入库"判据 = INDEX.md 有条目**,sources 有而 INDEX 无 = 待入库 → 入库幂等、崩溃可恢复、零 migration。dotfile 命名同 `.memory` 双向防呆,GET /v1/files 天然隐藏。 - **入库管线**(`core/kb_ingest.py`,上传即触发 + 手动兜底):markitdown Python API 转 md → 扫描件 PDF(文本近零)走方舟文档理解 OCR 兜底(§8.13 同通道)→ deepseek flash 单次 chat 写 标题/摘要/关键词(失败降级文件名+正文开头,不阻塞)→ 追加 INDEX 行。编排照定时执行器:create_task + to_thread + per-(user,库) 内存锁去重;进度存内存供前端轮询,崩了靠 FS 判据续跑。 -- **agent 侧零新工具**:`kb_block`(照 memory_block)把 INDEX 全文 + 契约(主动查阅无需点名 / INDEX 行格式 / 答题标来源)注 prompt,**用户有库才注入**;fs 工具在 user_root 内可读写、docker 沙箱整 user_root bind → `.kb` 天然可达。INDEX 行格式是对话内手动入库与后台产出的同一契约。 +- **agent 侧零新工具**:`kb_block`(照 memory_block)把 INDEX 全文 + 契约(主动查阅无需点名 / INDEX 行格式 / 答题标来源)注 prompt;**零库时注极简冷启动契约**(建库步骤 + INDEX 行格式 + "成篇资料进 KB / 短事实进记忆"分工,~百 token)——原"有库才注入"省 token,但零注入让模型不知道 KB 机制存在,用户说"放进知识库"被就近写进 `.memory/`(2026-07-24 真实事故),与 memory 空契约常驻是同一课:教会第一次,建库落盘后下轮 build_agent 自然切全量。fs 工具在 user_root 内可读写、docker 沙箱整 user_root bind → `.kb` 天然可达。INDEX 行格式是对话内手动入库与后台产出的同一契约。 - **API 薄壳**(`/v1/kb*` 8 端点):列/建/删库、详情(带入库进度)、上传即入库、手动 ingest、看/删单篇。**不设 HTTP 检索端点**——检索是 agent 的事。前端两栏 modal(kb.js)管上传/删除,查询全走对话。 - **记账**:`usage_events` kind="kb_ingest"(OCR 那笔走 kind="vision"),无 task 上下文 → 0022 放宽 task_id 可 NULL,溯源靠 units JSONB `{"kb", "source"}`。 diff --git a/core/agent_builder.py b/core/agent_builder.py index cfc9b53..b94e61b 100644 --- a/core/agent_builder.py +++ b/core/agent_builder.py @@ -334,7 +334,8 @@ def _build_system_prompt( user_root(workspace_dir, user_id) / ".memory" ) prompt += memory_block(workspace_dir, user_id, mem_dir_display) - # 个人知识库 .kb/:路径换算同 .memory(docker 给容器路径);用户没建库时注空串零成本。 + # 个人知识库 .kb/:路径换算同 .memory(docker 给容器路径);零库时注冷启动契约 + # (否则用户说「放进知识库」会被就近写进记忆 —— 模型不知道 KB 机制存在)。 kb_dir_display = "/workspace/.kb" if is_docker else str( user_root(workspace_dir, user_id) / ".kb" ) diff --git a/core/kb.py b/core/kb.py index 58fd1a5..adf44e2 100644 --- a/core/kb.py +++ b/core/kb.py @@ -239,6 +239,25 @@ def save_source(workspace_dir: Path, user_id: UUID, name: str, filename: str, da # ── prompt 注入(照 memory_block 范式) ──────────────────────────────── +# 零库时的冷启动契约:曾因零库零注入,用户说「放进知识库」被 agent 就近写进 +# .memory/(2026-07-24)—— 模型根本不知道 KB 机制存在。与 memory 空契约常驻 +# 同一课:教会第一次即可,建库落盘后下轮 build_agent 自然切到全量注入。 +_KB_COLD_START = """\ + + +## 个人知识库 (user 级,跨 task 共享) + +用户还没有任何知识库(`{base}/` 下无库)。当用户明确要求把资料**放进知识库 / +长期保存备查**时,不要写进记忆 —— 直接用 fs 工具建库入库(无需专用工具): +- **建库**:创建 `{base}/<库名>/docs/` 与 `{base}/<库名>/sources/` 两个目录,写 + `{base}/<库名>/INDEX.md`(首行 `# <库名>`)。库名用中文/字母/数字。 +- **入库**:原件放 `sources/`(对话里没有原件就跳过);正文转成 markdown 写 + `docs/<文件名>.md`;再往 INDEX.md 追加一行,**格式必须是**: + `{fmt}` + (摘要写准 —— 它是下次召回的依据)。 +- **分工**:成篇资料(文档/规范/报告)进知识库;关于用户本人的短事实才写记忆。 + 仅用户明确表达"存知识库/长期保存"时才建库,临时讨论材料不要自动入库。""" + _KB_CONTRACT = """\ 用法规矩: - **主动查阅**:回答前先扫一眼下方各库条目,标题 / 摘要 / 关键词与用户问题相关就先 @@ -257,7 +276,7 @@ def kb_block( user_id: UUID, kb_dir_display: Optional[str] = None, ) -> str: - """构造注入 system prompt 的知识库段;用户没有任何库时返回空串(零成本)。 + """构造注入 system prompt 的知识库段;零库时注极简冷启动契约(~百 token)。 kb_dir_display: `.kb/` 在 agent 视角下的路径前缀(docker 传 `/workspace/.kb`, host 传 None ⇒ 宿主绝对路径)—— 与 memory_block 的 mem_dir_display 同款约定。 @@ -265,10 +284,10 @@ def kb_block( description 逻辑),几十篇的个人库体量注得起;正文仍按需 read。 """ kbs = list_kbs(workspace_dir, user_id) - if not kbs: - return "" root = kb_root(workspace_dir, user_id) base = (kb_dir_display if kb_dir_display is not None else str(root)).rstrip("/") + if not kbs: + return _KB_COLD_START.replace("{base}", base).replace("{fmt}", INDEX_LINE_FORMAT) parts = ["\n\n## 个人知识库 (user 级,跨 task 共享)\n"] parts.append(_KB_CONTRACT.replace("{fmt}", INDEX_LINE_FORMAT)) diff --git a/tests/test_kb_block.py b/tests/test_kb_block.py new file mode 100644 index 0000000..b9f4c9d --- /dev/null +++ b/tests/test_kb_block.py @@ -0,0 +1,77 @@ +"""kb_block 注入契约:零库冷启动 vs 有库全量。 + +背景(2026-07-24):零库时 kb_block 返回空串,agent 不知道 KB 机制存在,用户说 +「放进我的知识库」被就近写进 .memory/。修复 = 零库注极简冷启动契约(建库步骤 + +INDEX 行格式 + 知识库/记忆分工),与 memory 空契约常驻同一课。本测试锁住两个分支。 +""" +from __future__ import annotations + +import sys +import tempfile +import unittest +from pathlib import Path +from uuid import UUID + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from core.kb import ( # noqa: E402 + INDEX_LINE_FORMAT, + create_kb, + format_index_line, + kb_block, +) + +_UID = UUID("6b14d2ab-7a6e-4d0b-8562-ea62a801e32c") + + +class TestKbBlock(unittest.TestCase): + def setUp(self): + self.ws = Path(tempfile.mkdtemp()) / "workspace" + + def test_cold_start_injects_minimal_contract(self): + block = kb_block(self.ws, _UID, "/workspace/.kb") + # 机制存在 + 建库/入库指引 + 行格式契约都在 + self.assertIn("个人知识库", block) + self.assertIn("还没有任何知识库", block) + self.assertIn("/workspace/.kb/<库名>/docs/", block) + self.assertIn(INDEX_LINE_FORMAT, block) + # 与记忆的分工写明(误写 .memory 正是要防的失败模式) + self.assertIn("短事实才写记忆", block) + # 冷启动块不含任何库清单 + self.assertNotIn("### 库「", block) + + def test_cold_start_host_path_display(self): + block = kb_block(self.ws, _UID, None) + root = self.ws / "users" / str(_UID) / ".kb" + self.assertIn(str(root), block) + + def test_with_kb_injects_full_index(self): + d = create_kb(self.ws, _UID, "标准库") + assert d is not None + line = format_index_line( + title="水泥胶砂强度检验方法", + doc="docs/gbt17671.md", + source="sources/gbt17671.pdf", + summary="ISO 法测定水泥胶砂抗压抗折强度。", + keywords="水泥,强度,GB/T 17671", + ) + (d / "INDEX.md").write_text(f"# 标准库\n\n{line}\n", encoding="utf-8") + block = kb_block(self.ws, _UID, "/workspace/.kb") + # 全量分支:库清单 + 条目 + 完整契约(对话内入库段) + self.assertIn("### 库「标准库」", block) + self.assertIn("水泥胶砂强度检验方法", block) + self.assertIn("对话内入库", block) + # 冷启动文案不应出现 + self.assertNotIn("还没有任何知识库", block) + + def test_empty_kb_counts_as_having_kb(self): + # 建了库但零文档:走全量分支(注库名 + 空库提示),不再是冷启动 + create_kb(self.ws, _UID, "新库") + block = kb_block(self.ws, _UID, "/workspace/.kb") + self.assertIn("### 库「新库」", block) + self.assertIn("空库", block) + self.assertNotIn("还没有任何知识库", block) + + +if __name__ == "__main__": + unittest.main()