涵盖(每条对应已存的审计记录): - 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 逐一处理
451 lines
16 KiB
Python
451 lines
16 KiB
Python
# -*- 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.3:assistant 消息挂 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
|