Files
Neo-ZQYY/apps/backend/app/routers/internal_ai.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

142 lines
4.7 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 -*-
"""
内部 AI 触发 API — ETL/内部服务调用入口。
端点:
- POST /api/internal/ai/trigger — 接收事件触发请求,异步执行 AI 调用链
认证方式Authorization: Internal-Token {token}
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, Depends, Header, HTTPException, status
from pydantic import BaseModel, Field
from app.ai.config import AIConfig
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/internal/ai", tags=["internal-ai"])
# ── 请求/响应模型 ────────────────────────────────────────────
class TriggerRequest(BaseModel):
"""内部触发请求体。"""
event_type: str = Field(..., description="事件类型: consumption / dws_completed / note_created / task_assigned")
connector_type: str = Field("feiqiu", description="连接器类型")
site_id: int = Field(..., description="门店 ID")
member_id: int | None = Field(None, description="会员 ID可选")
payload: dict | None = Field(None, description="附加数据")
is_forced: bool = Field(False, description="是否强制执行(跳过去重)")
class TriggerResponse(BaseModel):
"""触发响应。"""
trigger_job_id: int
status: str = "pending"
# ── 认证依赖 ─────────────────────────────────────────────────
def verify_internal_token(authorization: str = Header(...)) -> str:
"""校验 Internal-Token 认证。
Header 格式Authorization: Internal-Token {token}
token 不匹配或缺失时返回 HTTP 401。
"""
prefix = "Internal-Token "
if not authorization.startswith(prefix):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="无效的认证格式,需要 Internal-Token",
)
token = authorization[len(prefix):]
if not token:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token 不能为空",
)
# 从环境变量加载期望 token
try:
config = AIConfig.from_env()
except ValueError:
logger.error("AIConfig 加载失败,无法校验 internal token")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="AI 配置异常",
)
# P1-7 修正:用 hmac.compare_digest 防时序攻击,而不是 Python `==` 字符串比较
import hmac
if not hmac.compare_digest(token, config.internal_api_token or ""):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token 不匹配",
)
return token
# ── 端点 ─────────────────────────────────────────────────────
@router.post("/trigger", response_model=TriggerResponse)
async def trigger_ai_event(
body: TriggerRequest,
_token: str = Depends(verify_internal_token),
) -> TriggerResponse:
"""接收 ETL/内部事件,写 ai_trigger_jobs 后异步执行。
立即返回 trigger_job_id调用链在后台异步执行。
"""
from app.ai.dispatcher import AIDispatcher, TriggerEvent
# 构建触发事件
event = TriggerEvent(
event_type=body.event_type,
site_id=body.site_id,
member_id=body.member_id,
connector_type=body.connector_type,
payload=body.payload or {},
is_forced=body.is_forced,
)
# 获取 dispatcher 实例并触发
# 延迟导入避免循环依赖dispatcher 实例由应用启动时创建
dispatcher = _get_dispatcher()
job_id = await dispatcher.handle_trigger(event)
return TriggerResponse(trigger_job_id=job_id, status="pending")
# ── 辅助函数 ─────────────────────────────────────────────────
# 全局 dispatcher 实例(应用启动时初始化)
_dispatcher_instance: AIDispatcher | None = None
def set_dispatcher(dispatcher: "AIDispatcher") -> None:
"""设置全局 dispatcher 实例(应用启动时调用)。"""
global _dispatcher_instance
_dispatcher_instance = dispatcher
def _get_dispatcher() -> "AIDispatcher":
"""获取全局 dispatcher 实例。"""
if _dispatcher_instance is None:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="AI Dispatcher 尚未初始化",
)
return _dispatcher_instance