Files
AgentLab/backend/app/chat/turn.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

167 lines
10 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.
"""问数据一轮里的几个「决定」(细案 01 做法 1,总方案 2.1)。
一轮问数据是:建图(助手的生成过程,api/copilot.answer_ops)→ 要不要运行 → 运行(输入怎么填)→ 复核 → 要不要带着原图
重来一次、重来时说什么。以前这几个决定都写在问数据页里(frontend/src/store/chat.ts 的 plan / launch / settle / retry),
后端跑不了一轮问数据:评测要么照着抄一份(迟早走样,量出来的不是用户拿到的那一轮),要么做不了;这也违反「界面层不放
逻辑」。现在决定写在这里,是纯函数:
- next_step:生成过程交出结局之后做什么(含生成过程一个操作都没给时自动重来一次);
- fill_input:用户的问题填进这张图的入口字段;
- retry_after_review:复核之后要不要带着原图重来一次;
- retry_instruction:重来时交给助手的那句话(两种措辞)。
接口把决定交给问数据页:/copilot/generate-stream 的 final 带 next(run | ready | fail),error 带 next(retry | fail),
/copilot/review 的结果带 retry_instruction(要重来时是那句话,不重来是 null)。问数据页照后端给的走;老后端没有这两个字段时退回它自己那份同样的
规则(tests/test_chat_turn_policy.py 逐条对照两边的常量和措辞)。评测(第 3 波)用同一组函数把一轮串起来。
整轮编排后来也搬到了服务端(docs/design/orchestration-round §1):流程在 chat/drive.py(问数据和评测共用),问数据的后台任务在
chat/job.py;页面只发起、只看,不再自己判这些。老服务端那条退路(页面自己那份规则)随之去掉。
"""
from __future__ import annotations
import re
from collections.abc import Mapping
from typing import Any
#: 一轮最多自动试几次(含第一次):第二次还不行多半是问题本身没说清,该让用户介入(同 chat.ts 的 MAX_ATTEMPTS)
MAX_ATTEMPTS = 2
#: final 之后的三种走法(final.next):运行、只建不跑(摆着等用户点运行)、判失败(没过检查的图不拿去跑)
NEXT_RUN, NEXT_READY, NEXT_FAIL = "run", "ready", "fail"
NEXT_STEPS = (NEXT_RUN, NEXT_READY, NEXT_FAIL)
#: 别的结局:直接回答了(reply)、助手反问了等用户答(asked)、重来一次(retry:没自查过的老服务端交来没过检查的图,或者
#: 生成过程一个操作都没给)
NEXT_REPLY, NEXT_ASKED, NEXT_RETRY = "reply", "asked", "retry"
#: 生成过程一个操作都没给就结束了(error 操作的 code):模型只写了思考(deepseek 这类推理模型偶发)、或者格式跑偏每一行都被
#: 跳过。这是偶发的,重来一次多半就好,按「重来一次」的规矩自动重来(最多 MAX_ATTEMPTS 次,含第一次);被截断的不重来——额度
#: 不变,重来还是截断
EMPTY_GENERATION = "copilot_empty"
#: 自动重来的 error(code)
RETRY_ERRORS = frozenset({EMPTY_GENERATION})
#: error 操作的 message 前缀(api/copilot.answer_ops):重来时交给助手的原因不带它
_ERROR_PREFIX = "助手本轮未完成:"
#: 问题往哪个入口字段放:名字像「问题」的优先(同 chat.ts fillInput 的正则,大小写不敏感)
QUESTION_FIELD = re.compile(r"question|query|topic|input|问题|需求|主题", re.IGNORECASE)
#: 带着原图重来时交给助手的话:原图交回去改(调大步数、改参数这种事就是改一个字段,不是推倒重来)
RETRY_WITH_BASE = ("这个工作流上一次运行没有成功:{reason}。\n\n"
"请针对这个原因改它(比如调大最大步数、改正工具参数、换个查询方式),"
"改完继续回答原来的问题:{question}")
#: 没有图可交回(上一次连图都没建出来)时:原问题后面缀上原因,从零再建
RETRY_FROM_SCRATCH = "{question}\n\n(上一次没有成功:{reason}。请针对这个原因调整,重新给出完整的工作流。)"
#: 用户点「重试本轮」时交给助手的话(以前在问数据页 retryTurn 里拼,一轮搬到服务端之后在这里):有图时说的是「没有给出可用的
#: 结果」——上一次未必是运行失败(停止了、被驳回、结论不可用都算),原因是那一次的归档说法(chat/record.attempt_reason);
#: 没有图的同 RETRY_FROM_SCRATCH
RETRY_USER_WITH_BASE = ("这个工作流上一次没有给出可用的结果:{reason}。\n\n"
"请针对这个原因改它(比如调大最大步数、改正工具参数、换个查询方式),"
"改完继续回答原来的问题:{question}")
def _blockers(final: Mapping[str, Any]) -> list[str]:
return [str(i.get("message") or "") for i in final.get("issues") or []
if isinstance(i, Mapping) and i.get("level") == "error"]
def next_step(terminal: Mapping[str, Any] | None, *, self_checked: bool = True) -> str:
"""生成过程交出结局之后做什么。terminal 是最后那条结局操作(final / reply / error);流结束时只有反问(ask)、没有
别的结局的给 None。
- reply:直接回答,这一轮到此为止;只有反问:等用户回答;
- error:生成过程一个操作都没给(code 是 EMPTY_GENERATION)时重来一次(retry,次数由调用方按 MAX_ATTEMPTS 管,原因
取 error_reason),别的判失败;
- final:有 error 级问题时,服务端已经自查过(收到过 check 的结论)就判失败——没过的问题已经交回模型在同一轮里改过
了,再整轮重来是再烧一次完整生成、改的还是同一批问题;没自查过(老服务端)才重来一次(retry);模型说不运行
(autorun=false:用户要的是流程本身、或者问了用户)只建不跑;其余运行。
"""
if terminal is None:
return NEXT_ASKED
kind = terminal.get("op")
if kind == "reply":
return NEXT_REPLY
if kind == "error":
return NEXT_RETRY if terminal.get("code") in RETRY_ERRORS else NEXT_FAIL
if kind != "final":
return NEXT_FAIL
if _blockers(terminal):
return NEXT_FAIL if self_checked else NEXT_RETRY
if terminal.get("autorun") is False:
return NEXT_READY
return NEXT_RUN
def error_reason(error: Mapping[str, Any]) -> str:
"""生成过程交出 error 时重来的原因(交给助手的那句话里用):error 带的 reason,没有就是 message 去掉「助手本轮未完成:」。"""
reason = str(error.get("reason") or "").strip()
if reason:
return reason
message = str(error.get("message") or "").strip()
return message[len(_ERROR_PREFIX):] if message.startswith(_ERROR_PREFIX) else message
def blocked_reason(final: Mapping[str, Any]) -> str:
"""没过检查的图重来时的原因(只有老服务端那条路用得到)。"""
return "生成的工作流没有通过校验:" + ";".join(_blockers(final))
def fill_input(graph: Mapping[str, Any] | None, question: str, history: str = "") -> dict[str, Any]:
"""用户的问题填进这张图的入口字段(同 chat.ts 的 fillInput)。
入口没声明字段:{question, history}(下游可能用 input.question)。声明了:每个字段先填默认值(没有就空串),问题放进
名字像「问题」的那个,其次第一个必填的,再不然第一个;history(前几轮压成的文字)照样带上——图跑起来时模型得知道
「它们」指的是上一轮那批数据。
"""
nodes = (graph or {}).get("nodes") or []
entry = next((n for n in nodes if isinstance(n, Mapping) and n.get("type") == "input"), None)
config = ((entry or {}).get("data") or {}).get("config") or {}
fields = [f for f in (config.get("fields") or []) if isinstance(f, Mapping)]
if not fields:
return {"question": question, "history": history}
payload: dict[str, Any] = {"history": history}
for f in fields:
if f.get("name"):
payload[str(f["name"])] = f["default"] if f.get("default") is not None else ""
named = next((f for f in fields if QUESTION_FIELD.search(str(f.get("name") or ""))), None)
target = named or next((f for f in fields if f.get("required")), None) or fields[0]
if target.get("name"):
payload[str(target["name"])] = question
return payload
def review_reason(review: Mapping[str, Any]) -> str:
"""复核判不可用时重来的原因:各条信号的说明,没有就用给用户看的那句说明。"""
details = [str(s.get("detail") or "") for s in review.get("signals") or [] if isinstance(s, Mapping)]
return ";".join(d for d in details if d) or str(review.get("note") or "")
def retry_after_review(review: Mapping[str, Any] | None, attempt: int) -> bool:
"""复核之后要不要带着原图重来一次:复核说换个配置重跑大概率就好(retry)、答案不可信(severity=broken)、这一轮还没
试满(attempt 从 0 数,attempt + 1 < MAX_ATTEMPTS)。"""
if not review:
return False
return bool(review.get("retry")) and review.get("severity") == "broken" and attempt + 1 < MAX_ATTEMPTS
def retry_instruction(question: str, reason: str, has_base: bool) -> str:
"""重来时交给助手的话。has_base:有上一次建出的图可以交回去改。"""
template = RETRY_WITH_BASE if has_base else RETRY_FROM_SCRATCH
return template.format(question=question, reason=reason)
def user_retry_instruction(question: str, reason: str, has_base: bool) -> str:
"""用户点「重试本轮」时交给助手的话。has_base:有上一次建出的图可以交回去改。"""
template = RETRY_USER_WITH_BASE if has_base else RETRY_FROM_SCRATCH
return template.format(question=question, reason=reason)
def has_nodes(graph: Any) -> bool:
"""一张图有没有节点(有才交回去改)。"""
return isinstance(graph, Mapping) and bool(graph.get("nodes"))
__all__ = ["EMPTY_GENERATION", "MAX_ATTEMPTS", "NEXT_ASKED", "NEXT_FAIL", "NEXT_READY", "NEXT_REPLY", "NEXT_RETRY",
"NEXT_RUN", "NEXT_STEPS", "QUESTION_FIELD", "RETRY_ERRORS", "RETRY_FROM_SCRATCH", "RETRY_USER_WITH_BASE",
"RETRY_WITH_BASE", "blocked_reason", "error_reason", "fill_input", "has_nodes", "next_step", "retry_after_review",
"retry_instruction", "review_reason", "user_retry_instruction"]