Files
AgentLab/backend/app/api/chat_turns.py
ThearchyHelios d18a496ef9 问数据整轮放到服务端跑(后端):一轮是一个后台任务,建图、运行、复核、按复核重来、落库都在服务端,页面断了也会跑完
为什么:一轮问数据以前是浏览器在推(store/chat.ts 的 plan / launch / settle):网络一抖建图那条流就断、建了一半的图丢掉;
发起运行、复核、重来、落库都要页面在,页面一关这一轮就停在 running,15 分钟后才被判成断了(总方案 §0 的调查结论)。

做了什么:
- 流程和评测共用一份:eval/answer.Driver 里的流程抽到 chat/drive.TurnDriver,差异写成子类给的几项(生成的请求、目录视图、
  改图、入口、发起参数、停在审批上怎么办、复核等多久)。评测的 Driver 成了它的子类,行为和测试一字不变(test_eval_* 照跑)。
- chat/job.py:问数据的一轮是 core/job_runtime 上的后台任务(不占数据源的队,并发归「交互」类,第 2 波再给上限和优先级)。
  停在审批上照旧等人(评测是取消);复核最多等 25 秒,晚到的那次用量照记(review.late);按复核自动重来一次;用户点的
  重试本轮、重新运行、放宽步数、跑一下、继续运行、接着收尾都是同一轮里的一次尝试,上一次的结局归档进 meta.attempts。
- 事件日志(新表 chat_turn_events,models_chat.py,不进运行的封存范围):每一步按 seq 落库再叫醒订阅方,订阅方按 seq
  回库里读——回放和实时是同一段代码;心跳一拍也回库里看一次,第 2 波多进程时别的进程写的最迟一拍送到。
- 接口 api/chat_turns.py:POST /api/chat/turns(202 带 turn_id)、GET /{id}、GET /{id}/stream?after=(SSE:先回放、再推
  实时、带心跳、结束补 stream.end)、POST /{id}/cancel。挂 chat_scope,每个接口按这一轮所属的对话核对归属;访问密钥调不了。
- conversation_turns 那一行由服务端写(chat/record.py:状态、运行 id、答案、复核、meta 各项和页面以前写的一一对应);失败
  只记原因(meta.cause),说法留在界面层按 lib/explain 讲。TurnOut 带这一轮的任务(job),列表判「还在跑」先看任务。
- 使用者的问数据运行规则(白名单、自己的记忆范围、遮罩、只读)抽到 chat/launch.user_chat_run,POST /api/runs 和服务端的
  一轮共用,说法一字不变。
- 服务重启:还在建图的标中断(可重试本轮);已发起运行的接着等那次运行、照常复核收尾;和调度循环同一个开关,拷来的数据
  目录启动时不替人收尾,页面打开对话时再请服务端接着收尾(mode=resume)。老轮次停在建图中的同样标中断。
- 删对话时先停掉它正在跑的几轮(取消各自的运行),任务和事件日志一并删。

测试:tests/test_chat_turns.py——一轮跑完不依赖页面、直接回答与反问、开流前被拒、实时流回放后接实时与按 seq 重连(补齐、
不重复)、思考合并、建图中与运行中停止、重启时建图中标中断与已发起运行的接着收尾、开关关着时等人来接、等审批后收尾、
按复核重来、用户重试归档、一段对话一次一轮、继续运行被拒照 /runs 的说法、删对话停掉这一轮、使用者的归属与遮罩只读、
访问密钥调不了、驳回与失败与发起被拒的原因、晚到的复核、跑一下与重新运行不重建。去掉几处关键实现回跑,相应的测试都会失败。
2026-10-18 16:05:53 +08:00

117 lines
5.9 KiB
Python
Raw Permalink 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
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)
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)
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}