Files
qihuaiflow/tests/test_capability_discovery.py
T
qihu-bot 7e002ac2b5 P1-1 后端核心模块单测 (20260810-154318)
新增 test_capability_discovery.py (30) + test_llm_router.py (21) 共 51 个哨兵单测,覆盖 #874 双 Claw 能力发现 / #875 LLM 路由熔断 / #876 OTel 桥接关键路径. 套件: 116 -> 165 passed.
2026-08-10 15:43:19 +08:00

382 lines
13 KiB
Python

"""P1-1: 单测 — capability_discovery 核心函数(双 Claw 共享层)。
为什么这个文件存在:
历史上 #866 部署因为端侧重写了发现逻辑,把 #764/#765 的注入改动覆盖回退。
这次把 discovery 层的关键纯函数 + 三级降级路径锁死为单测,
防止「第二个实现」再悄悄出现。
验证范围:
1. rank_by_query: 池子 ≤ top_k 原样返回, 池子 > top_k 按词元重叠排序
2. rank_by_query: 中文友好(整词 + 2-gram)
3. _env_int / _env_flag: 缺失 / 异常 / 正常 路径
4. _safe_name: 命名安全(消除 mcp__tool__ 里被注入特殊字符)
5. discover_skills: 三级降级(installed / published / empty) + 异常降级
6. discover_agents: explicit 优先 + 三级降级
7. discover_mcp_servers: explicit 走 resolve_active, auto 走 list_enabled
8. 双 Claw 标签注入(log 里有 claw=web/im)
"""
from __future__ import annotations
import os
import sys
import asyncio
import logging
import importlib
import pytest
_HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.join(_HERE, ".."))
from app.core import capability_discovery as cd # noqa: E402
from app.core.capability_discovery import ( # noqa: E402
rank_by_query,
_env_int,
_env_flag,
_safe_name,
_tokens,
discover_skills,
discover_agents,
discover_mcp_servers,
build_mcp_tool_defs,
)
# =====================================================================
# 1. rank_by_query: 纯函数
# =====================================================================
def test_rank_by_query_empty():
assert rank_by_query([], "query", 5) == []
def test_rank_by_query_below_limit_returns_intact():
items = [
{"name": "search", "description": "网页搜索"},
{"name": "calc", "description": "数学计算"},
]
out = rank_by_query(items, "anything", 5)
assert out == items # top_k 大于池子,原样返回
def test_rank_by_query_above_limit_sorts_by_overlap():
items = [
{"name": "weather", "description": "查天气"},
{"name": "translate", "description": "翻译文档"},
{"name": "weather_alert", "description": "天气预警"},
{"name": "ocr", "description": "图片文字识别"},
]
out = rank_by_query(items, "天气", top_k=2)
# "天气" 在 weather/weather_alert 描述里都出现,应排前
assert len(out) == 2
assert all("weather" in (x.get("name") or "") or "天气" in (x.get("description") or "") for x in out)
def test_rank_by_query_zh_2gram_friendly():
items = [
{"name": "code_review", "description": "代码审查"},
{"name": "music", "description": "音乐生成"},
]
out = rank_by_query(items, "代码审查", top_k=1)
assert out[0]["name"] == "code_review"
def test_rank_by_query_top_k_zero_returns_empty():
assert rank_by_query([{"a": 1}], "x", 0) == []
def test_tokens_chinese_split():
toks = _tokens("代码审查 Code Review")
assert "code" in toks
assert "review" in toks
assert "代码" in toks
assert "码审" in toks # 2-gram
# =====================================================================
# 2. 环境旋钮
# =====================================================================
def test_env_int_default_when_missing(monkeypatch):
monkeypatch.delenv("CAP_SKILL_LIMIT", raising=False)
assert _env_int("CAP_SKILL_LIMIT", 12) == 12
def test_env_int_uses_value(monkeypatch):
monkeypatch.setenv("CAP_SKILL_LIMIT", "20")
assert _env_int("CAP_SKILL_LIMIT", 12) == 20
def test_env_int_handles_garbage(monkeypatch):
monkeypatch.setenv("CAP_SKILL_LIMIT", "not-a-number")
assert _env_int("CAP_SKILL_LIMIT", 12) == 12
def test_env_flag_default_true():
assert _env_flag("__NONEXIST__") is True
def test_env_flag_default_false():
assert _env_flag("__NONEXIST__", default=False) is False
def test_env_flag_truthy(monkeypatch):
for v in ("1", "true", "yes", "on", "TRUE"):
monkeypatch.setenv("__TEST_FLAG__", v)
assert _env_flag("__TEST_FLAG__") is True
def test_env_flag_falsy(monkeypatch):
for v in ("0", "false", "no", "off", "garbage"):
monkeypatch.setenv("__TEST_FLAG__", v)
assert _env_flag("__TEST_FLAG__") is False
# =====================================================================
# 3. _safe_name — MCP 工具命名安全
# =====================================================================
def test_safe_name_basic():
assert _safe_name("hello world", 30) == "hello_world"
def test_safe_name_maxlen():
assert _safe_name("abcdefghij" * 5, 10) == "abcdefghij"
def test_safe_name_special_chars():
# 工具名只允许 [A-Za-z0-9_-]
assert _safe_name("tool/name:v1", 30) == "tool_name_v1"
def test_safe_name_empty():
assert _safe_name("", 30) == ""
# =====================================================================
# 4. discover_skills: 三级降级(用 mock db 模拟 SQLAlchemy session)
# =====================================================================
class FakeQuery:
def __init__(self, rows):
self._rows = rows
def filter(self, *a, **kw):
return self
def all(self):
return self._rows
def order_by(self, *a, **kw):
return self
class FakeDb:
"""极简 db mock: 支持 .query(Class).filter(...).all() 链式调用。"""
def __init__(self, app_install_rows=None, market_app_rows=None):
self._app_install = app_install_rows or []
self._market_app = market_app_rows or []
def query(self, cls):
name = getattr(cls, "__name__", str(cls))
if name == "AppInstallation":
return FakeQuery(self._app_install)
if name == "MarketApp":
return FakeQuery(self._market_app)
return FakeQuery([])
class FakeAppInstall:
def __init__(self, app_id):
self.app_id = app_id
class FakeMarketApp:
def __init__(self, id, type, status):
self.id = id
self.type = type
self.status = status
def test_discover_skills_installed_path(monkeypatch):
"""user_id 提供 + app_installations 非空 → 走 installed 路径"""
db = FakeDb(
app_install_rows=[FakeAppInstall("skill-1")],
market_app_rows=[FakeMarketApp("skill-1", "skill", "published")],
)
# stub _resolve_market_skills 返回一个标定 list
monkeypatch.setattr(
"app.api.chat_skill._resolve_market_skills",
lambda d, ids, uid: [{"id": "skill-1", "name": "Search"}],
raising=False,
)
out = discover_skills(db, user_id="u-1", query="", limit=5, claw="web")
assert out and out[0]["id"] == "skill-1"
def test_discover_skills_fallback_published(monkeypatch):
"""app_installations 为空 → 降级 published 全集"""
db = FakeDb(
app_install_rows=[],
market_app_rows=[
FakeMarketApp("skill-1", "skill", "published"),
FakeMarketApp("skill-2", "skill", "published"),
],
)
monkeypatch.setattr(
"app.api.chat_skill._resolve_market_skills",
lambda d, ids, uid: [{"id": i} for i in ids],
raising=False,
)
out = discover_skills(db, user_id="u-1", query="", limit=5, claw="web")
assert len(out) == 2
ids = {x["id"] for x in out}
assert ids == {"skill-1", "skill-2"}
def test_discover_skills_fallback_disabled(monkeypatch):
"""CAP_SKILL_FALLBACK_PUBLISHED=0 → 不降级,空池"""
monkeypatch.setenv("CAP_SKILL_FALLBACK_PUBLISHED", "0")
db = FakeDb(
app_install_rows=[],
market_app_rows=[FakeMarketApp("skill-1", "skill", "published")],
)
out = discover_skills(db, user_id="u-1", query="", limit=5, claw="web")
assert out == []
def test_discover_skills_handles_exception(monkeypatch, caplog):
"""db.query 抛异常 → 降级继续,不冒泡"""
class BoomDb:
def query(self, *a, **kw):
raise RuntimeError("DB down")
with caplog.at_level(logging.WARNING):
out = discover_skills(BoomDb(), user_id="u-1", query="", limit=5, claw="web")
assert out == []
assert any("安装技能发现失败" in r.message for r in caplog.records)
def test_discover_skills_limit_zero():
db = FakeDb()
out = discover_skills(db, user_id=None, query="", limit=0, claw="im")
assert out == []
# =====================================================================
# 5. discover_agents: explicit > installed > published
# =====================================================================
def test_discover_agents_explicit_priority(monkeypatch):
"""explicit_ids 存在 → 跳过 installed/published"""
db = FakeDb() # 即便有 installed/published,也不该用
monkeypatch.setattr(
"app.api.chat_skill._resolve_market_agents",
lambda d, ids, uid: [{"id": "a-explicit"}],
raising=False,
)
out = discover_agents(db, user_id="u-1", query="", explicit_ids=["a-explicit"], limit=5, claw="im")
assert out and out[0]["id"] == "a-explicit"
def test_discover_agents_installed_path(monkeypatch):
db = FakeDb(
app_install_rows=[FakeAppInstall("a-1")],
market_app_rows=[FakeMarketApp("a-1", "agent", "published")],
)
monkeypatch.setattr(
"app.api.chat_skill._resolve_market_agents",
lambda d, ids, uid: [{"id": i} for i in ids],
raising=False,
)
out = discover_agents(db, user_id="u-1", query="", explicit_ids=None, limit=5, claw="web")
assert out and out[0]["id"] == "a-1"
# =====================================================================
# 6. discover_mcp_servers: explicit vs auto
# =====================================================================
def test_discover_mcp_explicit(monkeypatch):
db = FakeDb()
monkeypatch.setattr(
"app.api.chat_mcp._resolve_active_mcp_servers",
lambda d, ids: [{"id": i, "name": i} for i in ids],
raising=False,
)
out = discover_mcp_servers(db, explicit_ids=["m1", "m2"], limit=5, claw="web")
assert len(out) == 2
assert {x["id"] for x in out} == {"m1", "m2"}
def test_discover_mcp_auto(monkeypatch):
db = FakeDb()
monkeypatch.setattr(
"app.api.chat_mcp._list_enabled_mcp_servers",
lambda d: [{"id": "m-auto", "name": "auto"}],
raising=False,
)
out = discover_mcp_servers(db, explicit_ids=None, limit=5, claw="im")
assert out and out[0]["id"] == "m-auto"
def test_discover_mcp_auto_disabled(monkeypatch):
monkeypatch.setenv("CAP_MCP_AUTO", "0")
db = FakeDb()
out = discover_mcp_servers(db, explicit_ids=None, limit=5, claw="im")
assert out == []
def test_discover_mcp_limit_clips(monkeypatch):
db = FakeDb()
monkeypatch.setattr(
"app.api.chat_mcp._resolve_active_mcp_servers",
lambda d, ids: [{"id": i, "name": i} for i in ids],
raising=False,
)
out = discover_mcp_servers(db, explicit_ids=["a", "b", "c", "d"], limit=2, claw="web")
assert len(out) == 2
# =====================================================================
# 7. build_mcp_tool_defs: 命名 + 异常降级
# =====================================================================
def test_build_mcp_tool_defs_naming():
"""验证命名规则 mcp__{server}__{tool}__{idx} 严格一致"""
# 旁路 list_mcp_tools 直接构造 servers
async def fake_list(srv):
return [{"name": "list_things", "description": "List things", "inputSchema": {"type": "object"}}]
import app.mcp_client
orig = app.mcp_client.list_mcp_tools
app.mcp_client.list_mcp_tools = fake_list
try:
defs, tool_map = asyncio.run(build_mcp_tool_defs(
[{"id": "s1", "name": "github"}, {"id": "s2", "name": "fs"}],
idx_start=0,
))
finally:
app.mcp_client.list_mcp_tools = orig
assert len(defs) == 2
assert defs[0]["name"] == "mcp__github__list_things__0"
assert defs[1]["name"] == "mcp__fs__list_things__1"
assert defs[0]["name"] in tool_map
assert tool_map[defs[0]["name"]]["server_name"] == "github"
def test_build_mcp_tool_defs_continues_on_error(caplog):
async def fake_list(srv):
if srv["id"] == "bad":
raise RuntimeError("mcp boom")
return [{"name": "ok", "description": "ok", "inputSchema": {}}]
import app.mcp_client
orig = app.mcp_client.list_mcp_tools
app.mcp_client.list_mcp_tools = fake_list
try:
with caplog.at_level(logging.WARNING):
defs, tool_map = asyncio.run(build_mcp_tool_defs(
[{"id": "bad", "name": "broken"}, {"id": "ok", "name": "good"}],
))
finally:
app.mcp_client.list_mcp_tools = orig
assert len(defs) == 1
assert defs[0]["name"] == "mcp__good__ok__1"
assert any("MCP 拉取工具失败" in r.message for r in caplog.records)