fix(kb): 零库时注入冷启动契约,修「放进知识库」被误写进记忆

根因: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 <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-07-24 15:31:50 +08:00
parent 0cd5b07124
commit e0aa8130af
4 changed files with 102 additions and 5 deletions

View File

@ -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 天然隐藏。 - **做成机制而非 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 判据续跑。 - **入库管线**(`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)管上传/删除,查询全走对话。 - **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"}` - **记账**:`usage_events` kind="kb_ingest"(OCR 那笔走 kind="vision"),无 task 上下文 → 0022 放宽 task_id 可 NULL,溯源靠 units JSONB `{"kb", "source"}`

View File

@ -334,7 +334,8 @@ def _build_system_prompt(
user_root(workspace_dir, user_id) / ".memory" user_root(workspace_dir, user_id) / ".memory"
) )
prompt += memory_block(workspace_dir, user_id, mem_dir_display) 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( kb_dir_display = "/workspace/.kb" if is_docker else str(
user_root(workspace_dir, user_id) / ".kb" user_root(workspace_dir, user_id) / ".kb"
) )

View File

@ -239,6 +239,25 @@ def save_source(workspace_dir: Path, user_id: UUID, name: str, filename: str, da
# ── prompt 注入(照 memory_block 范式) ──────────────────────────────── # ── 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 = """\ _KB_CONTRACT = """\
用法规矩: 用法规矩:
- **主动查阅**:回答前先扫一眼下方各库条目,标题 / 摘要 / 关键词与用户问题相关就先 - **主动查阅**:回答前先扫一眼下方各库条目,标题 / 摘要 / 关键词与用户问题相关就先
@ -257,7 +276,7 @@ def kb_block(
user_id: UUID, user_id: UUID,
kb_dir_display: Optional[str] = None, kb_dir_display: Optional[str] = None,
) -> str: ) -> str:
"""构造注入 system prompt 的知识库段;用户没有任何库时返回空串(零成本)。 """构造注入 system prompt 的知识库段;零库时注极简冷启动契约(~百 token)。
kb_dir_display: `.kb/` agent 视角下的路径前缀(docker `/workspace/.kb`, kb_dir_display: `.kb/` agent 视角下的路径前缀(docker `/workspace/.kb`,
host None 宿主绝对路径) memory_block mem_dir_display 同款约定 host None 宿主绝对路径) memory_block mem_dir_display 同款约定
@ -265,10 +284,10 @@ def kb_block(
description 逻辑),几十篇的个人库体量注得起;正文仍按需 read description 逻辑),几十篇的个人库体量注得起;正文仍按需 read
""" """
kbs = list_kbs(workspace_dir, user_id) kbs = list_kbs(workspace_dir, user_id)
if not kbs:
return ""
root = kb_root(workspace_dir, user_id) root = kb_root(workspace_dir, user_id)
base = (kb_dir_display if kb_dir_display is not None else str(root)).rstrip("/") 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 = ["\n\n## 个人知识库 (user 级,跨 task 共享)\n"]
parts.append(_KB_CONTRACT.replace("{fmt}", INDEX_LINE_FORMAT)) parts.append(_KB_CONTRACT.replace("{fmt}", INDEX_LINE_FORMAT))

77
tests/test_kb_block.py Normal file
View File

@ -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()