kb-memory 2.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
kb/mcp.py ADDED
@@ -0,0 +1,300 @@
1
+ """MCP 服务:8 个工具的薄封装,全部委托 KBService;由 api.py 挂载进同一 ASGI 应用。
2
+
3
+ 工具函数为模块级定义,可直接导入调用(测试与内部复用);
4
+ 服务实例通过模块级单例 _service 提供:create_mcp_server 注入时覆盖,
5
+ 未注入时惰性自建 KBService()(指向当前配置环境,测试顺序无关)。
6
+
7
+ English: MCP service: a thin wrapper over 8 tools, all delegating to KBService; it is mounted
8
+ into the same ASGI app by api.py. Tool functions are module-level and importable directly.
9
+ The service instance is provided through the module-level singleton _service:
10
+ create_mcp_server overrides it when injected; otherwise a KBService() is lazily created.
11
+ """
12
+ from mcp.server.mcpserver import MCPServer
13
+ from mcp.server.mcpserver.context import Context
14
+
15
+ from kb import __version__
16
+ from kb.service import (EmbeddingUnavailableError, KBService, LLMDisabledError,
17
+ UnsupportedFormatError, WebFetchError, validate_agent_id,
18
+ validate_client, validate_project)
19
+ from kb.i18n import mcp_instructions
20
+
21
+ # 模块级服务单例:None 表示尚未注入,首次调用工具时惰性自建
22
+ _service: KBService | None = None
23
+
24
+
25
+ def _svc() -> KBService:
26
+ """取当前服务单例;未注入时惰性自建 KBService(绑定当前配置环境)。
27
+ English: Get the current service singleton; lazily create a KBService (bound to the current config
28
+ environment) when none has been injected."""
29
+ global _service
30
+ if _service is None:
31
+ _service = KBService()
32
+ return _service
33
+
34
+
35
+ def _client_from_ctx(ctx) -> str:
36
+ """从 MCP 请求上下文提取客户端名(clientInfo.name,如 TraeWork / Claude Code / Cursor);
37
+ 直调(测试)或拿不到时回退 "default"。
38
+ English: Extract the client name from the MCP request context (clientInfo.name);
39
+ falls back to "default" on direct calls (tests) or when unavailable."""
40
+ try:
41
+ if ctx is None:
42
+ return "default"
43
+ params = ctx.request_context.session.client_params
44
+ info = getattr(params, "client_info", None)
45
+ name = getattr(info, "name", None)
46
+ return name or "default"
47
+ except Exception:
48
+ return "default"
49
+
50
+
51
+ def _check_identity(ctx, client: str | None,
52
+ project: str | None) -> str | None:
53
+ """身份字段规约校验(v2.1:project 选择项目子库,client 仅审计归类)。
54
+
55
+ - project 决定读写哪个项目子库(空=全局库),须通过目录名白名单校验。
56
+ - client 仅用于存取审计归类,从 MCP clientInfo 自动识别。
57
+ - 显式传入非法值拦截;返回 None=通过,否则返回错误消息。
58
+ English: Validate identity fields (v2.1: project selects the project sub-store and
59
+ client is audit-bucketing only). Explicit invalid values are rejected; returns None
60
+ on pass, else an error message."""
61
+ err = validate_client(client or _client_from_ctx(ctx))
62
+ if err:
63
+ return err
64
+ return validate_project(project)
65
+
66
+
67
+ def _resolve_client_ctx(ctx, client: str | None) -> str:
68
+ """统一取 client:显式传值优先,否则从 MCP clientInfo 自动识别。
69
+ English: Resolve client: explicit value wins, else MCP clientInfo."""
70
+ return client or _client_from_ctx(ctx) or "HTTP"
71
+
72
+
73
+ def _embedding_error(exc: EmbeddingUnavailableError) -> dict:
74
+ """MCP 通道统一返回嵌入模型不可用错误(不把底层堆栈抛给客户端)。
75
+ English: Unified MCP error for an unavailable embedding model (never leak the stack trace)."""
76
+ return {"error": "EMBED_MODEL_UNAVAILABLE", "message": str(exc),
77
+ "model": exc.model_name,
78
+ "hint": "检查 KB_EMBED_MODEL;国内网络可设 HF_ENDPOINT=https://hf-mirror.com"}
79
+
80
+
81
+ def write_memory(content: str,
82
+ tags: list[str] | None = None,
83
+ project: str | None = None,
84
+ client: str | None = None,
85
+ ctx: Context | None = None) -> dict:
86
+ """写入一条记忆短文本(事实/笔记/摘要),可选标签;返回 {"id": 记录ID}。
87
+ 内容为空串或纯空白时返回 {"error": "INVALID_ARGUMENT", "message": 原因}。
88
+ project 指定项目子库(空=全局库);client 仅用于审计归类(自动识别);
89
+ 记录主键由服务端生成。
90
+ English: Write a memory short text (fact/note/summary) with optional tags; returns {"id": record_id}.
91
+ Returns {"error": "INVALID_ARGUMENT", "message": reason} when content is empty or blank.
92
+ project selects the project sub-store (empty = global); client is audit-bucketing only
93
+ (auto-detected). The record primary key is generated server-side."""
94
+ if not content or not content.strip():
95
+ return {"error": "INVALID_ARGUMENT", "message": "content 不能为空或纯空白"}
96
+ err = _check_identity(ctx, client, project)
97
+ if err:
98
+ return {"error": "INVALID_ARGUMENT", "message": err}
99
+ c = _resolve_client_ctx(ctx, client)
100
+ try:
101
+ record = _svc().add_memory(content, tags=tags, agent_id=project or "default",
102
+ client=c, project=project)
103
+ except EmbeddingUnavailableError as exc:
104
+ return _embedding_error(exc)
105
+ return {"id": record.id}
106
+
107
+
108
+ def search_memory(query: str, top_k: int = 5,
109
+ project: str | None = None,
110
+ client: str | None = None,
111
+ ctx: Context | None = None) -> list[dict] | dict:
112
+ """混合检索记忆与知识(向量语义 + BM25 关键词,归一化融合);
113
+ 返回命中列表,每项含 id/content/score/type/source。
114
+ project 指定项目子库时,检索范围 = 该项目子库 + 全局库(项目命中加分);
115
+ 不传 project 时只检索全局库。
116
+ top_k 小于 1 时返回 {"error": "INVALID_ARGUMENT", "message": 原因}。
117
+ client:来源客户端(可选,缺省从 clientInfo 自动识别,仅审计)。
118
+ English: Hybrid retrieval over memories and knowledge (vector semantics + BM25 keywords, normalized fusion);
119
+ returns a hit list, each item having id/content/score/type/source.
120
+ With project, the scope is that project's sub-store plus the global store (project hits boosted);
121
+ without project, only the global store is searched.
122
+ Returns {"error": "INVALID_ARGUMENT", "message": reason} when top_k is less than 1.
123
+ client: source client (optional; auto-detected from clientInfo when omitted; audit only)."""
124
+ if top_k < 1:
125
+ return {"error": "INVALID_ARGUMENT", "message": "top_k 必须为不小于 1 的整数"}
126
+ if not query or not query.strip():
127
+ return {"error": "INVALID_ARGUMENT", "message": "query 不能为空或纯空白"}
128
+ err = _check_identity(ctx, client, project)
129
+ if err:
130
+ return {"error": "INVALID_ARGUMENT", "message": err}
131
+ c = _resolve_client_ctx(ctx, client)
132
+ try:
133
+ return _svc().search(query, top_k=top_k, agent_id=project or "default",
134
+ client=c, project=project)
135
+ except EmbeddingUnavailableError as exc:
136
+ return _embedding_error(exc)
137
+
138
+
139
+ def read_memory(record_id: str,
140
+ project: str | None = None,
141
+ client: str | None = None,
142
+ ctx: Context | None = None) -> dict:
143
+ """按 ID 读取单条记忆完整内容;记录不存在返回 {"error": "NOT_FOUND"}。
144
+ project:先查该项目子库,再查全局库;不传时先查全局库再查各项目子库。
145
+ client:来源客户端(可选,缺省从 clientInfo 自动识别,仅审计)。
146
+ English: Read the full content of a single memory by ID; returns {"error": "NOT_FOUND"} when absent.
147
+ project: search that project's sub-store first, then the global store; when omitted,
148
+ global first and then each project store. client is audit-only."""
149
+ err = _check_identity(ctx, client, project)
150
+ if err:
151
+ return {"error": "INVALID_ARGUMENT", "message": err}
152
+ c = _resolve_client_ctx(ctx, client)
153
+ record = _svc().get_memory(record_id, agent_id=project or "default",
154
+ client=c, project=project)
155
+ if record is None:
156
+ return {"error": "NOT_FOUND"}
157
+ return record.model_dump()
158
+
159
+
160
+ def update_memory(record_id: str, content: str,
161
+ project: str | None = None,
162
+ client: str | None = None,
163
+ ctx: Context | None = None) -> dict:
164
+ """按 ID 更新记忆内容(变更后自动重新嵌入);记录不存在返回 {"error": "NOT_FOUND"};
165
+ 内容为空串或纯空白时返回 {"error": "INVALID_ARGUMENT", "message": 原因}。
166
+ project:先查该项目子库,再查全局库;不传时先查全局库再查各项目子库。
167
+ client:来源客户端(可选,缺省从 clientInfo 自动识别,仅审计)。
168
+ English: Update a memory's content by ID (auto re-embed on change); returns {"error": "NOT_FOUND"}
169
+ when absent; empty content returns {"error": "INVALID_ARGUMENT", "message": reason}.
170
+ project: locate in that project's sub-store, then the global store; when omitted,
171
+ global first and then each project store. client is audit-only."""
172
+ if not content or not content.strip():
173
+ return {"error": "INVALID_ARGUMENT", "message": "content 不能为空或纯空白"}
174
+ err = _check_identity(ctx, client, project)
175
+ if err:
176
+ return {"error": "INVALID_ARGUMENT", "message": err}
177
+ c = _resolve_client_ctx(ctx, client)
178
+ try:
179
+ record = _svc().update_memory(record_id, content=content,
180
+ agent_id=project or "default",
181
+ client=c, project=project)
182
+ except EmbeddingUnavailableError as exc:
183
+ return _embedding_error(exc)
184
+ if record is None:
185
+ return {"error": "NOT_FOUND"}
186
+ return record.model_dump()
187
+
188
+
189
+ def delete_memory(record_id: str,
190
+ project: str | None = None,
191
+ client: str | None = None,
192
+ ctx: Context | None = None) -> dict:
193
+ """按 ID 删除一条记忆;成功返回 {"ok": true},记录不存在返回 {"error": "NOT_FOUND"}。
194
+ project:先查该项目子库,再查全局库;不传时先查全局库再查各项目子库。
195
+ client:来源客户端(可选,缺省从 clientInfo 自动识别,仅审计)。
196
+ English: Delete a memory by ID; returns {"ok": true} on success and {"error": "NOT_FOUND"} when
197
+ it does not exist. project: locate in that project's sub-store then the global store;
198
+ when omitted, global first and then each project store. client is audit-only."""
199
+ err = _check_identity(ctx, client, project)
200
+ if err:
201
+ return {"error": "INVALID_ARGUMENT", "message": err}
202
+ c = _resolve_client_ctx(ctx, client)
203
+ if not _svc().delete_memory(record_id, agent_id=project or "default",
204
+ client=c, project=project):
205
+ return {"error": "NOT_FOUND"}
206
+ return {"ok": True}
207
+
208
+
209
+ def add_document(path: str,
210
+ project: str | None = None,
211
+ client: str | None = None,
212
+ ctx: Context | None = None) -> dict:
213
+ """导入本地文档(PDF/DOCX/MD/TXT 及 Office 格式)切分入库;
214
+ 返回 {"source": 文件名, "chunks": 块数};文件不存在或格式不支持时
215
+ 返回 {"error": "FILE_NOT_FOUND" | "UNSUPPORTED_FORMAT", "message": 原因}。
216
+ project:文档 chunk 落入该项目子库(空=全局库);client:仅用于审计归类。
217
+ English: Import a local document (PDF/DOCX/MD/TXT and Office formats), split and ingest it;
218
+ returns {"source": filename, "chunks": count}; when the file is missing or the format is unsupported
219
+ returns {"error": "FILE_NOT_FOUND" | "UNSUPPORTED_FORMAT", "message": reason}.
220
+ project: chunks go to that project's sub-store (empty = global); client is audit-only."""
221
+ err = _check_identity(ctx, client, project)
222
+ if err:
223
+ return {"error": "INVALID_ARGUMENT", "message": err}
224
+ c = _resolve_client_ctx(ctx, client)
225
+ try:
226
+ return _svc().add_document(path, agent_id=project or "default",
227
+ client=c, project=project)
228
+ except UnsupportedFormatError as exc:
229
+ return {"error": "UNSUPPORTED_FORMAT", "message": str(exc)}
230
+ except EmbeddingUnavailableError as exc:
231
+ return _embedding_error(exc)
232
+ except OSError as exc:
233
+ return {"error": "FILE_NOT_FOUND", "message": f"文件无法读取:{exc}"}
234
+
235
+
236
+ def add_webpage(url: str,
237
+ project: str | None = None,
238
+ client: str | None = None,
239
+ ctx: Context | None = None) -> dict:
240
+ """抓取网页正文并切分入库;返回 {"source": url, "chunks": 块数};
241
+ 抓取/正文提取失败时返回 {"error": "WEB_FETCH_FAILED", "message": 原因}。
242
+ project:web chunk 落入该项目子库(空=全局库);client:仅用于审计归类。
243
+ English: Fetch a webpage body and ingest it after splitting; returns {"source": url, "chunks": count};
244
+ on fetch/body-extraction failure returns {"error": "WEB_FETCH_FAILED", "message": reason}.
245
+ project: chunks go to that project's sub-store (empty = global); client is audit-only."""
246
+ err = _check_identity(ctx, client, project)
247
+ if err:
248
+ return {"error": "INVALID_ARGUMENT", "message": err}
249
+ c = _resolve_client_ctx(ctx, client)
250
+ try:
251
+ return _svc().add_webpage(url, agent_id=project or "default",
252
+ client=c, project=project)
253
+ except WebFetchError as exc:
254
+ return {"error": "WEB_FETCH_FAILED", "message": str(exc)}
255
+ except EmbeddingUnavailableError as exc:
256
+ return _embedding_error(exc)
257
+
258
+
259
+ def ask_kb(question: str,
260
+ project: str | None = None,
261
+ client: str | None = None,
262
+ ctx: Context | None = None) -> dict:
263
+ """基于知识库的 RAG 问答(检索 → 上下文拼装 → 护栏生成),返回 answer 与 sources;
264
+ project:检索该项目子库 + 全局库(空=仅全局库),并写 ask 存取审计;
265
+ client:来源客户端(可选,缺省从 clientInfo 自动识别,仅审计);
266
+ LLM 不可用时返回 {"error": "LLM_DISABLED", "message": 配置指引}。
267
+ English: Knowledge-base RAG Q&A (retrieve → build context → guarded generation), returning answer and sources;
268
+ project: search that project's sub-store plus the global store (empty = global only); an ask
269
+ access-audit is emitted; client is audit-only;
270
+ returns {"error": "LLM_DISABLED", "message": setup guidance} when the LLM is unavailable."""
271
+ err = _check_identity(ctx, client, project)
272
+ if err:
273
+ return {"error": "INVALID_ARGUMENT", "message": err}
274
+ c = _resolve_client_ctx(ctx, client)
275
+ try:
276
+ return _svc().ask(question, agent_id=project or "default",
277
+ client=c, project=project)
278
+ except LLMDisabledError as exc:
279
+ return {"error": "LLM_DISABLED", "message": str(exc)}
280
+ except EmbeddingUnavailableError as exc:
281
+ return _embedding_error(exc)
282
+
283
+
284
+ def create_mcp_server(service: KBService) -> MCPServer:
285
+ """创建 MCP 服务器并注册全部 8 个工具(工具名即函数名)。
286
+
287
+ 注入 service 同时覆盖模块级单例,保证 MCP 工具直连调用与 REST 共享同一 KBService。
288
+ English: Create the MCP server and register all 8 tools (tool names are the function names).
289
+ Injecting the service also overrides the module-level singleton, so MCP tools and REST share the same KBService."""
290
+ global _service
291
+ _service = service
292
+ # v2.1:MCPServer instructions 承载接入规约——任何客户端挂载 MCP 即自动注入 AI 上下文;
293
+ # 语言按 KB_MEMORY_LANG/系统语言选择,并注入用户自定义记忆规则(~/.kb/rules.md、
294
+ # kb_data/rules.md)。核心:不要求先做健康探测、反馈极简、项目记忆按 project 隔离。
295
+ mcp = MCPServer(name="kb", version=__version__,
296
+ instructions=mcp_instructions())
297
+ for fn in (write_memory, search_memory, read_memory, update_memory,
298
+ delete_memory, add_document, add_webpage, ask_kb):
299
+ mcp.tool()(fn)
300
+ return mcp
kb/models.py ADDED
@@ -0,0 +1,102 @@
1
+ """数据模型:Record 与 Chroma metadata 互转(Chroma metadata 不支持 list,tags 逗号拼接)。"""
2
+ import math
3
+ from datetime import datetime
4
+ from enum import Enum
5
+ from uuid import uuid4
6
+
7
+ from pydantic import BaseModel, Field
8
+
9
+
10
+ class RecordType(str, Enum):
11
+ """来源类型。"""
12
+ MEMORY = "memory"
13
+ DOC_CHUNK = "doc_chunk"
14
+ WEB_CHUNK = "web_chunk"
15
+
16
+
17
+ class Record(BaseModel):
18
+ """记忆条目与文档 chunk 的统一模型。"""
19
+
20
+ id: str = Field(default_factory=lambda: uuid4().hex) # 主键:服务端生成,AI 不参与
21
+ content: str
22
+ type: RecordType = RecordType.MEMORY
23
+ namespace: str = "default"
24
+ # v2.1(2026-10-20):project 记录所属子库(空=全局库),项目间物理隔离;
25
+ # client 仅审计归类;agent_id 为兼容冗余(新写入时=project 或 "default")
26
+ agent_id: str = "default"
27
+ client: str = "default" # 来源客户端(框架自动识别:MCP clientInfo / REST HTTP / CLI)
28
+ project: str = "" # 项目子库名(空=全局库)
29
+ source: str | None = None
30
+ tags: list[str] = Field(default_factory=list)
31
+ importance: float = 0.5
32
+ created_at: str = Field(default_factory=lambda: datetime.now().isoformat())
33
+ updated_at: str = Field(default_factory=lambda: datetime.now().isoformat())
34
+ # N21a/TASK-0067:访问元数据(A3 spec §2.2)
35
+ access_count: int = 0 # 被检索命中次数,0=从未命中
36
+ last_accessed: str = "" # 最近一次命中时间(ISO),空=从未命中
37
+
38
+ def to_metadata(self) -> dict:
39
+ """转 Chroma metadata;content 不入 metadata(它是 Chroma 的 document),tags 逗号拼接。"""
40
+ return {
41
+ "id": self.id,
42
+ "type": self.type.value,
43
+ "namespace": self.namespace,
44
+ "agent_id": self.agent_id,
45
+ "client": self.client,
46
+ "project": self.project,
47
+ "source": self.source,
48
+ "tags": ",".join(self.tags),
49
+ "importance": self.importance,
50
+ "created_at": self.created_at,
51
+ "updated_at": self.updated_at,
52
+ "access_count": self.access_count,
53
+ "last_accessed": self.last_accessed,
54
+ }
55
+
56
+ @classmethod
57
+ def from_chroma(cls, record_id: str, document: str, metadata: dict) -> "Record":
58
+ """从 Chroma 的 id/document/metadata 还原;tags 按逗号拆分。
59
+ 旧记录缺失 access_count/last_accessed 时用 .get 默认值 0/""(向后兼容,无需迁移);
60
+ project 缺失回落 ""(默认桶),agent_id/client 缺失回落 "default"。"""
61
+ return cls(
62
+ id=record_id,
63
+ content=document,
64
+ type=RecordType(metadata.get("type", RecordType.MEMORY.value)),
65
+ namespace=metadata.get("namespace", "default"),
66
+ agent_id=metadata.get("agent_id", "default"),
67
+ client=metadata.get("client", "default"),
68
+ project=metadata.get("project", ""),
69
+ source=metadata.get("source"),
70
+ tags=[t for t in (metadata.get("tags", "") or "").split(",") if t],
71
+ importance=metadata.get("importance", 0.5),
72
+ created_at=metadata.get("created_at", ""),
73
+ updated_at=metadata.get("updated_at", ""),
74
+ access_count=metadata.get("access_count", 0),
75
+ last_accessed=metadata.get("last_accessed", ""),
76
+ )
77
+
78
+
79
+ def decay_factor(last_accessed: str, created_at: str, access_count: int,
80
+ lambda_: float = 0.02, gamma: float = 0.3,
81
+ now: datetime | None = None) -> float:
82
+ """访问频率衰减因子(A3 spec §3.1,N21a 纯函数)。
83
+
84
+ 公式:decay_factor = exp(-λ * days_since_last_accessed) * (1 + γ * log₂(1 + access_count))
85
+ - last_accessed 为空时用 created_at 替代(从未命中=创建时间)
86
+ - 无效日期或无时间参考时返回 1.0(不衰减,容错)
87
+ - 纯函数无副作用,N21b 负责将其集成进检索评分管道
88
+ - now 可注入(2026-08-30 v2):时钟注入验证——测试可拨到第 N 天验证衰减机理,
89
+ 生产默认 datetime.now();遗忘机制验证见 USER_GUIDE 对应章节
90
+ """
91
+ ref = last_accessed if last_accessed else created_at
92
+ if not ref:
93
+ return 1.0
94
+ try:
95
+ ref_dt = datetime.fromisoformat(ref)
96
+ except (ValueError, TypeError):
97
+ return 1.0
98
+ now = now or datetime.now()
99
+ days = (now - ref_dt).total_seconds() / 86400
100
+ if days < 0:
101
+ days = 0.0 # 未来时间不衰减
102
+ return math.exp(-lambda_ * days) * (1 + gamma * math.log2(1 + access_count))