Files
qihuaiflow/backend/python/app/api/chat.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

2829 lines
18 KiB
Python

from fastapi import APIRouter, Depends, HTTPException, Query, Response
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
from sqlalchemy import and_, desc, asc, func
from pydantic import BaseModel, Field
from typing import Optional
import uuid
from datetime import datetime, timezone, timedelta
import re
import json
import asyncio
import math
import httpx
import os
import logging
from ..database import get_db, SessionLocal
from ..models import (
User, Conversation, Message, AIModel, ModelProvider, TokenUsageLog,
KnowledgeBase, KnowledgeDocument, KnowledgeDocumentChunk, EvolutionSkill,
EvolutionTrajectory, MarketApp, AppPurchase, AppInstallation,
MCPServer,
gen_uuid
)
from ..auth import get_current_user
from ..platform_knowledge import get_platform_knowledge
from ..mcp_client import list_mcp_tools, call_mcp_tool
from ..core.claw_adapters import SseAdapter
from ..middleware.log_service import LogService
from ..capability_registry import resolve_auto_model
from app.api.chat_agents import (_call_coze_agent, _call_dify_agent, _call_external_agent, _call_n8n_agent, _normalize_dify_chat_url)
from app.api.chat_context import (_build_system_prompt, _get_conversation_context, _ingest_answer_to_dify, _retrieve_kb_context, _build_chat_history, _maybe_compress_history)
from app.api.chat_knowledge import (_build_knowledge_graph)
from app.api.chat_mcp import (_list_enabled_mcp_servers, _resolve_active_mcp_servers, _resolve_market_mcp)
from app.api.chat_media import (_agnes_post_json, _friendly_media_err, _generate_media, _generate_media_task, _optimize_media_prompt, _write_media_token_log, detect_ambiguous_creative_intent, detect_media_intent)
from app.api.chat_skill import (_bm25_search, _discover_installed_skills, _execute_skill_tool, _parse_skill_content, _resolve_market_agents, _resolve_market_skills)
from app.api.chat_utils import (CST_TZ, CreateConversationRequest, SendMessageRequest, UpdateConversationRequest, _HK_PROXY, _extract_text_from_json, _html_escape, _outbound_proxy, _proxy_for_url, cst_now, logger)
"""
增强版 Chat API — Phase 2.5
包含: Skill ID统一(MarketApp映射), 真实Skill执行, Agent工作流注入,
多轮上下文保持, BM25 KB检索, Function Calling循环增强
"""
from app.api.chat_service import _send_message_core
from app.core.llm_router import set_route_ctx
router = APIRouter()
_MEDIA_RELAY_ALLOWED = ("agnes-ai.com", "agnes-ai.space", "qihuyun.com")
@router.get("/media/relay")
async def media_relay(url: str = Query(...)):
"""中转 AGNES 媒体(图片/视频)到平台域名,供微信/iLink 内联渲染。
仅允许白名单域名;否则 400。返回的媒体流保持原 content-type。
"""
import urllib.parse as _up
host = (_up.urlparse(url).hostname or "").lower()
if not any(allowed in host for allowed in _MEDIA_RELAY_ALLOWED):
raise HTTPException(status_code=400, detail="unsupported media host")
try:
async with httpx.AsyncClient(timeout=180, proxy=_proxy_for_url(url), trust_env=False) as client:
r = await client.get(url)
if r.status_code != 200:
raise HTTPException(status_code=r.status_code, detail="upstream error")
content_type = r.headers.get("content-type", "application/octet-stream")
data = await r.aread()
return Response(content=data, media_type=content_type)
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=502, detail=f"relay failed: {e}")
@router.get("/conversations")
def list_conversations(
status: Optional[str] = Query(None),
search: Optional[str] = Query(None),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
query = db.query(Conversation).filter(Conversation.user_id == current_user.id)
if status:
query = query.filter(Conversation.status == status)
else:
query = query.filter(Conversation.status != "archived")
if search:
query = query.filter(Conversation.title.ilike(f"%{search}%"))
query = query.order_by(desc(Conversation.pinned), desc(Conversation.updated_at))
conversations = query.all()
result = []
for conv in conversations:
result.append({
"id": conv.id,
"title": conv.title,
"model_id": conv.model_id,
"status": conv.status,
"pinned": conv.pinned,
"knowledge_graph_count": conv.knowledge_graph_count or 0,
"source": getattr(conv, "source", None),
"created_at": str(conv.created_at) if conv.created_at else None,
"updated_at": str(conv.updated_at) if conv.updated_at else None,
})
return {"code": 0, "data": result, "message": "ok"}
@router.post("/conversations")
def create_conversation(
req: CreateConversationRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
conv = Conversation(
id=gen_uuid(),
user_id=current_user.id,
title=req.title,
model_id=req.model_id,
status="active",
pinned=False,
knowledge_graph_count=0,
created_at=cst_now(),
updated_at=cst_now(),
)
db.add(conv)
db.commit()
db.refresh(conv)
return {
"code": 0,
"data": {
"id": conv.id,
"title": conv.title,
"model_id": conv.model_id,
"status": conv.status,
"pinned": conv.pinned,
"knowledge_graph_count": conv.knowledge_graph_count,
"created_at": str(conv.created_at) if conv.created_at else None,
"updated_at": str(conv.updated_at) if conv.updated_at else None,
},
"message": "创建成功",
}
@router.put("/conversations/{conv_id}")
def update_conversation(
conv_id: str,
req: UpdateConversationRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
conv = db.query(Conversation).filter(
and_(Conversation.id == conv_id, Conversation.user_id == current_user.id)
).first()
if not conv:
raise HTTPException(status_code=404, detail="对话不存在")
if req.title is not None:
conv.title = req.title
if req.status is not None:
conv.status = req.status
if req.pinned is not None:
conv.pinned = req.pinned
# Stage B.2: 保存agent/skill/kb选择到对话上下文
# (存储在第一条system消息的knowledge_ids/agent_ids/skill_ids字段中)
conv.updated_at = cst_now()
db.commit()
db.refresh(conv)
return {"code": 0, "data": {
"id": conv.id, "title": conv.title, "status": conv.status, "pinned": conv.pinned,
"updated_at": str(conv.updated_at),
}, "message": "更新成功"}
@router.delete("/conversations/{conv_id}")
def delete_conversation(
conv_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
conv = db.query(Conversation).filter(
and_(Conversation.id == conv_id, Conversation.user_id == current_user.id)
).first()
if not conv:
raise HTTPException(status_code=404, detail="对话不存在")
conv.status = "archived"
conv.updated_at = cst_now()
db.commit()
return {"code": 0, "message": "删除成功"}
@router.get("/conversations/{conv_id}/messages")
def get_messages(
conv_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
conv = db.query(Conversation).filter(
and_(Conversation.id == conv_id, Conversation.user_id == current_user.id)
).first()
if not conv:
raise HTTPException(status_code=404, detail="对话不存在")
messages = (
db.query(Message)
.filter(Message.conversation_id == conv_id)
.order_by(asc(Message.created_at), asc(Message.id))
.all()
)
result = []
for msg in messages:
result.append({
"id": msg.id,
"conversation_id": msg.conversation_id,
"role": msg.role,
"content": msg.content,
"token_used": msg.token_used or 0,
"model_id": msg.model_id,
"used_skills": msg.used_skills or [],
"used_mcp": msg.used_mcp or [],
"created_at": str(msg.created_at) if msg.created_at else None,
})
return {"code": 0, "data": result, "message": "ok"}
@router.post("/conversations/{conv_id}/messages")
async def send_message(
conv_id: str,
req: SendMessageRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
from app.core.observability import span
# #876 双 Claw 维度:入口最早注入,确保外层 span 即带 tenant_id + claw_type 标签
set_route_ctx(
tenant_id=str(getattr(current_user, "tenant_id", "") or ""),
claw_type="web",
)
async with span("chat.send_message", conv_id=conv_id, user_id=getattr(current_user, "id", None)):
return await _send_message_core(conv_id, req, db, current_user)
@router.post("/conversations/{conv_id}/messages/stream")
async def send_message_stream(
conv_id: str,
req: SendMessageRequest,
current_user: User = Depends(get_current_user),
):
"""SSE 流式聊天端点(#873)。
与 /messages 共用 EngineCore(_send_message_core),仅通过 stream=True +
on_event=SseAdapter 实时吐出增量(delta/reasoning/step),末尾推 done 终帧
(含 message_id 等)。SseAdapter 保证线格式与改造前逐字节一致。
"""
db = SessionLocal()
try:
# #876 双 Claw 维度:入口最早注入。runner() 经 asyncio.create_task 派生子协程,
# contextvars 自动随上下文传播,使 _send_message_core 内所有 span 均带 tenant_id + claw_type。
set_route_ctx(
tenant_id=str(getattr(current_user, "tenant_id", "") or ""),
claw_type="web",
)
adapter = SseAdapter(
trace_id=f"sse-{conv_id}",
tenant_id=str(getattr(current_user, "tenant_id", "") or ""),
)
async def runner():
try:
await _send_message_core(
conv_id, req, db, current_user,
stream=True, on_event=adapter,
)
except Exception as exc:
logger.warning(f"[sse] chat stream error: {exc}", exc_info=True)
await adapter.emit_error(str(exc))
finally:
await adapter.close()
task = asyncio.create_task(runner())
async def event_gen():
try:
async for frame in adapter.frames():
yield frame
finally:
await task
db.close()
return StreamingResponse(event_gen(), media_type="text/event-stream")
except Exception:
db.close()
raise
@router.post("/conversations/{conv_id}/clear")
def clear_conversation(
conv_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
conv = db.query(Conversation).filter(
and_(Conversation.id == conv_id, Conversation.user_id == current_user.id)
).first()
if not conv:
raise HTTPException(status_code=404, detail="对话不存在")
db.query(Message).filter(
Message.conversation_id == conv_id
).delete()
conv.knowledge_graph_count = 0
conv.updated_at = cst_now()
db.commit()
return {"code": 0, "message": "对话上下文已清空"}
@router.post("/conversations/{conv_id}/to-knowledge-graph")
def convert_to_knowledge_graph(
conv_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""将对话内容转换为知识图谱(幂等:同一对话仅生成一次知识库)"""
conv = db.query(Conversation).filter(
and_(Conversation.id == conv_id, Conversation.user_id == current_user.id)
).first()
if not conv:
raise HTTPException(status_code=404, detail="对话不存在")
messages = (
db.query(Message)
.filter(Message.conversation_id == conv_id)
.order_by(asc(Message.created_at), asc(Message.id))
.all()
)
if not messages:
raise HTTPException(status_code=400, detail="对话中没有消息, 无法生成知识图谱")
# 幂等:若该对话已生成过知识图谱且知识库仍存在,则直接刷新返回
if conv.knowledge_graph_base_id:
existing_kb = db.query(KnowledgeBase).filter(
KnowledgeBase.id == conv.knowledge_graph_base_id
).first()
if existing_kb:
graph_data = _build_knowledge_graph(db, existing_kb)
conv.knowledge_graph_count = 1
conv.updated_at = cst_now()
db.commit()
return {
"code": 0,
"data": {
"knowledge_base_id": existing_kb.id,
"knowledge_base_name": existing_kb.name,
"graph": graph_data,
"knowledge_graph_count": 1,
"already_exists": True,
},
"message": "知识图谱已存在, 已刷新展示",
}
kb_name = f"对话图谱-{conv.title}"
kb = KnowledgeBase(
id=gen_uuid(),
user_id=current_user.id,
name=kb_name,
description=f"从对话「{conv.title}」自动生成的知识图谱",
directory_path="/",
document_count=0,
created_at=cst_now(),
updated_at=cst_now(),
)
db.add(kb)
db.flush()
doc_count = 0
for idx, msg in enumerate(messages):
role_label = "用户" if msg.role == "user" else "AI"
preview = re.sub(r'[\\n\\r\\t]+', ' ', msg.content[:40]).strip()
filename = f"msg_{idx+1:03d}_{role_label}_{preview}.txt"
doc = KnowledgeDocument(
id=gen_uuid(),
knowledge_base_id=kb.id,
filename=filename,
file_type="txt",
file_size=len(msg.content.encode('utf-8')),
content=msg.content,
status="completed",
chunk_count=max(1, len(msg.content) // 500 + 1),
created_at=cst_now(),
updated_at=cst_now(),
)
db.add(doc)
doc_count += 1
kb.document_count = doc_count
db.flush()
# 构建知识图谱节点和边
graph_data = _build_knowledge_graph(db, kb)
conv.knowledge_graph_base_id = kb.id
conv.knowledge_graph_count = 1
conv.updated_at = cst_now()
db.commit()
LogService.log_operation(
user_id=current_user.id, username=current_user.username, action="convert_to_graph",
target="conversation", target_id=conv_id,
detail=f"将对话 {conv.title} 转换为知识图谱 {kb.name}",
)
return {
"code": 0,
"data": {
"knowledge_base_id": kb.id,
"knowledge_base_name": kb.name,
"graph": graph_data,
"knowledge_graph_count": 1,
"already_exists": False,
},
"message": f"知识图谱构建完成",
}
@router.get("/models")
def get_models(db: Session = Depends(get_db)):
models = db.query(AIModel).filter(AIModel.is_enabled == True).order_by(AIModel.is_default.desc(), AIModel.name.asc()).all()
result = []
for m in models:
provider = db.query(ModelProvider).filter(ModelProvider.id == m.provider_id).first()
result.append({
"id": m.id,
"name": m.name,
"display_name": getattr(m, "display_name", None) or m.name,
"provider_id": m.provider_id,
"provider_name": provider.name if provider else "",
"capabilities": m.capabilities or [],
"status": m.status,
"price_per_million_tokens": float(m.price_per_million_tokens or 0),
"is_enabled": m.is_enabled,
"is_default": bool(m.is_default) if hasattr(m, 'is_default') else False,
"total_calls": m.total_calls or 0,
"total_tokens": m.total_tokens or 0,
"created_at": str(m.created_at) if m.created_at else None,
})
result.sort(key=lambda x: (0 if x["is_default"] else 1, x["name"]))
return {"code": 0, "data": result, "message": "ok"}