kb-memory 2.1.0__tar.gz → 2.1.2__tar.gz
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_memory-2.1.0/kb_memory.egg-info → kb_memory-2.1.2}/PKG-INFO +6 -10
- {kb_memory-2.1.0 → kb_memory-2.1.2}/README.md +5 -9
- kb_memory-2.1.2/kb/__init__.py +2 -0
- {kb_memory-2.1.0 → kb_memory-2.1.2}/kb/api.py +11 -6
- {kb_memory-2.1.0 → kb_memory-2.1.2}/kb/cli.py +21 -38
- {kb_memory-2.1.0 → kb_memory-2.1.2}/kb/discovery.py +13 -3
- kb_memory-2.1.2/kb/idle.py +36 -0
- {kb_memory-2.1.0 → kb_memory-2.1.2}/kb/service.py +4 -0
- {kb_memory-2.1.0 → kb_memory-2.1.2}/kb/watcher.py +22 -2
- {kb_memory-2.1.0 → kb_memory-2.1.2/kb_memory.egg-info}/PKG-INFO +6 -10
- {kb_memory-2.1.0 → kb_memory-2.1.2}/kb_memory.egg-info/SOURCES.txt +1 -0
- {kb_memory-2.1.0 → kb_memory-2.1.2}/pyproject.toml +1 -1
- {kb_memory-2.1.0 → kb_memory-2.1.2}/tests/test_n10_ask.py +3 -1
- {kb_memory-2.1.0 → kb_memory-2.1.2}/tests/test_n15_watcher.py +35 -0
- {kb_memory-2.1.0 → kb_memory-2.1.2}/tests/test_v21_bootstrap.py +34 -0
- {kb_memory-2.1.0 → kb_memory-2.1.2}/tests/test_v21_runtime.py +66 -0
- kb_memory-2.1.0/kb/__init__.py +0 -2
- {kb_memory-2.1.0 → kb_memory-2.1.2}/LICENSE +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.2}/kb/__main__.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.2}/kb/audit.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.2}/kb/bm25.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.2}/kb/config.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.2}/kb/consolidation.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.2}/kb/embedder.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.2}/kb/eval.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.2}/kb/governance.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.2}/kb/i18n.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.2}/kb/ingest.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.2}/kb/llm.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.2}/kb/logging_setup.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.2}/kb/mcp.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.2}/kb/models.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.2}/kb/monitor.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.2}/kb/reranker.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.2}/kb/retriever.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.2}/kb/scopes.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.2}/kb/sparse.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.2}/kb/storage.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.2}/kb_memory.egg-info/dependency_links.txt +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.2}/kb_memory.egg-info/entry_points.txt +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.2}/kb_memory.egg-info/requires.txt +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.2}/kb_memory.egg-info/top_level.txt +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.2}/setup.cfg +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.2}/tests/test_agent_isolation.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.2}/tests/test_api_auth.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.2}/tests/test_charset_json.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.2}/tests/test_consolidation.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.2}/tests/test_embed_model_resolution.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.2}/tests/test_hotfix_validation.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.2}/tests/test_improvements_bm25_batch.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.2}/tests/test_improvements_concurrency.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.2}/tests/test_improvements_isolation_recall.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.2}/tests/test_n01_config.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.2}/tests/test_n02_models.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.2}/tests/test_n03_embedder.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.2}/tests/test_n04_storage.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.2}/tests/test_n05_bm25.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.2}/tests/test_n06_retriever_service.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.2}/tests/test_n07_api.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.2}/tests/test_n08_api_ext.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.2}/tests/test_n09_llm.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.2}/tests/test_n11_routing.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.2}/tests/test_n12_mcp.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.2}/tests/test_n13_ingest.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.2}/tests/test_n13_ingest_extra.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.2}/tests/test_n14_web.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.2}/tests/test_n16_regression.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.2}/tests/test_n17_logging.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.2}/tests/test_n18_logs_api.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.2}/tests/test_n18_logs_doc.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.2}/tests/test_n18_monitor.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.2}/tests/test_n19_nav.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.2}/tests/test_n21_decay.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.2}/tests/test_n21_metadata.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.2}/tests/test_n22_dedup.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.2}/tests/test_n22_governance.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.2}/tests/test_n23_audit.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.2}/tests/test_n23_cli.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.2}/tests/test_n24_reranker.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.2}/tests/test_n25_sparse.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.2}/tests/test_n26_eval.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.2}/tests/test_n27_perf.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.2}/tests/test_n28_cli.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.2}/tests/test_n29_security.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.2}/tests/test_n30_mcp_stdio.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.2}/tests/test_n31_serve_probe.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.2}/tests/test_n32_hybrid_fusion_recall.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.2}/tests/test_skill_package.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.2}/tests/test_user_experience_fixes.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.2}/tests/test_v21_i18n.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.2}/tests/test_v21_packaging.py +0 -0
- {kb_memory-2.1.0 → kb_memory-2.1.2}/tests/test_watcher_missing_dir.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: kb-memory
|
|
3
|
-
Version: 2.1.
|
|
3
|
+
Version: 2.1.2
|
|
4
4
|
Summary: Local-first agent memory & knowledge service: hybrid retrieval + memory governance (decay / dedup / freshness), REST + MCP dual protocol, fully usable offline without an LLM
|
|
5
5
|
Author: fish827-08
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -60,7 +60,7 @@ Dynamic: license-file
|
|
|
60
60
|
|
|
61
61
|
<!-- GitHub Topics: mcp, memory-service, rag, ai-agent, knowledge-base, local-first, llm, claude-code, hybrid-search, embedding -->
|
|
62
62
|
|
|
63
|
-
**kb** 是本地优先、完全免费的 Agent 记忆与知识服务(v2.1.
|
|
63
|
+
**kb** 是本地优先、完全免费的 Agent 记忆与知识服务(v2.1.2)。
|
|
64
64
|
|
|
65
65
|
> 开源协议:[Apache-2.0](LICENSE)(含专利授权,可商用)。
|
|
66
66
|
|
|
@@ -150,7 +150,7 @@ curl http://127.0.0.1:8000/api/v1/healthz
|
|
|
150
150
|
>
|
|
151
151
|
> **CPU 即可运行,不占显卡**:全新安装默认拿到 CPU 版 torch(`device=cpu`),
|
|
152
152
|
> 不占用 GPU 显存,无独立显卡的机器也能完整使用。有显卡想加速(大库、批量入库)
|
|
153
|
-
> 可另装 CUDA 版 torch
|
|
153
|
+
> 可另装 CUDA 版 torch 后显式 `KB_DEVICE=cuda` 启用 GPU 加速。
|
|
154
154
|
>
|
|
155
155
|
> **LLM 默认关闭(`KB_LLM_MODE=off`)**:服务启动不会探测/加载/调用任何大模型,
|
|
156
156
|
> 零显存、零成本、纯离线;记忆写入、文档入库、混合检索完整可用。
|
|
@@ -398,18 +398,14 @@ python -m kb dedup --threshold 0.92 # 语义去重
|
|
|
398
398
|
```
|
|
399
399
|
kb/ kb 服务源码(config / models / embedder / storage / bm25 / retriever /
|
|
400
400
|
service / llm / ingest / watcher / api / mcp / cli + reranker / sparse / eval)
|
|
401
|
-
tests/ kb 验收测试(
|
|
402
|
-
docs/
|
|
401
|
+
tests/ kb 验收测试(425 项通过 / 1 跳过,含 eval_zh_50.jsonl 检索评测数据集)
|
|
402
|
+
docs/ 用户指南、开发者手册与设计文档
|
|
403
403
|
kb_data/ kb 运行数据(gitignore)
|
|
404
|
-
_archive/ 旧学习项目 + 历史 spec/plan 归档(仅本地保留,禁止参考)
|
|
405
404
|
```
|
|
406
405
|
|
|
407
406
|
## 更多文档
|
|
408
407
|
|
|
409
|
-
-
|
|
410
|
-
- **AI 接力文档(AI 助手入口)**:[PROJECT.md](PROJECT.md)(项目状态 / 进度看板 / 接手指南)
|
|
408
|
+
- **用户使用手册**:[docs/USER_GUIDE.md](docs/USER_GUIDE.md)
|
|
411
409
|
- 设计文档(需求、架构、API、里程碑):`docs/superpowers/specs/2026-08-23-kb-memory-service-design.md`
|
|
412
410
|
- **v2.1 设计(当前)**:`docs/superpowers/specs/2026-10-20-kb-v2.1-design.md`
|
|
413
411
|
- 开发者维护手册:`docs/DEV_HANDBOOK.md`
|
|
414
|
-
- AI 协作规范:`AGENTS.md`
|
|
415
|
-
- 历史 spec / 节点计划已归档到本地 `_archive/docs/`(不上传,不再作为开发依据)
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
<!-- GitHub Topics: mcp, memory-service, rag, ai-agent, knowledge-base, local-first, llm, claude-code, hybrid-search, embedding -->
|
|
12
12
|
|
|
13
|
-
**kb** 是本地优先、完全免费的 Agent 记忆与知识服务(v2.1.
|
|
13
|
+
**kb** 是本地优先、完全免费的 Agent 记忆与知识服务(v2.1.2)。
|
|
14
14
|
|
|
15
15
|
> 开源协议:[Apache-2.0](LICENSE)(含专利授权,可商用)。
|
|
16
16
|
|
|
@@ -100,7 +100,7 @@ curl http://127.0.0.1:8000/api/v1/healthz
|
|
|
100
100
|
>
|
|
101
101
|
> **CPU 即可运行,不占显卡**:全新安装默认拿到 CPU 版 torch(`device=cpu`),
|
|
102
102
|
> 不占用 GPU 显存,无独立显卡的机器也能完整使用。有显卡想加速(大库、批量入库)
|
|
103
|
-
> 可另装 CUDA 版 torch
|
|
103
|
+
> 可另装 CUDA 版 torch 后显式 `KB_DEVICE=cuda` 启用 GPU 加速。
|
|
104
104
|
>
|
|
105
105
|
> **LLM 默认关闭(`KB_LLM_MODE=off`)**:服务启动不会探测/加载/调用任何大模型,
|
|
106
106
|
> 零显存、零成本、纯离线;记忆写入、文档入库、混合检索完整可用。
|
|
@@ -348,18 +348,14 @@ python -m kb dedup --threshold 0.92 # 语义去重
|
|
|
348
348
|
```
|
|
349
349
|
kb/ kb 服务源码(config / models / embedder / storage / bm25 / retriever /
|
|
350
350
|
service / llm / ingest / watcher / api / mcp / cli + reranker / sparse / eval)
|
|
351
|
-
tests/ kb 验收测试(
|
|
352
|
-
docs/
|
|
351
|
+
tests/ kb 验收测试(425 项通过 / 1 跳过,含 eval_zh_50.jsonl 检索评测数据集)
|
|
352
|
+
docs/ 用户指南、开发者手册与设计文档
|
|
353
353
|
kb_data/ kb 运行数据(gitignore)
|
|
354
|
-
_archive/ 旧学习项目 + 历史 spec/plan 归档(仅本地保留,禁止参考)
|
|
355
354
|
```
|
|
356
355
|
|
|
357
356
|
## 更多文档
|
|
358
357
|
|
|
359
|
-
-
|
|
360
|
-
- **AI 接力文档(AI 助手入口)**:[PROJECT.md](PROJECT.md)(项目状态 / 进度看板 / 接手指南)
|
|
358
|
+
- **用户使用手册**:[docs/USER_GUIDE.md](docs/USER_GUIDE.md)
|
|
361
359
|
- 设计文档(需求、架构、API、里程碑):`docs/superpowers/specs/2026-08-23-kb-memory-service-design.md`
|
|
362
360
|
- **v2.1 设计(当前)**:`docs/superpowers/specs/2026-10-20-kb-v2.1-design.md`
|
|
363
361
|
- 开发者维护手册:`docs/DEV_HANDBOOK.md`
|
|
364
|
-
- AI 协作规范:`AGENTS.md`
|
|
365
|
-
- 历史 spec / 节点计划已归档到本地 `_archive/docs/`(不上传,不再作为开发依据)
|
|
@@ -17,6 +17,7 @@ from pydantic import BaseModel, Field, field_validator
|
|
|
17
17
|
|
|
18
18
|
from kb import __version__
|
|
19
19
|
from kb.config import Settings, get_settings
|
|
20
|
+
from kb.idle import start_idle_watchdog
|
|
20
21
|
from kb.logging_setup import setup_logging
|
|
21
22
|
from kb.monitor import MonitorAgent, run_once_summary
|
|
22
23
|
from kb.mcp import create_mcp_server
|
|
@@ -507,7 +508,8 @@ def _wrap_api_key(app, settings):
|
|
|
507
508
|
|
|
508
509
|
def create_app(settings: Settings | None = None,
|
|
509
510
|
enable_watcher: bool = False) -> FastAPI:
|
|
510
|
-
"""应用工厂;全局单例 KBService
|
|
511
|
+
"""应用工厂;全局单例 KBService 在工厂内创建,由 REST/MCP 共享。
|
|
512
|
+
返回经鉴权/charset/访问日志中间件包装的 ASGI 应用(外部拿不到 .state)。
|
|
511
513
|
统一错误 JSON:{"error": "<CODE>", "message": "<人话>"}。
|
|
512
514
|
|
|
513
515
|
enable_watcher=True 时(仅 serve 模式)在 lifespan 中挂目录监听线程;
|
|
@@ -542,6 +544,10 @@ def create_app(settings: Settings | None = None,
|
|
|
542
544
|
serve_log.info("鉴权已启用(KB_API_KEY 已配置,要求 Bearer/X-API-Key)")
|
|
543
545
|
else:
|
|
544
546
|
serve_log.info("鉴权未启用(本地模式,KB_API_KEY 为空)")
|
|
547
|
+
# v2.1 空闲卸载:守护线程必须在工厂内部启动——create_app 最终返回
|
|
548
|
+
# 中间件包装后的 ASGI 函数,外部拿不到 app.state.kb。
|
|
549
|
+
idle_stop = start_idle_watchdog(lambda: kb.embedder,
|
|
550
|
+
kb.settings.idle_unload_seconds)
|
|
545
551
|
# N29 启动安全警告:非回环监听 + 未鉴权 → warning 日志(终端醒目横幅由 serve 命令负责)
|
|
546
552
|
warn = exposure_warning(kb.settings.api_host, kb.settings.api_key)
|
|
547
553
|
if warn:
|
|
@@ -556,7 +562,7 @@ def create_app(settings: Settings | None = None,
|
|
|
556
562
|
"未安装 watchdog,目录监听已禁用"
|
|
557
563
|
"(pip install \"kb-memory[ingest]\" 可启用)")
|
|
558
564
|
else:
|
|
559
|
-
watcher = KBWatcher(kb, wd)
|
|
565
|
+
watcher = KBWatcher(kb, wd, ignore_dir=kb.settings.data_dir)
|
|
560
566
|
watcher.start()
|
|
561
567
|
# 本地监控 Agent(TASK-0017):serve 模式默认启用,仅读配置决定间隔;
|
|
562
568
|
# TestClient 测试默认不启动(monitor_enabled 默认 False,测试按需开)
|
|
@@ -570,6 +576,7 @@ def create_app(settings: Settings | None = None,
|
|
|
570
576
|
serve_log.info("服务就绪 records=%s", kb.stats().get("records"))
|
|
571
577
|
yield
|
|
572
578
|
finally:
|
|
579
|
+
idle_stop.set()
|
|
573
580
|
if monitor is not None:
|
|
574
581
|
monitor.stop()
|
|
575
582
|
if watcher is not None:
|
|
@@ -804,11 +811,9 @@ def create_app(settings: Settings | None = None,
|
|
|
804
811
|
try:
|
|
805
812
|
return kb.ask(body.question, agent_id=body.agent_id,
|
|
806
813
|
client=body.client, project=body.project)
|
|
807
|
-
except LLMDisabledError:
|
|
814
|
+
except LLMDisabledError as exc:
|
|
808
815
|
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 兼容服务商)"})
|
|
816
|
+
"error": "LLM_DISABLED", "message": str(exc)})
|
|
812
817
|
|
|
813
818
|
@app.get("/api/v1/audit")
|
|
814
819
|
def audit(client: str | None = None, project: str | None = None,
|
|
@@ -14,12 +14,28 @@ from rich.table import Table
|
|
|
14
14
|
|
|
15
15
|
from kb.config import Settings, get_settings
|
|
16
16
|
from kb.governance import days_since
|
|
17
|
+
from kb.idle import start_idle_watchdog # 兼容既有导入:kb.cli.start_idle_watchdog
|
|
17
18
|
from kb.service import KBService
|
|
18
19
|
|
|
19
20
|
app = typer.Typer()
|
|
20
21
|
console = Console()
|
|
21
22
|
|
|
22
23
|
|
|
24
|
+
@app.callback(invoke_without_command=True)
|
|
25
|
+
def _version_option(
|
|
26
|
+
ctx: typer.Context,
|
|
27
|
+
version: bool = typer.Option(
|
|
28
|
+
False, "--version", "-V", is_eager=True,
|
|
29
|
+
help="显示版本并退出 / Show version and exit")):
|
|
30
|
+
if version:
|
|
31
|
+
from kb import __version__
|
|
32
|
+
console.print(f"kb {__version__}")
|
|
33
|
+
raise typer.Exit()
|
|
34
|
+
if ctx.invoked_subcommand is None:
|
|
35
|
+
console.print(ctx.get_help())
|
|
36
|
+
raise typer.Exit()
|
|
37
|
+
|
|
38
|
+
|
|
23
39
|
def _default_project() -> str:
|
|
24
40
|
"""当前目录名规整为合法项目名;无法规整时回退全局库(空串)。
|
|
25
41
|
|
|
@@ -387,8 +403,11 @@ def ask(
|
|
|
387
403
|
except LLMDisabledError:
|
|
388
404
|
# LLM 不可用:检索结果仍有价值,输出命中并给出配置指引
|
|
389
405
|
hits = svc.search(question, top_k=top_k)
|
|
390
|
-
|
|
391
|
-
|
|
406
|
+
if getattr(getattr(svc, "settings", None), "llm_mode", "") == "off":
|
|
407
|
+
reason = "LLM 已关闭(KB_LLM_MODE=off,默认不调用 LLM)"
|
|
408
|
+
else:
|
|
409
|
+
reason = "LLM 不可用(本地 Ollama 未响应且未配置云端 Key)"
|
|
410
|
+
console.print(f"[yellow]{reason},以下为检索结果(仅检索未生成)[/yellow]")
|
|
392
411
|
console.print(f"配置指引:在 .env 设 KB_LLM_MODE=local 并配置 KB_LLM_MODEL=(先用 "
|
|
393
412
|
"ollama pull 选一个模型,名称以 ollama list 为准),或设"
|
|
394
413
|
" KB_LLM_API_KEY 启用云端(任意 OpenAI 兼容服务商)")
|
|
@@ -409,39 +428,6 @@ def ask(
|
|
|
409
428
|
raise typer.Exit(code=1)
|
|
410
429
|
|
|
411
430
|
|
|
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
431
|
@app.command()
|
|
446
432
|
def serve(non_interactive: bool = typer.Option(
|
|
447
433
|
False, "--non-interactive",
|
|
@@ -468,15 +454,12 @@ def serve(non_interactive: bool = typer.Option(
|
|
|
468
454
|
console.print(f"[bold red]{warn}[/bold red]")
|
|
469
455
|
console.print(f"[bold red]{'!' * 68}[/bold red]\n")
|
|
470
456
|
app = create_app(s, enable_watcher=True)
|
|
471
|
-
idle_stop = start_idle_watchdog(lambda: app.state.kb.embedder,
|
|
472
|
-
s.idle_unload_seconds)
|
|
473
457
|
# kb.pid 由服务进程自身管理:启动前写入(真实存活到此处才写,不再由
|
|
474
458
|
# start_kb.bat 猜写);无论正常退出还是异常,退出时清理,不留指向死进程的旧 pid。
|
|
475
459
|
write_pid_file()
|
|
476
460
|
try:
|
|
477
461
|
uvicorn.run(app, host=s.api_host, port=s.api_port)
|
|
478
462
|
finally:
|
|
479
|
-
idle_stop.set()
|
|
480
463
|
remove_pid_file()
|
|
481
464
|
|
|
482
465
|
|
|
@@ -76,6 +76,15 @@ def _running(host: str, port: int) -> bool:
|
|
|
76
76
|
return False
|
|
77
77
|
|
|
78
78
|
|
|
79
|
+
def _absolute_data_dir(value) -> str:
|
|
80
|
+
"""把数据目录解析为绝对路径:相对路径按服务启动时的 cwd 落盘,where 必须显示真实位置。"""
|
|
81
|
+
path = Path(value).expanduser()
|
|
82
|
+
try:
|
|
83
|
+
return str(path.resolve())
|
|
84
|
+
except OSError:
|
|
85
|
+
return str(path.absolute())
|
|
86
|
+
|
|
87
|
+
|
|
79
88
|
def discover(settings=None) -> dict:
|
|
80
89
|
"""按 manifest → PATH → 当前解释器 的顺序发现服务安装。
|
|
81
90
|
|
|
@@ -93,7 +102,8 @@ def discover(settings=None) -> dict:
|
|
|
93
102
|
"source": "manifest",
|
|
94
103
|
"python": manifest["python"],
|
|
95
104
|
"version": manifest.get("version") or _package_version(),
|
|
96
|
-
"data_dir":
|
|
105
|
+
"data_dir": _absolute_data_dir(
|
|
106
|
+
manifest.get("data_dir") or settings.data_dir),
|
|
97
107
|
"venv": manifest.get("venv") or "",
|
|
98
108
|
"running": _running(host, port),
|
|
99
109
|
"endpoint": f"http://{host}:{port}/mcp/",
|
|
@@ -111,7 +121,7 @@ def discover(settings=None) -> dict:
|
|
|
111
121
|
"source": "path",
|
|
112
122
|
"python": python,
|
|
113
123
|
"version": _package_version(),
|
|
114
|
-
"data_dir":
|
|
124
|
+
"data_dir": _absolute_data_dir(settings.data_dir),
|
|
115
125
|
"venv": "",
|
|
116
126
|
"running": _running(host, port),
|
|
117
127
|
"endpoint": f"http://{host}:{port}/mcp/",
|
|
@@ -121,7 +131,7 @@ def discover(settings=None) -> dict:
|
|
|
121
131
|
"source": "package",
|
|
122
132
|
"python": sys.executable,
|
|
123
133
|
"version": _package_version(),
|
|
124
|
-
"data_dir":
|
|
134
|
+
"data_dir": _absolute_data_dir(settings.data_dir),
|
|
125
135
|
"venv": "",
|
|
126
136
|
"running": _running(host, port),
|
|
127
137
|
"endpoint": f"http://{host}:{port}/mcp/",
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""空闲卸载守护线程(v2.1 资源占用优化)。
|
|
2
|
+
|
|
3
|
+
独立模块以便 create_app(服务生命周期)与 CLI 复用;create_app 返回的是
|
|
4
|
+
经中间件包装的 ASGI 函数,无法再从外部访问 app.state,因此守护线程必须在
|
|
5
|
+
工厂内部、拿得到 KBService 的地方启动。
|
|
6
|
+
"""
|
|
7
|
+
import logging
|
|
8
|
+
import threading
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def start_idle_watchdog(embedder, idle_seconds: int,
|
|
12
|
+
check_seconds: float | None = None):
|
|
13
|
+
"""启动空闲卸载守护线程(daemon),返回停止事件;idle_seconds<=0 不启动。
|
|
14
|
+
|
|
15
|
+
embedder 可以是 Embedder 实例,也可以是返回实例的可调用对象(延迟解析,
|
|
16
|
+
兼容测试替身与延迟初始化)。每轮调用 unload_if_idle();异常记 WARNING,
|
|
17
|
+
守护线程不因单次失败退出。
|
|
18
|
+
"""
|
|
19
|
+
stop = threading.Event()
|
|
20
|
+
if idle_seconds <= 0:
|
|
21
|
+
return stop
|
|
22
|
+
interval = (check_seconds if check_seconds is not None
|
|
23
|
+
else max(1.0, min(30.0, idle_seconds / 4)))
|
|
24
|
+
|
|
25
|
+
def _loop() -> None:
|
|
26
|
+
log = logging.getLogger("kb.idle")
|
|
27
|
+
while not stop.wait(interval):
|
|
28
|
+
try:
|
|
29
|
+
target = embedder() if callable(embedder) else embedder
|
|
30
|
+
if target.unload_if_idle():
|
|
31
|
+
log.info("嵌入模型空闲超过 %s 秒,已卸载", idle_seconds)
|
|
32
|
+
except Exception as exc: # noqa: BLE001 - 守护线程不因单次失败退出
|
|
33
|
+
log.warning("空闲卸载检查失败:%s", exc)
|
|
34
|
+
|
|
35
|
+
threading.Thread(target=_loop, name="kb-idle-unload", daemon=True).start()
|
|
36
|
+
return stop
|
|
@@ -767,6 +767,10 @@ class KBService:
|
|
|
767
767
|
- v2.1: with project, retrieval covers that project's sub-store plus the global store;
|
|
768
768
|
an ask access-audit is emitted."""
|
|
769
769
|
if self.llm.status is LLMStatus.DISABLED:
|
|
770
|
+
if self.settings.llm_mode == "off":
|
|
771
|
+
raise LLMDisabledError(
|
|
772
|
+
"LLM 已关闭(KB_LLM_MODE=off,默认不调用 LLM);"
|
|
773
|
+
"如需 /ask 问答请配置 local/auto/cloud")
|
|
770
774
|
raise LLMDisabledError("LLM 不可用:本地 Ollama 未响应且未配置云端 Key")
|
|
771
775
|
results = sorted(self.search(question, top_k=5, agent_id=agent_id,
|
|
772
776
|
client=client, project=project),
|
|
@@ -47,10 +47,16 @@ def _is_supported(path: Path) -> bool:
|
|
|
47
47
|
class KBWatcher:
|
|
48
48
|
"""目录监听器:watchdog Observer 采集事件 + 后台 worker 去抖处理。"""
|
|
49
49
|
|
|
50
|
-
def __init__(self, service, watch_dir: Path, debounce_seconds: float = 2.0
|
|
51
|
-
|
|
50
|
+
def __init__(self, service, watch_dir: Path, debounce_seconds: float = 2.0,
|
|
51
|
+
ignore_dir: Path | None = None):
|
|
52
|
+
"""service 为 KBService;watch_dir 为监听目录(仅顶层,不递归)。
|
|
53
|
+
|
|
54
|
+
ignore_dir 为服务数据目录:与 watch_dir 重合时 start() 跳过监听,
|
|
55
|
+
避免把 bm25_corpus.json 等数据文件反复入库(自我入库死循环)。
|
|
56
|
+
"""
|
|
52
57
|
self.service = service
|
|
53
58
|
self.watch_dir = Path(watch_dir)
|
|
59
|
+
self.ignore_dir = Path(ignore_dir) if ignore_dir is not None else None
|
|
54
60
|
self.debounce_seconds = debounce_seconds
|
|
55
61
|
self._pending: dict[Path, float] = {} # 待入库路径 → 最近一次事件时间
|
|
56
62
|
self._deletes: queue.Queue[str] = queue.Queue() # 待删除 source(文件名)
|
|
@@ -60,6 +66,15 @@ class KBWatcher:
|
|
|
60
66
|
self._worker: threading.Thread | None = None
|
|
61
67
|
|
|
62
68
|
# ---- 生命周期 ----
|
|
69
|
+
def _overlaps_data_dir(self) -> bool:
|
|
70
|
+
"""监听目录是否就是数据目录(相对路径/符号链接解析后比较)。"""
|
|
71
|
+
if self.ignore_dir is None:
|
|
72
|
+
return False
|
|
73
|
+
try:
|
|
74
|
+
return self.watch_dir.resolve() == self.ignore_dir.resolve()
|
|
75
|
+
except OSError:
|
|
76
|
+
return False
|
|
77
|
+
|
|
63
78
|
def start(self) -> None:
|
|
64
79
|
"""启动 watchdog Observer 与后台处理线程。
|
|
65
80
|
|
|
@@ -75,6 +90,11 @@ class KBWatcher:
|
|
|
75
90
|
logger.info("监听目录不存在或不可用,跳过目录监听:%s",
|
|
76
91
|
self.watch_dir)
|
|
77
92
|
return
|
|
93
|
+
if self._overlaps_data_dir():
|
|
94
|
+
logger.warning(
|
|
95
|
+
"监听目录与数据目录相同(%s),已跳过目录监听以避免自我入库;"
|
|
96
|
+
"请把 KB_WATCH_DIR 指向数据目录之外", self.watch_dir)
|
|
97
|
+
return
|
|
78
98
|
self._observer = Observer()
|
|
79
99
|
self._observer.schedule(_EventHandler(self), str(self.watch_dir),
|
|
80
100
|
recursive=False)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: kb-memory
|
|
3
|
-
Version: 2.1.
|
|
3
|
+
Version: 2.1.2
|
|
4
4
|
Summary: Local-first agent memory & knowledge service: hybrid retrieval + memory governance (decay / dedup / freshness), REST + MCP dual protocol, fully usable offline without an LLM
|
|
5
5
|
Author: fish827-08
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -60,7 +60,7 @@ Dynamic: license-file
|
|
|
60
60
|
|
|
61
61
|
<!-- GitHub Topics: mcp, memory-service, rag, ai-agent, knowledge-base, local-first, llm, claude-code, hybrid-search, embedding -->
|
|
62
62
|
|
|
63
|
-
**kb** 是本地优先、完全免费的 Agent 记忆与知识服务(v2.1.
|
|
63
|
+
**kb** 是本地优先、完全免费的 Agent 记忆与知识服务(v2.1.2)。
|
|
64
64
|
|
|
65
65
|
> 开源协议:[Apache-2.0](LICENSE)(含专利授权,可商用)。
|
|
66
66
|
|
|
@@ -150,7 +150,7 @@ curl http://127.0.0.1:8000/api/v1/healthz
|
|
|
150
150
|
>
|
|
151
151
|
> **CPU 即可运行,不占显卡**:全新安装默认拿到 CPU 版 torch(`device=cpu`),
|
|
152
152
|
> 不占用 GPU 显存,无独立显卡的机器也能完整使用。有显卡想加速(大库、批量入库)
|
|
153
|
-
> 可另装 CUDA 版 torch
|
|
153
|
+
> 可另装 CUDA 版 torch 后显式 `KB_DEVICE=cuda` 启用 GPU 加速。
|
|
154
154
|
>
|
|
155
155
|
> **LLM 默认关闭(`KB_LLM_MODE=off`)**:服务启动不会探测/加载/调用任何大模型,
|
|
156
156
|
> 零显存、零成本、纯离线;记忆写入、文档入库、混合检索完整可用。
|
|
@@ -398,18 +398,14 @@ python -m kb dedup --threshold 0.92 # 语义去重
|
|
|
398
398
|
```
|
|
399
399
|
kb/ kb 服务源码(config / models / embedder / storage / bm25 / retriever /
|
|
400
400
|
service / llm / ingest / watcher / api / mcp / cli + reranker / sparse / eval)
|
|
401
|
-
tests/ kb 验收测试(
|
|
402
|
-
docs/
|
|
401
|
+
tests/ kb 验收测试(425 项通过 / 1 跳过,含 eval_zh_50.jsonl 检索评测数据集)
|
|
402
|
+
docs/ 用户指南、开发者手册与设计文档
|
|
403
403
|
kb_data/ kb 运行数据(gitignore)
|
|
404
|
-
_archive/ 旧学习项目 + 历史 spec/plan 归档(仅本地保留,禁止参考)
|
|
405
404
|
```
|
|
406
405
|
|
|
407
406
|
## 更多文档
|
|
408
407
|
|
|
409
|
-
-
|
|
410
|
-
- **AI 接力文档(AI 助手入口)**:[PROJECT.md](PROJECT.md)(项目状态 / 进度看板 / 接手指南)
|
|
408
|
+
- **用户使用手册**:[docs/USER_GUIDE.md](docs/USER_GUIDE.md)
|
|
411
409
|
- 设计文档(需求、架构、API、里程碑):`docs/superpowers/specs/2026-08-23-kb-memory-service-design.md`
|
|
412
410
|
- **v2.1 设计(当前)**:`docs/superpowers/specs/2026-10-20-kb-v2.1-design.md`
|
|
413
411
|
- 开发者维护手册:`docs/DEV_HANDBOOK.md`
|
|
414
|
-
- AI 协作规范:`AGENTS.md`
|
|
415
|
-
- 历史 spec / 节点计划已归档到本地 `_archive/docs/`(不上传,不再作为开发依据)
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "kb-memory"
|
|
7
|
-
version = "2.1.
|
|
7
|
+
version = "2.1.2"
|
|
8
8
|
description = "Local-first agent memory & knowledge service: hybrid retrieval + memory governance (decay / dedup / freshness), REST + MCP dual protocol, fully usable offline without an LLM"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -51,4 +51,6 @@ def test_禁用转503(env_isolated):
|
|
|
51
51
|
r = c.post("/api/v1/ask", json={"question": "测试"})
|
|
52
52
|
assert r.status_code == 503
|
|
53
53
|
assert r.json()["error"] == "LLM_DISABLED"
|
|
54
|
-
|
|
54
|
+
# 默认 KB_LLM_MODE=off:必须说明是主动关闭,而不是误报 Ollama 不可用
|
|
55
|
+
assert "已关闭" in r.json()["message"]
|
|
56
|
+
assert "KB_LLM_MODE=off" in r.json()["message"]
|
|
@@ -39,3 +39,38 @@ def test_文件删除级联清理(env_isolated):
|
|
|
39
39
|
assert s.list_records(q="将被删除")[1] == 0
|
|
40
40
|
finally:
|
|
41
41
|
w.stop()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_监听目录与数据目录相同时跳过(tmp_path, caplog):
|
|
45
|
+
"""watch_dir == data_dir 时 listener 会把自己的 bm25 语料反复入库,必须跳过。"""
|
|
46
|
+
from kb.watcher import KBWatcher
|
|
47
|
+
|
|
48
|
+
class FakeService:
|
|
49
|
+
def add_document(self, path): # pragma: no cover - 不应被调用
|
|
50
|
+
raise AssertionError("不应入库")
|
|
51
|
+
|
|
52
|
+
data = tmp_path / "data"
|
|
53
|
+
data.mkdir()
|
|
54
|
+
w = KBWatcher(FakeService(), data, ignore_dir=data)
|
|
55
|
+
with caplog.at_level("WARNING", logger="kb.watcher"):
|
|
56
|
+
w.start()
|
|
57
|
+
try:
|
|
58
|
+
assert w._observer is None
|
|
59
|
+
assert w._worker is None
|
|
60
|
+
assert "自我入库" in caplog.text
|
|
61
|
+
finally:
|
|
62
|
+
w.stop()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_监听目录与数据目录不同仍正常监听(tmp_path):
|
|
66
|
+
"""ignore_dir 只拦截与监听目录重合的数据目录,不影响正常监听。"""
|
|
67
|
+
from kb.watcher import KBWatcher
|
|
68
|
+
|
|
69
|
+
watch = tmp_path / "watch"; watch.mkdir()
|
|
70
|
+
data = tmp_path / "data"; data.mkdir()
|
|
71
|
+
w = KBWatcher(object(), watch, ignore_dir=data)
|
|
72
|
+
w.start()
|
|
73
|
+
try:
|
|
74
|
+
assert w._observer is not None and w._worker is not None
|
|
75
|
+
finally:
|
|
76
|
+
w.stop()
|
|
@@ -96,3 +96,37 @@ def test_清单发现与仅启动(boot, tmp_path, capsys):
|
|
|
96
96
|
def test_无清单时状态提示(boot, capsys):
|
|
97
97
|
assert boot.main(["--status", "--port", "1"]) == 1
|
|
98
98
|
assert "未找到安装清单" in capsys.readouterr().out
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class FailingFirstPipRunner(FakeRunner):
|
|
102
|
+
"""首次 pip 安装 kb-memory 返回 1,模拟镜像未同步新包。"""
|
|
103
|
+
|
|
104
|
+
def __call__(self, cmd, check=False, capture_output=False, text=False):
|
|
105
|
+
self.calls.append(list(cmd))
|
|
106
|
+
joined = " ".join(cmd)
|
|
107
|
+
if "pip install kb-memory" in joined \
|
|
108
|
+
and "--extra-index-url" not in joined:
|
|
109
|
+
return SimpleNamespace(returncode=1, stdout="")
|
|
110
|
+
return SimpleNamespace(returncode=0, stdout="2.1.2\n")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def test_pip源缺包时自动官方兜底(boot, tmp_path, capsys):
|
|
114
|
+
inst = tmp_path / "inst"
|
|
115
|
+
venv_python = boot.venv_python(inst / "venv")
|
|
116
|
+
venv_python.parent.mkdir(parents=True)
|
|
117
|
+
venv_python.write_text("", encoding="utf-8")
|
|
118
|
+
runner = FailingFirstPipRunner()
|
|
119
|
+
rc = boot.main(["--yes", "--no-start", "--dir", str(inst),
|
|
120
|
+
"--port", "1"], runner=runner)
|
|
121
|
+
assert rc == 0
|
|
122
|
+
joined = [" ".join(c) for c in runner.calls]
|
|
123
|
+
assert any("--extra-index-url https://pypi.org/simple/" in j
|
|
124
|
+
for j in joined)
|
|
125
|
+
assert "官方 PyPI 兜底重试" in capsys.readouterr().out
|
|
126
|
+
assert boot.read_manifest() is not None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def test_兜底命令不重复追加(boot):
|
|
130
|
+
cmd = ["python", "-m", "pip", "install", "kb-memory[ingest]",
|
|
131
|
+
"--extra-index-url", "https://example.com/simple/"]
|
|
132
|
+
assert boot.fallback_command(cmd) is None
|
|
@@ -45,6 +45,36 @@ def test_空闲守护线程可启停(env_isolated):
|
|
|
45
45
|
stop.set()
|
|
46
46
|
|
|
47
47
|
|
|
48
|
+
def test_服务生命周期启动并停止空闲守护(tmp_path, monkeypatch, env_isolated):
|
|
49
|
+
"""create_app 返回包装后的 ASGI 函数,外部拿不到 app.state;守护线程必须在
|
|
50
|
+
lifespan 内用工厂里的 KBService 启动,否则空闲卸载永远不会生效。"""
|
|
51
|
+
from fastapi.testclient import TestClient
|
|
52
|
+
from kb import api as api_module
|
|
53
|
+
from kb.config import Settings
|
|
54
|
+
from kb.embedder import Embedder
|
|
55
|
+
|
|
56
|
+
started = {}
|
|
57
|
+
stopped = threading.Event()
|
|
58
|
+
|
|
59
|
+
class FakeStop:
|
|
60
|
+
def set(self):
|
|
61
|
+
stopped.set()
|
|
62
|
+
|
|
63
|
+
def fake_start(target, idle_seconds, check_seconds=None):
|
|
64
|
+
started["idle_seconds"] = idle_seconds
|
|
65
|
+
resolved = target() if callable(target) else target
|
|
66
|
+
started["target_is_embedder"] = isinstance(resolved, Embedder)
|
|
67
|
+
return FakeStop()
|
|
68
|
+
|
|
69
|
+
monkeypatch.setattr(api_module, "start_idle_watchdog", fake_start)
|
|
70
|
+
settings = Settings(data_dir=tmp_path / "data", log_dir=tmp_path / "logs",
|
|
71
|
+
idle_unload_seconds=42, llm_mode="off", watch_dir="")
|
|
72
|
+
with TestClient(api_module.create_app(settings)):
|
|
73
|
+
assert started["idle_seconds"] == 42
|
|
74
|
+
assert started["target_is_embedder"] is True
|
|
75
|
+
assert stopped.is_set(), "服务停止时应通知守护线程退出"
|
|
76
|
+
|
|
77
|
+
|
|
48
78
|
def test_发现顺序_manifest优先(tmp_path, monkeypatch, env_isolated):
|
|
49
79
|
from kb import discovery
|
|
50
80
|
fake_home = tmp_path / "home"
|
|
@@ -96,6 +126,42 @@ def test_where命令输出(env_isolated):
|
|
|
96
126
|
assert key in result.output
|
|
97
127
|
|
|
98
128
|
|
|
129
|
+
def test_version选项(env_isolated):
|
|
130
|
+
from typer.testing import CliRunner
|
|
131
|
+
from kb import __version__
|
|
132
|
+
from kb.cli import app
|
|
133
|
+
result = CliRunner().invoke(app, ["--version"])
|
|
134
|
+
assert result.exit_code == 0, result.output
|
|
135
|
+
assert __version__ in result.output
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def test_发现数据目录为绝对路径(tmp_path, monkeypatch, env_isolated):
|
|
139
|
+
"""相对 data_dir 实际按启动 cwd 落盘;where 必须显示绝对路径,避免误解。"""
|
|
140
|
+
from kb import discovery
|
|
141
|
+
from kb.config import Settings
|
|
142
|
+
fake_home = tmp_path / "home"
|
|
143
|
+
fake_home.mkdir()
|
|
144
|
+
monkeypatch.setattr(Path, "home", classmethod(lambda cls: fake_home))
|
|
145
|
+
monkeypatch.setattr(discovery.shutil, "which", lambda name: None)
|
|
146
|
+
monkeypatch.chdir(tmp_path)
|
|
147
|
+
settings = Settings(data_dir=Path("relative_data"),
|
|
148
|
+
log_dir=tmp_path / "logs", llm_mode="off",
|
|
149
|
+
watch_dir="")
|
|
150
|
+
info = discovery.discover(settings)
|
|
151
|
+
assert Path(info["data_dir"]) == (tmp_path / "relative_data").resolve()
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def test_ask关闭时提示已关闭(monkeypatch, env_isolated):
|
|
155
|
+
from kb.service import KBService, LLMDisabledError
|
|
156
|
+
monkeypatch.setenv("KB_LLM_MODE", "off")
|
|
157
|
+
from kb import config
|
|
158
|
+
config.get_settings.cache_clear()
|
|
159
|
+
svc = KBService()
|
|
160
|
+
with pytest.raises(LLMDisabledError) as exc:
|
|
161
|
+
svc.ask("任意问题")
|
|
162
|
+
assert "已关闭" in str(exc.value)
|
|
163
|
+
|
|
164
|
+
|
|
99
165
|
@pytest.mark.skipif(os.name == "nt", reason="Windows 启动项由 .cmd 实现")
|
|
100
166
|
def test_linux_systemd_unit内容(tmp_path, monkeypatch, env_isolated):
|
|
101
167
|
from kb import discovery
|
kb_memory-2.1.0/kb/__init__.py
DELETED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|