P1-1 后端核心模块单测 (20260810-154318)
新增 test_capability_discovery.py (30) + test_llm_router.py (21) 共 51 个哨兵单测,覆盖 #874 双 Claw 能力发现 / #875 LLM 路由熔断 / #876 OTel 桥接关键路径. 套件: 116 -> 165 passed.
This commit is contained in:
1 parent
cee260d4bd
commit
7e002ac2b5
2 files changed
+648
No files matched your search
@@ -0,0 +1,381 @@
|
||||
"""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)
|
||||
|
||||
Reference in new issue
Block a user