"""个人知识库: `workspace/users//.kb/<库名>/` —— 纯文件,无向量无 DB。 照「记忆」机制范式(core/memory.py):真实文件为准 + agentic search,索引只是 可重建的派生视图。每个库一个目录: .kb/<库名>/ INDEX.md —— 库目录(单行一条,格式见 INDEX_LINE_FORMAT),注 prompt 的召回依据 docs/.md —— 转换后的 markdown 正文(agent 用 read/grep 按需拉) sources/ —— 原始文件(pdf/docx/...),留档 + 待入库队列 **「已入库」判据 = INDEX.md 有指向该 doc 的条目**;sources 有而 INDEX 无对应 doc = 待入库。由此入库天然幂等(重跑只补缺口)、崩溃可恢复(半截转换重来即可)、 零 DB migration。入库编排在 core/kb_ingest.py,本模块只管状态读写与视图。 .kb 是 dotfile:被 GET /v1/files 天然隐藏、validate_task_name 拒 `.` 起头 —— 与 .memory/.skills 同款防呆。agent 无需新工具:fs 工具 user_root 内可读写, docker 沙箱把整个 user_root bind 到 /workspace,.kb 随之可见。 """ from __future__ import annotations import re import shutil from pathlib import Path from typing import Any, Dict, List, Optional from uuid import UUID # INDEX 单行格式(全角 | 分隔,摘要/关键词内允许半角标点)。agent 对话内手动入库 # 与后台 ingest 产出同一格式 —— 契约文本(kb_block)里原样给出。 INDEX_LINE_FORMAT = "- [标题](docs/<文件名>.md)|来源 sources/<原件名>|摘要:<两三句>|关键词:<逗号分隔>" _INDEX_LINE_RE = re.compile( r"^-\s*\[(?P[^\]]*)\]\((?P<doc>docs/[^)]+)\)" r"\s*|\s*来源\s*(?P<source>sources/[^|]+?)" r"\s*|\s*摘要[::]\s*(?P<summary>[^|]*)" r"(?:\s*|\s*关键词[::]\s*(?P<keywords>.*))?\s*$" ) # 库名:中文/字母/数字/-/_,拒 dotfile、路径分隔、Windows 保留字符。 _KB_NAME_RE = re.compile(r"^[\w一-鿿][\w一-鿿\-. ]{0,39}$") # 库内文件名(docs/ 与 sources/ 下的扁平文件):拒斜杠 / `..` / dotfile。 _FILE_NAME_RE = re.compile(r"^[^/\\]{1,200}$") def kb_root(workspace_dir: Path, user_id: UUID) -> Path: return workspace_dir / "users" / str(user_id) / ".kb" def is_safe_kb_name(name: str) -> bool: if not name or name != name.strip() or name.startswith("."): return False if ".." in name or any(c in name for c in '/\\:*?"<>|'): return False return bool(_KB_NAME_RE.match(name)) def is_safe_file_name(name: str) -> bool: if not name or name.startswith(".") or ".." in name: return False return bool(_FILE_NAME_RE.match(name)) def kb_dir(workspace_dir: Path, user_id: UUID, name: str) -> Optional[Path]: """库目录(校验名字合法 + 落在 .kb 子树内);非法返回 None(调用方转 4xx)。""" if not is_safe_kb_name(name): return None root = kb_root(workspace_dir, user_id).resolve() d = (root / name).resolve() if d.parent != root: return None return d def parse_index(text: str) -> List[Dict[str, str]]: """解析 INDEX.md → [{title, doc, source, summary, keywords}];不合格式的行忽略。""" out: List[Dict[str, str]] = [] for raw in text.splitlines(): m = _INDEX_LINE_RE.match(raw.strip()) if not m: continue out.append({ "title": m.group("title").strip(), "doc": m.group("doc").strip(), "source": (m.group("source") or "").strip(), "summary": (m.group("summary") or "").strip(), "keywords": (m.group("keywords") or "").strip(), }) return out def format_index_line(*, title: str, doc: str, source: str, summary: str, keywords: str) -> str: """产出与 INDEX_LINE_FORMAT 一致的单行(后台 ingest 用;agent 侧照契约手写)。""" def clean(s: str) -> str: return " ".join((s or "").split()).replace("|", "|") return ( f"- [{clean(title)}]({doc})|来源 {source}" f"|摘要:{clean(summary)}|关键词:{clean(keywords)}" ) def _read_index(d: Path) -> List[Dict[str, str]]: p = d / "INDEX.md" if not p.is_file(): return [] try: return parse_index(p.read_text(encoding="utf-8")) except (OSError, UnicodeDecodeError): return [] def _list_sources(d: Path) -> List[str]: src = d / "sources" if not src.is_dir(): return [] return sorted(p.name for p in src.iterdir() if p.is_file() and not p.name.startswith(".")) def pending_sources(d: Path) -> List[str]: """sources 有而 INDEX 无 = 待入库(判据即幂等性来源,见模块注释)。""" indexed = {e["source"].removeprefix("sources/") for e in _read_index(d)} return [n for n in _list_sources(d) if n not in indexed] def list_kbs(workspace_dir: Path, user_id: UUID) -> List[Dict[str, Any]]: """所有库概览:[{name, doc_count, pending_count}],按名排序。""" root = kb_root(workspace_dir, user_id) if not root.is_dir(): return [] out: List[Dict[str, Any]] = [] for d in sorted(root.iterdir()): if not d.is_dir() or d.name.startswith("."): continue entries = _read_index(d) out.append({ "name": d.name, "doc_count": len(entries), "pending_count": len(pending_sources(d)), }) return out def create_kb(workspace_dir: Path, user_id: UUID, name: str) -> Optional[Path]: """建库(幂等):目录 + 空 INDEX.md + docs/ + sources/。名字非法返回 None。""" d = kb_dir(workspace_dir, user_id, name) if d is None: return None (d / "docs").mkdir(parents=True, exist_ok=True) (d / "sources").mkdir(parents=True, exist_ok=True) idx = d / "INDEX.md" if not idx.exists(): idx.write_text(f"# {name}\n\n", encoding="utf-8") return d def delete_kb(workspace_dir: Path, user_id: UUID, name: str) -> bool: """整库删除(原件 + docs + INDEX 一起没,前端已二次确认)。""" d = kb_dir(workspace_dir, user_id, name) if d is None or not d.is_dir(): return False shutil.rmtree(d) return True def kb_detail(workspace_dir: Path, user_id: UUID, name: str) -> Optional[Dict[str, Any]]: """单库全貌:INDEX 条目 + 待入库 sources 列表。库不存在返回 None。""" d = kb_dir(workspace_dir, user_id, name) if d is None or not d.is_dir(): return None return { "name": name, "entries": _read_index(d), "pending": pending_sources(d), } def read_doc(workspace_dir: Path, user_id: UUID, name: str, filename: str) -> Optional[str]: """读单篇 docs/<filename> 原文;非法 / 不存在 → None(调用方转 404)。""" d = kb_dir(workspace_dir, user_id, name) if d is None or not is_safe_file_name(filename) or not filename.endswith(".md"): return None target = (d / "docs" / filename).resolve() if target.parent != (d / "docs").resolve() or not target.is_file(): return None try: return target.read_text(encoding="utf-8") except (OSError, UnicodeDecodeError): return None def delete_doc(workspace_dir: Path, user_id: UUID, name: str, filename: str) -> bool: """删单篇:docs 文件 + INDEX 对应行 + 对应 source 原件一起删(否则原件会被当 待入库重新转一遍 —— 判据使然)。""" d = kb_dir(workspace_dir, user_id, name) if d is None or not is_safe_file_name(filename) or not filename.endswith(".md"): return False doc_rel = f"docs/{filename}" entries = _read_index(d) hit = next((e for e in entries if e["doc"] == doc_rel), None) target = (d / "docs" / filename).resolve() if target.parent != (d / "docs").resolve(): return False if not target.is_file() and hit is None: return False if target.is_file(): target.unlink() if hit is not None: src_name = hit["source"].removeprefix("sources/") if is_safe_file_name(src_name): src = d / "sources" / src_name if src.is_file(): src.unlink() idx = d / "INDEX.md" try: lines = idx.read_text(encoding="utf-8").splitlines() kept = [ ln for ln in lines if not (_INDEX_LINE_RE.match(ln.strip()) and f"({doc_rel})" in ln) ] idx.write_text("\n".join(kept).rstrip() + "\n", encoding="utf-8") except (OSError, UnicodeDecodeError): pass return True def save_source(workspace_dir: Path, user_id: UUID, name: str, filename: str, data: bytes) -> Optional[str]: """上传原件落 sources/(同名覆盖 —— 重传即重新入库的自然语义)。 返回落盘文件名;库不存在 / 文件名非法 → None。""" d = kb_dir(workspace_dir, user_id, name) if d is None or not d.is_dir() or not is_safe_file_name(filename): return None src_dir = d / "sources" src_dir.mkdir(parents=True, exist_ok=True) # 覆盖旧 doc 判据:同名 source 若已在 INDEX,删掉旧条目让它重新排队入库 doc_rel_hits = [e for e in _read_index(d) if e["source"] == f"sources/{filename}"] for e in doc_rel_hits: delete_doc(workspace_dir, user_id, name, e["doc"].removeprefix("docs/")) (src_dir / filename).write_bytes(data) return filename # ── 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 = """\ 用法规矩: - **主动查阅**:回答前先扫一眼下方各库条目,标题 / 摘要 / 关键词与用户问题相关就先 `read` 对应 `docs/*.md` 正文再作答(量大时 `grep` 先缩范围)。**无需用户点名 "知识库"** —— 用户资料里可能已有的内容,宁可多查一次,不要凭空回答。 - **答题标来源**:引用了哪个库哪篇就在回答里注明(标题或文件名)。 - **对话内入库**:用户在对话里给了值得长期留的资料时,可直接写入:原件放 `sources/`(没有原件就跳过)、正文转成 markdown 写 `docs/<slug>.md`、再往该库 INDEX.md 追加一行,**格式必须是**: `{fmt}` (与后台自动入库产出一致;摘要写准 —— 它是下次召回的依据)。""" def kb_block( workspace_dir: Path, user_id: UUID, kb_dir_display: Optional[str] = None, ) -> str: """构造注入 system prompt 的知识库段;零库时注极简冷启动契约(~百 token)。 kb_dir_display: `.kb/` 在 agent 视角下的路径前缀(docker 传 `/workspace/.kb`, host 传 None ⇒ 宿主绝对路径)—— 与 memory_block 的 mem_dir_display 同款约定。 注 INDEX 全文而非只注库名:INDEX 就是召回索引(照 memory extended 的 description 逻辑),几十篇的个人库体量注得起;正文仍按需 read。 """ kbs = list_kbs(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("/") 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)) for kb in kbs: d = root / kb["name"] parts.append(f"\n\n### 库「{kb['name']}」(`{base}/{kb['name']}/`)\n") entries = _read_index(d) if not entries: parts.append("(空库,尚无已入库文档)\n") continue for e in entries: kw = f"|关键词:{e['keywords']}" if e["keywords"] else "" parts.append( f"- [{e['title']}](`{base}/{kb['name']}/{e['doc']}`)" f"|摘要:{e['summary']}{kw}\n" ) return "".join(parts)