chore(align): sync Git with running container as authoritative source
Standards alignment (STANDARDS_AUDIT_2026-07-24): Phase 1 标准化核心 + Phase 2 质量层 landed in the running container and captured in Git so the three sources (container := image := Git) agree. Backend (Phase 1 标准化核心): - mcp_client.py: replaced hand-rolled JSON-RPC with official `mcp` SDK (ClientSession, streamable_http/sse transports, protocol auto-negotiation) - core/otel.py + core/observability.py: standard OpenTelemetry SDK bridge (OTLP gRPC), wired to Grafana Alloy (qihu-aiflow-alloy:4317) which forwards metrics to Prometheus; emits aiflow_span_duration_ms / aiflow_span_count - deployments: Grafana Alloy container + Prometheus remote-write receiver Backend (Phase 2 质量层): - core/rag_pipeline.py: standardized RAG (recursive overlap chunking + rerank hook) reusing pgvector_search - core/llm_client.py: SDK-ified LLM + embeddings (aembed/embed) collapsing hand-rolled httpx calls; trust_env=False to bypass container proxy Migrations: - 018_normalize_marketapp_scene.sql: fixed capability allowlist (prevents capability wipe on restart) Alignment performed 2026-07-24 12:18:51. No deletions; secrets/venv/node_modules/ caches were not touched (tracked-source-only copy).
This commit is contained in:
1 parent
acde481bed
commit
e8a1791c9f
8 files changed
+5031
-4779
No files matched your search
+282
-283
@@ -1,283 +1,282 @@
|
||||
"""
|
||||
IM Bot 真实 LLM 调用。
|
||||
|
||||
原实现位于 channels.py(通用频道管理模块),该模块已移除。
|
||||
为保持 IM Bot(微信机器人等)正常生成回复,将 _call_real_llm 迁移至此独立模块。
|
||||
|
||||
行为(对齐 AI 聊天 — 2026-07-23 同步至最新):
|
||||
- Issue 7:图像/视频/语音意图优先走平台媒体模型(与 AI 聊天同一套 Auto 路由),
|
||||
不再对"制作图片"这类请求只回文本。
|
||||
- Issue 8:默认 Auto 模型;每轮先自动发现本账号已安装且平台已部署的技能,
|
||||
多个适用时由模型自行判断选 1 个最优,通过 function calling 执行。
|
||||
- 2026-07-15:修复 trust_env=False + proxy 处理,避免容器坏代理导致调用失败;
|
||||
媒体生成前先经 _optimize_media_prompt 精炼提示词,提升 AGNES 生成质量。
|
||||
- 2026-07-23:对齐 Web Chat 最新能力:
|
||||
* 模型 failover(Auto 模式自动切换备选模型)
|
||||
* 平台知识库注入(get_platform_knowledge,技能清单实时同步)
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import re as _re
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from typing import Optional
|
||||
from urllib.parse import urlparse as _urlparse
|
||||
|
||||
import httpx
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..capability_registry import resolve_auto_model
|
||||
from ..models import AIModel, ModelProvider, TokenUsageLog, gen_uuid
|
||||
from app.core.observability import get_trace_id
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# 香港出网代理(实测对 AGNES 延迟更低,2026-07-15 用户实测),与 chat.py 同值。
|
||||
_HK_PROXY = "http://qyyfw:XHR521yhf@103.242.14.208:11495"
|
||||
|
||||
|
||||
def _proxy_for_url(url: str) -> str | None:
|
||||
"""按目标域名选择是否走代理(与 chat.py 同逻辑)。
|
||||
|
||||
2026-07-15 调整:AGNES 改走香港代理(实测延迟更低),不再直连。
|
||||
国内域名(尊重 NO_PROXY)直连,更稳定。
|
||||
"""
|
||||
import os
|
||||
host = (_urlparse(url).hostname or "").lower()
|
||||
if "agnes-ai.com" in host or "agnes-ai.space" in host:
|
||||
return _HK_PROXY
|
||||
if "qihuyun.com" in host:
|
||||
return None
|
||||
_no_proxy = [h.strip().lower() for h in os.environ.get("NO_PROXY", "").split(",") if h.strip()]
|
||||
for _np in _no_proxy:
|
||||
if host == _np or host.endswith("." + _np) or _np in host:
|
||||
return None
|
||||
return os.environ.get("HTTPS_PROXY") or os.environ.get("HTTP_PROXY") or "http://172.18.0.1:7890"
|
||||
|
||||
|
||||
def _run_async(coro, timeout=None):
|
||||
"""在同步上下文中运行协程,兼容「无事件循环」与「已有事件循环」两种场景。
|
||||
|
||||
IM 个人微信在独立线程中调用(无事件循环)→ asyncio.run;
|
||||
IM 多平台 Webhook 可能在 FastAPI 协程内调用(已有事件循环)→ 丢到线程池执行。
|
||||
|
||||
timeout: 可选,秒数;超时抛出 asyncio.TimeoutError(由调用方捕获)。
|
||||
"""
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
loop = None
|
||||
|
||||
if timeout is not None:
|
||||
async def _timed():
|
||||
return await asyncio.wait_for(coro, timeout=timeout)
|
||||
target = _timed()
|
||||
else:
|
||||
target = coro
|
||||
|
||||
if loop is not None and loop.is_running():
|
||||
import concurrent.futures
|
||||
with concurrent.futures.ThreadPoolExecutor(1) as ex:
|
||||
return ex.submit(lambda: asyncio.run(target)).result(timeout=(timeout + 10) if timeout else None)
|
||||
return asyncio.run(target)
|
||||
|
||||
|
||||
def _log_media_usage(db: Session, user_id: str, ai_model, media_type: str, prompt: str):
|
||||
"""IM Bot 媒体生成记账:把图片/视频消耗写入 TokenUsageLog,供驾驶舱统计。
|
||||
|
||||
此前 IM Bot 路径完全不写 TokenUsageLog,导致微信生成的图片/视频在驾驶舱里"查无此项"。
|
||||
媒体 API 不返回 token 用量,这里用与 chat.py _generate_media_task 一致的估算。
|
||||
"""
|
||||
if not user_id or not ai_model:
|
||||
return
|
||||
try:
|
||||
base = 1200 if media_type == "video" else 400
|
||||
est = base + (len(prompt or "") // 4)
|
||||
db.add(TokenUsageLog(
|
||||
id=gen_uuid(),
|
||||
user_id=user_id,
|
||||
model_id=ai_model.id,
|
||||
model_name=ai_model.name,
|
||||
trace_id=get_trace_id(),
|
||||
prompt_tokens=est,
|
||||
completion_tokens=0,
|
||||
total_tokens=est,
|
||||
created_at=datetime.now(timezone(timedelta(hours=8))).replace(tzinfo=None),
|
||||
))
|
||||
db.commit()
|
||||
except Exception as e:
|
||||
try:
|
||||
db.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
logger.warning(f"[im_llm] 媒体 Token 记账失败(忽略): {e}")
|
||||
|
||||
|
||||
def _call_real_llm(db: Session, user=None, prompt: str = "", platform: str = "im", user_id: str = None) -> "str | dict":
|
||||
"""调用七虎AI 生成回复,供 IM Bot 复用。支持媒体意图路由、已安装技能自动发现、模型 failover。"""
|
||||
try:
|
||||
from app.api.chat import (
|
||||
_generate_media,
|
||||
detect_media_intent,
|
||||
_discover_installed_skills,
|
||||
_build_system_prompt,
|
||||
_execute_skill_tool,
|
||||
)
|
||||
|
||||
# ===== 1) 媒体意图路由(Issue 7)=====
|
||||
intent = detect_media_intent(prompt)
|
||||
if intent:
|
||||
ai_model = resolve_auto_model(intent, db)
|
||||
if ai_model:
|
||||
try:
|
||||
result = _run_async(_generate_media(db, ai_model, prompt, user_id), timeout=300)
|
||||
except Exception:
|
||||
# 任何未预期异常(如 AGNES 连接重置)统一兜底为友好提示,
|
||||
# 绝不直接把原始异常(如 "Server disconnected")甩给用户
|
||||
result = "⚠️ 媒体生成暂时失败(连接或服务波动),请稍后重试。"
|
||||
if result:
|
||||
# 媒体生成成功:返回结构化对象,交由 im_bot_user 在微信内联发送图片/视频
|
||||
s = result.strip()
|
||||
if s.startswith("<img") or s.startswith("<video"):
|
||||
m = _re.search(r'src="([^"]+)"', s)
|
||||
url = m.group(1) if m else ""
|
||||
media_type = "image" if s.startswith("<img") else "video"
|
||||
if not url:
|
||||
return "(生成完成,但未能提取媒体地址)"
|
||||
# IM Bot 路径此前完全不记账 → 补写媒体消耗,驾驶舱才看得到图片/视频数据
|
||||
_log_media_usage(db, user_id, ai_model, media_type, prompt)
|
||||
return {"__media__": True, "media_type": media_type, "url": url}
|
||||
# 媒体生成失败:_generate_media 已返回友好中文(如限频/队列繁忙),
|
||||
# 直接透传,绝不把原始提示词/标签/JSON 透传给用户
|
||||
if s.startswith("⚠️") or s.startswith("(媒体"):
|
||||
return s
|
||||
return "(媒体生成未能返回有效内容,请重试)"
|
||||
|
||||
# ===== 2) 文本回复:模型 failover + 技能自动发现 + 平台知识库注入 =====
|
||||
from ..platform_knowledge import get_platform_knowledge
|
||||
|
||||
# 构建候选模型列表(对齐 Web Chat failover)
|
||||
ai_model = resolve_auto_model("text", db)
|
||||
_try_models = [ai_model] if (ai_model and ai_model.is_enabled) else []
|
||||
# Auto 模式附加备选(IM Bot 始终 Auto)
|
||||
_fallbacks = db.query(AIModel).filter(
|
||||
AIModel.is_enabled == True,
|
||||
AIModel.id != (ai_model.id if ai_model else ""),
|
||||
).order_by(AIModel.is_default.desc()).limit(4).all()
|
||||
_try_models.extend([m for m in _fallbacks if m not in _try_models])
|
||||
|
||||
if not _try_models:
|
||||
return "(未配置可用文本模型,无法生成回复)"
|
||||
|
||||
# 技能自动发现(提前执行一次,不必每个候选模型重复做)
|
||||
skills = []
|
||||
if user_id:
|
||||
try:
|
||||
skills = _discover_installed_skills(db, user_id)[:15]
|
||||
except Exception:
|
||||
skills = []
|
||||
|
||||
# 平台知识库注入(对齐 Web Chat——微信用户提问"平台有哪些技能"也能得到完整清单)
|
||||
kb_context = ""
|
||||
try:
|
||||
pk = get_platform_knowledge(db=db)
|
||||
if pk:
|
||||
kb_context = f"【平台知识库——优先使用以下知识回答用户问题】\n{pk}"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 尝试每个候选模型
|
||||
_retry_log = []
|
||||
for _try_model in _try_models:
|
||||
ai_model = _try_model
|
||||
|
||||
provider = db.query(ModelProvider).filter(ModelProvider.id == ai_model.provider_id).first()
|
||||
api_key = ai_model.api_key or (provider.api_key if provider else None)
|
||||
api_url = ai_model.endpoint
|
||||
if not api_url and provider and provider.api_base_url:
|
||||
api_url = f"{provider.api_base_url.rstrip('/')}/v1/chat/completions"
|
||||
if not (api_key and api_url):
|
||||
_retry_log.append(f"{ai_model.name}: 缺少密钥或端点")
|
||||
continue
|
||||
|
||||
system_prompt, function_definitions = _build_system_prompt(
|
||||
skills=skills,
|
||||
agents=[],
|
||||
kb_context=kb_context,
|
||||
user_content=prompt,
|
||||
mcp=[],
|
||||
auto_discovered=bool(skills),
|
||||
)
|
||||
messages = [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": prompt},
|
||||
]
|
||||
|
||||
# 尝试调用
|
||||
try:
|
||||
max_rounds = 3
|
||||
ai_content = ""
|
||||
_http_ok = True
|
||||
for round_num in range(max_rounds):
|
||||
request_body = {"model": ai_model.name, "messages": messages, "max_tokens": 2048}
|
||||
if function_definitions and skills:
|
||||
request_body["tools"] = [
|
||||
{"type": "function", "function": fd} for fd in function_definitions
|
||||
]
|
||||
request_body["tool_choice"] = "auto"
|
||||
with httpx.Client(
|
||||
timeout=ai_model.timeout or 120,
|
||||
proxy=_proxy_for_url(api_url),
|
||||
trust_env=False,
|
||||
) as client:
|
||||
resp = client.post(
|
||||
api_url,
|
||||
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
|
||||
json=request_body,
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
_retry_log.append(f"{ai_model.name}: HTTP {resp.status_code}")
|
||||
_http_ok = False
|
||||
break
|
||||
data = resp.json()
|
||||
message = data.get("choices", [{}])[0].get("message", {})
|
||||
tool_calls = message.get("tool_calls", [])
|
||||
if tool_calls and skills:
|
||||
messages.append({
|
||||
"role": "assistant",
|
||||
"content": message.get("content") or "",
|
||||
"tool_calls": tool_calls,
|
||||
})
|
||||
for tc in tool_calls:
|
||||
func_name = tc.get("function", {}).get("name", "")
|
||||
try:
|
||||
func_args = json.loads(tc.get("function", {}).get("arguments", "{}"))
|
||||
except Exception:
|
||||
func_args = {"input": tc.get("function", {}).get("arguments", "")}
|
||||
tool_result = _execute_skill_tool(db, func_name, func_args, skills, prompt)
|
||||
messages.append({
|
||||
"role": "tool",
|
||||
"tool_call_id": tc.get("id", "call_1"),
|
||||
"content": tool_result,
|
||||
})
|
||||
continue
|
||||
ai_content = message.get("content", "")
|
||||
break
|
||||
|
||||
# 检查本轮调用是否成功
|
||||
if _http_ok and ai_content:
|
||||
return ai_content or "(AI 回复为空)"
|
||||
# 本轮失败:记录日志,切换备选
|
||||
logger.warning(f"[im_llm] 模型 {ai_model.name} 失败,切换备选...")
|
||||
continue
|
||||
except Exception as exc:
|
||||
_retry_log.append(f"{ai_model.name}: {str(exc)[:60]}")
|
||||
logger.warning(f"[im_llm] 模型 {ai_model.name} 异常: {exc}")
|
||||
continue
|
||||
|
||||
# 全部失败
|
||||
_summary = "; ".join(_retry_log[-3:])
|
||||
return f"(已尝试 {len(_try_models)} 个模型均调用失败。最后错误: {_summary}。请稍后重试。)"
|
||||
except Exception as e:
|
||||
return f"(AI 回复生成失败:{e})"
|
||||
"""
|
||||
IM Bot 真实 LLM 调用。
|
||||
|
||||
原实现位于 channels.py(通用频道管理模块),该模块已移除。
|
||||
为保持 IM Bot(微信机器人等)正常生成回复,将 _call_real_llm 迁移至此独立模块。
|
||||
|
||||
行为(对齐 AI 聊天 — 2026-07-23 同步至最新):
|
||||
- Issue 7:图像/视频/语音意图优先走平台媒体模型(与 AI 聊天同一套 Auto 路由),
|
||||
不再对"制作图片"这类请求只回文本。
|
||||
- Issue 8:默认 Auto 模型;每轮先自动发现本账号已安装且平台已部署的技能,
|
||||
多个适用时由模型自行判断选 1 个最优,通过 function calling 执行。
|
||||
- 2026-07-15:修复 trust_env=False + proxy 处理,避免容器坏代理导致调用失败;
|
||||
媒体生成前先经 _optimize_media_prompt 精炼提示词,提升 AGNES 生成质量。
|
||||
- 2026-07-23:对齐 Web Chat 最新能力:
|
||||
* 模型 failover(Auto 模式自动切换备选模型)
|
||||
* 平台知识库注入(get_platform_knowledge,技能清单实时同步)
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import re as _re
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from typing import Optional
|
||||
from urllib.parse import urlparse as _urlparse
|
||||
|
||||
import httpx
|
||||
from app.core.llm_client import chat_complete # 统一模型层(07-24): web/IM 共用标准 OpenAI SDK 客户端
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..capability_registry import resolve_auto_model
|
||||
from ..models import AIModel, ModelProvider, TokenUsageLog, gen_uuid
|
||||
from app.core.observability import get_trace_id
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# 香港出网代理(实测对 AGNES 延迟更低,2026-07-15 用户实测),与 chat.py 同值。
|
||||
_HK_PROXY = "http://qyyfw:XHR521yhf@103.242.14.208:11495"
|
||||
|
||||
|
||||
def _proxy_for_url(url: str) -> str | None:
|
||||
"""按目标域名选择是否走代理(与 chat.py 同逻辑)。
|
||||
|
||||
2026-07-15 调整:AGNES 改走香港代理(实测延迟更低),不再直连。
|
||||
国内域名(尊重 NO_PROXY)直连,更稳定。
|
||||
"""
|
||||
import os
|
||||
host = (_urlparse(url).hostname or "").lower()
|
||||
if "agnes-ai.com" in host or "agnes-ai.space" in host:
|
||||
return _HK_PROXY
|
||||
if "qihuyun.com" in host:
|
||||
return None
|
||||
_no_proxy = [h.strip().lower() for h in os.environ.get("NO_PROXY", "").split(",") if h.strip()]
|
||||
for _np in _no_proxy:
|
||||
if host == _np or host.endswith("." + _np) or _np in host:
|
||||
return None
|
||||
return os.environ.get("HTTPS_PROXY") or os.environ.get("HTTP_PROXY") or "http://172.18.0.1:7890"
|
||||
|
||||
|
||||
def _run_async(coro, timeout=None):
|
||||
"""在同步上下文中运行协程,兼容「无事件循环」与「已有事件循环」两种场景。
|
||||
|
||||
IM 个人微信在独立线程中调用(无事件循环)→ asyncio.run;
|
||||
IM 多平台 Webhook 可能在 FastAPI 协程内调用(已有事件循环)→ 丢到线程池执行。
|
||||
|
||||
timeout: 可选,秒数;超时抛出 asyncio.TimeoutError(由调用方捕获)。
|
||||
"""
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
loop = None
|
||||
|
||||
if timeout is not None:
|
||||
async def _timed():
|
||||
return await asyncio.wait_for(coro, timeout=timeout)
|
||||
target = _timed()
|
||||
else:
|
||||
target = coro
|
||||
|
||||
if loop is not None and loop.is_running():
|
||||
import concurrent.futures
|
||||
with concurrent.futures.ThreadPoolExecutor(1) as ex:
|
||||
return ex.submit(lambda: asyncio.run(target)).result(timeout=(timeout + 10) if timeout else None)
|
||||
return asyncio.run(target)
|
||||
|
||||
|
||||
def _log_media_usage(db: Session, user_id: str, ai_model, media_type: str, prompt: str):
|
||||
"""IM Bot 媒体生成记账:把图片/视频消耗写入 TokenUsageLog,供驾驶舱统计。
|
||||
|
||||
此前 IM Bot 路径完全不写 TokenUsageLog,导致微信生成的图片/视频在驾驶舱里"查无此项"。
|
||||
媒体 API 不返回 token 用量,这里用与 chat.py _generate_media_task 一致的估算。
|
||||
"""
|
||||
if not user_id or not ai_model:
|
||||
return
|
||||
try:
|
||||
base = 1200 if media_type == "video" else 400
|
||||
est = base + (len(prompt or "") // 4)
|
||||
db.add(TokenUsageLog(
|
||||
id=gen_uuid(),
|
||||
user_id=user_id,
|
||||
model_id=ai_model.id,
|
||||
model_name=ai_model.name,
|
||||
trace_id=get_trace_id(),
|
||||
prompt_tokens=est,
|
||||
completion_tokens=0,
|
||||
total_tokens=est,
|
||||
created_at=datetime.now(timezone(timedelta(hours=8))).replace(tzinfo=None),
|
||||
))
|
||||
db.commit()
|
||||
except Exception as e:
|
||||
try:
|
||||
db.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
logger.warning(f"[im_llm] 媒体 Token 记账失败(忽略): {e}")
|
||||
|
||||
|
||||
def _call_real_llm(db: Session, user=None, prompt: str = "", platform: str = "im", user_id: str = None) -> "str | dict":
|
||||
"""调用七虎AI 生成回复,供 IM Bot 复用。支持媒体意图路由、已安装技能自动发现、模型 failover。"""
|
||||
try:
|
||||
from app.api.chat import (
|
||||
_generate_media,
|
||||
detect_media_intent,
|
||||
_discover_installed_skills,
|
||||
_build_system_prompt,
|
||||
_execute_skill_tool,
|
||||
)
|
||||
|
||||
# ===== 1) 媒体意图路由(Issue 7)=====
|
||||
intent = detect_media_intent(prompt)
|
||||
if intent:
|
||||
ai_model = resolve_auto_model(intent, db)
|
||||
if ai_model:
|
||||
try:
|
||||
result = _run_async(_generate_media(db, ai_model, prompt, user_id), timeout=300)
|
||||
except Exception:
|
||||
# 任何未预期异常(如 AGNES 连接重置)统一兜底为友好提示,
|
||||
# 绝不直接把原始异常(如 "Server disconnected")甩给用户
|
||||
result = "⚠️ 媒体生成暂时失败(连接或服务波动),请稍后重试。"
|
||||
if result:
|
||||
# 媒体生成成功:返回结构化对象,交由 im_bot_user 在微信内联发送图片/视频
|
||||
s = result.strip()
|
||||
if s.startswith("<img") or s.startswith("<video"):
|
||||
m = _re.search(r'src="([^"]+)"', s)
|
||||
url = m.group(1) if m else ""
|
||||
media_type = "image" if s.startswith("<img") else "video"
|
||||
if not url:
|
||||
return "(生成完成,但未能提取媒体地址)"
|
||||
# IM Bot 路径此前完全不记账 → 补写媒体消耗,驾驶舱才看得到图片/视频数据
|
||||
_log_media_usage(db, user_id, ai_model, media_type, prompt)
|
||||
return {"__media__": True, "media_type": media_type, "url": url}
|
||||
# 媒体生成失败:_generate_media 已返回友好中文(如限频/队列繁忙),
|
||||
# 直接透传,绝不把原始提示词/标签/JSON 透传给用户
|
||||
if s.startswith("⚠️") or s.startswith("(媒体"):
|
||||
return s
|
||||
return "(媒体生成未能返回有效内容,请重试)"
|
||||
|
||||
# ===== 2) 文本回复:模型 failover + 技能自动发现 + 平台知识库注入 =====
|
||||
from ..platform_knowledge import get_platform_knowledge
|
||||
|
||||
# 构建候选模型列表(对齐 Web Chat failover)
|
||||
ai_model = resolve_auto_model("text", db)
|
||||
_try_models = [ai_model] if (ai_model and ai_model.is_enabled) else []
|
||||
# Auto 模式附加备选(IM Bot 始终 Auto)
|
||||
_fallbacks = db.query(AIModel).filter(
|
||||
AIModel.is_enabled == True,
|
||||
AIModel.id != (ai_model.id if ai_model else ""),
|
||||
).order_by(AIModel.is_default.desc()).limit(4).all()
|
||||
_try_models.extend([m for m in _fallbacks if m not in _try_models])
|
||||
|
||||
if not _try_models:
|
||||
return "(未配置可用文本模型,无法生成回复)"
|
||||
|
||||
# 技能自动发现(提前执行一次,不必每个候选模型重复做)
|
||||
skills = []
|
||||
if user_id:
|
||||
try:
|
||||
skills = _discover_installed_skills(db, user_id)[:15]
|
||||
except Exception:
|
||||
skills = []
|
||||
|
||||
# 平台知识库注入(对齐 Web Chat——微信用户提问"平台有哪些技能"也能得到完整清单)
|
||||
kb_context = ""
|
||||
try:
|
||||
pk = get_platform_knowledge(db=db)
|
||||
if pk:
|
||||
kb_context = f"【平台知识库——优先使用以下知识回答用户问题】\n{pk}"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 尝试每个候选模型
|
||||
_retry_log = []
|
||||
for _try_model in _try_models:
|
||||
ai_model = _try_model
|
||||
|
||||
provider = db.query(ModelProvider).filter(ModelProvider.id == ai_model.provider_id).first()
|
||||
api_key = ai_model.api_key or (provider.api_key if provider else None)
|
||||
api_url = ai_model.endpoint
|
||||
if not api_url and provider and provider.api_base_url:
|
||||
api_url = f"{provider.api_base_url.rstrip('/')}/v1/chat/completions"
|
||||
if not (api_key and api_url):
|
||||
_retry_log.append(f"{ai_model.name}: 缺少密钥或端点")
|
||||
continue
|
||||
|
||||
system_prompt, function_definitions = _build_system_prompt(
|
||||
skills=skills,
|
||||
agents=[],
|
||||
kb_context=kb_context,
|
||||
user_content=prompt,
|
||||
mcp=[],
|
||||
auto_discovered=bool(skills),
|
||||
)
|
||||
messages = [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": prompt},
|
||||
]
|
||||
|
||||
# 尝试调用
|
||||
try:
|
||||
max_rounds = 3
|
||||
ai_content = ""
|
||||
_http_ok = True
|
||||
for round_num in range(max_rounds):
|
||||
request_body = {"model": ai_model.name, "messages": messages, "max_tokens": 2048}
|
||||
if function_definitions and skills:
|
||||
request_body["tools"] = [
|
||||
{"type": "function", "function": fd} for fd in function_definitions
|
||||
]
|
||||
request_body["tool_choice"] = "auto"
|
||||
try:
|
||||
data = chat_complete(
|
||||
api_url,
|
||||
api_key,
|
||||
request_body,
|
||||
proxy=_proxy_for_url(api_url),
|
||||
timeout=ai_model.timeout or 120,
|
||||
)
|
||||
except Exception as _exc:
|
||||
_retry_log.append(f"{ai_model.name}: {str(_exc)[:60]}")
|
||||
logger.warning(f"[im_llm] 模型 {ai_model.name} 异常: {_exc}")
|
||||
_http_ok = False
|
||||
break
|
||||
message = data.get("choices", [{}])[0].get("message", {})
|
||||
tool_calls = message.get("tool_calls", [])
|
||||
if tool_calls and skills:
|
||||
messages.append({
|
||||
"role": "assistant",
|
||||
"content": message.get("content") or "",
|
||||
"tool_calls": tool_calls,
|
||||
})
|
||||
for tc in tool_calls:
|
||||
func_name = tc.get("function", {}).get("name", "")
|
||||
try:
|
||||
func_args = json.loads(tc.get("function", {}).get("arguments", "{}"))
|
||||
except Exception:
|
||||
func_args = {"input": tc.get("function", {}).get("arguments", "")}
|
||||
tool_result = _execute_skill_tool(db, func_name, func_args, skills, prompt)
|
||||
messages.append({
|
||||
"role": "tool",
|
||||
"tool_call_id": tc.get("id", "call_1"),
|
||||
"content": tool_result,
|
||||
})
|
||||
continue
|
||||
ai_content = message.get("content", "")
|
||||
break
|
||||
|
||||
# 检查本轮调用是否成功
|
||||
if _http_ok and ai_content:
|
||||
return ai_content or "(AI 回复为空)"
|
||||
# 本轮失败:记录日志,切换备选
|
||||
logger.warning(f"[im_llm] 模型 {ai_model.name} 失败,切换备选...")
|
||||
continue
|
||||
except Exception as exc:
|
||||
_retry_log.append(f"{ai_model.name}: {str(exc)[:60]}")
|
||||
logger.warning(f"[im_llm] 模型 {ai_model.name} 异常: {exc}")
|
||||
continue
|
||||
|
||||
# 全部失败
|
||||
_summary = "; ".join(_retry_log[-3:])
|
||||
return f"(已尝试 {len(_try_models)} 个模型均调用失败。最后错误: {_summary}。请稍后重试。)"
|
||||
except Exception as e:
|
||||
return f"(AI 回复生成失败:{e})"
|
||||
Reference in new issue
Block a user