Files
tick-stock-panel/backend/app/custom/assistant/routes.py
T
shy3130 f443627377 fix(assistant): 保留跨轮推理上下文并兼容旧会话
保留最终回答和工具前正文段的推理字段,通过已有会话存储保存并在追问时回传;按供应商、协议、接口与模型隔离上下文。兼容 DeepSeek 旧会话缺失字段,推理内容计入每轮输入预算。

验证:隔离全量后端测试 3898 项通过、13 项跳过、1 项预期失败;前端会话相关 7 项测试与生产构建通过。DeepSeek 使用协议模拟验证,未进行真实接口联调。

Signed-off-by: shy3130 <shy3130@qq.com>
2026-11-01 21:06:22 +08:00

137 lines
5.3 KiB
Python

"""AI 助手 HTTP 端点 — 薄层: 参数校验 + 流式响应映射。
POST /api/custom/assistant/chat NDJSON 事件流(协议见模块 __init__)
GET /api/custom/assistant/status 供应商配置状态(前端门控展示)
GET /api/custom/assistant/suggests 空会话快捷指令
GET /api/custom/assistant/sessions 会话历史(服务端, 跨设备)
PUT /api/custom/assistant/sessions 会话历史整体保存(前端防抖写入)
POST /api/custom/assistant/actions/{call_id}/decision
动作工具确认/拒绝(确认卡按钮回调)
"""
from __future__ import annotations
from pathlib import Path
from typing import Literal
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from app.custom.assistant import actions as assistant_actions
from app.custom.assistant import sessions as assistant_sessions
from app.custom.assistant import tools as assistant_tools
from app.custom.assistant.chat_service import chat_stream
from app.services.ai_provider import (
ai_configured,
current_ai_model,
current_ai_provider,
)
from app.services.ndjson_heartbeat import with_heartbeat
class HistoryMessage(BaseModel):
role: Literal["user", "assistant"]
content: str = Field(min_length=1, max_length=20000)
reasoning_content: str | None = None
reasoning_context: str | None = Field(default=None, max_length=64)
class AssistantContext(BaseModel):
page: str = Field(default="", max_length=100)
symbol: str = Field(default="", max_length=32)
class ChatRequest(BaseModel):
messages: list[HistoryMessage] = Field(min_length=1, max_length=200)
context: AssistantContext | None = None
class ActionDecisionRequest(BaseModel):
approve: bool
class AssistantSessionsPayload(BaseModel):
"""前端 store.ts 的持久化载荷: 会话数组 + 活跃会话 id。
sessions 用宽松的 list[dict] (消息形状由前端定义, 后端只裁剪条数),
上限与前端一致, 防御异常客户端把文件写爆。字段名对齐前端 (activeId)。
"""
sessions: list[dict] = Field(max_length=assistant_sessions.MAX_SESSIONS)
active_id: str = Field(default="", max_length=64, alias="activeId")
model_config = {"populate_by_name": True}
def build_router() -> APIRouter:
router = APIRouter(prefix="/api/custom/assistant", tags=["custom-assistant"])
@router.post("/chat")
async def chat(request: Request, req: ChatRequest):
engine = getattr(request.app.state, "strategy_engine", None)
repo = getattr(request.app.state, "repo", None)
data_dir = str(repo.store.data_dir) if repo is not None else None
context = req.context.model_dump() if req.context else None
async def gen():
async for line in with_heartbeat(chat_stream(
history=[m.model_dump(exclude_none=True) for m in req.messages],
context=context,
engine=engine,
data_dir=data_dir,
repo=repo,
quote_service=getattr(request.app.state, "quote_service", None),
depth_service=getattr(request.app.state, "depth_service", None),
capabilities=getattr(request.app.state, "capabilities", None),
financial_scheduler=getattr(request.app.state, "financial_scheduler", None),
)):
yield line + "\n"
return StreamingResponse(
gen(),
media_type="application/x-ndjson",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
@router.get("/status")
def status() -> dict:
configured = ai_configured()
return {
"configured": configured,
"provider": current_ai_provider(),
"model": current_ai_model(),
"supports_tools": configured,
}
@router.get("/suggests")
def suggests() -> dict:
return {"suggests": assistant_tools.QUICK_SUGGESTS}
@router.get("/sessions")
def sessions(request: Request) -> dict:
repo = getattr(request.app.state, "repo", None)
if repo is None:
raise HTTPException(status_code=503, detail="数据目录不可用")
return assistant_sessions.load_sessions(Path(repo.store.data_dir))
@router.put("/sessions")
def put_sessions(request: Request, req: AssistantSessionsPayload) -> dict:
repo = getattr(request.app.state, "repo", None)
if repo is None:
raise HTTPException(status_code=503, detail="数据目录不可用")
n = assistant_sessions.save_sessions(
Path(repo.store.data_dir),
{"sessions": req.sessions, "activeId": req.active_id},
)
return {"ok": True, "sessions": n}
@router.post("/actions/{call_id}/decision")
async def action_decision(call_id: str, req: ActionDecisionRequest) -> dict:
"""确认卡回调: 唤醒挂起中的动作工具(批准执行或拒绝)。"""
action = await assistant_actions.registry.resolve(call_id, req.approve)
if action is None:
raise HTTPException(status_code=404, detail="确认请求不存在或已过期")
return {"ok": True, "call_id": call_id, "decision": "approved" if req.approve else "denied"}
return router