Files
AgentLab/backend/app/api/chat_turns.py
T
ThearchyHelios 46a2988dc1 问数据双轨重构:快速直查与深度分析分流、可点击数字证据闭环及双语文档
- 重构问数据执行架构为「快速直查(fast)」与「深度分析(deep)」双模式,内置语义预命中(Grounding)注入指标卡片、标准实体编码与表目录血缘
- 快速直查免工作流生成单节点执行,新增零额外模型调用的数字证据归一与封存(direct_evidence),打通可点击数字溯源与 /verify 校验
- 深度分析与助手编排接入跨源本体穿透(自动升级重跑与一键沿本体排查关联数据源),优化数据源范围智能收敛与往返缓存
- 补齐端到端回归测试矩阵与中英文全量文档说明
2026-11-12 12:58:42 +08:00

119 lines
6.2 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.
"""问数据的一轮:发起、看、停(docs/design/orchestration-round/00-总方案.md §1;做法在 app/chat/job.py)。
- POST /api/chat/turns:发起一轮,202 {turn_id, turn}。新问带 conversation_id、question(可选 datasource_ids 限定数据源);对已有
的一轮重试、重新运行、放宽步数、跑一下、继续运行、接着收尾带 retry_of 和 mode(MODES),重试时 datasource_ids 给了就换范围
(空数组是不限),不给沿用这一轮的。同一段对话里有一轮正在跑(停在审批上的不算)时 409 chat_turn_busy;继续运行被拒照
POST /api/runs/{id}/continue 的说法(409,带那边的机器码)。
- GET /api/chat/turns/{id}:这一轮的任务现在怎样(status、stage、run_id、last_seq、attempt_seq……),连同那一行(turn)。老轮次
没有任务时 job 为 null。
- GET /api/chat/turns/{id}/stream?after=<seq>:SSE。先回放 seq > after 的,再推实时;隔一拍没有新事件发一行注释心跳;这一轮不在
进行中、也没有新的了,补一条 stream.end 再关(chat/log.py)。断了按收到的最后一个 seq 重连,补齐、不重复。
- POST /api/chat/turns/{id}/cancel:停止(取消生成、取消运行,照现有的取消规则)。已经结束的 409 chat_turn_finished。
权限照问数据的规矩:路由挂 chat_scope(使用者要管理员开了问数据),每个接口再按这一轮所属的对话核对归属(对话按人隔离,
conversations.turn_access / ensure_conversation_access);访问密钥调不了(不在 auth/keys.KEY_ROUTES 里)。
"""
from __future__ import annotations
from typing import Any, Literal
from fastapi import APIRouter, Depends, Header, HTTPException, Query
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.coded import CodedHTTPException
from app.api.deps import actor_of
from app.db.base import get_session
router = APIRouter(prefix="/api/chat/turns", tags=["chat"])
class TurnStartIn(BaseModel):
conversation_id: str = Field(min_length=1)
question: str = ""
#: 这一问只查这几个数据源(id 或名字)。不给:新问不限,重试沿用这一轮的;空数组:不限
datasource_ids: list[str] | None = None
#: 对已有的哪一轮(重试、重新运行、继续运行……)
retry_of: str | None = None
#: ask | retry | rerun | max_steps | run | continue | resume(app/chat/job.MODES)。不给:新问是 ask,带 retry_of 是 retry
mode: str | None = None
#: 放宽到几步(mode=max_steps)
max_steps: int | None = Field(default=None, ge=1, le=10_000)
#: 问数模式:fast 快速直查(毫秒级语义预命中 + 确定性直查图) | deep 深度分析(助手规划建工作流 + 报告 + 复核)
depth: Literal["fast", "deep"] | None = None
def _refused(e: Any) -> HTTPException:
if e.code:
return CodedHTTPException(e.status_code, e.message, e.code)
return HTTPException(e.status_code, e.message)
@router.post("", status_code=202)
async def start_turn(payload: TurnStartIn, session: AsyncSession = Depends(get_session),
x_actor: str | None = Header(default=None)) -> dict[str, Any]:
from app.api.conversations import _get, turn_access, turn_out
from app.chat import job as chat_job
retry_of = None
if payload.retry_of:
conversation, retry_of = await turn_access(session, payload.retry_of)
if conversation.id != payload.conversation_id:
raise HTTPException(404, "该轮对话不属于这段对话")
else:
conversation = await _get(session, payload.conversation_id)
try:
turn_id = await chat_job.start(session, conversation=conversation, question=payload.question,
datasource_ids=payload.datasource_ids, actor=actor_of(x_actor), mode=payload.mode,
retry_of=retry_of, max_steps=payload.max_steps, depth=payload.depth)
except chat_job.TurnRefused as e:
raise _refused(e) from e
from app.db.base import SessionLocal
from app.db.models import ConversationTurn
async with SessionLocal() as fresh:
row = await fresh.get(ConversationTurn, turn_id)
turn = (await turn_out(fresh, row)).model_dump(mode="json") if row is not None else None
return {"turn_id": turn_id, "turn": turn}
@router.get("/{turn_id}")
async def get_turn(turn_id: str, session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
from app.api.conversations import turn_access, turn_out
from app.chat import job as chat_job
_, row = await turn_access(session, turn_id)
return {"id": row.id, "conversation_id": row.conversation_id, "job": await chat_job.state(session, row.id),
"turn": (await turn_out(session, row)).model_dump(mode="json")}
@router.get("/{turn_id}/stream")
async def stream_turn(turn_id: str, after: int = Query(default=0, ge=0),
session: AsyncSession = Depends(get_session)) -> StreamingResponse:
from app.api.conversations import turn_access
from app.chat import job as chat_job
from app.chat import log as turn_log
await turn_access(session, turn_id)
await session.close() # 流可能开很久:核对完就把这次请求的会话还回去
async def active() -> str | None:
return await chat_job.stream_status(turn_id)
return StreamingResponse(turn_log.stream(turn_id, after, active=active), media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
@router.post("/{turn_id}/cancel")
async def cancel_turn(turn_id: str, session: AsyncSession = Depends(get_session),
x_actor: str | None = Header(default=None)) -> dict[str, Any]:
from app.api.conversations import turn_access
from app.chat import job as chat_job
await turn_access(session, turn_id)
await session.close()
if not await chat_job.cancel(turn_id, actor_of(x_actor)):
raise CodedHTTPException(409, "这一轮已经结束,无需停止", chat_job.TURN_FINISHED)
return {"ok": True}