Files
AgentLab/backend/tests/test_copilot_selfcheck.py
T
ThearchyHelios 0997603ccf 画完先自查:条件写错在校验时就拦下,Copilot 交付前按同一套规则查一遍、查出问题先改
开发库里四次运行死在同一句"循环条件写错了:表达式里不允许出现 Set"。条件原文是
`{{ vars.gate }} != "ok"`——把模板写法带进了表达式:`{{ x }}` 在 Python 里是"装着
一个集合的集合"。Copilot 的提示词写着"模板语法在任意字符串里用",模型照做而已;
校验又不看表达式,写错了只有跑到那一步才知道,前面的步骤白跑。

1. 表达式:解析和静态检查收成一个 parse_expression,运行时、校验、Copilot 自查共用,
   不会出现"校验说能跑、跑起来报错"。多套的 {{ }} 意思无歧义,在语法树上剥掉(字符串
   里的花括号不动),按 vars.x 理解并提示。真写错的给能照改的提示:| 过滤器 → len(x)、
   集合 → 列表、不认识的函数列出能用的、= 当 ==、引号里的 {{ }} 永远不成立、推导式、
   切片、私有属性。and / or 短路掉的那半边也查。

2. 校验:按节点类型和模式挑出真正求值的字段(分支条件、while 条件、整形表达式、口径卡
   指标、skip_if)。写错是 error、挡住运行;漏写 vars. 的名字提示会取到空值;while 没写
   条件、整形没填表达式也说出来。foreach 的 condition、模板模式的 expression 不求值,不查。

3. 代码节点:assign_to 拿的是 stdout。「测试 1」修好条件后跑通了、结果却是空的——代码
   最后一行是裸表达式,那是 notebook 的行为。设了 assign_to 却找不到任何输出的 Python
   代码,校验直接拦下,提示 print(json.dumps(结果))。宁可漏判不可误判:子进程、exec、
   附带文件里的 print 都算有输出;整行只有一个 {{ }} 的代码是运行时才拿到的(⑤号示例
   整段就是 {{ vars.code }}),看不见,不猜。库里 20 张工作流无一误拦。

4. Copilot 自查:搭完用同一套校验过一遍,error 级的问题连同图交回模型——只修这些、只
   输出改动,最多两轮。改好了才自动运行;改不好照实交付、列出剩下的问题,不自动开跑。
   没问题的图不多花一次调用;只是多套了 {{ }} 的不返工。提示词改成"模板只在普通文本
   字段里用",并列出表达式字段的对 / 错写法和代码节点必须 print。

5. 前端:自查这一段在 Copilot 的过程里看得见——发现几处问题、交回去改、改好了没有;
   改不好标红并说明没有自动运行。"搭好了,共几步"那一行不会被挤掉。

测试:新增 test_expressions(开发库里真实死过的四条条件、静态提示、按模式挑字段、代码
输出、带 {{ }} 的循环真跑一遍)与 test_copilot_selfcheck(脚本化模型:改好 → 自动运行;
改不好 → 不运行、最多两轮;干净的图一次调用;多套 {{ }} 不返工)。test_continue_run 里
用来制造运行时失败的表达式换成 `2 ** 100000 > 1`——原来那条现在在校验阶段就被拦下了。
check-decode 补了自查的解码断言。
2026-09-25 17:04:39 +08:00

141 lines
6.3 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():
if line.startswith("data: "):
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 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"])