Files
qihuaiflow/backend/python/app/core/capability_discovery.py
T
QihuAI Design 29a1ce2e54 fix: 双Claw能力对齐 + MCP/LLM代理绕过(P0止血)
- 新增 app/core/capability_discovery.py:统一能力发现层,Web/IM 双Claw共用单一实现
  (三级降级:用户安装 → 平台published → 相关性裁剪;MCP 自动挂载已启用服务)
- im_llm.py:IM Bot Claw 改用统一发现层,恢复 skills(12) + MCP(26工具) 能力,
  修复 #866 部署回退导致的 agents=[]/mcp=[] 回归;mcp__ 前缀分流到 call_mcp_tool
- mcp_client.py:注入 httpx_client_factory(trust_env=False) 绕过容器 HTTP_PROXY,
  修复外部 MCP(xiaobenyang.com / anysearch.com) tools/list 全部静默失败(mcp_tools 0→26)
- llm_client.py / im_bot_user.py:2026-07-31 代理劫持内部请求 + 媒体断连修复(随镜像已上线)
2026-08-04 10:21:19 +08:00

278 lines
9.6 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.
"""统一能力发现层(双 Claw 共用 · 单一实现)。
背景
----
七虎平台对外有两个 Claw 端口:
* Web Claw —— SSE 流式,用户可在 UI 勾选 skills / mcp / agents
* IM Bot Claw —— 微信等 IM,server push 整条回复,**没有任何勾选 UI**
历史问题(2026-08-04 线上实证):
1. `im_llm.py` 写死 `agents=[]` / `mcp=[]`,IM 端永远拿不到 MCP 工具;
2. `_discover_installed_skills()` 强依赖 `app_installations` 表,而线上该表
**0 行** → 97 个 published skill 在 Web / IM **双端同时发现不到**;
3. IM 端 skills 还被 `[:15]` 截断(截断一个空列表,属噪声)。
设计原则
--------
能力发现只允许有 **一份实现**(本模块)。任何 Claw 想要"技能/工具",
都必须调用这里的函数;禁止在端侧另写一套发现逻辑——历史上 #866 部署
就是因为存在第二份实现而把 #764/#765 的注入改动整体覆盖回退。
发现策略(三级降级,双端一致)
--------------------------
1) 用户显式安装(app_installations)—— 最高优先,尊重用户意愿
2) 无安装记录 → 降级为平台已发布(status='published')全集
3) 池子超过预算 → 按当前问题做轻量相关性排序,取 top-K 控制 token
运维旋钮(环境变量,改完重启生效)
--------------------------------
CAP_SKILL_LIMIT 单轮注入技能数上限,默认 12
CAP_MCP_LIMIT 单轮注入 MCP 服务数上限,默认 8
CAP_SKILL_FALLBACK_PUBLISHED 安装表为空时是否降级为 published,默认 开
CAP_MCP_AUTO 未显式指定时是否自动挂载已启用 MCP,默认 开
"""
from __future__ import annotations
import logging
import os
import re
from typing import Any, Iterable
logger = logging.getLogger(__name__)
__all__ = [
"discover_skills",
"discover_mcp_servers",
"build_mcp_tool_defs",
"rank_by_query",
]
# --------------------------------------------------------------------------
# 环境旋钮
# --------------------------------------------------------------------------
def _env_int(name: str, default: int) -> int:
try:
raw = (os.environ.get(name) or "").strip()
return int(raw) if raw else default
except Exception:
return default
def _env_flag(name: str, default: bool = True) -> bool:
raw = (os.environ.get(name) or "").strip().lower()
if not raw:
return default
return raw in ("1", "true", "yes", "on")
# --------------------------------------------------------------------------
# 轻量相关性排序(中文友好:整词 + 2-gram)
# --------------------------------------------------------------------------
def _tokens(text: str) -> set:
text = re.sub(r"[^\w\u4e00-\u9fff]+", " ", (text or "").lower())
out: set = set()
for w in text.split():
if len(w) >= 2:
out.add(w)
for i in range(len(w) - 1):
out.add(w[i : i + 2])
return out
def rank_by_query(items: list, query: str, top_k: int) -> list:
"""按与 query 的词元重叠度排序取前 top_k。
池子本来就不超预算时原样返回,避免无谓抖动(同一问题两次调用结果稳定)。
"""
if not items:
return []
if top_k <= 0 or len(items) <= top_k:
return items[:top_k] if top_k > 0 else []
q = _tokens(query)
if not q:
return items[:top_k]
scored = []
for idx, it in enumerate(items):
blob = " ".join(
str(it.get(k) or "")
for k in ("name", "description", "category", "func_name")
)
t = _tokens(blob)
if t:
# 交集数按文档长度做平方根归一,抑制"长描述天然占优"
score = len(q & t) / (len(t) ** 0.5)
else:
score = 0.0
# idx 作为稳定次序的 tie-breaker,保证同分时结果可复现
scored.append((-score, idx, it))
scored.sort(key=lambda x: (x[0], x[1]))
return [it for _, _, it in scored[:top_k]]
# --------------------------------------------------------------------------
# 技能发现
# --------------------------------------------------------------------------
def discover_skills(
db,
user_id: str | None,
query: str = "",
limit: int | None = None,
claw: str = "web",
) -> list:
"""发现当前请求可用的技能列表(双 Claw 共用)。
返回 `_resolve_market_skills` 的结构:
[{id, name, description, skill_content, category, func_name, parameters}]
"""
limit = limit if limit is not None else _env_int("CAP_SKILL_LIMIT", 12)
if limit <= 0:
return []
# 延迟导入:避免 app.core ←→ app.api 循环依赖
from app.api.chat_skill import _resolve_market_skills
from app.models import AppInstallation, MarketApp
pool: list = []
source = "empty"
# 1) 用户显式安装
if user_id:
try:
app_ids = [
i.app_id
for i in db.query(AppInstallation)
.filter(AppInstallation.user_id == user_id)
.all()
]
if app_ids:
ids = [
a.id
for a in db.query(MarketApp)
.filter(
MarketApp.id.in_(app_ids),
MarketApp.type == "skill",
MarketApp.status == "published",
)
.all()
]
if ids:
pool = _resolve_market_skills(db, ids, user_id)
source = "installed"
except Exception as e:
logger.warning("[cap] 安装技能发现失败(降级继续): %s", e)
# 2) 降级:平台已发布全集
if not pool and _env_flag("CAP_SKILL_FALLBACK_PUBLISHED", True):
try:
ids = [
a.id
for a in db.query(MarketApp)
.filter(MarketApp.type == "skill", MarketApp.status == "published")
.all()
]
if ids:
pool = _resolve_market_skills(db, ids, user_id or "")
source = "published"
except Exception as e:
logger.warning("[cap] published 技能发现失败: %s", e)
# 3) 预算裁剪
picked = rank_by_query(pool, query, limit)
logger.info(
"[cap] skills claw=%s source=%s pool=%d picked=%d",
claw,
source,
len(pool),
len(picked),
)
return picked
# --------------------------------------------------------------------------
# MCP 发现
# --------------------------------------------------------------------------
def discover_mcp_servers(
db,
explicit_ids: Iterable[str] | None = None,
limit: int | None = None,
claw: str = "web",
) -> list:
"""发现当前请求可用的 MCP 服务连接(双 Claw 共用)。
Web Claw 传 explicit_ids(用户在 UI 勾选的);
IM Bot Claw 无勾选通道 → 传 None,自动挂载平台已启用的 MCP。
返回 [{id, name, description, transport, endpoint, url, headers}]
"""
limit = limit if limit is not None else _env_int("CAP_MCP_LIMIT", 8)
if limit <= 0:
return []
from app.api.chat_mcp import _list_enabled_mcp_servers, _resolve_active_mcp_servers
ids = [i for i in (explicit_ids or []) if i]
if ids:
out = _resolve_active_mcp_servers(db, ids)[:limit]
logger.info("[cap] mcp claw=%s source=explicit picked=%d", claw, len(out))
return out
if not _env_flag("CAP_MCP_AUTO", True):
return []
out = _list_enabled_mcp_servers(db)[:limit]
logger.info("[cap] mcp claw=%s source=auto picked=%d", claw, len(out))
return out
# --------------------------------------------------------------------------
# MCP → function definitions
# --------------------------------------------------------------------------
def _safe_name(raw: str, maxlen: int) -> str:
return re.sub(r"[^A-Za-z0-9_-]", "_", (raw or "")[:maxlen])
async def build_mcp_tool_defs(servers: list, idx_start: int = 0) -> tuple:
"""把 MCP 服务列表展开为 (function_definitions, tool_map)。
命名规则与 Web Claw 现有实现严格一致:`mcp__{server}__{tool}__{idx}`,
保证两端产生的工具名可互相对照、可被同一套审批/统计逻辑识别。
返回:
function_definitions: [{name, description, parameters}]
tool_map: {func_name: {server_id, name, server_name}}
"""
from app.mcp_client import list_mcp_tools
defs: list = []
tool_map: dict = {}
for offset, srv in enumerate(servers or []):
idx = idx_start + offset
try:
tools = await list_mcp_tools(srv)
except Exception as e:
logger.warning("[cap] MCP 拉取工具失败 [%s]: %s", srv.get("name"), e)
continue
for t in tools or []:
sname = _safe_name(srv.get("name") or "unknown", 30)
tname = _safe_name(t.get("name") or "tool", 50)
fname = f"mcp__{sname}__{tname}__{idx}"
tool_map[fname] = {
"server_id": srv.get("id"),
"name": t.get("name"),
"server_name": srv.get("name"),
}
defs.append(
{
"name": fname,
"description": (
f"[MCP:{srv.get('name')}] {t.get('description') or t.get('name') or ''}"
)[:900],
"parameters": t.get("inputSchema")
or {"type": "object", "properties": {}},
}
)
return defs, tool_map