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/__init__.py +2 -0
- kb/__main__.py +4 -0
- kb/api.py +975 -0
- kb/audit.py +263 -0
- kb/bm25.py +144 -0
- kb/cli.py +788 -0
- kb/config.py +136 -0
- kb/consolidation.py +228 -0
- kb/discovery.py +264 -0
- kb/embedder.py +134 -0
- kb/eval.py +136 -0
- kb/governance.py +188 -0
- kb/i18n.py +257 -0
- kb/ingest.py +122 -0
- kb/llm.py +155 -0
- kb/logging_setup.py +75 -0
- kb/mcp.py +300 -0
- kb/models.py +102 -0
- kb/monitor.py +421 -0
- kb/reranker.py +61 -0
- kb/retriever.py +281 -0
- kb/scopes.py +197 -0
- kb/service.py +947 -0
- kb/sparse.py +238 -0
- kb/storage.py +204 -0
- kb/watcher.py +171 -0
- kb_memory-2.1.0.dist-info/METADATA +415 -0
- kb_memory-2.1.0.dist-info/RECORD +32 -0
- kb_memory-2.1.0.dist-info/WHEEL +5 -0
- kb_memory-2.1.0.dist-info/entry_points.txt +2 -0
- kb_memory-2.1.0.dist-info/licenses/LICENSE +201 -0
- kb_memory-2.1.0.dist-info/top_level.txt +1 -0
kb/api.py
ADDED
|
@@ -0,0 +1,975 @@
|
|
|
1
|
+
"""REST API:FastAPI 应用工厂 + memories CRUD + healthz + ask + 统一错误格式。
|
|
2
|
+
English: REST API: FastAPI app factory with memories CRUD, healthz, ask, and a unified error format."""
|
|
3
|
+
import hmac
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import tempfile
|
|
7
|
+
import time
|
|
8
|
+
from collections import deque
|
|
9
|
+
from contextlib import asynccontextmanager
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Literal
|
|
12
|
+
|
|
13
|
+
from fastapi import FastAPI, HTTPException, Query
|
|
14
|
+
from fastapi.responses import HTMLResponse, JSONResponse
|
|
15
|
+
from fastapi.requests import Request
|
|
16
|
+
from pydantic import BaseModel, Field, field_validator
|
|
17
|
+
|
|
18
|
+
from kb import __version__
|
|
19
|
+
from kb.config import Settings, get_settings
|
|
20
|
+
from kb.logging_setup import setup_logging
|
|
21
|
+
from kb.monitor import MonitorAgent, run_once_summary
|
|
22
|
+
from kb.mcp import create_mcp_server
|
|
23
|
+
from kb.service import (EmbeddingUnavailableError, KBService, LLMDisabledError,
|
|
24
|
+
UnsupportedFormatError, WebFetchError, validate_project)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# ---- 日志查看端点常量(N18)----
|
|
28
|
+
SCAN_MAX = 20000 # /logs 尾部向后扫描行数上限
|
|
29
|
+
LOG_LIMIT_MAX = 1000 # /logs limit 上限
|
|
30
|
+
EVENT_WINDOW_MAX = 10000 # /logs/events window 上限
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def exposure_warning(host: str, api_key: str) -> str | None:
|
|
34
|
+
"""N29 启动安全警告:非回环监听且未启用鉴权时返回警告文本,否则 None。
|
|
35
|
+
|
|
36
|
+
判定口径(与 N19 鉴权中间件一致):
|
|
37
|
+
- key 为空或全空白 → 视为未鉴权;
|
|
38
|
+
- host 为空串(默认 127.0.0.1)、localhost、::1 或 127.0.0.0/8 段 → 视为回环,本地零摩擦;
|
|
39
|
+
- 其余地址(0.0.0.0 / 内网 IP / 公网 IP)且无 key → 返回醒目警告。
|
|
40
|
+
English: N29 startup security warning: return a warning text when listening on a non-loopback and auth is
|
|
41
|
+
not enabled, otherwise None. Judgment rules (consistent with the N19 auth middleware): an empty or all-blank
|
|
42
|
+
key is treated as unauthenticated; an empty host (default 127.0.0.1), localhost, ::1, or the 127.0.0.0/8 range
|
|
43
|
+
is treated as loopback (zero local friction); any other address (0.0.0.0 / LAN IP / public IP) with no key
|
|
44
|
+
returns a prominent warning.
|
|
45
|
+
"""
|
|
46
|
+
if (api_key or "").strip():
|
|
47
|
+
return None
|
|
48
|
+
h = (host or "").strip().lower()
|
|
49
|
+
if h in ("", "localhost", "::1") or h.startswith("127."):
|
|
50
|
+
return None
|
|
51
|
+
return ("安全警告:服务监听 %s(非回环地址)且未设置 KB_API_KEY,"
|
|
52
|
+
"REST/MCP 端点将对网络完全开放(记忆读写/文档删除均可匿名调用)。"
|
|
53
|
+
"如确需对外监听,请设置 KB_API_KEY 启用鉴权,或改回 127.0.0.1 仅本机访问。"
|
|
54
|
+
% host)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
_LOG_LEVELS = {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"}
|
|
58
|
+
# logging 缩写别名(大小写不敏感归一):warn/err/fatal → 标准级别名
|
|
59
|
+
_LOG_LEVEL_ALIASES = {"WARN": "WARNING", "ERR": "ERROR", "FATAL": "CRITICAL"}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _parse_log_line(line: str) -> dict | None:
|
|
63
|
+
"""解析一行日志(asctime | level | name | message)为 dict。
|
|
64
|
+
|
|
65
|
+
message 可能含 "|" 需保留余下全部(只切前 3 个 |);字段不足或内容
|
|
66
|
+
非法返回 None,调用方跳过不中断读取。返回 dict 不含 line 字段,
|
|
67
|
+
行号由调用方按文件位置补充。
|
|
68
|
+
English: Parse one log line (asctime | level | name | message) into a dict.
|
|
69
|
+
The message may contain "|" and must keep all the remainder (only the first 3 "|" are split); a dict with
|
|
70
|
+
too few fields or invalid content returns None and the caller skips it without interrupting the read.
|
|
71
|
+
The returned dict has no line field; the line number is filled in by the caller from the file position."""
|
|
72
|
+
if not line or not line.strip():
|
|
73
|
+
return None
|
|
74
|
+
parts = line.split("|", 3)
|
|
75
|
+
if len(parts) < 4:
|
|
76
|
+
return None
|
|
77
|
+
time_s, level_s, name_s, message = (p.strip() for p in parts)
|
|
78
|
+
if not time_s or not level_s or not name_s or not message:
|
|
79
|
+
return None
|
|
80
|
+
return {"time": time_s, "level": level_s, "logger": name_s,
|
|
81
|
+
"message": message}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _read_log_tail(path: Path, scan_max: int = SCAN_MAX) -> tuple[list[str], bool, int]:
|
|
85
|
+
"""读日志文件尾部至多 scan_max 行,返回 (lines, truncated, start_line)。
|
|
86
|
+
|
|
87
|
+
lines 按文件顺序(时间升序);truncated 表示文件行数超过 scan_max 提前
|
|
88
|
+
截断(可能漏掉更早行);start_line 为 lines[0] 的 1 基文件行号(定位用)。
|
|
89
|
+
文件不存在/为空 → ([], False, 1),不视为错误;文件可存在但不可读时抛
|
|
90
|
+
OSError(由端点转 500 LOG_READ_ERROR)。
|
|
91
|
+
English: Read the tail of a log file, at most scan_max lines, returning (lines, truncated, start_line).
|
|
92
|
+
lines follow file order (ascending time); truncated indicates the file has more than scan_max lines and
|
|
93
|
+
earlier lines may be missed; start_line is the 1-based file line of lines[0] (for locating).
|
|
94
|
+
A nonexistent/empty file → ([], False, 1), not an error; an existing-but-unreadable file raises OSError
|
|
95
|
+
(mapped to 500 LOG_READ_ERROR by the endpoint)."""
|
|
96
|
+
if not path.exists():
|
|
97
|
+
return [], False, 1
|
|
98
|
+
total = 0
|
|
99
|
+
tail = deque(maxlen=scan_max)
|
|
100
|
+
with path.open("r", encoding="utf-8", errors="replace") as f:
|
|
101
|
+
for line in f:
|
|
102
|
+
total += 1
|
|
103
|
+
tail.append(line.rstrip("\n"))
|
|
104
|
+
truncated = total > scan_max
|
|
105
|
+
start_line = max(total - len(tail) + 1, 1)
|
|
106
|
+
return list(tail), truncated, start_line
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _normalize_level(level: str | None) -> str | None:
|
|
110
|
+
"""level 参数:大小写不敏感 + 缩写别名归一(warn→WARNING 等);非法抛 422。
|
|
111
|
+
English: level param: case-insensitive plus abbreviation-alias normalization (warn→WARNING etc.); illegal raises 422."""
|
|
112
|
+
if level is None:
|
|
113
|
+
return None
|
|
114
|
+
lvl = _LOG_LEVEL_ALIASES.get(level.strip().upper(), level.strip().upper())
|
|
115
|
+
if lvl not in _LOG_LEVELS:
|
|
116
|
+
raise HTTPException(status_code=422, detail={
|
|
117
|
+
"error": "INVALID_LEVEL",
|
|
118
|
+
"message": f"level 非法:{level},可选 {', '.join(sorted(_LOG_LEVELS))}"})
|
|
119
|
+
return lvl
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# ---- 根路径极简导航页(N19):纯字符串 HTML,无模板依赖 ----
|
|
123
|
+
_NAV_HTML = """<!DOCTYPE html>
|
|
124
|
+
<html lang="zh-CN">
|
|
125
|
+
<head>
|
|
126
|
+
<meta charset="utf-8">
|
|
127
|
+
<title>kb 记忆服务</title>
|
|
128
|
+
<style>
|
|
129
|
+
body { margin: 0; min-height: 100vh; background: #f4f6f8; color: #24292f;
|
|
130
|
+
font-family: "Microsoft YaHei", system-ui, sans-serif;
|
|
131
|
+
display: flex; align-items: center; justify-content: center; }
|
|
132
|
+
.card { width: 420px; max-width: 90vw; border-radius: 12px; overflow: hidden;
|
|
133
|
+
background: #fff; border: 1px solid #e3e8ee;
|
|
134
|
+
box-shadow: 0 4px 16px rgba(0,0,0,.08); }
|
|
135
|
+
.head { padding: 18px 24px; color: #fff;
|
|
136
|
+
background: linear-gradient(135deg, #1e293b 0%, #312e81 100%); }
|
|
137
|
+
.head h1 { margin: 0; font-size: 20px; font-weight: 600; }
|
|
138
|
+
.body { padding: 12px 8px; }
|
|
139
|
+
.body a { display: block; padding: 12px 16px; margin: 4px 8px; border-radius: 8px;
|
|
140
|
+
color: #1e293b; text-decoration: none; font-size: 15px;
|
|
141
|
+
border: 1px solid #e3e8ee; transition: background .15s; }
|
|
142
|
+
.body a:hover { background: #f0f2f5; }
|
|
143
|
+
</style>
|
|
144
|
+
</head>
|
|
145
|
+
<body>
|
|
146
|
+
<div class="card">
|
|
147
|
+
<div class="head"><h1>kb 记忆服务</h1></div>
|
|
148
|
+
<div class="body">
|
|
149
|
+
<a href="/api/v1/healthz">API 状态</a>
|
|
150
|
+
<a href="/docs">REST API 文档(Swagger)</a>
|
|
151
|
+
</div>
|
|
152
|
+
</div>
|
|
153
|
+
</body>
|
|
154
|
+
</html>
|
|
155
|
+
"""
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class MemoryCreate(BaseModel):
|
|
159
|
+
"""创建记忆请求。
|
|
160
|
+
English: Create-memory request."""
|
|
161
|
+
content: str
|
|
162
|
+
tags: list[str] = []
|
|
163
|
+
source: str | None = None
|
|
164
|
+
namespace: str = "default"
|
|
165
|
+
agent_id: str = "default" # 写入方 Agent 身份(A 节点;推荐任务名);默认 default
|
|
166
|
+
client: str = "HTTP" # 来源客户端;REST 未显式传时默认 HTTP(审计文件名不再落 default)
|
|
167
|
+
project: str | None = None # 项目名(可选,选择项目子库;空=全局库)
|
|
168
|
+
|
|
169
|
+
@field_validator("content")
|
|
170
|
+
@classmethod
|
|
171
|
+
def content_not_blank(cls, v: str) -> str:
|
|
172
|
+
"""拒绝空串与纯空白内容(strip 后为空即非法,pydantic 校验失败转 422)。
|
|
173
|
+
English: Reject empty and all-blank content (blank after strip is illegal; a pydantic validation
|
|
174
|
+
failure maps to 422)."""
|
|
175
|
+
if not v.strip():
|
|
176
|
+
raise ValueError("content 不能为空或纯空白")
|
|
177
|
+
return v
|
|
178
|
+
|
|
179
|
+
@field_validator("agent_id", "client", "project")
|
|
180
|
+
@classmethod
|
|
181
|
+
def identity_fields_valid(cls, v: str | None, info) -> str | None:
|
|
182
|
+
"""规约校验身份字段(A 节点):agent_id/client/project 白名单格式,
|
|
183
|
+
非法直接 422,杜绝调用方随意传任意字符串。
|
|
184
|
+
English: Validate identity fields against the whitelist; invalid values are rejected
|
|
185
|
+
with 422 so callers cannot pass arbitrary strings."""
|
|
186
|
+
from kb.service import validate_agent_id, validate_client, validate_project
|
|
187
|
+
if info.field_name == "agent_id":
|
|
188
|
+
err = validate_agent_id(v)
|
|
189
|
+
elif info.field_name == "client":
|
|
190
|
+
err = validate_client(v)
|
|
191
|
+
else:
|
|
192
|
+
err = validate_project(v)
|
|
193
|
+
if err:
|
|
194
|
+
raise ValueError(err)
|
|
195
|
+
return v
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
class MemoryUpdate(BaseModel):
|
|
199
|
+
"""更新记忆请求;至少一项非空。
|
|
200
|
+
English: Update-memory request; at least one field must be non-empty."""
|
|
201
|
+
content: str | None = None
|
|
202
|
+
tags: list[str] | None = None
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
class SearchRequest(BaseModel):
|
|
206
|
+
"""检索请求;query 必填,top_k/mode 带默认值,type/tag 可选过滤。
|
|
207
|
+
project:选择项目子库(空=仅全局库;非空=项目子库+全局库);agent_id/client 仅审计。
|
|
208
|
+
English: Search request; query is required, top_k/mode have defaults, and type/tag are optional filters.
|
|
209
|
+
project selects the project sub-store (empty = global only; non-empty = project + global);
|
|
210
|
+
agent_id/client are audit-only."""
|
|
211
|
+
query: str
|
|
212
|
+
top_k: int = Field(default=5, ge=1)
|
|
213
|
+
mode: Literal["hybrid", "vector", "keyword"] = "hybrid"
|
|
214
|
+
type: str | None = None
|
|
215
|
+
tag: str | None = None
|
|
216
|
+
agent_id: str = "default"
|
|
217
|
+
client: str = "HTTP"
|
|
218
|
+
project: str | None = None # 项目名(可选,选择项目子库;空=全局库)
|
|
219
|
+
|
|
220
|
+
@field_validator("query")
|
|
221
|
+
@classmethod
|
|
222
|
+
def query_not_blank(cls, v: str) -> str:
|
|
223
|
+
"""拒绝空查询,避免空串被当作“匹配全部”污染 Agent 上下文。
|
|
224
|
+
English: Reject a blank query so it cannot be treated as "match all" and
|
|
225
|
+
pollute the agent context."""
|
|
226
|
+
if not v.strip():
|
|
227
|
+
raise ValueError("query 不能为空或纯空白")
|
|
228
|
+
return v
|
|
229
|
+
|
|
230
|
+
@field_validator("agent_id", "client", "project")
|
|
231
|
+
@classmethod
|
|
232
|
+
def identity_fields_valid(cls, v: str | None, info) -> str | None:
|
|
233
|
+
"""规约校验身份字段(A 节点),非法 422。"""
|
|
234
|
+
from kb.service import validate_agent_id, validate_client, validate_project
|
|
235
|
+
if info.field_name == "agent_id":
|
|
236
|
+
err = validate_agent_id(v)
|
|
237
|
+
elif info.field_name == "client":
|
|
238
|
+
err = validate_client(v)
|
|
239
|
+
else:
|
|
240
|
+
err = validate_project(v)
|
|
241
|
+
if err:
|
|
242
|
+
raise ValueError(err)
|
|
243
|
+
return v
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
class AskRequest(BaseModel):
|
|
247
|
+
"""问答请求;question 必填。
|
|
248
|
+
English: Ask request; question is required."""
|
|
249
|
+
question: str
|
|
250
|
+
agent_id: str = "default"
|
|
251
|
+
client: str = "HTTP"
|
|
252
|
+
project: str | None = None # 项目名(可选,选择项目子库;空=全局库)
|
|
253
|
+
|
|
254
|
+
@field_validator("question")
|
|
255
|
+
@classmethod
|
|
256
|
+
def question_not_blank(cls, v: str) -> str:
|
|
257
|
+
"""拒绝空问题,避免无意义检索与 LLM 调用。
|
|
258
|
+
English: Reject a blank question to avoid pointless retrieval and LLM calls."""
|
|
259
|
+
if not v.strip():
|
|
260
|
+
raise ValueError("question 不能为空或纯空白")
|
|
261
|
+
return v
|
|
262
|
+
|
|
263
|
+
@field_validator("agent_id", "client", "project")
|
|
264
|
+
@classmethod
|
|
265
|
+
def identity_fields_valid(cls, v: str | None, info) -> str | None:
|
|
266
|
+
"""规约校验身份字段(A 节点),非法 422。"""
|
|
267
|
+
from kb.service import validate_agent_id, validate_client, validate_project
|
|
268
|
+
if info.field_name == "agent_id":
|
|
269
|
+
err = validate_agent_id(v)
|
|
270
|
+
elif info.field_name == "client":
|
|
271
|
+
err = validate_client(v)
|
|
272
|
+
else:
|
|
273
|
+
err = validate_project(v)
|
|
274
|
+
if err:
|
|
275
|
+
raise ValueError(err)
|
|
276
|
+
return v
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
class WebIngestRequest(BaseModel):
|
|
280
|
+
"""网页摄取请求;url 必填,project 可选(指定项目子库)。
|
|
281
|
+
English: Web-ingest request; url is required, project optionally selects a project sub-store."""
|
|
282
|
+
url: str
|
|
283
|
+
agent_id: str = "default"
|
|
284
|
+
client: str = "HTTP"
|
|
285
|
+
project: str | None = None
|
|
286
|
+
|
|
287
|
+
@field_validator("agent_id", "client", "project")
|
|
288
|
+
@classmethod
|
|
289
|
+
def identity_fields_valid(cls, v: str | None, info) -> str | None:
|
|
290
|
+
from kb.service import validate_agent_id, validate_client, validate_project
|
|
291
|
+
if info.field_name == "agent_id":
|
|
292
|
+
err = validate_agent_id(v)
|
|
293
|
+
elif info.field_name == "client":
|
|
294
|
+
err = validate_client(v)
|
|
295
|
+
else:
|
|
296
|
+
err = validate_project(v)
|
|
297
|
+
if err:
|
|
298
|
+
raise ValueError(err)
|
|
299
|
+
return v
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _wrap_sse_charset(app):
|
|
303
|
+
"""轻量 ASGI 中间件:给 MCP 的 SSE 响应头补 charset=utf-8。
|
|
304
|
+
|
|
305
|
+
纯函数式包装:拦截 http.response.start 消息,当 content-type 为
|
|
306
|
+
text/event-stream 且未声明 charset 时追加 "; charset=utf-8",
|
|
307
|
+
兼容按默认编码解码 SSE 流的客户端;其余消息与作用域原样透传。
|
|
308
|
+
English: Lightweight ASGI middleware: append charset=utf-8 to the MCP SSE response header.
|
|
309
|
+
A pure functional wrapper: intercept http.response.start messages and append "; charset=utf-8" when the
|
|
310
|
+
content-type is text/event-stream and no charset is declared, for clients that decode the SSE stream with
|
|
311
|
+
the default encoding; all other messages and scopes pass through unchanged."""
|
|
312
|
+
|
|
313
|
+
async def middleware(scope, receive, send):
|
|
314
|
+
# 非 HTTP 作用域(如 lifespan)直接透传
|
|
315
|
+
if scope["type"] != "http":
|
|
316
|
+
await app(scope, receive, send)
|
|
317
|
+
return
|
|
318
|
+
|
|
319
|
+
async def send_wrapper(message):
|
|
320
|
+
# 只在响应起始消息上补头;命中时构造新消息,不改动原 dict
|
|
321
|
+
if message["type"] == "http.response.start":
|
|
322
|
+
headers = message.get("headers") or []
|
|
323
|
+
new_headers = []
|
|
324
|
+
patched = False
|
|
325
|
+
for k, v in headers:
|
|
326
|
+
if (k.lower() == b"content-type"
|
|
327
|
+
and v.lower().startswith(b"text/event-stream")
|
|
328
|
+
and b"charset" not in v.lower()):
|
|
329
|
+
new_headers.append((k, v + b"; charset=utf-8"))
|
|
330
|
+
patched = True
|
|
331
|
+
else:
|
|
332
|
+
new_headers.append((k, v))
|
|
333
|
+
if patched:
|
|
334
|
+
message = {**message, "headers": new_headers}
|
|
335
|
+
await send(message)
|
|
336
|
+
|
|
337
|
+
await app(scope, receive, send_wrapper)
|
|
338
|
+
|
|
339
|
+
return middleware
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _wrap_json_charset(app):
|
|
343
|
+
"""轻量 ASGI 中间件:给 JSON REST 响应头补 charset=utf-8。
|
|
344
|
+
|
|
345
|
+
与 v1.0.1 的 SSE 中间件同构:拦截 http.response.start 消息,
|
|
346
|
+
content-type 为 application/json 且未声明 charset 时追加
|
|
347
|
+
"; charset=utf-8",解决 PowerShell 等按默认编码解码的客户端
|
|
348
|
+
对中文 JSON 的乱码问题;其余消息与作用域原样透传(含挂载的
|
|
349
|
+
/mcp SSE 子应用,其 text/event-stream 头不在此匹配范围)。
|
|
350
|
+
English: Lightweight ASGI middleware: append charset=utf-8 to JSON REST response headers.
|
|
351
|
+
Isomorphic to the v1.0.1 SSE middleware: intercept http.response.start messages and append "; charset=utf-8"
|
|
352
|
+
when the content-type is application/json with no declared charset, fixing mojibake of Chinese JSON for
|
|
353
|
+
clients that decode with the default encoding (e.g. PowerShell); all other messages and scopes pass
|
|
354
|
+
through unchanged (including the mounted /mcp SSE sub-app, whose text/event-stream header is not matched
|
|
355
|
+
here)."""
|
|
356
|
+
|
|
357
|
+
async def middleware(scope, receive, send):
|
|
358
|
+
# 非 HTTP 作用域(如 lifespan)直接透传
|
|
359
|
+
if scope["type"] != "http":
|
|
360
|
+
await app(scope, receive, send)
|
|
361
|
+
return
|
|
362
|
+
|
|
363
|
+
async def send_wrapper(message):
|
|
364
|
+
# 只在响应起始消息上补头;命中时构造新消息,不改动原 dict
|
|
365
|
+
if message["type"] == "http.response.start":
|
|
366
|
+
headers = message.get("headers") or []
|
|
367
|
+
new_headers = []
|
|
368
|
+
patched = False
|
|
369
|
+
for k, v in headers:
|
|
370
|
+
if (k.lower() == b"content-type"
|
|
371
|
+
and v.lower().startswith(b"application/json")
|
|
372
|
+
and b"charset" not in v.lower()):
|
|
373
|
+
new_headers.append((k, v + b"; charset=utf-8"))
|
|
374
|
+
patched = True
|
|
375
|
+
else:
|
|
376
|
+
new_headers.append((k, v))
|
|
377
|
+
if patched:
|
|
378
|
+
message = {**message, "headers": new_headers}
|
|
379
|
+
await send(message)
|
|
380
|
+
|
|
381
|
+
await app(scope, receive, send_wrapper)
|
|
382
|
+
|
|
383
|
+
return middleware
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _wrap_request_log(app):
|
|
387
|
+
"""请求访问日志中间件(N17/TASK-0012):ASGI 栈最外层,REST 与 MCP 统一覆盖。
|
|
388
|
+
|
|
389
|
+
请求进入记录 event=request.start(method/path),请求结束记录
|
|
390
|
+
event=request.end(method/path/status/耗时ms)。不记录请求 body
|
|
391
|
+
(日志设计第 5 节敏感红线);MCP 挂载子应用(/mcp)同样经过本层。
|
|
392
|
+
English: Request access-log middleware (N17/TASK-0012): the outermost ASGI layer, covering REST and MCP alike.
|
|
393
|
+
On entry it logs event=request.start (method/path); on exit it logs event=request.end (method/path/status/latency-ms).
|
|
394
|
+
It never logs request bodies (sensitive red line in the logging-design section 5); the mounted MCP sub-app
|
|
395
|
+
(/mcp) also passes through this layer."""
|
|
396
|
+
|
|
397
|
+
async def middleware(scope, receive, send):
|
|
398
|
+
# 非 HTTP 作用域(lifespan 等)原样透传
|
|
399
|
+
if scope["type"] != "http":
|
|
400
|
+
await app(scope, receive, send)
|
|
401
|
+
return
|
|
402
|
+
api_log = logging.getLogger("kb.api")
|
|
403
|
+
method = scope.get("method", "")
|
|
404
|
+
path = scope.get("path", "")
|
|
405
|
+
api_log.info("request.start method=%s path=%s", method, path)
|
|
406
|
+
start = time.perf_counter()
|
|
407
|
+
status = 0
|
|
408
|
+
|
|
409
|
+
async def send_wrapper(message):
|
|
410
|
+
nonlocal status
|
|
411
|
+
if message["type"] == "http.response.start":
|
|
412
|
+
status = message.get("status", 0)
|
|
413
|
+
await send(message)
|
|
414
|
+
|
|
415
|
+
try:
|
|
416
|
+
await app(scope, receive, send_wrapper)
|
|
417
|
+
finally:
|
|
418
|
+
elapsed_ms = (time.perf_counter() - start) * 1000
|
|
419
|
+
api_log.info("request.end method=%s path=%s status=%s 耗时=%.1fms",
|
|
420
|
+
method, path, status, elapsed_ms)
|
|
421
|
+
|
|
422
|
+
return middleware
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _wrap_api_key(app, settings):
|
|
426
|
+
"""API Key 鉴权中间件(N19/TASK-0062):纯 ASGI,空 key 直通,非空校验 Bearer/X-API-Key。
|
|
427
|
+
|
|
428
|
+
与 _wrap_sse_charset / _wrap_request_log 同模式(纯函数式包装,不依赖 FastAPI
|
|
429
|
+
依赖注入,确保覆盖 mount 的 /mcp 子应用)。
|
|
430
|
+
|
|
431
|
+
流程:
|
|
432
|
+
1) settings.api_key 为空 → 直通(降级模式,等同 v1.x 行为)
|
|
433
|
+
2) 白名单 GET /api/v1/healthz 与别名 GET /health → 放行(存活探针)
|
|
434
|
+
3) 取 key:Authorization: Bearer <key> 优先,其次 X-API-Key: <key>
|
|
435
|
+
4) 缺失/不匹配 → 401 JSON {"error":"UNAUTHORIZED","message":"missing or invalid api key"}
|
|
436
|
+
(Content-Type: application/json; charset=utf-8;不区分缺失与错误,防探测)
|
|
437
|
+
5) hmac.compare_digest 匹配 → 放行
|
|
438
|
+
|
|
439
|
+
注册顺序(create_app 内,从内到外):业务 → 本中间件 → JSON charset → 访问日志;
|
|
440
|
+
即请求进入:访问日志 → JSON charset → 鉴权 → 业务;401 响应向外经 JSON charset
|
|
441
|
+
补 charset(本中间件自身也显式声明 charset=utf-8,双保险)。
|
|
442
|
+
English: API-Key auth middleware (N19/TASK-0062): pure ASGI; empty key passes through, non-empty validates
|
|
443
|
+
Bearer/X-API-Key. Same pattern as _wrap_sse_charset / _wrap_request_log (pure functional wrapping, no FastAPI
|
|
444
|
+
dependency injection, ensuring coverage of the mounted /mcp sub-app).
|
|
445
|
+
Flow: 1) empty settings.api_key → pass through (legacy mode); 2) whitelist GET /api/v1/healthz and its
|
|
446
|
+
alias GET /health → allow (liveness);
|
|
447
|
+
3) extract key: Authorization: Bearer <key> first, then X-API-Key: <key>;
|
|
448
|
+
4) missing/mismatch → 401 JSON {"error":"UNAUTHORIZED","message":"missing or invalid api key"}
|
|
449
|
+
(Content-Type: application/json; charset=utf-8; no distinction between missing and wrong, to prevent probing);
|
|
450
|
+
5) hmac.compare_digest match → allow.
|
|
451
|
+
Registration order (in create_app, inner to outer): business → this middleware → JSON charset → access log;
|
|
452
|
+
so requests enter: access log → JSON charset → auth → business; the 401 response gets charset added by the
|
|
453
|
+
JSON charset wrapper as it flows out (this middleware also declares charset=utf-8 explicitly, double insurance)."""
|
|
454
|
+
api_key = (settings.api_key or "").strip()
|
|
455
|
+
|
|
456
|
+
async def middleware(scope, receive, send):
|
|
457
|
+
# 非 HTTP 作用域(lifespan 等)原样透传
|
|
458
|
+
if scope["type"] != "http":
|
|
459
|
+
await app(scope, receive, send)
|
|
460
|
+
return
|
|
461
|
+
# 空 key 直通(降级模式)
|
|
462
|
+
if not api_key:
|
|
463
|
+
await app(scope, receive, send)
|
|
464
|
+
return
|
|
465
|
+
method = scope.get("method", "")
|
|
466
|
+
path = scope.get("path", "")
|
|
467
|
+
# 白名单:GET /api/v1/healthz 及其别名 /health(存活探针,无敏感数据)
|
|
468
|
+
if method == "GET" and path in ("/api/v1/healthz", "/health"):
|
|
469
|
+
await app(scope, receive, send)
|
|
470
|
+
return
|
|
471
|
+
# 取 key:Authorization: Bearer <key> 优先
|
|
472
|
+
headers = scope.get("headers", [])
|
|
473
|
+
provided_key = ""
|
|
474
|
+
for k, v in headers:
|
|
475
|
+
if k.lower() == b"authorization":
|
|
476
|
+
val = v.decode("latin-1", errors="replace")
|
|
477
|
+
if val.lower().startswith("bearer "):
|
|
478
|
+
provided_key = val[7:].strip()
|
|
479
|
+
break
|
|
480
|
+
# 其次 X-API-Key: <key>
|
|
481
|
+
if not provided_key:
|
|
482
|
+
for k, v in headers:
|
|
483
|
+
if k.lower() == b"x-api-key":
|
|
484
|
+
provided_key = v.decode("latin-1", errors="replace").strip()
|
|
485
|
+
break
|
|
486
|
+
# compare_digest 比较(防时序攻击);缺失或不匹配均 401(不区分,防探测)
|
|
487
|
+
if not provided_key or not hmac.compare_digest(provided_key, api_key):
|
|
488
|
+
body = json.dumps(
|
|
489
|
+
{"error": "UNAUTHORIZED",
|
|
490
|
+
"message": "missing or invalid api key"}
|
|
491
|
+
).encode("utf-8")
|
|
492
|
+
await send({
|
|
493
|
+
"type": "http.response.start",
|
|
494
|
+
"status": 401,
|
|
495
|
+
"headers": [
|
|
496
|
+
(b"content-type", b"application/json; charset=utf-8"),
|
|
497
|
+
(b"content-length", str(len(body)).encode("ascii")),
|
|
498
|
+
],
|
|
499
|
+
})
|
|
500
|
+
await send({"type": "http.response.body", "body": body})
|
|
501
|
+
return
|
|
502
|
+
# 匹配放行
|
|
503
|
+
await app(scope, receive, send)
|
|
504
|
+
|
|
505
|
+
return middleware
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def create_app(settings: Settings | None = None,
|
|
509
|
+
enable_watcher: bool = False) -> FastAPI:
|
|
510
|
+
"""应用工厂;全局单例 KBService 挂 app.state.kb(REST 与 MCP 共享该实例)。
|
|
511
|
+
统一错误 JSON:{"error": "<CODE>", "message": "<人话>"}。
|
|
512
|
+
|
|
513
|
+
enable_watcher=True 时(仅 serve 模式)在 lifespan 中挂目录监听线程;
|
|
514
|
+
TestClient 测试默认 False 不触发。watch_dir 为空(""/".")时不启动。
|
|
515
|
+
English: App factory; a global singleton KBService is attached to app.state.kb (shared by REST and MCP).
|
|
516
|
+
Unified error JSON: {"error": "<CODE>", "message": "<human-readable>"}.
|
|
517
|
+
With enable_watcher=True (serve mode only) a directory-watch thread is started in lifespan; TestClient
|
|
518
|
+
defaults to False and does not trigger it. Nothing starts when watch_dir is empty (""/".")."""
|
|
519
|
+
# 日志装配(N17):**必须先于 KBService**——服务初始化期间也会产生日志
|
|
520
|
+
# (例如"库中模型与配置不一致,已沿用建库模型"这类启动告警),若晚于此处
|
|
521
|
+
# 则 handler 尚未就绪,关键 WARNING 会被无声丢弃。
|
|
522
|
+
setup_logging(settings)
|
|
523
|
+
kb = KBService(settings)
|
|
524
|
+
# MCP 服务器与 REST 共享同一 KBService 单例;先建 streamable http 应用
|
|
525
|
+
# (内部懒创建会话管理器,随后由主应用 lifespan 托管启停)
|
|
526
|
+
mcp_server = create_mcp_server(kb)
|
|
527
|
+
mcp_app = mcp_server.streamable_http_app(streamable_http_path="/")
|
|
528
|
+
watcher = None # 延迟导入 KBWatcher:未启用目录监听时不加载 watchdog 依赖
|
|
529
|
+
monitor: MonitorAgent | None = None
|
|
530
|
+
|
|
531
|
+
@asynccontextmanager
|
|
532
|
+
async def lifespan(app: FastAPI):
|
|
533
|
+
"""托管 MCP 会话管理器、目录监听线程与本地监控线程的生命周期(含启停日志)。
|
|
534
|
+
English: Manage the lifecycle of the MCP session manager, directory-watch thread, and local monitor
|
|
535
|
+
thread (including start/stop logs)."""
|
|
536
|
+
nonlocal watcher, monitor
|
|
537
|
+
serve_log = logging.getLogger("kb.serve")
|
|
538
|
+
serve_log.info("服务启动 version=%s host=%s port=%s", __version__,
|
|
539
|
+
kb.settings.api_host, kb.settings.api_port)
|
|
540
|
+
# 鉴权状态(N19/TASK-0062):只记启用/未启用,不回显 key
|
|
541
|
+
if (kb.settings.api_key or "").strip():
|
|
542
|
+
serve_log.info("鉴权已启用(KB_API_KEY 已配置,要求 Bearer/X-API-Key)")
|
|
543
|
+
else:
|
|
544
|
+
serve_log.info("鉴权未启用(本地模式,KB_API_KEY 为空)")
|
|
545
|
+
# N29 启动安全警告:非回环监听 + 未鉴权 → warning 日志(终端醒目横幅由 serve 命令负责)
|
|
546
|
+
warn = exposure_warning(kb.settings.api_host, kb.settings.api_key)
|
|
547
|
+
if warn:
|
|
548
|
+
serve_log.warning(warn)
|
|
549
|
+
wd = kb.settings.watch_dir
|
|
550
|
+
if enable_watcher and str(wd) not in ("", "."):
|
|
551
|
+
try:
|
|
552
|
+
from kb.watcher import KBWatcher
|
|
553
|
+
except ImportError:
|
|
554
|
+
# watchdog 属可选 [ingest]:缺失时禁用监听而非拒绝启动
|
|
555
|
+
serve_log.warning(
|
|
556
|
+
"未安装 watchdog,目录监听已禁用"
|
|
557
|
+
"(pip install \"kb-memory[ingest]\" 可启用)")
|
|
558
|
+
else:
|
|
559
|
+
watcher = KBWatcher(kb, wd)
|
|
560
|
+
watcher.start()
|
|
561
|
+
# 本地监控 Agent(TASK-0017):serve 模式默认启用,仅读配置决定间隔;
|
|
562
|
+
# TestClient 测试默认不启动(monitor_enabled 默认 False,测试按需开)
|
|
563
|
+
if kb.settings.monitor_enabled and str(wd) not in ("", "."):
|
|
564
|
+
monitor = MonitorAgent(kb, kb.settings.monitor_interval,
|
|
565
|
+
dispatch_enabled=kb.settings.dispatch_enabled)
|
|
566
|
+
monitor.start()
|
|
567
|
+
serve_log.info("监控线程启动 interval=%s分钟", kb.settings.monitor_interval)
|
|
568
|
+
try:
|
|
569
|
+
async with mcp_server.session_manager.run():
|
|
570
|
+
serve_log.info("服务就绪 records=%s", kb.stats().get("records"))
|
|
571
|
+
yield
|
|
572
|
+
finally:
|
|
573
|
+
if monitor is not None:
|
|
574
|
+
monitor.stop()
|
|
575
|
+
if watcher is not None:
|
|
576
|
+
watcher.stop()
|
|
577
|
+
serve_log.info("服务停止")
|
|
578
|
+
|
|
579
|
+
app = FastAPI(title="kb memory service", lifespan=lifespan)
|
|
580
|
+
app.state.kb = kb
|
|
581
|
+
|
|
582
|
+
@app.exception_handler(EmbeddingUnavailableError)
|
|
583
|
+
def embedding_exc_handler(request: Request, exc: EmbeddingUnavailableError):
|
|
584
|
+
"""嵌入模型不可用 → 503 结构化错误(不再裸 500)。
|
|
585
|
+
English: Embedding model unavailable → structured 503 instead of a bare 500."""
|
|
586
|
+
return JSONResponse(status_code=503, content={
|
|
587
|
+
"error": "EMBED_MODEL_UNAVAILABLE",
|
|
588
|
+
"message": f"嵌入模型不可用:{exc.model_name}({exc.cause})",
|
|
589
|
+
"model": exc.model_name,
|
|
590
|
+
"hint": "检查 KB_EMBED_MODEL 是否正确;国内网络可设 "
|
|
591
|
+
"HF_ENDPOINT=https://hf-mirror.com 后重试,缓存成功后断网可用。",
|
|
592
|
+
})
|
|
593
|
+
|
|
594
|
+
@app.exception_handler(HTTPException)
|
|
595
|
+
def http_exc_handler(request: Request, exc: HTTPException):
|
|
596
|
+
"""统一错误 JSON:detail 为 dict 时直接扁平返回(不包 detail 层)。
|
|
597
|
+
English: Unified error JSON: when detail is a dict, return it flattened directly (without wrapping in a detail layer)."""
|
|
598
|
+
if isinstance(exc.detail, dict):
|
|
599
|
+
return JSONResponse(status_code=exc.status_code, content=exc.detail)
|
|
600
|
+
return JSONResponse(status_code=exc.status_code,
|
|
601
|
+
content={"error": "HTTP_ERROR", "message": str(exc.detail)})
|
|
602
|
+
|
|
603
|
+
@app.post("/api/v1/memories")
|
|
604
|
+
def create_memory(body: MemoryCreate) -> dict:
|
|
605
|
+
from kb.governance import DuplicateError
|
|
606
|
+
try:
|
|
607
|
+
r = kb.add_memory(body.content, tags=body.tags,
|
|
608
|
+
source=body.source, namespace=body.namespace,
|
|
609
|
+
agent_id=body.agent_id, client=body.client,
|
|
610
|
+
project=body.project)
|
|
611
|
+
except DuplicateError as e:
|
|
612
|
+
# N22a/TASK-0069:语义去重命中,返回 409 + 已有记录 id 与相似度
|
|
613
|
+
return JSONResponse(
|
|
614
|
+
status_code=409,
|
|
615
|
+
content={"error": "DUPLICATE",
|
|
616
|
+
"message": "语义重复,已存在相似记录",
|
|
617
|
+
"duplicate_of": e.existing_id,
|
|
618
|
+
"similarity": round(e.similarity, 4)})
|
|
619
|
+
return {"id": r.id, **r.model_dump()}
|
|
620
|
+
|
|
621
|
+
@app.get("/api/v1/memories")
|
|
622
|
+
def list_memories(type: str | None = None, tag: str | None = None,
|
|
623
|
+
source: str | None = None, q: str | None = None,
|
|
624
|
+
project: str | None = None,
|
|
625
|
+
limit: int = 100, offset: int = 0) -> dict:
|
|
626
|
+
err = validate_project(project)
|
|
627
|
+
if err:
|
|
628
|
+
raise HTTPException(status_code=422, detail={
|
|
629
|
+
"error": "INVALID_ARGUMENT", "message": err})
|
|
630
|
+
records, total = kb.list_memories(project=project, type=type, tag=tag,
|
|
631
|
+
source=source, q=q, limit=limit,
|
|
632
|
+
offset=offset)
|
|
633
|
+
return {"items": [r.model_dump() for r in records], "total": total}
|
|
634
|
+
|
|
635
|
+
@app.get("/api/v1/memories/{record_id}")
|
|
636
|
+
def get_memory(record_id: str, agent_id: str = "default",
|
|
637
|
+
client: str = "HTTP",
|
|
638
|
+
project: str | None = None) -> dict:
|
|
639
|
+
err = validate_project(project)
|
|
640
|
+
if err:
|
|
641
|
+
raise HTTPException(status_code=422, detail={
|
|
642
|
+
"error": "INVALID_ARGUMENT", "message": err})
|
|
643
|
+
r = kb.get_memory(record_id, agent_id=agent_id, client=client,
|
|
644
|
+
project=project)
|
|
645
|
+
if r is None:
|
|
646
|
+
raise HTTPException(status_code=404,
|
|
647
|
+
detail={"error": "NOT_FOUND", "message": "记录不存在"})
|
|
648
|
+
return r.model_dump()
|
|
649
|
+
|
|
650
|
+
@app.patch("/api/v1/memories/{record_id}")
|
|
651
|
+
def update_memory(record_id: str, body: MemoryUpdate,
|
|
652
|
+
agent_id: str = "default",
|
|
653
|
+
client: str = "HTTP",
|
|
654
|
+
project: str | None = None) -> dict:
|
|
655
|
+
err = validate_project(project)
|
|
656
|
+
if err:
|
|
657
|
+
raise HTTPException(status_code=422, detail={
|
|
658
|
+
"error": "INVALID_ARGUMENT", "message": err})
|
|
659
|
+
r = kb.update_memory(record_id, content=body.content, tags=body.tags,
|
|
660
|
+
agent_id=agent_id, client=client, project=project)
|
|
661
|
+
if r is None:
|
|
662
|
+
raise HTTPException(status_code=404,
|
|
663
|
+
detail={"error": "NOT_FOUND", "message": "记录不存在"})
|
|
664
|
+
return r.model_dump()
|
|
665
|
+
|
|
666
|
+
@app.delete("/api/v1/memories/{record_id}")
|
|
667
|
+
def delete_memory(record_id: str, agent_id: str = "default",
|
|
668
|
+
client: str = "HTTP",
|
|
669
|
+
project: str | None = None) -> dict:
|
|
670
|
+
err = validate_project(project)
|
|
671
|
+
if err:
|
|
672
|
+
raise HTTPException(status_code=422, detail={
|
|
673
|
+
"error": "INVALID_ARGUMENT", "message": err})
|
|
674
|
+
if not kb.delete_memory(record_id, agent_id=agent_id, client=client,
|
|
675
|
+
project=project):
|
|
676
|
+
raise HTTPException(status_code=404,
|
|
677
|
+
detail={"error": "NOT_FOUND", "message": "记录不存在"})
|
|
678
|
+
return {"ok": True}
|
|
679
|
+
|
|
680
|
+
@app.post("/api/v1/search")
|
|
681
|
+
def search(body: SearchRequest) -> dict:
|
|
682
|
+
"""混合检索;带 project 时检索该项目子库 + 全局库(项目命中加分)。
|
|
683
|
+
English: Hybrid retrieval; with project, search that project sub-store plus
|
|
684
|
+
the global store (project hits are boosted)."""
|
|
685
|
+
results = kb.search(body.query, top_k=body.top_k, mode=body.mode,
|
|
686
|
+
type=body.type, tag=body.tag,
|
|
687
|
+
agent_id=body.agent_id, client=body.client,
|
|
688
|
+
project=body.project)
|
|
689
|
+
return {"results": results}
|
|
690
|
+
|
|
691
|
+
@app.post("/api/v1/documents")
|
|
692
|
+
async def add_document(request: Request) -> dict:
|
|
693
|
+
"""导入文档:multipart 文件上传(字段 file)或 JSON {"path": 本地路径}。
|
|
694
|
+
|
|
695
|
+
返回 {"source": 文件名, "chunks": 块数};
|
|
696
|
+
缺参 / 文件不存在 / 格式不支持 → 400(统一错误 JSON)。
|
|
697
|
+
上传文件保留扩展名落临时文件走统一解析管道,source 用上传文件名。
|
|
698
|
+
English: Import a document: multipart file upload (field file) or JSON {"path": local path}.
|
|
699
|
+
Returns {"source": filename, "chunks": count}; missing param / nonexistent file / unsupported format
|
|
700
|
+
→ 400 (unified error JSON). The uploaded file is saved to a temp file with its extension kept and goes
|
|
701
|
+
through the unified parse pipeline, with source set to the uploaded file name."""
|
|
702
|
+
content_type = request.headers.get("content-type", "")
|
|
703
|
+
try:
|
|
704
|
+
if content_type.startswith("multipart/form-data"):
|
|
705
|
+
form = await request.form()
|
|
706
|
+
upload = form.get("file")
|
|
707
|
+
if upload is None or isinstance(upload, str):
|
|
708
|
+
raise HTTPException(status_code=400, detail={
|
|
709
|
+
"error": "BAD_REQUEST",
|
|
710
|
+
"message": "multipart 上传需提供文件字段 file"})
|
|
711
|
+
name = Path(upload.filename or "upload").name
|
|
712
|
+
project = form.get("project")
|
|
713
|
+
if project is not None and not isinstance(project, str):
|
|
714
|
+
project = None
|
|
715
|
+
err = validate_project(project)
|
|
716
|
+
if err:
|
|
717
|
+
raise HTTPException(status_code=422, detail={
|
|
718
|
+
"error": "INVALID_ARGUMENT", "message": err})
|
|
719
|
+
with tempfile.NamedTemporaryFile(
|
|
720
|
+
suffix=Path(name).suffix, delete=False) as tmp:
|
|
721
|
+
tmp.write(await upload.read())
|
|
722
|
+
tmp_path = Path(tmp.name)
|
|
723
|
+
try:
|
|
724
|
+
return kb.add_document(tmp_path, source=name, project=project)
|
|
725
|
+
finally:
|
|
726
|
+
tmp_path.unlink(missing_ok=True)
|
|
727
|
+
try:
|
|
728
|
+
body = await request.json()
|
|
729
|
+
except Exception:
|
|
730
|
+
body = None
|
|
731
|
+
path = (body or {}).get("path")
|
|
732
|
+
if not path:
|
|
733
|
+
raise HTTPException(status_code=400, detail={
|
|
734
|
+
"error": "BAD_REQUEST",
|
|
735
|
+
"message": "需提供 multipart file 字段或 JSON path 字段"})
|
|
736
|
+
# JSON path 模式可带 agent_id/client/project(project 选择子库)
|
|
737
|
+
body_agent = (body or {}).get("agent_id", "default")
|
|
738
|
+
body_client = (body or {}).get("client", "HTTP")
|
|
739
|
+
body_project = (body or {}).get("project")
|
|
740
|
+
err = validate_project(body_project)
|
|
741
|
+
if err:
|
|
742
|
+
raise HTTPException(status_code=422, detail={
|
|
743
|
+
"error": "INVALID_ARGUMENT", "message": err})
|
|
744
|
+
if not Path(path).is_file():
|
|
745
|
+
raise HTTPException(status_code=400, detail={
|
|
746
|
+
"error": "FILE_NOT_FOUND",
|
|
747
|
+
"message": f"文件不存在:{path}"})
|
|
748
|
+
return kb.add_document(path, agent_id=body_agent, client=body_client,
|
|
749
|
+
project=body_project)
|
|
750
|
+
except UnsupportedFormatError as exc:
|
|
751
|
+
raise HTTPException(status_code=400, detail={
|
|
752
|
+
"error": "UNSUPPORTED_FORMAT", "message": str(exc)})
|
|
753
|
+
|
|
754
|
+
@app.get("/api/v1/documents")
|
|
755
|
+
def list_documents(project: str | None = None) -> dict:
|
|
756
|
+
"""按 source 聚合的文档列表;project 指定子库。
|
|
757
|
+
English: Document list aggregated by source; project selects the sub-store."""
|
|
758
|
+
err = validate_project(project)
|
|
759
|
+
if err:
|
|
760
|
+
raise HTTPException(status_code=422, detail={
|
|
761
|
+
"error": "INVALID_ARGUMENT", "message": err})
|
|
762
|
+
return {"items": kb.list_documents(project=project)}
|
|
763
|
+
|
|
764
|
+
@app.delete("/api/v1/documents/{source}")
|
|
765
|
+
def delete_document(source: str, project: str | None = None) -> dict:
|
|
766
|
+
"""按 source 删除文档全部记录;project 指定子库。
|
|
767
|
+
English: Delete all records of a document by source; project selects the sub-store."""
|
|
768
|
+
err = validate_project(project)
|
|
769
|
+
if err:
|
|
770
|
+
raise HTTPException(status_code=422, detail={
|
|
771
|
+
"error": "INVALID_ARGUMENT", "message": err})
|
|
772
|
+
return {"deleted": kb.delete_document(source, project=project)}
|
|
773
|
+
|
|
774
|
+
@app.delete("/api/v1/documents")
|
|
775
|
+
def delete_document_by_query(source: str = Query(..., min_length=1),
|
|
776
|
+
project: str | None = None) -> dict:
|
|
777
|
+
"""按查询参数删除文档(URL 等含斜杠的 source 必须走这里)。
|
|
778
|
+
|
|
779
|
+
示例:DELETE /api/v1/documents?source=https%3A%2F%2Fexample.com
|
|
780
|
+
English: Delete a document by query parameter. Sources containing slashes
|
|
781
|
+
(e.g. URLs) must use this route, since a path parameter cannot carry them.
|
|
782
|
+
"""
|
|
783
|
+
err = validate_project(project)
|
|
784
|
+
if err:
|
|
785
|
+
raise HTTPException(status_code=422, detail={
|
|
786
|
+
"error": "INVALID_ARGUMENT", "message": err})
|
|
787
|
+
return {"deleted": kb.delete_document(source, project=project)}
|
|
788
|
+
|
|
789
|
+
@app.post("/api/v1/ingest/web")
|
|
790
|
+
def ingest_web(body: WebIngestRequest) -> dict:
|
|
791
|
+
"""抓取网页正文切分入库;抓取/正文提取失败返回 400 与原因。
|
|
792
|
+
English: Fetch a webpage body, split and ingest it; returns 400 with the reason on fetch/body-extraction failure."""
|
|
793
|
+
try:
|
|
794
|
+
return kb.add_webpage(body.url, agent_id=body.agent_id,
|
|
795
|
+
client=body.client, project=body.project)
|
|
796
|
+
except WebFetchError as exc:
|
|
797
|
+
raise HTTPException(status_code=400, detail={
|
|
798
|
+
"error": "WEB_FETCH_FAILED", "message": str(exc)})
|
|
799
|
+
|
|
800
|
+
@app.post("/api/v1/ask")
|
|
801
|
+
def ask(body: AskRequest) -> dict:
|
|
802
|
+
"""基础 RAG 问答;LLM 禁用时返回 503 与配置指引。
|
|
803
|
+
English: Basic RAG Q&A; returns 503 with setup guidance when the LLM is disabled."""
|
|
804
|
+
try:
|
|
805
|
+
return kb.ask(body.question, agent_id=body.agent_id,
|
|
806
|
+
client=body.client, project=body.project)
|
|
807
|
+
except LLMDisabledError:
|
|
808
|
+
raise HTTPException(status_code=503, detail={
|
|
809
|
+
"error": "LLM_DISABLED",
|
|
810
|
+
"message": "未检测到可用的 LLM:请在 .env 配置 KB_LLM_MODE=local "
|
|
811
|
+
"(Ollama)或 KB_LLM_API_KEY 启用云端(任意 OpenAI 兼容服务商)"})
|
|
812
|
+
|
|
813
|
+
@app.get("/api/v1/audit")
|
|
814
|
+
def audit(client: str | None = None, project: str | None = None,
|
|
815
|
+
agent: str | None = None,
|
|
816
|
+
action: str | None = None,
|
|
817
|
+
days: int | None = None, limit: int = 100) -> dict:
|
|
818
|
+
"""用户查询存取审计(v2 spec 2.5):按 (client, project) 过滤分文件
|
|
819
|
+
agent-audit,可选 action/days/limit。返回 {"items": [...], "total": N}。
|
|
820
|
+
agent 为兼容参数(旧三段式文件名回溯)。
|
|
821
|
+
English: Query the access audit (v2 spec 2.5): filters the per-(client, project) files under
|
|
822
|
+
agent-audit, optionally by action/days/limit. Returns {"items": [...], "total": N}.
|
|
823
|
+
agent is kept for backward compatibility with old three-segment file names."""
|
|
824
|
+
if not client and not agent:
|
|
825
|
+
raise HTTPException(status_code=422, detail={
|
|
826
|
+
"error": "INVALID_ARGUMENT", "message": "client 或 agent 参数至少提供一个"})
|
|
827
|
+
items = kb.query_access_audit(
|
|
828
|
+
client=client, project=project, agent=agent,
|
|
829
|
+
action=action, days=days, limit=limit)
|
|
830
|
+
return {"items": items, "total": len(items)}
|
|
831
|
+
|
|
832
|
+
@app.get("/health")
|
|
833
|
+
@app.get("/api/v1/healthz")
|
|
834
|
+
def healthz() -> dict:
|
|
835
|
+
# /health 为 /api/v1/healthz 的别名(复用同一处理函数):部分 agent/探针习惯探测 /health,
|
|
836
|
+
# 避免 404 误判“服务未启动”。鉴权白名单同步豁免(见 _wrap_api_key)。
|
|
837
|
+
return {"status": "ok", **kb.stats()}
|
|
838
|
+
|
|
839
|
+
@app.post("/api/v1/monitor/summary")
|
|
840
|
+
def post_monitor_summary() -> dict:
|
|
841
|
+
"""按需生成监控摘要(TASK-0021 去常驻 / TASK-0059 降级 / TASK-0065 纯文本模式):单轮(快照→摘要→写comm:monitor)。
|
|
842
|
+
|
|
843
|
+
monitor_llm="off"(默认)时全程纯文本不调 LLM(零成本/不抢GPU);"auto" 时
|
|
844
|
+
LLM 可用摘要化、不可用降级纯文本仍返回 200;仅 build_snapshot/add_memory
|
|
845
|
+
失败返回 None → 502 MONITOR_UNAVAILABLE。
|
|
846
|
+
不依赖常驻线程,前端按钮/定时器按需调用。
|
|
847
|
+
English: On-demand monitor summary (TASK-0021 no-resident / TASK-0059 degrade / TASK-0065 plain-text
|
|
848
|
+
mode): one round (snapshot → summary → write comm:monitor). With monitor_llm="off" (default) it is
|
|
849
|
+
plain-text throughout without calling the LLM (zero cost, no GPU contention); with "auto", the LLM
|
|
850
|
+
summarizes when available and degrades to plain text otherwise, still returning 200; only
|
|
851
|
+
build_snapshot/add_memory failure returns None → 502 MONITOR_UNAVAILABLE. It does not depend on a
|
|
852
|
+
resident thread; the frontend button/timer calls on demand."""
|
|
853
|
+
record = run_once_summary(kb, max_tokens=kb.settings.monitor_max_tokens,
|
|
854
|
+
dispatch_enabled=kb.settings.dispatch_enabled,
|
|
855
|
+
monitor_llm=kb.settings.monitor_llm)
|
|
856
|
+
if record is None:
|
|
857
|
+
raise HTTPException(status_code=502, detail={
|
|
858
|
+
"error": "MONITOR_UNAVAILABLE",
|
|
859
|
+
"message": "摘要生成失败:本地 LLM 不可用或摘要为空"})
|
|
860
|
+
return {"summary": record.content, "id": record.id}
|
|
861
|
+
|
|
862
|
+
@app.get("/api/v1/config")
|
|
863
|
+
def get_config() -> dict:
|
|
864
|
+
"""客户端只读配置(TASK-0021):仅暴露最小字段(monitor_autotimer)。
|
|
865
|
+
English: Client read-only config (TASK-0021): exposes only the minimal field (monitor_autotimer)."""
|
|
866
|
+
return {"monitor_autotimer": kb.settings.monitor_autotimer}
|
|
867
|
+
|
|
868
|
+
@app.get("/api/v1/governance/stats")
|
|
869
|
+
def get_governance_stats() -> dict:
|
|
870
|
+
"""治理统计(TASK-0070,A3 spec §4.2):只读无副作用。
|
|
871
|
+
|
|
872
|
+
返回总记录数、平均 access_count、超 90 天未命中数;access_count/last_accessed
|
|
873
|
+
用 getattr 兼容 TASK-0067 未合入(未合入时均为 0/"",统计值为 0)。
|
|
874
|
+
English: Governance stats (TASK-0070, A3 spec §4.2): read-only, no side effects.
|
|
875
|
+
Returns total record count, average access_count, and the count of records not hit for over 90 days;
|
|
876
|
+
access_count/last_accessed use getattr to stay compatible with pre-merge TASK-0067 (all 0/"" and stats
|
|
877
|
+
of 0 when not merged)."""
|
|
878
|
+
from kb.governance import compute_stats
|
|
879
|
+
return compute_stats(kb.store.iter_all())
|
|
880
|
+
|
|
881
|
+
@app.get("/api/v1/governance/config")
|
|
882
|
+
def get_governance_config() -> dict:
|
|
883
|
+
"""治理配置(TASK-0070):GET 当前衰减+新鲜度配置,只读。
|
|
884
|
+
English: Governance config (TASK-0070): GET the current decay + freshness config, read-only."""
|
|
885
|
+
s = kb.settings
|
|
886
|
+
return {
|
|
887
|
+
"decay_enabled": getattr(s, "decay_enabled", False),
|
|
888
|
+
"decay_lambda": getattr(s, "decay_lambda", 0.02),
|
|
889
|
+
"decay_gamma": getattr(s, "decay_gamma", 0.3),
|
|
890
|
+
"freshness_enabled": getattr(s, "freshness_enabled", False),
|
|
891
|
+
"freshness_beta": getattr(s, "freshness_beta", 0.05),
|
|
892
|
+
"freshness_alpha": getattr(s, "freshness_alpha", 0.3),
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
@app.get("/")
|
|
896
|
+
def root() -> HTMLResponse:
|
|
897
|
+
"""根路径极简导航页(N19):纯字符串 HTML,无模板依赖。
|
|
898
|
+
English: Minimal root navigation page (N19): plain-string HTML, no template dependency."""
|
|
899
|
+
return HTMLResponse(_NAV_HTML)
|
|
900
|
+
|
|
901
|
+
@app.get("/api/v1/logs")
|
|
902
|
+
def get_logs(limit: int = Query(100, ge=1, le=LOG_LIMIT_MAX),
|
|
903
|
+
level: str | None = Query(None),
|
|
904
|
+
event: str | None = Query(None)) -> dict:
|
|
905
|
+
"""日志查看(N18):尾部向前扫描,按 level/event 过滤凑满 limit 条,按文件序返回。
|
|
906
|
+
|
|
907
|
+
limit 越界 / level 非法 → 422;文件不可读 → 500 LOG_READ_ERROR;
|
|
908
|
+
文件不存在或为空 → items=[](不视为错误)。
|
|
909
|
+
English: Log viewing (N18): scan backward from the tail, filter by level/event to fill limit items, return in
|
|
910
|
+
file order. Out-of-range limit / illegal level → 422; unreadable file → 500 LOG_READ_ERROR; a nonexistent
|
|
911
|
+
or empty file → items=[] (not an error)."""
|
|
912
|
+
lvl = _normalize_level(level)
|
|
913
|
+
try:
|
|
914
|
+
lines, truncated, start_line = _read_log_tail(kb.settings.log_file)
|
|
915
|
+
except OSError as exc:
|
|
916
|
+
raise HTTPException(status_code=500, detail={
|
|
917
|
+
"error": "LOG_READ_ERROR", "message": str(exc)})
|
|
918
|
+
items = []
|
|
919
|
+
for back_idx, line in enumerate(reversed(lines)):
|
|
920
|
+
parsed = _parse_log_line(line)
|
|
921
|
+
if parsed is None:
|
|
922
|
+
continue
|
|
923
|
+
if lvl and parsed["level"].upper() != lvl:
|
|
924
|
+
continue
|
|
925
|
+
if event and event.lower() not in parsed["message"].lower():
|
|
926
|
+
continue
|
|
927
|
+
parsed["line"] = start_line + (len(lines) - 1 - back_idx)
|
|
928
|
+
items.append(parsed)
|
|
929
|
+
if len(items) >= limit:
|
|
930
|
+
break
|
|
931
|
+
items.reverse()
|
|
932
|
+
return {"items": items, "total": len(items), "truncated": truncated}
|
|
933
|
+
|
|
934
|
+
@app.get("/api/v1/logs/events")
|
|
935
|
+
def get_log_events(window: int = Query(1000, ge=1, le=EVENT_WINDOW_MAX),
|
|
936
|
+
level: str | None = Query(None)) -> dict:
|
|
937
|
+
"""日志事件统计(N18):按 level 与 logger 两维度统计最近 window 行的行数。
|
|
938
|
+
|
|
939
|
+
当前文本行格式无结构化 event 字段,以 logger/level 代理统计(见设计书)。
|
|
940
|
+
English: Log event statistics (N18): count the lines of the most recent `window` lines by level and logger.
|
|
941
|
+
The current text-line format has no structured event field, so logger/level proxy the stats (see design
|
|
942
|
+
doc)."""
|
|
943
|
+
lvl = _normalize_level(level)
|
|
944
|
+
try:
|
|
945
|
+
recent, _, _ = _read_log_tail(kb.settings.log_file, scan_max=window)
|
|
946
|
+
except OSError as exc:
|
|
947
|
+
raise HTTPException(status_code=500, detail={
|
|
948
|
+
"error": "LOG_READ_ERROR", "message": str(exc)})
|
|
949
|
+
by_level: dict[str, int] = {}
|
|
950
|
+
by_logger: dict[str, int] = {}
|
|
951
|
+
for line in recent:
|
|
952
|
+
parsed = _parse_log_line(line)
|
|
953
|
+
if parsed is None:
|
|
954
|
+
continue
|
|
955
|
+
lv = parsed["level"].upper()
|
|
956
|
+
if lvl and lv != lvl:
|
|
957
|
+
continue
|
|
958
|
+
by_level[lv] = by_level.get(lv, 0) + 1
|
|
959
|
+
by_logger[parsed["logger"]] = by_logger.get(parsed["logger"], 0) + 1
|
|
960
|
+
return {"window": window, "total_lines": len(recent),
|
|
961
|
+
"by_level": by_level, "by_logger": by_logger}
|
|
962
|
+
|
|
963
|
+
# MCP streamable http 端点挂载在 /mcp(子路径 "/",即完整路径 /mcp/);
|
|
964
|
+
# 外包 SSE charset 中间件,保证 text/event-stream 响应头带 charset=utf-8
|
|
965
|
+
app.mount("/mcp", _wrap_sse_charset(mcp_app))
|
|
966
|
+
# 鉴权中间件(N19/TASK-0062):纯 ASGI,空 key 直通,非空校验 Bearer/X-API-Key;
|
|
967
|
+
# 注册在 JSON charset 内层(401 响应向外经 JSON charset 补 charset)、访问日志内层
|
|
968
|
+
app = _wrap_api_key(app, kb.settings)
|
|
969
|
+
# 兜底:整体再包 JSON charset 中间件,保证所有 application/json
|
|
970
|
+
# 响应头带 charset=utf-8(含 422 校验错误与错误 JSON)
|
|
971
|
+
app = _wrap_json_charset(app)
|
|
972
|
+
# 请求访问日志中间件(N17/TASK-0012):ASGI 栈最外层,REST 与 MCP 统一覆盖
|
|
973
|
+
app = _wrap_request_log(app)
|
|
974
|
+
|
|
975
|
+
return app
|