Files
Neo-ZQYY/apps/backend/app/routers/xcx_chat.py
Neo caf179a5da feat: 2026-04-15~05-02 累积变更基线 — AI 重构 + Runtime Context + DWS 修复
涵盖(每条对应已存的审计记录):
- AI 模块拆分:apps/backend/app/ai/apps -> prompts/(8 个 APP + app2a 派生)
  audit: 2026-04-20__ai-module-complete.md
- admin-web AI 管理套件:AIDashboard / AIOperations / AIRunLogs / AITriggers / TriggerManager
  audit: 2026-04-21__admin-web-ai-management-suite.md
- App2 财务洞察 prompt v3 -> v5.1 + 小程序 AI 接入(chat / board-finance)
  audit: 2026-04-22__app2_prompt_v5_1_and_miniprogram_ai_insight.md
- App2 prewarm 全过滤器 + AI 触发器 cron reschedule
  audit: 2026-04-21__app2-finance-prewarm-all-filters.md
  migration: 20260420_ai_trigger_jobs_and_app2_prewarm.sql / 20260421_app2_prewarm_cron_reschedule.sql
- AppType 联合类型对齐 + adminAiAppTypes.test.ts
  audit: 2026-04-30__admin_web_ai_app_type_alignment.md
- DashScope tokens_used 提取修复
  audit: 2026-04-30__backend_dashscope_tokens_used_extraction.md
- App3 线索完整详情 prompt
  audit: 2026-05-01__backend_app3_full_detail_prompt.md
- Runtime Context 沙箱(5-1~5-2 主线):
  - 后端 schema/service + admin_runtime_context / xcx_runtime_clock 两个 router
  - admin-web RuntimeContext.tsx + miniprogram runtime-clock.ts
  - migration: 20260501__runtime_context_sandbox.sql
  - tools/db/verify_admin_web_sandbox.py + verify_sandbox_end_to_end.py
  - database/changes: 7 份 sandbox_* 验证报告
- 飞球 DWS 修复:finance_area_daily 区域汇总 + task_engine 调整
  + RLS 视图业务日上界(migration 20260502 + scripts/ops/gen_rls_business_date_migration.py)

合规:
- .gitignore 启用 tmp/ 排除
- 不入仓:apps/etl/connectors/feiqiu/.env(API_TOKEN secret,本地修改保留)

待验证清单:
- docs/audit/changes/2026-05-04__cumulative_baseline_pending_verification.md
  每个主题的功能完整性 / 上线验证几乎都未收口,按优先级 P0~P3 逐一处理
2026-05-04 02:30:19 +08:00

451 lines
16 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
filters = {}
if body.page_context:
filters = body.page_context
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.3assistant 消息挂 reference_card若用户从特定详情页入口发起对话
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:
_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