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/cli.py ADDED
@@ -0,0 +1,788 @@
1
+ """CLI 入口:add / search / info / serve / mcp / forget / dedup / stats / ask / eval。
2
+
3
+ CLI entry: add / search / info / serve / mcp / forget / dedup / stats / ask / eval.
4
+ """
5
+ import json
6
+ import os
7
+ import sys
8
+ from datetime import datetime
9
+ from pathlib import Path
10
+
11
+ import typer
12
+ from rich.console import Console
13
+ from rich.table import Table
14
+
15
+ from kb.config import Settings, get_settings
16
+ from kb.governance import days_since
17
+ from kb.service import KBService
18
+
19
+ app = typer.Typer()
20
+ console = Console()
21
+
22
+
23
+ def _default_project() -> str:
24
+ """当前目录名规整为合法项目名;无法规整时回退全局库(空串)。
25
+
26
+ English: Normalize the current directory name into a valid project name;
27
+ fall back to the global store (empty string) when it cannot be normalized.
28
+ """
29
+ import re
30
+ from kb.service import validate_project
31
+ name = re.sub(r"[^A-Za-z0-9_\-\u4e00-\u9fff]", "-", Path.cwd().name)
32
+ name = name.strip("-")[:64]
33
+ return name if name and validate_project(name) is None else ""
34
+
35
+
36
+ def _force_utf8_stdio() -> None:
37
+ """强制本进程三个标准流以 UTF-8 编解码,Windows 下把控制台代码页切到 65001。
38
+
39
+ 背景:Windows 控制台默认代码页 936(GBK);若 Python 进程按系统编码输出,
40
+ 而终端/管道按 UTF-8 解码(或反之),中文即显示为乱码——MCP stdio/SSE
41
+ 传输除协议本身外,错误信息、日志、CLI 输出都会踩中。本函数:
42
+ 1) reconfigure stdin/stdout/stderr 为 UTF-8(Python 3.7+);
43
+ 2) 设置 PYTHONIOENCODING,令 uvicorn 等子进程继承 UTF-8;
44
+ 3) Windows 下 SetConsoleOutputCP/SetConsoleCP(65001)(失败静默)。
45
+ 幂等,可重复调用。
46
+ English: Force the three standard streams of this process to UTF-8 and, on Windows, switch the
47
+ console code page to 65001. Windows consoles default to 936 (GBK); when a Python process emits in the
48
+ system encoding while the terminal/pipe decodes as UTF-8 (or vice versa), Chinese turns into mojibake —
49
+ affecting MCP stdio/SSE, error messages, logs and CLI output alike. This: 1) reconfigures
50
+ stdin/stdout/stderr to UTF-8 (Python 3.7+); 2) sets PYTHONIOENCODING so child processes (uvicorn) inherit
51
+ UTF-8; 3) on Windows calls SetConsoleOutputCP/SetConsoleCP(65001) (fails silently). Idempotent."""
52
+ for stream in (sys.stdin, sys.stdout, sys.stderr):
53
+ try:
54
+ stream.reconfigure(encoding="utf-8", errors="replace")
55
+ except (AttributeError, OSError, ValueError):
56
+ pass # 非文本流或旧 Python,跳过
57
+ os.environ["PYTHONIOENCODING"] = "utf-8"
58
+ if sys.platform == "win32":
59
+ try:
60
+ import ctypes
61
+ ctypes.windll.kernel32.SetConsoleOutputCP(65001)
62
+ ctypes.windll.kernel32.SetConsoleCP(65001)
63
+ except Exception:
64
+ pass # 非交互终端(管道/服务)无控制台,静默即可
65
+
66
+
67
+ # ---- N23a 维护 CLI 候选筛选纯函数(可单测,不依赖 CLI/服务)----
68
+
69
+ def find_stale_records(records, days: int, now: datetime | None = None):
70
+ """筛选超 N 天未命中的记录(N23a forget --stale)。
71
+
72
+ Find records not touched for more than N days (N23a forget --stale).
73
+
74
+ last_accessed 为空时用 created_at 替代(governance.days_since 语义)。
75
+ 返回 [(record, days_float), ...],按天数降序。
76
+ """
77
+ if now is None:
78
+ now = datetime.now()
79
+ stale = []
80
+ for r in records:
81
+ d = days_since(r.last_accessed, r.created_at, now=now)
82
+ if d > days:
83
+ stale.append((r, d))
84
+ stale.sort(key=lambda x: x[1], reverse=True)
85
+ return stale
86
+
87
+
88
+ def find_duplicate_pairs(records_with_embeddings, threshold: float = 0.85):
89
+ """筛选相似度 > threshold 的记录对(N23a dedup)。
90
+
91
+ records_with_embeddings: [(record, embedding_vector), ...]
92
+ 余弦相似度;返回 [(record_a, record_b, similarity), ...],按相似度降序,无重复对。
93
+ """
94
+ import math
95
+ pairs = []
96
+ seen = set()
97
+ n = len(records_with_embeddings)
98
+ for i in range(n):
99
+ r1, e1 = records_with_embeddings[i]
100
+ for j in range(i + 1, n):
101
+ r2, e2 = records_with_embeddings[j]
102
+ dot = sum(a * b for a, b in zip(e1, e2))
103
+ norm1 = math.sqrt(sum(a * a for a in e1))
104
+ norm2 = math.sqrt(sum(b * b for b in e2))
105
+ if norm1 == 0 or norm2 == 0:
106
+ continue
107
+ sim = dot / (norm1 * norm2)
108
+ if sim > threshold:
109
+ pair_key = tuple(sorted([r1.id, r2.id]))
110
+ if pair_key not in seen:
111
+ seen.add(pair_key)
112
+ pairs.append((r1, r2, sim))
113
+ pairs.sort(key=lambda x: x[2], reverse=True)
114
+ return pairs
115
+
116
+
117
+ def _truncate(text: str, n: int = 50) -> str:
118
+ """内容摘要截断,避免 CLI 输出过长。"""
119
+ return text[:n] + "…" if len(text) > n else text
120
+
121
+
122
+ # ---- N28 CLI 增强(A4 spec §2):stats 纯函数 + 命令 ----
123
+
124
+ def compute_type_distribution(records) -> dict[str, dict]:
125
+ """类型分布:{type: {"count": 条数, "pct": 百分比}}(N28 stats)。
126
+
127
+ 空输入返回空 dict;百分比保留 1 位小数。
128
+ """
129
+ counts: dict[str, int] = {}
130
+ for r in records:
131
+ t = getattr(getattr(r, "type", None), "value", None) or str(
132
+ getattr(r, "type", "unknown"))
133
+ counts[t] = counts.get(t, 0) + 1
134
+ total = sum(counts.values())
135
+ if total == 0:
136
+ return {}
137
+ return {t: {"count": c, "pct": round(c * 100.0 / total, 1)}
138
+ for t, c in sorted(counts.items())}
139
+
140
+
141
+ def compute_hot_records(records, top: int = 5) -> list[tuple]:
142
+ """访问热度 top N:[(record, access_count)] 按 access_count 降序(N28 stats)。
143
+
144
+ 零计数记录排除(从未访问无热度意义)。
145
+ """
146
+ items = [(r, getattr(r, "access_count", 0) or 0) for r in records]
147
+ items = [(r, c) for r, c in items if c > 0]
148
+ items.sort(key=lambda x: x[1], reverse=True)
149
+ return items[:top]
150
+
151
+
152
+ def resolve_device(settings: Settings, interactive: bool, input_fn=input) -> str:
153
+ """优先级:settings.device 显式非空值 > runtime.json 已存选择 > 交互询问(cuda 可用时)
154
+ > 默认 cpu。interactive=False 时不询问直接 cpu。
155
+
156
+ 交互询问额外要求 stdin 是终端:pythonw/后台脚本/CI 的 stdin 不可读,
157
+ 若仍调用 input() 会 EOFError 导致服务直接退出(Windows 一键脚本首跑阻断)。
158
+ 此时静默回退 cpu,用户之后可在 .env 显式设 KB_DEVICE=cuda。
159
+ English: Device priority: explicit settings.device > persisted runtime.json choice >
160
+ interactive prompt (when CUDA is available) > cpu. interactive=False skips the prompt.
161
+ The prompt also requires stdin to be a TTY: pythonw/background scripts/CI have no
162
+ readable stdin, and input() would raise EOFError and kill the service (Windows
163
+ one-click first-run blocker). In that case fall back to cpu silently; users can
164
+ set KB_DEVICE=cuda explicitly later.
165
+ """
166
+ # 1. 显式配置最高优先,直接返回(不写文件)
167
+ if settings.device:
168
+ return settings.device
169
+ # 2. runtime.json 已有持久化选择
170
+ if settings.runtime_file.exists():
171
+ try:
172
+ saved = json.loads(settings.runtime_file.read_text(encoding="utf-8"))
173
+ except json.JSONDecodeError:
174
+ saved = {}
175
+ if saved.get("device"):
176
+ return saved["device"]
177
+ # 3. 交互询问(仅 interactive 且 stdin 为终端且 cuda 可用时);
178
+ # 仅此路径写入 runtime.json
179
+ stdin_tty = False
180
+ if interactive:
181
+ try:
182
+ stdin_tty = bool(sys.stdin and sys.stdin.isatty())
183
+ except (AttributeError, OSError, ValueError):
184
+ stdin_tty = False
185
+ if interactive and stdin_tty:
186
+ try:
187
+ import torch
188
+ cuda_ok = torch.cuda.is_available()
189
+ except ImportError:
190
+ cuda_ok = False
191
+ if cuda_ok:
192
+ try:
193
+ answer = input_fn("检测到独立显卡,是否启用 GPU 加速?[y/n]")
194
+ except (EOFError, KeyboardInterrupt):
195
+ answer = "n"
196
+ device = "cuda" if answer.strip().lower() == "y" else "cpu"
197
+ settings.runtime_file.parent.mkdir(parents=True, exist_ok=True)
198
+ settings.runtime_file.write_text(
199
+ json.dumps({"device": device}), encoding="utf-8")
200
+ return device
201
+ # 4. 默认 cpu(不写文件)
202
+ return "cpu"
203
+
204
+
205
+ def _service() -> KBService:
206
+ return KBService(get_settings())
207
+
208
+
209
+ # ---- serve 启动健壮性:端口预检与 kb.pid 自管理(任务 #2)----
210
+
211
+ def _pid_file() -> Path:
212
+ """kb.pid 路径:当前工作目录(经 start_kb.bat 启动时即仓库根)。"""
213
+ return Path.cwd() / "kb.pid"
214
+
215
+
216
+ def is_port_in_use(host: str, port: int) -> bool:
217
+ """端口占用预检:getaddrinfo 解析 host 确定地址族(IPv4/IPv6 均支持),
218
+ 取首个结果试绑,绑定失败即视为已被占用。
219
+
220
+ 背景:pythonw 无控制台,端口被占时绑定失败完全静默,进程活着但不监听,
221
+ 形成“挂死空壳”。启动前先预检,被占立刻报错退出而非制造空壳。
222
+ 无法解析(如地址非法)时保守按“已占用”处理,宁可拒绝启动也不留空壳。
223
+ 注意:预检与真正绑定之间存在 TOCTOU 残余窗口(期间被别的进程抢占),
224
+ 由 start_kb.bat 的健康轮询兜底,此处只防典型的双实例竞跑。
225
+ """
226
+ import socket
227
+ try:
228
+ infos = socket.getaddrinfo(host, port, type=socket.SOCK_STREAM)
229
+ except OSError:
230
+ return True # 无法解析,保守拒绝启动
231
+ if not infos:
232
+ return True
233
+ family, _, _, _, addr = infos[0]
234
+ with socket.socket(family, socket.SOCK_STREAM) as s:
235
+ try:
236
+ s.bind(addr)
237
+ except OSError:
238
+ return True
239
+ return False
240
+
241
+
242
+ def write_pid_file() -> None:
243
+ """由服务进程自身写入工作区根 kb.pid(当前进程 PID,供 stop_kb.bat 使用)。"""
244
+ _pid_file().write_text(f"{os.getpid()}\n", encoding="utf-8")
245
+
246
+
247
+ def remove_pid_file() -> None:
248
+ """退出时清理 kb.pid;仅当内容确为本进程 PID 时才删除,避免误删另一实例的记录。"""
249
+ pid_path = _pid_file()
250
+ try:
251
+ if pid_path.exists() and \
252
+ pid_path.read_text(encoding="utf-8").strip() == str(os.getpid()):
253
+ pid_path.unlink()
254
+ except OSError:
255
+ pass # 清理失败不阻断退出(stop_kb.bat 另有端口/命令行兜底)
256
+
257
+
258
+ @app.command()
259
+ def add(content: str,
260
+ tags: str = typer.Option("", "--tags", help="逗号分隔的标签"),
261
+ source: str | None = typer.Option(None, "--source", help="来源标识"),
262
+ namespace: str = typer.Option("default", "--namespace", help="命名空间"),
263
+ agent: str = typer.Option("default", "--agent", help="Agent 身份(兼容参数,v2 不再参与隔离)"),
264
+ client: str = typer.Option("CLI", "--client", help="来源客户端(默认 CLI)"),
265
+ project: str | None = typer.Option(None, "--project", help="项目名;缺省自动取当前目录名")):
266
+ """写入一条记忆(v2.1:缺省落到当前目录名对应的项目子库)。"""
267
+ svc = _service()
268
+ project = project if project is not None else _default_project()
269
+ r = svc.add_memory(
270
+ content, tags=[t for t in tags.split(",") if t] if tags else None,
271
+ source=source, namespace=namespace, agent_id=project or "default",
272
+ client=client, project=project)
273
+ console.print(f"已写入 id={r.id} client={client} project={project or '-'}")
274
+
275
+
276
+ @app.command()
277
+ def search(query: str,
278
+ top_k: int = typer.Option(5, "--top-k", help="返回条数"),
279
+ mode: str = typer.Option("hybrid", "--mode", help="hybrid/vector/keyword"),
280
+ agent: str = typer.Option("default", "--agent", help="Agent 身份(兼容参数)"),
281
+ client: str = typer.Option("CLI", "--client", help="来源客户端(默认 CLI)"),
282
+ project: str | None = typer.Option(None, "--project", help="项目名;缺省自动取当前目录名")):
283
+ """混合检索(v2.1:项目子库 + 全局库;缺省用当前目录名作为项目)。"""
284
+ project = project if project is not None else _default_project()
285
+ hits = _service().search(query, top_k=top_k, mode=mode, agent_id=agent,
286
+ client=client, project=project)
287
+ if not hits:
288
+ console.print("无结果")
289
+ return
290
+ for h in hits:
291
+ console.print(f"[{h['score']:.4f}] ({h['type']}) {h['source'] or '-'} :: {h['content']}")
292
+
293
+
294
+ @app.command()
295
+ def info():
296
+ """运行信息。"""
297
+ console.print(_service().stats())
298
+
299
+
300
+ @app.command()
301
+ def stats(
302
+ stale_days: int = typer.Option(90, "--stale-days", help="陈旧阈值天数"),
303
+ top: int = typer.Option(5, "--top", help="访问热度显示条数"),
304
+ ):
305
+ """记忆库统计:概览 / 类型分布 / 访问热度 / 陈旧分布(N28)。
306
+
307
+ Memory stats: overview / type distribution / hot records / stale distribution (N28).
308
+
309
+ 用法:
310
+ kb stats # 默认 90 天陈旧阈值,热度 top 5
311
+ kb stats --stale-days 30 --top 10
312
+ """
313
+ svc = _service()
314
+ base = svc.stats()
315
+ records = list(svc.store.iter_all())
316
+
317
+ console.print(f"[bold]记忆库统计 (Memory Stats)[/bold] 记录 {base['records']} 条 · "
318
+ f"device={base['device']} · llm={base['llm']}")
319
+
320
+ # 类型分布 / Type distribution
321
+ dist = compute_type_distribution(records)
322
+ if dist:
323
+ t1 = Table(title="类型分布 (By Type)")
324
+ t1.add_column("类型 (Type)", style="cyan")
325
+ t1.add_column("条数 (Count)", justify="right")
326
+ t1.add_column("占比 (Pct)", justify="right")
327
+ for t, d in dist.items():
328
+ t1.add_row(t, str(d["count"]), f"{d['pct']}%")
329
+ console.print(t1)
330
+ else:
331
+ console.print("[dim]类型分布:空库 (Type distribution: empty)[/dim]")
332
+
333
+ # 访问热度 top N / Hot records top N
334
+ hot = compute_hot_records(records, top=top)
335
+ if hot:
336
+ t2 = Table(title=f"访问热度 top {len(hot)} (Hot Records)")
337
+ t2.add_column("内容摘要 (Summary)", style="white")
338
+ t2.add_column("命中次数 (Hits)", justify="right", style="yellow")
339
+ t2.add_column("最后命中 (Last accessed)", style="dim")
340
+ for r, c in hot:
341
+ t2.add_row(_truncate(r.content), str(c),
342
+ r.last_accessed or "-")
343
+ console.print(t2)
344
+ else:
345
+ console.print("[dim]访问热度:暂无命中记录 (Hot records: none)[/dim]")
346
+
347
+ # 陈旧分布 / Stale distribution
348
+ stale = find_stale_records(records, stale_days)
349
+ console.print(f"陈旧分布 (Stale):超 {stale_days} 天未命中 "
350
+ f"[red]{len(stale)}[/red] 条"
351
+ + ("(kb forget --stale 可清理)" if stale else ""))
352
+
353
+
354
+ @app.command()
355
+ def ask(
356
+ question: str,
357
+ top_k: int = typer.Option(5, "--top-k", help="检索条数"),
358
+ agent: str = typer.Option("default", "--agent", help="Agent 身份(推荐用任务名)"),
359
+ client: str = typer.Option("CLI", "--client", help="来源客户端(默认 CLI)"),
360
+ project: str | None = typer.Option(None, "--project", help="项目名;缺省自动取当前目录名"),
361
+ ):
362
+ """终端 RAG 问答:检索 + 生成,直连 KBService.ask(N28)。
363
+
364
+ Terminal RAG Q&A: retrieval + generation, direct KBService.ask call (N28).
365
+
366
+ LLM 不可用时仍输出检索命中(标注"仅检索未生成"),退出码 1。
367
+ When the LLM is unavailable, still prints retrieval hits (marked "retrieval only"), exit code 1.
368
+ v2.1:带 project 时检索项目子库 + 全局库。
369
+ """
370
+ from kb.service import LLMDisabledError
371
+ svc = _service()
372
+ project = project if project is not None else _default_project()
373
+ console.print("[dim]正在检索与生成…(首次调用需加载模型,请稍候)[/dim]")
374
+ try:
375
+ result = svc.ask(question, agent_id=agent, client=client, project=project)
376
+ console.print(f"\n[bold green]{result['answer']}[/bold green]\n")
377
+ if result.get("sources"):
378
+ t = Table(title=f"来源({len(result['sources'])} 条 · "
379
+ f"llm={result.get('llm', '-')})")
380
+ t.add_column("ID", style="cyan", no_wrap=True)
381
+ t.add_column("分数", justify="right")
382
+ t.add_column("内容摘要", style="white")
383
+ for s in result["sources"]:
384
+ t.add_row(str(s["id"]), f"{s.get('score', 0):.4f}",
385
+ _truncate(s.get("content", ""), 40))
386
+ console.print(t)
387
+ except LLMDisabledError:
388
+ # LLM 不可用:检索结果仍有价值,输出命中并给出配置指引
389
+ hits = svc.search(question, top_k=top_k)
390
+ console.print("[yellow]LLM 不可用(本地 Ollama 未响应且未配置云端 "
391
+ "Key),以下为检索结果(仅检索未生成)[/yellow]")
392
+ console.print(f"配置指引:在 .env 设 KB_LLM_MODE=local 并配置 KB_LLM_MODEL=(先用 "
393
+ "ollama pull 选一个模型,名称以 ollama list 为准),或设"
394
+ " KB_LLM_API_KEY 启用云端(任意 OpenAI 兼容服务商)")
395
+ console.print("[dim]Setup: set KB_LLM_MODE=local + KB_LLM_MODEL= (ollama pull first, "
396
+ "name per `ollama list`), or set KB_LLM_API_KEY for cloud "
397
+ "(any OpenAI-compatible provider).[/dim]")
398
+ if hits:
399
+ t = Table(title=f"检索命中(top {len(hits)})")
400
+ t.add_column("ID", style="cyan", no_wrap=True)
401
+ t.add_column("分数", justify="right")
402
+ t.add_column("内容摘要", style="white")
403
+ for h in hits:
404
+ t.add_row(h["id"], f"{h['score']:.4f}",
405
+ _truncate(h["content"], 40))
406
+ console.print(t)
407
+ else:
408
+ console.print("[dim]无检索结果[/dim]")
409
+ raise typer.Exit(code=1)
410
+
411
+
412
+ def start_idle_watchdog(embedder, idle_seconds: int,
413
+ check_seconds: float | None = None):
414
+ """启动空闲卸载守护线程(daemon),返回停止事件;idle_seconds<=0 不启动。
415
+
416
+ 每轮调用 embedder.unload_if_idle(),空闲超时后卸载模型并释放显存;
417
+ 兜底异常记 WARNING,守护线程永不因单次失败退出。
418
+ English: Start the idle-unload daemon thread and return its stop event;
419
+ idle_seconds<=0 disables it. Each tick calls embedder.unload_if_idle(),
420
+ releasing memory/VRAM after the idle timeout; exceptions are logged as
421
+ WARNING so the thread never dies on a single failure.
422
+ """
423
+ import logging
424
+ import threading
425
+ stop = threading.Event()
426
+ if idle_seconds <= 0:
427
+ return stop
428
+ interval = (check_seconds if check_seconds is not None
429
+ else max(1.0, min(30.0, idle_seconds / 4)))
430
+
431
+ def _loop() -> None:
432
+ log = logging.getLogger("kb.idle")
433
+ while not stop.wait(interval):
434
+ try:
435
+ target = embedder() if callable(embedder) else embedder
436
+ if target.unload_if_idle():
437
+ log.info("嵌入模型空闲超过 %s 秒,已卸载", idle_seconds)
438
+ except Exception as exc: # noqa: BLE001 - 守护线程不因单次失败退出
439
+ log.warning("空闲卸载检查失败:%s", exc)
440
+
441
+ threading.Thread(target=_loop, name="kb-idle-unload", daemon=True).start()
442
+ return stop
443
+
444
+
445
+ @app.command()
446
+ def serve(non_interactive: bool = typer.Option(
447
+ False, "--non-interactive",
448
+ help="非交互模式(自启/脚本):不询问设备,按配置/runtime.json/cpu 启动")):
449
+ """启动常驻 REST 服务(python -m kb serve)。"""
450
+ import uvicorn
451
+ from kb.api import create_app, exposure_warning
452
+ s = get_settings()
453
+ # 端口占用预检:先于一切重初始化(设备检测/模型加载),被占立刻中文报错退出,
454
+ # 杜绝“进程活着但不监听”的双实例竞跑空壳(任务 #2)
455
+ if is_port_in_use(s.api_host, s.api_port):
456
+ console.print(f"[red]端口 {s.api_port} 已被占用,可能已有实例在运行。"
457
+ f"请先停止已有实例(Windows:scripts\\windows\\stop_kb.bat;"
458
+ f"Linux:systemctl stop kb 或 kill 对应进程),"
459
+ f"确认端口释放后重试。[/red]")
460
+ raise typer.Exit(code=1)
461
+ # 终端交互式设备检测(显式配置/runtime.json/询问),结果注入 settings;
462
+ # 自启/脚本模式跳过询问,避免无 stdin 时 EOFError
463
+ s.device = resolve_device(s, interactive=not non_interactive)
464
+ # N29 启动安全警告:非回环监听 + 未鉴权 → 终端醒目横幅(日志侧由 lifespan 记 warning)
465
+ warn = exposure_warning(s.api_host, s.api_key)
466
+ if warn:
467
+ console.print(f"\n[bold red]{'!' * 68}[/bold red]")
468
+ console.print(f"[bold red]{warn}[/bold red]")
469
+ console.print(f"[bold red]{'!' * 68}[/bold red]\n")
470
+ app = create_app(s, enable_watcher=True)
471
+ idle_stop = start_idle_watchdog(lambda: app.state.kb.embedder,
472
+ s.idle_unload_seconds)
473
+ # kb.pid 由服务进程自身管理:启动前写入(真实存活到此处才写,不再由
474
+ # start_kb.bat 猜写);无论正常退出还是异常,退出时清理,不留指向死进程的旧 pid。
475
+ write_pid_file()
476
+ try:
477
+ uvicorn.run(app, host=s.api_host, port=s.api_port)
478
+ finally:
479
+ idle_stop.set()
480
+ remove_pid_file()
481
+
482
+
483
+ @app.command("where")
484
+ def where_cmd(as_json: bool = typer.Option(False, "--json", help="输出 JSON")):
485
+ """显示服务安装位置、解释器、数据目录、版本与运行状态(v2.1)。"""
486
+ from kb import discovery
487
+ info = discovery.discover()
488
+ if as_json:
489
+ console.print_json(data=info)
490
+ return
491
+ console.print(f"安装来源 (source) : {info['source']}")
492
+ console.print(f"解释器 (python) : {info['python']}")
493
+ console.print(f"版本 (version) : {info['version']}")
494
+ console.print(f"数据目录 (data_dir) : {info['data_dir']}")
495
+ console.print(f"运行状态 (running) : "
496
+ f"{'running' if info['running'] else 'stopped'}")
497
+ console.print(f"MCP 端点 (endpoint) : {info['endpoint']}")
498
+ console.print("[dim]启动:kb serve --non-interactive;"
499
+ "登录自启:kb autostart enable[/dim]")
500
+
501
+
502
+ autostart_app = typer.Typer(help="登录自启管理:enable / disable / status")
503
+ app.add_typer(autostart_app, name="autostart")
504
+
505
+
506
+ @autostart_app.command("enable")
507
+ def autostart_enable_cmd():
508
+ """启用登录自启(Windows 启动目录 / systemd user / LaunchAgent)。"""
509
+ from kb import discovery
510
+ result = discovery.autostart_enable()
511
+ console.print(f"已写入自启文件:{result['file']}")
512
+ if result["platform"].startswith("linux"):
513
+ console.print("[dim]已尝试 systemctl --user enable --now kb.service;"
514
+ "若失败请检查 systemd 用户实例是否可用。[/dim]")
515
+
516
+
517
+ @autostart_app.command("disable")
518
+ def autostart_disable_cmd():
519
+ """停用登录自启并删除自启文件。"""
520
+ from kb import discovery
521
+ result = discovery.autostart_disable()
522
+ console.print(f"自启文件:{result['file']}"
523
+ f"({'已删除' if result['removed'] else '不存在'})")
524
+
525
+
526
+ @autostart_app.command("status")
527
+ def autostart_status_cmd():
528
+ """查看登录自启状态。"""
529
+ from kb import discovery
530
+ result = discovery.autostart_status()
531
+ console.print(f"登录自启:{'已启用' if result['enabled'] else '未启用'}")
532
+ console.print(f"自启文件:{result['file']}")
533
+
534
+
535
+ @app.command(name="mcp")
536
+ def mcp_stdio():
537
+ """以 stdio 模式运行 MCP 服务器(uvx / Claude Desktop 等客户端直接拉起,
538
+ 工具集与 REST 常驻服务完全一致;HTTP 挂载仍用 serve)。"""
539
+ import anyio
540
+ from kb.mcp import create_mcp_server
541
+ from kb.service import KBService
542
+ s = get_settings()
543
+ # stdio 协议流上不可交互:设备跟随显式配置/runtime.json,否则 cpu
544
+ s.device = resolve_device(s, interactive=False)
545
+ svc = KBService(s)
546
+ anyio.run(create_mcp_server(svc).run_stdio_async)
547
+
548
+
549
+ @app.command()
550
+ def forget(
551
+ stale: bool = typer.Option(False, "--stale", help="扫描超 N 天未命中的陈旧记忆"),
552
+ days: int = typer.Option(90, "--days", help="未命中天数阈值(默认90)"),
553
+ dry_run: bool = typer.Option(True, "--dry-run/--no-dry-run",
554
+ help="仅输出候选不删除(默认开启,安全优先)"),
555
+ ):
556
+ """维护:扫描超 N 天未命中的记忆,dry-run 输出候选,非 dry-run 可删除。
557
+
558
+ 用法:
559
+ kb forget --stale --days 90 --dry-run # 预览超90天未命中的记录
560
+ kb forget --stale --days 90 --no-dry-run # 确认后删除
561
+ """
562
+ if not stale:
563
+ console.print("[yellow]请指定 --stale(当前仅支持陈旧未命中模式)[/yellow]")
564
+ raise typer.Exit(code=1)
565
+ svc = _service()
566
+ records = list(svc.store.iter_all())
567
+ candidates = find_stale_records(records, days)
568
+ if not candidates:
569
+ console.print(f"[green]无超 {days} 天未命中的记录[/green]")
570
+ return
571
+ # 输出候选表
572
+ table = Table(title=f"超 {days} 天未命中的记忆({len(candidates)} 条)")
573
+ table.add_column("记录ID", style="cyan", no_wrap=True)
574
+ table.add_column("内容摘要", style="white")
575
+ table.add_column("最后命中", style="yellow")
576
+ table.add_column("天数", justify="right", style="red")
577
+ for r, d in candidates:
578
+ table.add_row(r.id, _truncate(r.content),
579
+ r.last_accessed or f"(创建:{r.created_at[:10]})",
580
+ f"{d:.1f}")
581
+ console.print(table)
582
+ if dry_run:
583
+ console.print(f"[blue][dry-run] 共 {len(candidates)} 条候选,未删除。"
584
+ f"加 --no-dry-run 执行删除[/blue]")
585
+ return
586
+ # 非 dry-run:确认后删除
587
+ confirm = typer.prompt(f"确认删除以上 {len(candidates)} 条记录?输入 yes 继续",
588
+ default="no")
589
+ if confirm.strip().lower() != "yes":
590
+ console.print("[yellow]已取消,未删除[/yellow]")
591
+ return
592
+ ids = [r.id for r, _ in candidates]
593
+ svc.store.delete(ids)
594
+ console.print(f"[green]已删除 {len(ids)} 条记录[/green]")
595
+
596
+
597
+ @app.command()
598
+ def dedup(
599
+ dry_run: bool = typer.Option(True, "--dry-run/--no-dry-run",
600
+ help="仅输出候选对不修改(默认开启,安全优先)"),
601
+ threshold: float = typer.Option(0.85, "--threshold", help="相似度阈值(默认0.85)"),
602
+ ):
603
+ """维护:全库扫描相似度超阈值的重复对,dry-run 输出供人工审核。
604
+
605
+ 用法:
606
+ kb dedup --dry-run # 预览相似度>0.85的重复对
607
+ kb dedup --threshold 0.90 --dry-run # 自定义阈值
608
+ """
609
+ svc = _service()
610
+ records = list(svc.store.iter_all())
611
+ if len(records) < 2:
612
+ console.print("[green]记录不足2条,无需去重扫描[/green]")
613
+ return
614
+ # 逐条计算 embedding(复用 embedder)
615
+ console.print(f"[blue]正在扫描 {len(records)} 条记录的相似度…[/blue]")
616
+ records_with_emb = []
617
+ for r in records:
618
+ vec = svc.embedder.embed_texts([r.content])[0]
619
+ records_with_emb.append((r, vec))
620
+ pairs = find_duplicate_pairs(records_with_emb, threshold=threshold)
621
+ if not pairs:
622
+ console.print(f"[green]无相似度 > {threshold} 的重复对[/green]")
623
+ return
624
+ # 输出候选对表
625
+ table = Table(title=f"相似度 > {threshold} 的重复对({len(pairs)} 对)")
626
+ table.add_column("记录A", style="cyan", no_wrap=True)
627
+ table.add_column("记录B", style="cyan", no_wrap=True)
628
+ table.add_column("相似度", justify="right", style="red")
629
+ table.add_column("A摘要", style="white")
630
+ table.add_column("B摘要", style="white")
631
+ for r1, r2, sim in pairs:
632
+ table.add_row(r1.id, r2.id, f"{sim:.4f}",
633
+ _truncate(r1.content, 30), _truncate(r2.content, 30))
634
+ console.print(table)
635
+ if dry_run:
636
+ console.print(f"[blue][dry-run] 共 {len(pairs)} 对候选,未修改数据。"
637
+ f"请人工审核后手动处理(自动合并待后续实现)[/blue]")
638
+ return
639
+ # 非 dry-run:自动合并暂未实现
640
+ console.print("[yellow]自动合并功能暂未实现(N23c 智能层 consolidation)。"
641
+ "请人工审核候选对后手动删除/合并。[/yellow]")
642
+
643
+
644
+ @app.command()
645
+ def audit(
646
+ client: str | None = typer.Option(None, "--client",
647
+ help="来源客户端(如 TraeWork / Claude Code / Cursor;缺省=全部)"),
648
+ project: str | None = typer.Option(None, "--project",
649
+ help="项目名(如 kb;缺省自动取当前目录名,可显式覆盖)"),
650
+ agent: str | None = typer.Option(None, "--agent",
651
+ help="Agent 任务名(兼容旧三段式审计文件;一般不需要)"),
652
+ action: str | None = typer.Option(None, "--action",
653
+ help="过滤操作类型:write/search/read/update/delete/ask/ingest"),
654
+ days: int | None = typer.Option(None, "--days", help="最近 N 天(默认全部)"),
655
+ limit: int = typer.Option(100, "--limit", help="条数上限(默认100)"),
656
+ ):
657
+ """查询记忆存取审计(谁从哪个客户端/项目存了什么/读了什么)。
658
+
659
+ 读本机 agent-audit/(JSON 行),按 (client, project) 过滤,倒序输出最新在前。
660
+ project 缺省自动取当前目录名(与 CLI 写入时一致)。
661
+ 用法:
662
+ kb audit --client TraeWork # 该客户端全部存取记录
663
+ kb audit --client TraeWork --project kb # 指定客户端+项目
664
+ kb audit --client TraeWork --action write --days 7 --limit 20
665
+ """
666
+ svc = _service()
667
+ if project is None:
668
+ project = _default_project()
669
+ items = svc.query_access_audit(client=client, project=project,
670
+ agent=agent, action=action,
671
+ days=days, limit=limit)
672
+ title = f"存取审计({len(items)} 条)"
673
+ if client:
674
+ title += f" client={client}"
675
+ if project:
676
+ title += f" project={project}"
677
+ if not items:
678
+ console.print(f"[yellow]无匹配的存取审计记录(client=`{client or '-'}` "
679
+ f"project=`{project or '-'}`;agent-audit 目录可能为空或未发生存取)[/yellow]")
680
+ return
681
+ table = Table(title=title)
682
+ table.add_column("时间", style="cyan", no_wrap=True)
683
+ table.add_column("客户端", style="yellow")
684
+ table.add_column("项目", style="green")
685
+ table.add_column("操作", style="magenta")
686
+ table.add_column("内容/查询", style="white")
687
+ table.add_column("命中", justify="right")
688
+ for rec in items:
689
+ ts = rec.get("timestamp", "")
690
+ act = rec.get("action", "")
691
+ body = rec.get("query") or rec.get("content") or rec.get("source") or ""
692
+ hits = str(rec.get("hits", "")) if rec.get("hits") is not None else ""
693
+ c = rec.get("client", "-")
694
+ p = rec.get("project", "-")
695
+ table.add_row(ts, c, p, act, _truncate(str(body), 40), hits)
696
+ console.print(table)
697
+ console.print("[dim]Tip: 内容/查询为前50字符摘要(敏感红线),完整内容不落日志。["
698
+ "/dim]")
699
+
700
+
701
+ @app.command("eval")
702
+ def eval_cmd(
703
+ file: str = typer.Option("tests/eval_zh_50.jsonl", "--file",
704
+ help="评测数据集路径(JSONL)"),
705
+ top_k: int = typer.Option(5, "--top-k", help="每条问题的检索条数"),
706
+ mode: str = typer.Option("hybrid", "--mode", help="hybrid/vector/keyword"),
707
+ rerank: bool = typer.Option(False, "--rerank",
708
+ help="临时开启交叉重排(对比指标收益)"),
709
+ sparse: bool = typer.Option(False, "--sparse",
710
+ help="临时开启稀疏第三路(对比指标收益)"),
711
+ json_out: str = typer.Option("", "--json", help="报告写入 JSON 文件路径"),
712
+ ):
713
+ """检索质量评测:Recall@1/@5 + MRR(独立临时库,不碰生产数据)。
714
+
715
+ 用法:
716
+ kb eval # 双路基线
717
+ kb eval --sparse --rerank # 三路 + 精排(量化收益)
718
+ kb eval --json report.json # 报告落盘
719
+ 评测完成即成功(退出码 0),指标基线值记入 A3.5 设计文档。
720
+ """
721
+ import shutil
722
+ import tempfile
723
+ from pathlib import Path
724
+
725
+ from kb.eval import EvalDatasetError, load_dataset, run_eval
726
+
727
+ try:
728
+ dataset = load_dataset(file)
729
+ except EvalDatasetError as e:
730
+ console.print(f"[red]数据集加载失败: {e}[/red]")
731
+ raise typer.Exit(code=1)
732
+
733
+ # 独立临时库:评测进程专用 KB_DATA_DIR,不碰生产库;
734
+ # 显式关闭治理与审计,避免用户 .env 里的 dedup/decay/freshness 干扰
735
+ # 评测语料写入(开启去重会被判定重复而直接失败),保证结果可复现。
736
+ tmp_dir = tempfile.mkdtemp(prefix="kb_eval_")
737
+ settings = Settings(data_dir=Path(tmp_dir),
738
+ rerank_enabled=rerank, sparse_enabled=sparse,
739
+ dedup_enabled=False, decay_enabled=False,
740
+ freshness_enabled=False, consolidation_enabled=False,
741
+ access_audit_enabled=False)
742
+ try:
743
+ svc = KBService(settings)
744
+ console.print(f"[blue]评测中:{len(dataset)} 条 × mode={mode} "
745
+ f"top_k={top_k}(rerank={'on' if rerank else 'off'} "
746
+ f"sparse={'on' if sparse else 'off'})[/blue]")
747
+ report = run_eval(svc, dataset, top_k=top_k, mode=mode)
748
+ finally:
749
+ shutil.rmtree(tmp_dir, ignore_errors=True)
750
+
751
+ # 终端表格:总体 + 分难度
752
+ table = Table(title=f"kb eval 报告(n={report['count']} mode={mode})")
753
+ table.add_column("分组", style="cyan")
754
+ table.add_column("Recall@1", justify="right")
755
+ table.add_column("Recall@5", justify="right")
756
+ table.add_column("MRR", justify="right")
757
+ table.add_column("条数", justify="right")
758
+ table.add_row("总体", f"{report['recall_at_1']:.3f}",
759
+ f"{report['recall_at_5']:.3f}", f"{report['mrr']:.3f}",
760
+ str(report["count"]))
761
+ for tag in ("keyword", "semantic", "distractor"):
762
+ sub = report["by_difficulty"].get(tag)
763
+ if sub:
764
+ table.add_row(tag, f"{sub['recall_at_1']:.3f}",
765
+ f"{sub['recall_at_5']:.3f}", f"{sub['mrr']:.3f}",
766
+ str(sub["count"]))
767
+ console.print(table)
768
+ console.print(f"平均检索延迟:{report['latency_ms_avg']:.1f} ms")
769
+ if report["misses"]:
770
+ miss_ids = ", ".join(str(m["qid"]) for m in report["misses"])
771
+ console.print(f"[yellow]未命中 qid:{miss_ids}[/yellow]")
772
+ else:
773
+ console.print("[green]全部命中[/green]")
774
+
775
+ if json_out:
776
+ Path(json_out).write_text(
777
+ json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
778
+ console.print(f"报告已写入 {json_out}")
779
+
780
+
781
+ def main() -> None:
782
+ # 乱码修复:先强制三流 UTF-8 + Windows 控制台 65001,再进 typer 分发
783
+ _force_utf8_stdio()
784
+ app()
785
+
786
+
787
+ if __name__ == "__main__":
788
+ main()