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

258 lines
14 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.
"""问数据一轮落进 conversation_turns 的那一行:服务端写(状态、运行 id、答案、复核、meta),页面只读。
以前这一行是页面写的(frontend/src/store/chat.ts 的 persist / buildMeta),一轮的编排搬到服务端之后由这里写,字段和页面以前
写的一一对应,读的一方(问数据页的 restoreTurn、助手拼历史的 copilot._history_section、列表的 conversations.last_status)不用变:
- status:running | done | error;error 列是一句原话(运行的报错、助手的说法、「已取消」「服务重启,本轮已中断」),助手拼
历史时照它写「这一轮失败了:…」;
- answer:答案正文(成果各字段拼起来,同页面的 answerText,上限 ANSWER_CAP);复核改写了的存改写后的;
- review:复核原样(含 verdict=ok、usage、retry_instruction);没复核过是 {};
- meta(v=1):runId、runClass、runStatus、issuance(瘦过的出具信息)、noQuery、outcome(cancelled | suspended)、rejected
(驳回的人、哪一步、备注:run.cancelled 的原样)、cause(失败的原因,见下)、attempts(用户点重试之前的各次)、ms、queries、
scope、asked、looks、evidence、usage({copilot, review, earlier, run})、at。
**失败只记原因(cause),不记说法。** 页面以前记的是翻成人话之后的 failure(标题、原因、怎么办:lib/explain 那一套),说法在
界面层;服务端只记发生了什么,页面恢复时照同一套 lib/explain 讲(frontend/src/store/chat.ts 的 failureFromCause):
{kind: refused, message, code} 开流之前就被拒(模型没配好、限定的数据源都不在)
{kind: copilot, message, hint, detail} 生成过程交出 error(助手本轮未完成)
{kind: blocked, issues, self_checked} 生成的图没过检查
{kind: start, message, code, status} 发起运行被拒(绑定的工具不在、使用者用不了的节点)
{kind: run, error, detail} 运行失败
{kind: gone} 运行记录不见了
{kind: build_interrupted} 建图时服务重启,没有可恢复的断点
{kind: crash, message} 这一轮的后台任务自己出错
老轮次的 meta.failure 照旧认(页面优先读它)。
"""
from __future__ import annotations
import json
import time
from collections.abc import Mapping
from typing import Any
#: 落库答案的上限(同页面的 ANSWER_CAP):只是防跑飞,不是显示上限
ANSWER_CAP = 20_000
ANSWER_CLIPPED = "\n\n…(内容过长,已截断,完整内容请查看运行记录)"
#: 归档的旧答案存多少(同页面的 ATTEMPT_ANSWER_CAP)
ATTEMPT_ANSWER_CAP = 4000
#: 停止、服务重启挂起时 error 列里的那一句(同页面落库时写的)
CANCELLED = "已取消"
SUSPENDED = "服务重启,本轮已中断"
#: 建图时服务重启
BUILD_INTERRUPTED = "服务重启,本轮在生成工作流时中断"
GONE = "运行记录不见了"
#: 用量里记的几项(同页面的 USAGE_KEYS)
USAGE_KEYS = ("cost_usd", "unpriced_calls", "calls", "unfinished_calls", "input_tokens", "output_tokens")
#: 出具信息里无法回指的数字最多存几个(同页面的 slimIssuance)
ISSUANCE_UNMATCHED_CAP = 20
def answer_text(output: Mapping[str, Any] | None) -> str:
"""成果压成一句话(同页面的 answerText):下划线开头的、空的不要;字符串原样,别的按 JSON;超过上限截断并说出来。"""
if not output:
return ""
parts = [value if isinstance(value, str) else json.dumps(value, ensure_ascii=False, separators=(",", ":"))
for key, value in output.items() if not str(key).startswith("_") and value not in (None, "")]
text = "\n".join(parts)
return text[:ANSWER_CAP] + ANSWER_CLIPPED if len(text) > ANSWER_CAP else text
def slim_issuance(issuance: Any) -> dict[str, Any] | None:
"""出具信息存进 meta 前瘦一下身(同页面的 slimIssuance):无法回指的数字只留前 20 个。"""
if not isinstance(issuance, Mapping) or not issuance.get("tier"):
return None
out = dict(issuance)
unmatched = issuance.get("unmatched_numbers")
if isinstance(unmatched, list):
out["unmatched_numbers"] = unmatched[:ISSUANCE_UNMATCHED_CAP]
return out
def has_evidence(output: Mapping[str, Any] | None) -> bool:
"""成果带不带逐段证据(同页面 lib/evidence 的 hasEvidence):_evidence 或它的 others 里有文档、有字段。"""
ev = (output or {}).get("_evidence") if isinstance(output, Mapping) else None
if not isinstance(ev, Mapping):
return False
refs = [ev, *[r for r in ev.get("others") or [] if isinstance(r, Mapping)]]
return any(isinstance(r.get("doc_artifact"), str) and r.get("doc_artifact") and isinstance(r.get("fields"), list)
and r.get("fields") for r in refs)
def _names(value: Any) -> list[str]:
return [str(v) for v in value if v not in (None, "")] if isinstance(value, list) else []
def _num(value: Any) -> float | None:
return value if isinstance(value, (int, float)) and not isinstance(value, bool) else None
def look_event(op: Mapping[str, Any]) -> dict[str, Any] | None:
"""助手的 look 操作 → 存进 meta.looks 的那一份(同页面 run/decode 的 lookEventOf):下一轮服务端据此写「上一轮查看过」。"""
if op.get("op") != "look":
return None
listed = op.get("objects") if isinstance(op.get("objects"), list) else op.get("sources")
if not isinstance(listed, list):
return None
objects = []
for x in listed:
if not isinstance(x, Mapping):
continue
kind = x.get("kind") if x.get("kind") in ("capability", "workflow_version", "caliber") else "table"
total, explored = _num(x.get("total")), _num(x.get("explored"))
obj: dict[str, Any] = {"kind": kind}
if isinstance(x.get("source"), str) and x.get("source"):
obj["source"] = x["source"]
if isinstance(x.get("source_id"), str) and x.get("source_id"):
obj["source_id"] = x["source_id"]
obj.update(named=_names(x.get("named")), picked=_names(x.get("picked")), joined=_names(x.get("joined")),
missing=_names(x.get("missing")))
if total is not None:
obj["total"] = total
if explored is not None and explored < total:
obj["explored"] = explored
if _num(x.get("catalog_tables")) is not None:
obj["catalog_tables"] = x["catalog_tables"]
if _names(x.get("added_to_scope")):
obj["added_to_scope"] = _names(x.get("added_to_scope"))
if _num(x.get("prefiltered")) is not None:
obj["prefiltered"] = x["prefiltered"]
objects.append(obj)
out: dict[str, Any] = {"op": "look", "id": str(op.get("id") or ""), "by": "system" if op.get("by") == "system" else "model",
"need": str(op.get("need") or ""),
"status": op.get("status") if op.get("status") in ("index", "failed") else "ok"}
if isinstance(op.get("reason"), str) and op.get("reason"):
out["reason"] = op["reason"]
out["elapsed_ms"] = _num(op.get("elapsed_ms")) or 0
out["objects"] = objects
return out
# --------------------------------------------------------------------------
# 用量:{copilot, review, earlier, run},每份是 UsageTotal(同页面的 TurnUsage)
# --------------------------------------------------------------------------
def usage_total(usage: Mapping[str, Any] | None) -> dict[str, Any] | None:
"""一份用量(providers/usage 的汇总、运行的 usage)只留要记的几项。不是对象交回 None。"""
return add_total({}, usage) if isinstance(usage, Mapping) else None
def add_total(a: Mapping[str, Any] | None, b: Mapping[str, Any] | None) -> dict[str, Any]:
"""两份用量相加(缺的当 0,同页面的 addUsage):只记不为 0 的项,金额保留 6 位。"""
out = {k: v for k, v in (a or {}).items() if k in USAGE_KEYS}
for key in USAGE_KEYS:
try:
value = float((b or {}).get(key) or 0)
except (TypeError, ValueError):
continue
if value:
out[key] = (out.get(key) or 0) + value
for key in USAGE_KEYS:
if key in out and key != "cost_usd":
out[key] = int(out[key])
if "cost_usd" in out:
out["cost_usd"] = round(float(out["cost_usd"]), 6)
return out
def retire_run(usage: Mapping[str, Any] | None) -> dict[str, Any]:
"""当前这次运行换下来了(自动重来、用户点重试):它的用量并进 earlier,这一轮的花费不丢这一笔(同页面的 retireRun)。"""
out = dict(usage or {})
run = out.pop("run", None)
if run:
out["earlier"] = add_total(out.get("earlier"), run)
return out
# --------------------------------------------------------------------------
# 用户点重试:上一次的结局归档
# --------------------------------------------------------------------------
#: 归档那一次的一句话(同页面以前 attemptOf 的写法)。也是重试时交给助手的原因
ATTEMPT_TEXT = {
"cancelled": "已手动停止",
"suspended": "服务重启,运行中断",
"rejected": "被驳回",
"unusable": "结论不可用:{note}",
"partial": "存在缺口:{note}",
"done": "已给出答案",
}
def _rejected_line(rejected: Mapping[str, Any]) -> str:
"""被驳回的那一句(同页面 REJECTED_TEXT.sub):谁在哪一步驳回、备注;页面记过 sub 的照用。"""
if isinstance(rejected.get("sub"), str) and rejected["sub"]:
return rejected["sub"]
who = str(rejected.get("actor") or "").strip()
label = str(rejected.get("label") or "").strip()
where = f"「{label}」" if label else "这一步"
note = str(rejected.get("note") or "").strip()
if rejected.get("decision") == "expired_reject":
return f"{where}超过时限,按设置驳回"
return (f"{who} 驳回了{where}" if who else f"{where}被驳回") + (f":{note}" if note else "")
def attempt_record(row: Any, meta: Mapping[str, Any], *, n: int, at_ms: int | None = None) -> dict[str, Any]:
"""这一轮现在的样子折成一条留档(同页面以前的 attemptOf):{n, outcome, summary, detail?, runId, answer?, at, cause?}。
用户主动重试不能把上一次抹掉:那一次的答案可能只是有缺口、并非全错,新的一次也未必更好,两份要能对照。"""
from app.api.conversations import turn_run_id
review = row.review if isinstance(row.review, Mapping) else {}
answer = row.answer or ""
if len(answer) > ATTEMPT_ANSWER_CAP:
answer = answer[:ATTEMPT_ANSWER_CAP] + "…"
base: dict[str, Any] = {"n": n, "at": at_ms if at_ms is not None else int(time.time() * 1000),
"runId": turn_run_id(row)}
if answer:
base["answer"] = answer
signals = "\n".join(str(s.get("detail") or "") for s in review.get("signals") or [] if isinstance(s, Mapping)) or None
outcome = meta.get("outcome")
if outcome == "cancelled" and isinstance(meta.get("rejected"), Mapping):
sub = _rejected_line(meta["rejected"])
return {**base, "outcome": "rejected", "summary": ATTEMPT_TEXT["rejected"] + (f":{sub}" if sub else "")}
if outcome == "cancelled" or (row.status == "error" and (row.error or "").strip() == CANCELLED):
return {**base, "outcome": "cancelled", "summary": ATTEMPT_TEXT["cancelled"]}
if outcome == "suspended":
return {**base, "outcome": "suspended", "summary": ATTEMPT_TEXT["suspended"]}
if row.status == "error":
failure = meta.get("failure") if isinstance(meta.get("failure"), Mapping) else None
line = (f"{failure.get('title')}:{failure.get('reason')}" if failure and failure.get("reason")
else str(failure.get("title")) if failure and failure.get("title") else (row.error or "发生错误"))
out = {**base, "outcome": "failed", "summary": line}
if failure and failure.get("detail"):
out["detail"] = str(failure["detail"])
if isinstance(meta.get("cause"), Mapping):
out["cause"] = dict(meta["cause"])
return out
note = str(review.get("note") or "")
if review.get("severity") == "broken":
return {**base, "outcome": "unusable", "summary": ATTEMPT_TEXT["unusable"].format(note=note), "detail": signals}
if review.get("severity") == "degraded":
return {**base, "outcome": "partial", "summary": ATTEMPT_TEXT["partial"].format(note=note), "detail": signals}
return {**base, "outcome": "done", "summary": ATTEMPT_TEXT["done"]}
def attempt_reason(record: Mapping[str, Any]) -> str:
"""重试时交给助手的原因(同页面以前的写法):那一次的一句话,有细节的括号里带前 300 字。"""
summary = str(record.get("summary") or "")
detail = record.get("detail")
return f"{summary}({str(detail)[:300]})" if detail else summary
def with_max_steps(graph: Mapping[str, Any], steps: int) -> dict[str, Any]:
"""图里每个 agent 节点的步数上限调到 steps(「放宽步数重跑」,同页面的 withMaxSteps)。"""
nodes = []
for node in graph.get("nodes") or []:
if isinstance(node, Mapping) and node.get("type") == "agent":
data = dict(node.get("data") or {})
data["config"] = {**dict(data.get("config") or {}), "max_steps": steps}
node = {**node, "data": data}
nodes.append(node)
return {**dict(graph), "nodes": nodes}
__all__ = ["ANSWER_CAP", "ATTEMPT_ANSWER_CAP", "ATTEMPT_TEXT", "BUILD_INTERRUPTED", "CANCELLED", "GONE", "SUSPENDED",
"USAGE_KEYS", "add_total", "answer_text", "attempt_reason", "attempt_record", "has_evidence", "look_event",
"retire_run", "slim_issuance", "usage_total", "with_max_steps"]