mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 17:32:00 +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
162 lines
7.7 KiB
Python
162 lines
7.7 KiB
Python
"""Copilot 搭完图先自查:和运行时同一套规则,有问题交回去改,改好再交付。
|
|
|
|
以前校验结果只是附在 final 上交给前端,error 级的问题照样交付、照样自动开跑,
|
|
跑到那一步才炸——开发库里四次运行就死在条件表达式上,前面的步骤全白跑。
|
|
|
|
这里守四件事:写错了会被交回去改、改好的图才自动运行;改不好也照实交付但不自动
|
|
运行;没问题的图不多花一次调用;只是多套了 {{ }} 的不算错,不为它返工。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
|
|
import pytest
|
|
from httpx import ASGITransport, AsyncClient
|
|
|
|
from app.main import app
|
|
|
|
|
|
@pytest.fixture
|
|
async def client():
|
|
transport = ASGITransport(app=app)
|
|
async with AsyncClient(transport=transport, base_url="http://test") as c:
|
|
yield c
|
|
|
|
|
|
def _graph_ops(condition: str) -> list[str]:
|
|
"""一张 while 循环的图,条件由参数给。"""
|
|
ops = [
|
|
{"op": "plan", "summary": "数一数"},
|
|
{"op": "add_node", "node": {"id": "start", "type": "input", "label": "输入",
|
|
"config": {"fields": [{"name": "question"}]}}},
|
|
{"op": "add_node", "node": {"id": "init", "type": "transform", "label": "准备",
|
|
"config": {"mode": "expression", "expression": "[1, 2]",
|
|
"assign_to": "items"}}},
|
|
{"op": "add_edge", "edge": {"source": "start", "target": "init"}},
|
|
{"op": "add_node", "node": {"id": "lp", "type": "loop", "label": "循环",
|
|
"config": {"mode": "while", "condition": condition,
|
|
"max_iterations": 2}}},
|
|
{"op": "add_edge", "edge": {"source": "init", "target": "lp"}},
|
|
{"op": "add_node", "node": {"id": "body", "type": "transform", "label": "循环体",
|
|
"config": {"mode": "expression", "expression": "1"}}},
|
|
{"op": "add_edge", "edge": {"source": "lp", "target": "body", "sourceHandle": "body"}},
|
|
{"op": "add_edge", "edge": {"source": "body", "target": "lp"}},
|
|
{"op": "add_node", "node": {"id": "out", "type": "output", "label": "成果",
|
|
"config": {"fields": [{"name": "结果", "value": "{{ vars.items }}"}]}}},
|
|
{"op": "add_edge", "edge": {"source": "lp", "target": "out", "sourceHandle": "done"}},
|
|
{"op": "done", "explanation": "数一数", "run": True},
|
|
]
|
|
return [json.dumps(o, ensure_ascii=False) for o in ops]
|
|
|
|
|
|
def _fix_ops(condition: str) -> list[str]:
|
|
return [json.dumps(o, ensure_ascii=False) for o in (
|
|
{"op": "plan", "summary": "改条件"},
|
|
{"op": "update_node", "id": "lp",
|
|
"config": {"mode": "while", "condition": condition, "max_iterations": 2}},
|
|
{"op": "done", "explanation": "改好了"},
|
|
)]
|
|
|
|
|
|
class _Scripted:
|
|
"""第一轮吐 first;收到自查请求(human 里有"上线前自查")时吐 fix。"""
|
|
|
|
def __init__(self, first: list[str], fix: list[str]) -> None:
|
|
self.first, self.fix = first, fix
|
|
self.calls: list[str] = []
|
|
|
|
async def astream(self, messages):
|
|
from langchain_core.messages import AIMessageChunk
|
|
|
|
human = next(text for role, text in messages if role == "human")
|
|
self.calls.append(human)
|
|
for line in (self.fix if "上线前自查" in human else self.first):
|
|
yield AIMessageChunk(content=line + "\n")
|
|
|
|
|
|
async def _generate(client, monkeypatch, model: _Scripted) -> list[dict]:
|
|
import app.api.copilot as copilot
|
|
|
|
async def _model(*_a, **_k):
|
|
return model, "mock-fast"
|
|
|
|
monkeypatch.setattr(copilot, "get_chat_model", _model)
|
|
events: list[dict] = []
|
|
async with client.stream("POST", "/api/copilot/generate-stream",
|
|
json={"instruction": "数一数"}) as r:
|
|
async for line in r.aiter_lines():
|
|
# 末尾那条 usage 是这一轮助手的用量(answer_ops),不是生成的操作
|
|
if line.startswith("data: ") and not line.startswith('data: {"op": "usage"'):
|
|
events.append(json.loads(line[6:]))
|
|
return events
|
|
|
|
|
|
def _condition(final: dict) -> str:
|
|
return next(n for n in final["graph"]["nodes"] if n["id"] == "lp")["data"]["config"]["condition"]
|
|
|
|
|
|
async def test_a_broken_condition_is_sent_back_and_fixed_before_delivery(client, monkeypatch):
|
|
model = _Scripted(_graph_ops("{{ vars.items | length }} > 5"), _fix_ops("len(vars.items) > 5"))
|
|
events = await _generate(client, monkeypatch, model)
|
|
|
|
checks = [e for e in events if e["op"] == "check"]
|
|
assert [c["status"] for c in checks] == ["repairing", "passed"], checks
|
|
assert any("len(x)" in i["message"] for i in checks[0]["issues"]), "交回去的问题里得带着能照改的提示"
|
|
assert "len(x)" in model.calls[1], "自查请求没把问题原文交给模型"
|
|
|
|
final = events[-1]
|
|
assert final["op"] == "final"
|
|
assert _condition(final) == "len(vars.items) > 5"
|
|
assert final["autorun"] is True
|
|
assert not [i for i in final["issues"] if i["level"] == "error"]
|
|
|
|
|
|
async def test_when_the_fix_does_not_take_it_is_delivered_but_not_run(client, monkeypatch):
|
|
model = _Scripted(_graph_ops("foo(vars.items)"), _fix_ops("bar(vars.items)"))
|
|
events = await _generate(client, monkeypatch, model)
|
|
|
|
checks = [e for e in events if e["op"] == "check"]
|
|
assert [c["status"] for c in checks] == ["repairing", "repairing", "failed"], checks
|
|
assert len(model.calls) == 3, "最多交回去改两轮"
|
|
final = events[-1]
|
|
assert final["autorun"] is False, "明知跑不起来的图不能自动开跑"
|
|
assert [i for i in final["issues"] if i["level"] == "error"]
|
|
|
|
|
|
async def test_a_clean_graph_costs_no_extra_call(client, monkeypatch):
|
|
model = _Scripted(_graph_ops("len(vars.items) > 5"), [])
|
|
events = await _generate(client, monkeypatch, model)
|
|
assert len(model.calls) == 1
|
|
assert [e["status"] for e in events if e["op"] == "check"] == ["passed"]
|
|
assert events[-1]["autorun"] is True
|
|
|
|
|
|
async def test_template_braces_are_accepted_without_a_rework(client, monkeypatch):
|
|
"""多套了 {{ }} 意思没有歧义,照样能跑——提示一句就行,不为它返工一轮。"""
|
|
model = _Scripted(_graph_ops("{{ vars.items }} != []"), [])
|
|
events = await _generate(client, monkeypatch, model)
|
|
assert len(model.calls) == 1
|
|
final = events[-1]
|
|
assert final["autorun"] is True
|
|
assert any("是多余的" in i["message"] for i in final["issues"])
|
|
|
|
|
|
async def test_check_issues_point_at_the_node_and_the_field(client, monkeypatch):
|
|
"""自查的问题是对象,不是拼好的一行字:界面要靠 node_id 定位到卡片、靠 field
|
|
落到检查器里具体的输入框。以前是「「lp」循环条件写错了」,节点 id 只能从字里抠。"""
|
|
model = _Scripted(_graph_ops("foo(vars.items)"), _fix_ops("bar(vars.items)"))
|
|
events = await _generate(client, monkeypatch, model)
|
|
|
|
checks = [e for e in events if e["op"] == "check"]
|
|
assert [c["status"] for c in checks] == ["repairing", "repairing", "failed"], checks
|
|
for check in checks:
|
|
assert check["issues"], check
|
|
for issue in check["issues"]:
|
|
assert isinstance(issue, dict), f"自查问题还是一行字:{issue!r}"
|
|
assert issue["node_id"] == "lp" and issue["field"] == "condition", issue
|
|
assert issue["level"] == "error"
|
|
assert issue["message"] and not issue["message"].startswith("「"), \
|
|
"节点另有 node_id,消息里不该再拼一遍"
|
|
# 交回模型的修正请求照旧是能读的一行一条,节点 id 在前
|
|
assert "「lp」" in model.calls[1]
|