Files
AgentLab/backend/app/db/models_chat.py
T
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

74 lines
4.6 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/db/models.py 末尾整模块导入,create_all 建表(都是新表,不改已有表)。这里不导入 app.db.models:models.py 导入本模块时
自己还没加载完,外键一律按表名写。
**为什么一轮一行任务、id 就是 conversation_turns 的 id。** 一轮问数据(建图 → 运行 → 复核 → 按复核重来、落库)以前是浏览器在推,
页面一断这一轮就没人管了。现在它是服务端的一个后台任务:发起、重试本轮、重新运行本轮、继续运行都还是「这一轮」,用户看到的
是同一轮里的几次尝试,所以任务按轮记、一轮一行,重试时把这一行重新排队(上一次的结局归档进 conversation_turns.meta.attempts)。
- ChatTurnJob:状态用后台任务的状态词(core/job_runtime:queued → running → succeeded / failed / cancelled / interrupted),
stage 是这一轮现在在哪一步(build 建图、run 运行、waiting 停在审批上等人、review 复核),run_id 是当前这次运行。request 是
这次要做什么(问题、限定的数据源、方式、重试的原因、放宽到几步……),principal 是发起的人(身份快照:服务重启后接着做时
按这个人的身份——遮罩、只读、对话归属都看它,见 app/chat/job.py)。attempt_seq 是这一次尝试在事件日志里从哪一条开始:
刷新、重开页面时从这里回放,不必把之前几次尝试的过程整条重放。
- ChatTurnEvent:这一轮的事件日志。每一步(助手的操作、心跳、查看、final、用量、运行 id、复核结论、重来、终局)按 seq
一条,页面先回放、再接实时,断了按 seq 补齐(GET /api/chat/turns/{id}/stream?after=)。不进运行的封存范围:运行自己的
事件仍在 run_events,这里只记「这一轮」的事。
"""
from __future__ import annotations
from datetime import datetime
from typing import Any
from sqlalchemy import Float, ForeignKey, Integer, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base, TimestampMixin, UTCDateTime, new_id
#: 这一轮现在在哪一步
STAGES = ("build", "run", "waiting", "review")
class ChatTurnJob(Base, TimestampMixin):
"""问数据一轮的后台任务(见模块说明)。"""
__tablename__ = "chat_turn_jobs"
#: 就是 conversation_turns.id
id: Mapped[str] = mapped_column(ForeignKey("conversation_turns.id", ondelete="CASCADE"), primary_key=True)
conversation_id: Mapped[str] = mapped_column(ForeignKey("conversations.id", ondelete="CASCADE"), index=True)
#: queued | running | succeeded | failed | cancelled | interrupted(core/job_runtime 的状态词)
status: Mapped[str] = mapped_column(String(20), default="queued", index=True)
stage: Mapped[str | None] = mapped_column(String(20), default=None)
#: ask | retry | rerun | max_steps | run | continue | resume(app/chat/job.py 的 MODES)
mode: Mapped[str] = mapped_column(String(20), default="ask")
run_id: Mapped[str | None] = mapped_column(String(32), default=None)
#: 这一次尝试里自动重来了几次(从 0 数,同 chat/turn.MAX_ATTEMPTS 的口径)
attempt: Mapped[int] = mapped_column(Integer, default=0)
request: Mapped[dict[str, Any]] = mapped_column(default=dict)
principal: Mapped[dict[str, Any]] = mapped_column(default=dict)
actor: Mapped[str | None] = mapped_column(String(100), default=None)
last_seq: Mapped[int] = mapped_column(Integer, default=0)
attempt_seq: Mapped[int] = mapped_column(Integer, default=0)
error: Mapped[str | None] = mapped_column(Text, default=None)
started_at: Mapped[datetime | None] = mapped_column(UTCDateTime, default=None)
finished_at: Mapped[datetime | None] = mapped_column(UTCDateTime, default=None)
class ChatTurnEvent(Base):
"""问数据一轮的事件日志里的一条(见模块说明)。"""
__tablename__ = "chat_turn_events"
__table_args__ = (UniqueConstraint("turn_id", "seq", name="uq_chat_turn_events_turn_seq"),)
id: Mapped[str] = mapped_column(String(32), primary_key=True, default=new_id)
turn_id: Mapped[str] = mapped_column(ForeignKey("conversation_turns.id", ondelete="CASCADE"), index=True)
seq: Mapped[int] = mapped_column(Integer)
type: Mapped[str] = mapped_column(String(40))
ts: Mapped[float] = mapped_column(Float)
data: Mapped[dict[str, Any]] = mapped_column(default=dict)
__all__ = ["STAGES", "ChatTurnEvent", "ChatTurnJob"]