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:
parent
0cd5b07124
commit
e0aa8130af
|
|
@ -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"}`。
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
|
|
|||
25
core/kb.py
25
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))
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
Loading…
Reference in New Issue