Files
qihuaiflow/backend/python/tests/test_dual_claw.py
T
QihuAI Design 04a0641202 feat(otel #876): 双 Claw 强制标签 + 入口接线 + Grafana 双 Claw 对比看板
#876 OpenTelemetry 可观测(双 Claw 标签):
- app/core/otel.py: 新增 set_otel_ctx/get_otel_ctx(ContextVar 三元组 tenant_id+claw_type+
  trace_id);current_span_cm 自动注入双 Claw 强制标签,全部 13 个 span 调用点零签名侵入带维度;
  record_span_metrics 指标维度补 tenant_id+claw_type(trace_id 因高基数不计入指标标签)。
- app/core/llm_router.py: set_route_ctx 桥接转调 set_otel_ctx(单次入口注入双端共享:
  路由上下文 + OTel 标签),失败安全。
- app/api/chat.py: send_message 与 send_message_stream 入口注入 set_route_ctx(web),
  stream 经 asyncio.create_task 子协程自动传播 contextvars,使 _send_message_core 内
  所有 span 均带 tenant_id+claw_type。
- app/api/im_llm.py: _call_real_llm 入口最早注入 set_route_ctx(im_wechat),并显式开启
  im.send_message span(此前 IM Claw 完全无 span,Grafana 无 IM 数据点),finally 关闭。
- tests/test_dual_claw.py: R-DUAL-6 由 skip 转为确定性断言(Web/IM 双 Claw span 必带
  tenant_id+claw_type),作为持续哨兵。
- 交付 Grafana 双 Claw 对比看板 JSON(aiflow_span_* / aiflow_llm_route_total 按 claw_type
  分组:P50/P95 延迟、成功率、QPS、租户健康 Top10)。Alloy OTLP 仅桥接 metrics→Prometheus,
  trace 无 pipeline(#800 已压制 UNIMPLEMENTED 噪声),看板基于 metrics 出图。

safe-align: 仅复制 5 个 git-tracked 文件,未触碰 secrets/venv/node_modules/caches。本地提交不 push。
2026-08-04 13:49:34 +08:00

234 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Dual-Claw 回归哨兵 —— R-DUAL-*(设计书 leagent_accel_belt_design.md §6,任务 #873)。
硬约束:Web Claw 与 IM Bot Claw 必须消费**同一份**能力清单与**同一套**工具命名。
本文件是「活体哨兵」:任何把 IM Claw 能力改回退(如 #866 覆盖 #764/#765 导致
mcp=[]/agents=[] 写死)的提交,会在 pre-push 阶段立即变红。
历史教训(见设计书 §2):双端对齐若靠「在第二份实现里再写一遍」必然被下一次覆盖
回退。**唯一根治法是消灭第二份实现**——即 im_llm 必须复用统一的 capability_discovery。
测试分两层:
A. 始终运行(确定性、无网络):源码契约守卫 + DB 级能力一致性 + 命名同源守卫。
B. 可选运行(需联网 MCP/LLM):置 RUN_DUAL_INTEGRATION=1 时执行真实工具调用,
作为持续哨兵;默认 skip,避免 CI 网关因外部网络抖动而误杀正常提交。
"""
import os
import re
import glob
import sys
import pytest
_HERE = os.path.dirname(os.path.abspath(__file__))
APP_DIR = os.path.abspath(os.path.join(_HERE, "..", "app"))
API_DIR = os.path.join(APP_DIR, "api")
sys.path.insert(0, os.path.join(_HERE, ".."))
from app.database import SessionLocal # noqa: E402
from app.core.capability_discovery import ( # noqa: E402
discover_skills,
discover_mcp_servers,
_safe_name,
)
def _read_api_sources():
parts = []
for p in sorted(glob.glob(os.path.join(API_DIR, "chat*.py"))) + \
[os.path.join(API_DIR, "im_llm.py")]:
if os.path.exists(p):
with open(p, "r", encoding="utf-8") as f:
parts.append(f.read())
return "\n".join(parts)
@pytest.fixture(scope="module")
def db():
session = SessionLocal()
yield session
session.close()
# ─────────────────────────────────────────────────────────────────────────────
# R-DUAL-2 (源码守卫):IM Claw 必须复用统一能力发现,禁止写死 mcp=[]
# ─────────────────────────────────────────────────────────────────────────────
def test_rdual2_im_reuses_unified_discovery():
"""IM Bot 引擎必须从 capability_discovery 取能力,且不得残留 mcp=[] 写死。
这是 #866 覆盖 #764/#765 的精确回归签名:当时 im_llm 把 mcp=[] 硬编码,
导致 IM 永远拿不到 MCP 工具。本测试捕获该字面量复现。
"""
im_src = ""
im_path = os.path.join(API_DIR, "im_llm.py")
with open(im_path, "r", encoding="utf-8") as f:
im_src = f.read()
# 1) 必须导入统一发现层
assert "from app.core.capability_discovery import" in im_src, \
"im_llm 未从 capability_discovery 导入——IM 可能又退化成第二份实现"
assert "discover_skills" in im_src, "im_llm 未使用 discover_skills"
assert "discover_mcp_servers" in im_src, "im_llm 未使用 discover_mcp_servers"
# 2) 禁止写死 mcp=[](agents=[] 是允许的:当前无已发布 agent)
assert "mcp=[]" not in im_src, \
"检测到 im_llm 写死 mcp=[]——IM Claw 的 MCP 能力已回退(#866 类回归)"
# 3) _build_system_prompt 调用必须传入真实 mcp(而非空列表)
assert "mcp=mcp_servers" in im_src or "mcp=mcp_servers," in im_src, \
"im_llm 未把发现的 MCP 服务器注入 system prompt"
# ─────────────────────────────────────────────────────────────────────────────
# R-DUAL-1 (DB 级一致性):双 Claw 返回同一份能力集合
# ─────────────────────────────────────────────────────────────────────────────
def test_rdual1_skill_set_parity(db):
"""同一用户,Web 与 IM 必须发现相同的技能集合(统一发现层 cliff 保证)。"""
web = discover_skills(db, "dual-claw-sentinel", claw="web")
im = discover_skills(db, "dual-claw-sentinel", claw="im")
assert len(web) > 0, "Web Claw 未发现任何技能(published fallback 可能失效)"
web_names = {s.get("name") for s in web}
im_names = {s.get("name") for s in im}
assert web_names == im_names, (
f"Web 与 IM 技能集合不一致:\n web={sorted(web_names)}\n im={sorted(im_names)}"
)
def test_rdual1_mcp_server_parity(db):
"""双 Claw 必须挂载相同的 MCP 服务器集合(IM 不再丢 MCP)。"""
web = discover_mcp_servers(db, None, claw="web")
im = discover_mcp_servers(db, None, claw="im")
assert len(web) > 0, "未发现有启用的 MCP 服务器"
web_names = sorted(s.get("name") for s in web)
im_names = sorted(s.get("name") for s in im)
assert web_names == im_names, (
f"Web 与 IM 的 MCP 服务器不一致:\n web={web_names}\n im={im_names}"
)
# ─────────────────────────────────────────────────────────────────────────────
# R-DUAL-4 (命名同源):双 Claw 工具命名必须同源,杜绝第二份实现发散
# ─────────────────────────────────────────────────────────────────────────────
def test_rdual4_tool_naming_single_source():
"""capability_discovery._safe_name 必须与 Web Claw 的命名公式完全一致。
Web Claw (chat_service.py:3428) 用:
_sname = re.sub(r'[^A-Za-z0-9_-]', '_', name[:30])
_tname = re.sub(r'[^A-Za-z0-9_-]', '_', name[:50])
_fname = f"mcp__{_sname}__{_tname}__{mcp_idx}"
统一层必须用同一个公式,否则两端工具名对不上 → 工具调用在 IM 端失效。
"""
# 统一层的公式
for raw, maxlen in [("My Server!", 30), ("some tool.name", 50), ("中文-工具", 50)]:
expected = re.sub(r"[^A-Za-z0-9_-]", "_", raw[:maxlen])
assert _safe_name(raw, maxlen) == expected, \
f"_safe_name 公式与 Web Claw 不一致: {_safe_name(raw, maxlen)!r} != {expected!r}"
# Web Claw 源码必须仍使用同一公式([:30] 给 server,[:50] 给 tool)
cs_path = os.path.join(API_DIR, "chat_service.py")
with open(cs_path, "r", encoding="utf-8") as f:
cs_src = f.read()
assert "re.sub(r'[^A-Za-z0-9_-]', '_', " in cs_src, \
"chat_service.py 工具命名公式缺失/被改写"
assert "[:30]" in cs_src and "[:50]" in cs_src, \
"chat_service.py 的 server/tool 截断长度与统一层不匹配(应为 30/50)"
# ─────────────────────────────────────────────────────────────────────────────
# R-DUAL-5 (媒体结构契约):IM 必须保留 __media__ 结构化返回
# ─────────────────────────────────────────────────────────────────────────────
def test_rdual5_im_preserves_media_struct():
"""微信内联图片依赖 __media__ 结构化返回,禁止在 IM 端退化成纯文本。"""
im_path = os.path.join(API_DIR, "im_llm.py")
with open(im_path, "r", encoding="utf-8") as f:
im_src = f.read()
assert "__media__" in im_src, \
"im_llm 缺失 __media__ 结构化返回——微信内联图片将失效(R-DUAL-5)"
# ─────────────────────────────────────────────────────────────────────────────
# R-DUAL-2 / R-DUAL-3(真实集成,需联网,默认 skip)
# ─────────────────────────────────────────────────────────────────────────────
_RUN_LIVE = os.environ.get("RUN_DUAL_INTEGRATION") == "1"
@pytest.mark.skipif(not _RUN_LIVE, reason="set RUN_DUAL_INTEGRATION=1 to run live MCP/LLM integration")
@pytest.mark.asyncio
async def test_rdual2_live_im_mcp_tools_reachable(db):
"""真实拉取启用 MCP 服务器的工具列表,证明 IM 端代理绕过已生效(对照 #P0 修复)。"""
from app.mcp_client import list_mcp_tools
servers = discover_mcp_servers(db, None, claw="im")
assert servers, "没有启用 MCP 服务器可供实时验证"
total = 0
for srv in servers:
try:
tools = await list_mcp_tools(srv, timeout=30, raise_on_error=False)
except Exception:
tools = []
total += len(tools or [])
assert total > 0, "实时拉取 MCP 工具为 0——IM 端 MCP 代理可能再次被代理劫持"
@pytest.mark.skipif(not _RUN_LIVE, reason="set RUN_DUAL_INTEGRATION=1 to run live agent integration")
def test_rdual3_im_agent_capability(db):
"""若已发布 agent,IM 端必须能纳入;当前无已发布 agent 则 skip(待 #873 薄壳化)。"""
from app.models import MarketApp
agent_count = db.query(MarketApp).filter(
MarketApp.type == "agent", MarketApp.status == "published"
).count()
if agent_count == 0:
pytest.skip("尚无已发布 agent(market_apps.type='agent'=0),IM agent 能力待 #873 薄壳化后激活")
# 待 agent 接入后补充:断言 im_llm 经 EngineCore 获得 agent 编排能力
# ─────────────────────────────────────────────────────────────────────────────
# R-DUAL-6(OTel 标签,#876 已完成):双 Claw span 须携带 tenant_id + claw_type
# ─────────────────────────────────────────────────────────────────────────────
def test_rdual6_spans_carry_tenant_and_claw():
"""双 Claw 链路的 span 必须带有 tenant_id 与 claw_type 标签(双端横向对比看板依赖)。
#876 在 otel.current_span_cm 层强制注入 tenant_id + claw_type + trace_id,
入口经 set_otel_ctx / set_route_ctx 注入一次,全部 span 自动带维度(零签名侵入)。
本测试以确定性方式验证该强制注入对 Web / IM 双 Claw 均生效。
"""
from app.core.otel import set_otel_ctx, current_span_cm, get_otel_ctx
# 模拟 Web Claw 入口注入
set_otel_ctx(tenant_id="dual-claw-sentinel", claw_type="web")
assert get_otel_ctx()["claw_type"] == "web"
cm = current_span_cm("test_rdual6_web_span", {"model": "deepseek"})
if cm is None:
pytest.skip("OTel tracer 不可用(OTEL_ENABLED=0 或 init 失败)—— 标签注入代码存在但未在本题环境演练")
span = cm.__enter__()
try:
attrs = dict(span.attributes)
assert attrs.get("tenant_id") == "dual-claw-sentinel", \
f"Web Claw span 缺失 tenant_id 标签: {attrs}"
assert attrs.get("claw_type") == "web", \
f"Web Claw span 缺失 claw_type 标签: {attrs}"
assert attrs.get("model") == "deepseek", \
f"业务字段 model 未透传: {attrs}"
finally:
cm.__exit__(None, None, None)
# 模拟 IM Claw 入口注入
set_otel_ctx(tenant_id="dual-claw-sentinel", claw_type="im_wechat")
cm2 = current_span_cm("test_rdual6_im_span")
if cm2 is None:
pytest.skip("OTel tracer 不可用,跳过 IM 断言")
span2 = cm2.__enter__()
try:
attrs2 = dict(span2.attributes)
assert attrs2.get("claw_type") == "im_wechat", \
f"IM Claw span 缺失 claw_type=im_wechat 标签: {attrs2}"
assert attrs2.get("tenant_id") == "dual-claw-sentinel", \
f"IM Claw span 缺失 tenant_id 标签: {attrs2}"
finally:
cm2.__exit__(None, None, None)