mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 08:11:59 +00:00
ba28dee1f0
- 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
130 lines
7.6 KiB
Python
130 lines
7.6 KiB
Python
"""问数据一轮里的几个「决定」(细案 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"]
|