""" tests/test_sample_kb.py — 示例知识库种子 + 平台 KB 工具桥接(KB 调用链核查修复)。 覆盖: 1. ensure_sample_kb: 建库/登记/索引、幂等、无租户时延迟。 2. _make_platform_kb_tools: KBSearch 查到挂接库内容;KBList 列出库; 无挂接时返回 {}(不注入,保留 lambdagent 内置行为)。 3. compiler 工具合并: subAgents 的 call_* 与宿主注入的自定义工具共存 (原来注入任何 tools 都会让 subAgents 整段跳过)。 """ from __future__ import annotations import json import os import pytest os.environ.setdefault("AGENTPAAS_DATABASE_URL", "sqlite:///:memory:") os.environ.setdefault("AGENTPAAS_TESTING", "1") @pytest.fixture() def fresh_db(monkeypatch): """独立内存 DB + 一个活跃租户。""" from agentpaas.db.models import Database, gen_id, now_utc import agentpaas.db.session as _session_mod prev = _session_mod._db _session_mod._db = Database("sqlite:///:memory:") db = _session_mod._db tid = gen_id("tn_") db.execute( "INSERT INTO tenants (id, name, plan, status, created_at) " "VALUES (?, 'test', 'free', 'active', ?)", (tid, now_utc())) db.commit() yield db, tid _session_mod._db = prev def test_sample_kb_created_and_indexed(fresh_db, tmp_path): from agentpaas.engine.sample_kb import ensure_sample_kb, SAMPLE_KB_NAME db, tid = fresh_db ensure_sample_kb(str(tmp_path)) kb = db.fetchone("SELECT * FROM knowledge_bases WHERE name = ?", (SAMPLE_KB_NAME,)) assert kb, "示例知识库应被创建" assert kb["tenant_id"] == tid # 文件已拷贝并登记 files = db.fetchall("SELECT * FROM kb_files WHERE kb_id = ?", (kb["id"],)) assert len(files) >= 4, files names = {f["file_name"] for f in files} assert "知识库使用指南.md" in names # pageindex 已生成且可检索 idx = os.path.join(kb["root_dir"], "rag_page_index.json") assert os.path.isfile(idx) entries = json.loads(open(idx, encoding="utf-8").read()) assert len(entries) > 10 # 按 ## 小节切分 from agentpaas.api.v1.knowledge import _page_index_search hits = _page_index_search(kb["root_dir"], "顺序表 插入 删除", top_k=3) assert hits and "顺序" in (hits[0].get("text", "") + hits[0].get("summary", "")) def test_sample_kb_idempotent(fresh_db, tmp_path): from agentpaas.engine.sample_kb import ensure_sample_kb, SAMPLE_KB_NAME db, _ = fresh_db ensure_sample_kb(str(tmp_path)) ensure_sample_kb(str(tmp_path)) # 再跑一次 rows = db.fetchall("SELECT id FROM knowledge_bases WHERE name = ?", (SAMPLE_KB_NAME,)) assert len(rows) == 1, "幂等:不应重复创建" def test_sample_kb_defers_without_tenant(tmp_path, monkeypatch): from agentpaas.db.models import Database import agentpaas.db.session as _session_mod from agentpaas.engine.sample_kb import ensure_sample_kb, SAMPLE_KB_NAME prev = _session_mod._db _session_mod._db = Database("sqlite:///:memory:") # 无租户 try: ensure_sample_kb(str(tmp_path)) kb = _session_mod._db.fetchone( "SELECT id FROM knowledge_bases WHERE name = ?", (SAMPLE_KB_NAME,)) assert kb is None, "无租户时应延迟到下次启动" finally: _session_mod._db = prev # ── 平台 KB 工具桥接 ──────────────────────────────────────────────────────── def test_platform_kb_tools_search_and_list(fresh_db, tmp_path): from agentpaas.engine.sample_kb import ensure_sample_kb, SAMPLE_KB_NAME from agentpaas.api.v1.agents import _make_platform_kb_tools db, tid = fresh_db ensure_sample_kb(str(tmp_path)) kb = db.fetchone("SELECT * FROM knowledge_bases WHERE name = ?", (SAMPLE_KB_NAME,)) tools = _make_platform_kb_tools(db, [kb["id"]], tid, search_mode="pageindex") assert set(tools) == {"KBSearch", "KBList"} # KBSearch: JSON 入参与裸字符串都支持 out = tools["KBSearch"]('{"query": "循环队列 队满"}') assert "知识库检索结果" in out and "队" in out out2 = tools["KBSearch"]("栈 后进先出") assert "知识库检索结果" in out2 # 查不到 → NO_MATCH 提示而非空串 miss = tools["KBSearch"]("量子引力 弦论") assert "NO_MATCH" in miss or "知识库检索结果" in miss # KBList 列出挂接库与文件数 lst = tools["KBList"]("") assert SAMPLE_KB_NAME in lst and "文件" in lst def test_platform_kb_tools_empty_when_no_kb(fresh_db): from agentpaas.api.v1.agents import _make_platform_kb_tools db, tid = fresh_db assert _make_platform_kb_tools(db, [], tid) == {} def test_platform_kb_tools_tenant_scoped(fresh_db, tmp_path): """别的租户挂了这个 kb_id 也查不到(audit #30 语义在工具层同样成立)。""" from agentpaas.engine.sample_kb import ensure_sample_kb, SAMPLE_KB_NAME from agentpaas.api.v1.agents import _make_platform_kb_tools db, tid = fresh_db ensure_sample_kb(str(tmp_path)) kb = db.fetchone("SELECT * FROM knowledge_bases WHERE name = ?", (SAMPLE_KB_NAME,)) tools = _make_platform_kb_tools(db, [kb["id"]], "tn_other_tenant", "pageindex") out = tools["KBSearch"]("顺序表") assert "NO_MATCH" in out # 跨租户被静默跳过 → 查无结果 assert SAMPLE_KB_NAME not in tools["KBList"]("") # ── compiler 工具合并(subAgents 与注入工具共存)──────────────────────────── def test_compiler_merges_injected_tools_with_subagents(): from lambdagent.fromconfig.compiler import build_agent cfg = { "name": "orch", "type": "react", "model": {"name": "ollama/qwen2.5:7b"}, "systemPrompt": "test", "react": {"maxSteps": 3}, "subAgents": { "helper": {"inline": {"type": "simple", "model": {"name": "ollama/qwen2.5:7b"}, "systemPrompt": "sub"}, "tool": "call_helper"}, }, "mcp": {"localTools": ["KBSearch", "terminate"]}, } sentinel = {"value": None} def fake_kb_search(x): sentinel["value"] = x return "[平台KB] hit" term = build_agent(cfg, {"tools": {"KBSearch": fake_kb_search}}) assert term is not None # 注入的 KBSearch 生效(而非 lambdagent 内置实现) from lambdagent.fromconfig.compiler import _compile_tools # noqa: F401 # 通过重新编译工具表来断言合并语义 tools = {} from lambdagent.fromconfig import compiler as _c merged = {** _c._compile_sub_agents(cfg, cfg["subAgents"], {}), **{"KBSearch": fake_kb_search}} assert "call_helper" in merged and merged["KBSearch"] is fake_kb_search def test_page_index_search_chinese_query(fresh_db, tmp_path): """中文检索回归:原 \\W+ 分词把整句中文当一个 token → 全 0 分乱序。 现在 CJK bigram 切分,长中文问句应命中正确小节。""" from agentpaas.engine.sample_kb import ensure_sample_kb, SAMPLE_KB_NAME from agentpaas.api.v1.knowledge import _page_index_search db, _ = fresh_db ensure_sample_kb(str(tmp_path)) kb = db.fetchone("SELECT * FROM knowledge_bases WHERE name = ?", (SAMPLE_KB_NAME,)) hits = _page_index_search(kb["root_dir"], "循环队列怎么判断队满?讲义里怎么说的", 3) assert hits and hits[0]["score"] > 0, "中文长句必须有非零得分" assert "栈与队列" in hits[0]["source"], f"应命中第3章, got {hits[0]['source']}" # 英文 query 不受影响 hits_en = _page_index_search(kb["root_dir"], "ADT stack push pop", 3) assert hits_en and hits_en[0]["score"] > 0 def test_resolve_tenant_prefers_busiest(fresh_db, tmp_path): """多租户老库:示例库应归属活跃智能体最多的租户(浏览器会话所在地), 而非最老租户或 config.json 租户。""" from agentpaas.db.models import gen_id, now_utc from agentpaas.engine.sample_kb import ensure_sample_kb, SAMPLE_KB_NAME db, tid_old = fresh_db # 第二个租户,拥有 2 个活跃智能体(模拟日常使用租户) tid_busy = gen_id("tn_") now = now_utc() db.execute("INSERT INTO tenants (id, name, plan, status, created_at) " "VALUES (?, 'busy', 'free', 'active', ?)", (tid_busy, now)) for i in range(2): db.execute( "INSERT INTO agents (id, tenant_id, name, current_version, status, " "created_at, updated_at) VALUES (?, ?, ?, 1, 'active', ?, ?)", (gen_id("ag_"), tid_busy, f"a{i}", now, now)) db.commit() ensure_sample_kb(str(tmp_path)) kb = db.fetchone("SELECT tenant_id FROM knowledge_bases WHERE name = ?", (SAMPLE_KB_NAME,)) assert kb["tenant_id"] == tid_busy, "应归属智能体最多的租户"