mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 10:41:59 +00:00
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 的说法、删对话停掉这一轮、使用者的归属与遮罩只读、
访问密钥调不了、驳回与失败与发起被拒的原因、晚到的复核、跑一下与重新运行不重建。去掉几处关键实现回跑,相应的测试都会失败。
258 lines
14 KiB
Python
258 lines
14 KiB
Python
"""问数据一轮落进 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"]
|