Files
AgentLab/backend/app/chat/turn.py
T
ThearchyHelios ba28dee1f0 生成过程抽成 answer_ops、复核抽成 review_of_run,助手建图和复核的用量也记下(细案 01 做法 2、3):评测和问数据页走同一条生成路径,这一轮的花费不再低报
- api/copilot.py:event_stream 原样搬成 async generator answer_ops(payload, *, catalog_view=None),交出的 op dict 不变;开流前的准备(数据源、目录检查器、助手模型、历史、查看服务)用自己的短会话,在第一条操作之前做完。SSE 路由只包一层:先取第一条,开流前的拒绝(模型没配好、限定的数据源都不在)照旧是 HTTP 错误
- catalog_view 一路传给查看服务和自查的 SQL 检查器(目录视图接缝的「生成过程从参数读」)
- providers/usage.py:Meter 是一个 LangChain 回调,和 get_usage_metadata_callback 同一个机制(登记过的上下文变量),按模型累计 tokens、次数,金额按价格目录;估不出的记 unpriced_calls。没直接用 get_usage_metadata_callback:with 块跨 yield 会把变量留在调用方上下文里,调用方随后发起的运行会继承它、被重复计一遍;metered 只在每一步 __anext__ 期间挂上,嵌套时并进外层
- 生成过程读到 done / look / ask 就关模型的流(后面的闲话不要),流末尾才报的用量拿不到:这一次照样计次数和已报的 token,记成估不出,不悄悄少算(遗留:真实接入上建图那几次多半是下限)
- providers/budget.py:BudgetFallback.astream 用 aclosing 包住里面那条流——以前调用方关流时里面那条要等垃圾回收才关,模型那次调用的收尾(用量回调)落在算完账之后;测试去掉它回跑会少计一次
- answer_ops 末尾多一条 {op: usage, by_model, input_tokens, output_tokens, calls, cost_usd, unpriced_calls};/copilot/review 拆出 review_of_run(问数据页、评测共用),结果带 usage(没调模型时全 0)和 retry_instruction(要不要带着原图重来、说什么,按 chat/turn.py 的决定算;ReviewIn 多一个 attempt)
- 新增 app/chat/turn.py(问数据一轮的决定,复核这边先用上;final.next、对照测试、前端照做在下一个提交);新增 tests/test_copilot_answer_ops.py(八种结局的 SSE 输出和抽出来之前录下的 fixtures/copilot_answer_ops_baseline.json 逐条相同,末尾多一条 usage;直接迭代和 SSE 同一串;开流前的拒绝仍是 HTTP 错误)、tests/test_usage_meter.py;读 SSE 的十一个测试文件的收流帮手跳过末尾那条 usage
2026-10-16 10:45:45 +08:00

130 lines
7.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.
"""问数据一轮里的几个「决定」(细案 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),/copilot/review 的结果带
retry_instruction(要重来时是那句话,不重来是 null)。问数据页照后端给的走;老后端没有这两个字段时退回它自己那份同样的
规则(tests/test_chat_turn_policy.py 逐条对照两边的常量和措辞)。评测(第 3 波)用同一组函数把一轮串起来。
只搬「决定」:重连、断点续看、取消、复核等多久这些仍在问数据页(把整轮编排搬到后端要重做这些,单独立项)。
"""
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"
#: 问题往哪个入口字段放:名字像「问题」的优先(同 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}。请针对这个原因调整,重新给出完整的工作流。)"
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:判失败;只有反问:等用户回答;
- 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 != "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 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 has_nodes(graph: Any) -> bool:
"""一张图有没有节点(有才交回去改)。"""
return isinstance(graph, Mapping) and bool(graph.get("nodes"))
__all__ = ["MAX_ATTEMPTS", "NEXT_ASKED", "NEXT_FAIL", "NEXT_READY", "NEXT_REPLY", "NEXT_RETRY", "NEXT_RUN", "NEXT_STEPS",
"QUESTION_FIELD", "RETRY_FROM_SCRATCH", "RETRY_WITH_BASE", "blocked_reason", "fill_input", "has_nodes",
"next_step", "retry_after_review", "retry_instruction", "review_reason"]