Files
Neo-ZQYY/apps/backend/app/routers/xcx_chat.py
Neo 2dfc926f96 feat(ai): W1-AI-CLOSURE 超级 Sprint — 9 APP 全链路收口 + chat 上下文真激活
Phase 2.3 chat 上下文捕获链路从未真正激活到完整工作:
- 14 处 ai-float-button 补 sourcePage,chat.ts 三分支同步设 pageFilters.contextId
- 后端 page_context 4 层 BUG 修(列名错位 + RLS site_id 未重设)
- xcx_chat filters.pop 破坏 body.page_context 引用 — dict() 浅拷贝隔离
- chat 流式 markdown 实时解析(表格/标题/列表/加粗 + KPI 富卡)
- reference_card KPI 富卡接入 SSE 路径,db 真写入
- 维客线索 source 显示规则:AI 来源用机器人 icon 替代长文字

数据库:
- public.member_retention_clue 加 emoji + runtime_mode + sandbox_instance_id
- biz.ai_run_logs 加 assistant_id + 复合索引
- chk_ai_cache_type CHECK 约束 8 类应用名
- cache_type / app_type 命名统一(app6_note / app7_customer / app8_consolidation)
- 历史 emoji 抽取脚本 44/44 成功

后端 silent failure 修:
- cleanup_service WHERE app_type → cache_type(90 天清理 + 20K 上限重新生效)
- _build_ai_insight 字段错位修复(app4 → app7 + 字段对齐 prompt schema)
- task_manager talkingPoints 改 app5_tactics + tactics 字段
- task_manager aiSuggestion 改取 one_line_summary
- cache_service.CACHE_EXPIRY_DAYS 加 app2a_finance_area
- WS /ws/ai-cache 加 token + JWT + site_id 校验(P0 信息泄露漏洞)
- internal_ai token 改 hmac.compare_digest

工具/文档:
- main.py 加 RotatingFileHandler logs/backend.log + uvicorn /health 过滤
- 新建 utils/clue_category.py(VI 6 类配色 + emoji fallback + source 显示规则)
- 新建 utils/markdown.ts(轻量 md 转 rich-text 解析 + streaming 容错)
- audit + 数据库变更说明 + backlog §七 #14 收口 + #15-#38 残余子任务
- backlog 追加 §十一 App1 参数/MCP/沙箱审计 + §十二 百炼/SQL MCP 主任务线

实地 MCP 走查:14 入口数据层 + 5 代表入口 sourcePage 注入 + customer-detail 全模块 + chat md 渲染 + reference_card 富卡 都已验证。9 项预先 BUG/UX 登记 §七 #29-#38 后续修复。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 16:39:07 +08:00

472 lines
18 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.
# -*- coding: utf-8 -*-
"""
小程序 CHAT 路由 —— CHAT-1/2/3/4 端点。
替代原 xcx_ai_chat.py/api/ai/*),统一迁移到 /api/xcx/chat/* 路径。
端点清单:
- GET /api/xcx/chat/history — CHAT-1 对话历史列表
- GET /api/xcx/chat/{chat_id}/messages — CHAT-2a 通过 chatId 查询消息
- GET /api/xcx/chat/messages?contextType=&contextId= — CHAT-2b 通过上下文查询消息
- POST /api/xcx/chat/{chat_id}/messages — CHAT-3 发送消息(同步回复)
- POST /api/xcx/chat/stream — CHAT-4 SSE 流式端点
所有端点使用 require_approved() 权限检查。
"""
from __future__ import annotations
import json
import logging
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi.responses import StreamingResponse
from app.ai.config import AIConfig
from app.ai.dashscope_client import DashScopeClient
from app.auth.dependencies import CurrentUser
from app.database import get_connection
from app.middleware.permission import require_approved
from app.schemas.xcx_chat import (
ChatHistoryItem,
ChatHistoryResponse,
ChatMessageItem,
ChatMessagesResponse,
ChatStreamRequest,
MessageBrief,
ReferenceCard,
SendMessageRequest,
SendMessageResponse,
)
from app.services.chat_service import ChatService
from app.trace.decorators import trace_service
from app.trace.sse_wrapper import (
record_ai_call,
record_ai_error,
record_sse_end,
record_sse_start,
record_sse_token,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/xcx/chat", tags=["小程序 CHAT"])
# ── CHAT-1: 对话历史列表 ─────────────────────────────────────
@router.get("/history", response_model=ChatHistoryResponse)
@trace_service("查询对话历史", "List chat history")
async def list_chat_history(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
user: CurrentUser = Depends(require_approved()),
) -> ChatHistoryResponse:
"""CHAT-1: 查询当前用户的对话历史列表,按最后消息时间倒序。"""
svc = ChatService()
items, total = svc.get_chat_history(
user_id=user.user_id,
site_id=user.site_id,
page=page,
page_size=page_size,
)
return ChatHistoryResponse(
items=[ChatHistoryItem(**item) for item in items],
total=total,
page=page,
page_size=page_size,
)
# ── CHAT-2b: 通过上下文查询消息 ─────────────────────────────
# ⚠️ 必须在 /{chat_id}/messages 之前注册,否则 "messages" 会被当作 chat_id 路径参数
@router.get("/messages", response_model=ChatMessagesResponse)
@trace_service("通过上下文查询消息", "Get messages by context")
async def get_chat_messages_by_context(
context_type: str = Query(..., alias="contextType"),
context_id: str = Query(..., alias="contextId"),
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=100),
user: CurrentUser = Depends(require_approved()),
) -> ChatMessagesResponse:
"""CHAT-2b: 通过上下文类型和 ID 查询消息(自动查找/创建对话)。"""
svc = ChatService()
# 按复用规则查找或创建对话
chat_id = svc.get_or_create_session(
user_id=user.user_id,
site_id=user.site_id,
context_type=context_type,
context_id=context_id if context_id else None,
)
messages, total, resolved_chat_id = svc.get_messages(
chat_id=chat_id,
user_id=user.user_id,
site_id=user.site_id,
page=page,
page_size=page_size,
)
return ChatMessagesResponse(
chat_id=resolved_chat_id,
items=[_to_message_item(m) for m in messages],
total=total,
page=page,
page_size=page_size,
)
# ── CHAT-2a: 通过 chatId 查询消息 ───────────────────────────
@router.get("/{chat_id}/messages", response_model=ChatMessagesResponse)
@trace_service("查询对话消息", "Get chat messages")
async def get_chat_messages(
chat_id: int,
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=100),
user: CurrentUser = Depends(require_approved()),
) -> ChatMessagesResponse:
"""CHAT-2a: 通过 chatId 查询对话消息列表,按 createdAt 正序。"""
svc = ChatService()
messages, total, resolved_chat_id = svc.get_messages(
chat_id=chat_id,
user_id=user.user_id,
site_id=user.site_id,
page=page,
page_size=page_size,
)
return ChatMessagesResponse(
chat_id=resolved_chat_id,
items=[_to_message_item(m) for m in messages],
total=total,
page=page,
page_size=page_size,
)
# ── CHAT-4: SSE 流式端点 ────────────────────────────────────
# ⚠️ 必须在 /{chat_id}/messages 之前注册,否则 "stream" 会被当作 chat_id 路径参数
@router.post("/stream")
@trace_service("SSE 流式对话", "Chat stream SSE")
async def chat_stream(
body: ChatStreamRequest,
user: CurrentUser = Depends(require_approved()),
) -> StreamingResponse:
"""CHAT-4: SSE 流式对话端点。
接收用户消息,通过百炼 API 流式返回 AI 回复。
SSE 事件类型message逐 token/ done完成/ error错误
chatId 归属验证:不属于当前用户返回 HTTP 403普通 JSON 错误,非 SSE
"""
if not body.content or not body.content.strip():
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="消息内容不能为空",
)
svc = ChatService()
content = body.content.strip()
# 归属验证(在 SSE 流开始前完成,失败时返回普通 HTTP 错误)
svc._verify_ownership(body.chat_id, user.user_id, user.site_id)
# 存入用户消息P5 PRD 合规:发送时即写入)
user_msg_id, user_created_at = svc._save_message(body.chat_id, "user", content)
async def event_generator():
"""SSE 事件生成器。
事件格式:
- event: message\\ndata: {"token": "..."}\\n\\n
- event: done\\ndata: {"messageId": ..., "createdAt": "..."}\\n\\n
- event: error\\ndata: {"message": "..."}\\n\\n
"""
import time as _time
full_reply_parts: list[str] = []
tokens_total = 0
_sse_start_ts = _time.perf_counter()
# SSE trace: 流开始
record_sse_start(
endpoint="/api/xcx/chat/stream",
user_id=user.user_id,
chat_id=str(body.chat_id),
)
try:
client = _get_dashscope_client()
config = AIConfig.from_env()
# 构建 prompt最近 20 条历史 + 当前消息已在历史中)
prompt = _build_prompt(body.chat_id)
# 构建 biz_params用户身份信息
biz_params = {
"User_ID": str(user.user_id),
"Role": getattr(user, "role", "coach"),
"Nickname": getattr(user, "nickname", ""),
}
# 看板入口:注入页面上下文到 prompt
if body.source_page:
try:
from app.ai.data_fetchers import build_page_text
# W1-AI-CLOSURE 复盘修正:必须 dict() 浅拷贝,否则 filters.pop("contextId")
# 会破坏 body.page_context(同引用),导致后续 reference_card 块
# 拿不到 contextId(返回 None,跳过 KPI 富卡构建)。
filters = dict(body.page_context) if body.page_context else {}
context_id = filters.pop("contextId", None)
page_text = await build_page_text(
source_page=body.source_page,
context_id=context_id,
site_id=user.site_id,
filters=filters if filters else None,
)
if page_text:
prompt = f"[页面上下文: {body.source_page}]\n{page_text}\n\n{prompt}"
except Exception:
logger.warning("页面上下文注入失败: source_page=%s", body.source_page, exc_info=True)
# 获取 session_id对话复用
session_id = svc.get_session_id(body.chat_id) if hasattr(svc, "get_session_id") else None
# SSE trace: AI 调用
record_ai_call(
app_id=config.app_id_1_chat,
prompt_length=len(prompt),
session_id=session_id or "",
)
# 流式调用 DashScope Application API
# 返回 (text_chunk, session_id_or_none) 元组:累积最后一次 session_id 用于回写
latest_session_id: str | None = session_id
async for chunk_text, chunk_session_id in client.call_app_stream(
app_id=config.app_id_1_chat,
prompt=prompt,
session_id=session_id,
biz_params=biz_params,
):
if chunk_session_id:
latest_session_id = chunk_session_id
if not chunk_text:
continue
full_reply_parts.append(chunk_text)
tokens_total += 1
# SSE trace: 每 10 个 token 记录一次
record_sse_token(token_count=1, total_tokens=tokens_total)
yield f"event: message\ndata: {json.dumps({'token': chunk_text}, ensure_ascii=False)}\n\n"
# 流结束:拼接完整回复并持久化
full_reply = "".join(full_reply_parts)
estimated_tokens = len(full_reply)
# Phase 1.3 + W1-AI-CLOSURE 组 4(P0-15):assistant 消息挂 reference_card。
# customer-detail / customer-service-records 入口走 KPI 富卡(余额/30 天消费/到店),
# 其他入口走简单跳转链接卡(保持现状)。
try:
from app.ai.references import build_app1_reference_card
_ref_card = None
_pc = body.page_context or {}
_ctx_id = (
_pc.get("contextId")
or _pc.get("taskId")
or _pc.get("customerId")
or _pc.get("coachId")
)
if body.source_page and _ctx_id:
if body.source_page in ("customer-detail", "customer-service-records"):
try:
_customer_id = int(_ctx_id)
_ref_card = svc.build_reference_card(_customer_id, user.site_id)
if _ref_card:
_ref_card.setdefault(
"link",
f"/pages/customer-detail/customer-detail?customerId={_ctx_id}",
)
_ref_card.setdefault("source_page", body.source_page)
except (ValueError, TypeError):
_ref_card = None
if _ref_card is None:
_ref_card = build_app1_reference_card(body.source_page, _ctx_id)
except Exception:
logger.warning("构建 reference_card 失败", exc_info=True)
_ref_card = None
ai_msg_id, ai_created_at = svc._save_message(
body.chat_id, "assistant", full_reply,
tokens_used=estimated_tokens,
reference_card=_ref_card,
)
svc._update_session_metadata(body.chat_id, full_reply)
# multi-turn 启用:回写百炼返回的 session_id若首次对话或服务端更新
if latest_session_id and latest_session_id != session_id:
try:
svc.save_session_id(body.chat_id, latest_session_id)
except Exception:
logger.warning(
"save_session_id 失败 chat_id=%s", body.chat_id, exc_info=True,
)
# 发送 done 事件
done_data = json.dumps(
{"messageId": ai_msg_id, "createdAt": ai_created_at},
ensure_ascii=False,
)
yield f"event: done\ndata: {done_data}\n\n"
# SSE trace: 流正常结束
_sse_elapsed = (_time.perf_counter() - _sse_start_ts) * 1000
record_sse_end(
total_tokens=tokens_total,
total_duration_ms=_sse_elapsed,
completed=True,
)
except Exception as e:
logger.error("SSE 流式对话异常: %s", e, exc_info=True)
# SSE trace: AI 错误
record_ai_error(
error_type=type(e).__name__,
message=str(e),
)
# 如果已有部分回复,仍然持久化
if full_reply_parts:
partial = "".join(full_reply_parts)
try:
svc._save_message(body.chat_id, "assistant", partial)
svc._update_session_metadata(body.chat_id, partial)
except Exception:
logger.error("持久化部分回复失败", exc_info=True)
error_data = json.dumps(
{"message": "AI 服务暂时不可用"},
ensure_ascii=False,
)
yield f"event: error\ndata: {error_data}\n\n"
# SSE trace: 流异常结束
_sse_elapsed = (_time.perf_counter() - _sse_start_ts) * 1000
record_sse_end(
total_tokens=tokens_total,
total_duration_ms=_sse_elapsed,
completed=False,
)
return StreamingResponse(
event_generator(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
# ── CHAT-3: 发送消息(同步回复) ─────────────────────────────
@router.post("/{chat_id}/messages", response_model=SendMessageResponse)
@trace_service("发送消息", "Send message")
async def send_message(
chat_id: int,
body: SendMessageRequest,
user: CurrentUser = Depends(require_approved()),
) -> SendMessageResponse:
"""CHAT-3: 发送用户消息并获取同步 AI 回复。
chatId 归属验证:不属于当前用户返回 HTTP 403。
AI 失败时返回错误提示消息HTTP 200
"""
if not body.content or not body.content.strip():
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="消息内容不能为空",
)
svc = ChatService()
result = await svc.send_message_sync(
chat_id=chat_id,
content=body.content.strip(),
user_id=user.user_id,
site_id=user.site_id,
)
return SendMessageResponse(
user_message=MessageBrief(**result["user_message"]),
ai_reply=MessageBrief(**result["ai_reply"]),
)
# ── 辅助函数 ─────────────────────────────────────────────────
def _to_message_item(msg: dict) -> ChatMessageItem:
"""将 chat_service 返回的消息 dict 转换为 ChatMessageItem。"""
ref_card = msg.get("reference_card")
reference_card = ReferenceCard(**ref_card) if ref_card and isinstance(ref_card, dict) else None
return ChatMessageItem(
id=msg["id"],
role=msg["role"],
content=msg["content"],
created_at=msg["created_at"],
reference_card=reference_card,
)
def _get_dashscope_client() -> DashScopeClient:
"""从环境变量构建 DashScopeClient缺失时报错。"""
config = AIConfig.from_env()
return DashScopeClient(api_key=config.api_key, workspace_id=config.workspace_id)
def _build_prompt(chat_id: int) -> str:
"""构建发送给 DashScope Application 的 prompt。
从 ai_messages 取最近 20 条历史,拼接为文本 prompt。
百炼 Application API 的 System Prompt 在控制台配置,此处只传用户对话内容。
"""
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"""
SELECT role, content FROM biz.ai_messages
WHERE conversation_id = %s AND role != 'system'
ORDER BY created_at ASC
""",
(chat_id,),
)
history = cur.fetchall()
finally:
conn.close()
# 取最近 20 条
recent = history[-20:] if len(history) > 20 else history
# 如果只有一条(刚发送的用户消息),直接返回内容
if len(recent) == 1:
return recent[0][1]
# 多条历史:拼接为对话格式,最后一条为当前用户消息
parts: list[str] = []
for role, msg_content in recent[:-1]:
label = "用户" if role == "user" else "AI"
parts.append(f"{label}: {msg_content}")
# 最后一条是当前用户消息,作为主 prompt
current_msg = recent[-1][1] if recent else ""
if parts:
context = "\n".join(parts)
return f"[历史对话]\n{context}\n\n[当前问题]\n{current_msg}"
return current_msg