Files
AgentLab/backend/tests/test_autofix_graph_defaults.py
T
ThearchyHelios 03c18c4d78 界面文案整改:口语改书面、术语统一、不再露出实现细节;修掉几处说错的提示;新增文案检查防回潮
一、文案(前端约 1600 处,后端传到界面的约 880 处)
- 术语统一:工作流层面用「运行」(继续运行、重新运行、试运行),节点和工具层面用「执行」,不再用「跑」;
  总时长 / 执行时长 / 等待审批;服务端;助手;Agent;token;工作流;固定版本;模型接入;系统提示;批准 / 驳回;受管级别
- 口语改书面:取不到、没取回来、读不懂、连不上、多半、照样、兜底、揉成这类说法改成「无法 / 未能 / 失败 / 请」;去掉拟人写法
- 不再露出实现细节:界面上不写 checkpoint、同一拍、哈希(凭证处除外),不写改版历史(「以前…」「老数据」);
  报错、校验、门禁、自动修复里裸写的配置键名和枚举值换成界面上的中文标签,由新的 backend/app/engine/labels.py 统一提供;
  发布弹窗里结论句的选项不再是 off / require_citation
- 同一件事只留一种说法:生成阶段文案、出具档位说明、画布锁定提示各合成一份
- 去掉界面上的工业场景示例(MES、ERP、「张工」「王工」)和真实产品名,换成通用示例

二、说错的提示(都有测试证明)
- 数据库测连接失败时的专用提示以前从不出现(按报错文字全等比较,永远比不上):改为按错误类型判断,不再拼出重复的前缀
- 「设置 → 供应商」改指「设置 → 模型接入」;「在设置里建数据源」改指「数据」页
- 缺少 API Key、模型接入已停用的报错不再夹英文,写明去哪里填
- 两处原因说错(「这种引用在后续版本支持」「这一版还不支持」)改成真实原因
- 节点说明里不准确的说法改正:代码节点在 microVM 或系统沙箱中执行;报告撰写、口径卡的说明不再说过头
- 导出的证据 CSV 里两列同名,第三列改为「片段编号」

三、前后端对齐
- 前端按后端原文识别日志、报错的地方,新旧两种写法都认得(历史运行里存的是旧文字),逐条补了新旧原文的核对
- 后端交回模型的原指令放进单独的 for_model 字段,前端在解析响应时去掉,不会显示到界面上

四、检查
- 检查脚本里按原文匹配的断言改成新文案;改完会变成永远成立的反向断言逐条修正,并做了变异验证
- 新增 check-copy(静态扫描前端界面字符串和后端上界面的文案):再出现「跑」「后端」「多半」「令牌」、
  界面上的 Copilot、裸写的配置键名、半角标点等就报错;确需保留的写进 scripts/copy-allow.json 并注明理由
2026-09-30 08:05:15 +08:00

253 lines
13 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.
"""受管门禁看全图默认的审批策略,自动修复改的也是全图默认。
agent 自己没写 approval 时,运行时跟随图级 defaults(context.approval_mode:节点 > 图级 defaults >
全局设置)。全图默认写着「全部自动放行」,带工具的 agent 就是全部自动放行,门禁以前只看节点
自己写的值,漏了这一类。
- 门禁:每个跟随它的 agent 各报一条(能定位到节点),code 是 governed.default_approval_never;
- 修复:只有一条 auto 修复(落在全图上,不落在节点上),把 defaults.approval 改成 dangerous,
跟随它的几个节点一起好;节点自己的配置一个字不动;
- 放宽方向照旧禁止。
"""
from __future__ import annotations
import copy
import pytest
from httpx import ASGITransport, AsyncClient
from app.engine import autofix
from app.engine.autofix import annotate, apply_fixes, check, forbidden_changes, plan_fixes
from app.engine.governance import lint_for_publish, publish_issues
from app.engine.schema import GraphSpec
from app.main import app
CODE = "governed.default_approval_never"
FIX = f"{CODE}:graph"
def node(nid, ntype, label=None, **config):
return {"id": nid, "type": ntype, "position": {"x": 10, "y": 20}, "width": 240,
"data": {"label": label or nid, "config": config}}
def weekly(default: str | None = "never", **ask_config) -> dict:
"""input → 查数员(agent,自己没写审批)→ 口径卡 → 报告撰写 → 出口,其余受管要求都满足。"""
ask = {"tools": ["db_query__shop"], "prompt": "用 db_query__shop 查 orders 的 gmv", **ask_config}
nodes = [
node("start", "input", "周期"),
node("ask", "agent", "查数员", **ask),
node("card", "metrics", "周报口径卡", caliber="周报口径", metrics=[
{"id": "gmv", "name": "销售额", "unit": "元", "expression": "vars.gmv"}]),
node("write", "report", "报告撰写", instructions="写周报",
numbers="strict", on_violation="fail", claims="require_citation"),
node("done", "output", "出具", fields=[{"name": "周报", "value": "{{ nodes.write.text }}"}],
contract={"report_from": "write", "metrics_from": ["card"], "required": ["gmv"], "strict": True}),
]
edges = [{"id": f"e{i}", "source": a["id"], "target": b["id"]} for i, (a, b) in enumerate(zip(nodes, nodes[1:]))]
graph = {"nodes": nodes, "edges": edges, "viewport": {"x": 1, "y": 2, "zoom": 1}}
if default is not None:
graph["defaults"] = {"approval": default, "model": "gpt-4o-mini"}
return graph
def add_agent(graph: dict, nid: str = "ask2", label: str = "复核员", **config) -> dict:
graph["nodes"].insert(2, node(nid, "agent", label, prompt="用 db_query__shop 复核", **config))
graph["edges"] += [{"source": "start", "target": nid}, {"source": nid, "target": "card"}]
return graph
def issues_of(graph: dict, level: str = "governed", code: str = CODE) -> list:
return [i for i in publish_issues(GraphSpec.model_validate(graph), level=level) if i.code == code]
def config(graph: dict, nid: str) -> dict:
return next(n for n in graph["nodes"] if n["id"] == nid)["data"]["config"]
# --------------------------------------------------------------------------
# 门禁
# --------------------------------------------------------------------------
def test_an_agent_following_a_never_default_is_blocked_on_the_governed_level():
found = issues_of(weekly())
assert len(found) == 1
issue = found[0]
assert issue.level == "error" and issue.node_id == "ask" and issue.field == "approval"
assert "跟随工作流默认设置" in issue.message and "全部无需审批" in issue.message and "「查数员」" in issue.message
# 已发布档照旧只给警告
assert [i.level for i in issues_of(weekly(), level="published")] == ["warning"]
@pytest.mark.parametrize("graph", [
weekly(default="dangerous"),
weekly(default="always"),
weekly(default=None), # 没写全图默认:退到全局设置,门禁不查全局
weekly(approval="dangerous"), # 节点自己写明了,不跟随全图默认
weekly(approval="always"),
weekly(tools=[]), # 没配工具,没有可放行的
], ids=["default-dangerous", "default-always", "no-default", "own-dangerous", "own-always", "no-tools"])
def test_nothing_to_report_when_the_agent_does_not_run_wide_open(graph):
assert issues_of(graph) == []
def test_an_empty_approval_counts_as_not_written():
"""运行时 ctx.cfg 把 None 和空串都当没写、往全图默认回退:门禁同一个口径。"""
assert [i.node_id for i in issues_of(weekly(approval=""))] == ["ask"]
assert [i.node_id for i in issues_of(weekly(approval=None))] == ["ask"]
def test_an_explicit_never_keeps_its_own_code():
"""节点自己写着全部自动放行的,照旧是 agent_approval_never(修的是节点),不重复报一条跟随默认的。"""
graph = weekly(approval="never")
assert [i.node_id for i in issues_of(graph, code="governed.agent_approval_never")] == ["ask"]
assert issues_of(graph) == []
def test_every_following_agent_is_named_on_its_own_node():
graph = add_agent(weekly(), tools=["db_query__shop"])
assert sorted(i.node_id for i in issues_of(graph)) == ["ask", "ask2"]
# --------------------------------------------------------------------------
# 修复
# --------------------------------------------------------------------------
def test_one_graph_level_auto_fix_serves_every_follower():
graph = add_agent(weekly(), tools=["db_query__shop"])
spec = GraphSpec.model_validate(graph)
fixes = {f["id"]: f for f in plan_fixes(spec, publish_issues(spec, level="governed"), level="governed")}
assert list(fixes) == [FIX]
fix = fixes[FIX]
assert fix["kind"] == "auto" and fix["node_id"] is None and fix["code"] == CODE
assert fix["preview"] == {"field": "defaults.approval", "before": "never", "after": "dangerous"}
assert "工作流默认设置" in fix["label"] and "「查数员」" in fix["label"] and "「复核员」" in fix["label"]
# 两条问题都挂上同一个修复 id(问题在节点上,修复不在)
out = check(spec, level="governed")
assert {i["node_id"]: i["fix"] for i in out["issues"] if i["code"] == CODE} == {"ask": FIX, "ask2": FIX}
assert [f["id"] for f in out["fixes"]] == [FIX]
def test_applying_it_changes_the_graph_default_and_nothing_else():
graph = add_agent(weekly(), tools=["db_query__shop"])
before = copy.deepcopy(graph)
spec = GraphSpec.model_validate(graph)
label = plan_fixes(spec, publish_issues(spec, level="governed"), level="governed")[0]["label"]
out = apply_fixes(graph, [FIX], level="governed")
assert out["applied"] == [FIX] and out["rejected"] == []
assert out["graph"]["defaults"] == {"approval": "dangerous", "model": "gpt-4o-mini"}
assert out["ok"] is True and CODE not in {i["code"] for i in out["remaining"]}
# 节点一个字不动:没给它们写上自己的审批,位置、尺寸、连线、视口原样
assert out["graph"]["nodes"] == before["nodes"] and out["graph"]["edges"] == before["edges"]
assert out["graph"]["viewport"] == before["viewport"]
assert graph == before # 入参没被改
assert out["changes"] == [{"fix_id": FIX, "node_id": None, "node_title": "", "field": "defaults.approval",
"before": "never", "after": "dangerous", "label": label}]
assert out["ops"] == [{"op": "update_defaults", "defaults": {"approval": "dangerous"}}]
assert forbidden_changes(before, out["graph"]) == []
def test_a_graph_without_defaults_gets_one():
"""defaults 缺成别的类型也补成对象再写(编辑器不崩、不丢修复)。"""
graph = weekly()
out = apply_fixes(graph, [FIX], level="governed")
assert out["graph"]["defaults"]["approval"] == "dangerous"
editor = autofix._EDITORS[CODE]
trial = {"nodes": [], "edges": [], "defaults": None}
edits = editor(trial, {"_set": {"defaults.approval": "dangerous"}, "kind": "auto"}, None)
assert trial["defaults"] == {"approval": "dangerous"}
assert edits == [(None, "defaults.approval", None, "dangerous")]
def test_mixed_explicit_and_following_agents_get_one_fix_each_way():
graph = add_agent(weekly(), approval="never", tools=["db_query__shop"])
spec = GraphSpec.model_validate(graph)
ids = [f["id"] for f in plan_fixes(spec, publish_issues(spec, level="governed"), level="governed")]
assert sorted(ids) == sorted([FIX, "governed.agent_approval_never:ask2"])
out = apply_fixes(graph, ids, level="governed")
assert sorted(out["applied"]) == sorted(ids) and out["ok"] is True
assert out["graph"]["defaults"]["approval"] == "dangerous" and config(out["graph"], "ask2")["approval"] == "dangerous"
assert "approval" not in config(out["graph"], "ask")
def test_a_rule_that_does_not_really_fix_it_is_rejected(monkeypatch):
"""图级修复要管住这个编号下挂在各个节点上的每一条:改完还在就不采纳,不能因为问题都挂在
节点上、修复不在节点上,就当成「本来就没有这处 error」放行。"""
monkeypatch.setitem(autofix._EDITORS, CODE, lambda graph, fix, value: [])
out = apply_fixes(weekly(), [FIX], level="governed")
assert out["applied"] == [] and out["rejected"][0]["fix_id"] == FIX
assert "仍然存在" in out["rejected"][0]["reason"]
@pytest.mark.parametrize("prev, now, word", [
("dangerous", "never", "全部无需审批"),
("always", "never", "全部无需审批"),
("always", "dangerous", "放宽"),
("dangerous", None, "工作流默认设置"),
])
def test_loosening_the_graph_default_is_still_forbidden(prev, now, word):
before = weekly(default=prev)
after = copy.deepcopy(before)
if now is None:
after["defaults"].pop("approval")
else:
after["defaults"]["approval"] = now
reasons = forbidden_changes(before, after)
assert reasons and any(word in r for r in reasons), reasons
def test_annotate_hands_a_graph_level_fix_to_node_issues_only_by_code():
"""图级修复认同一编号的每一条问题;别的编号、没有编号的不认。"""
fixes = [{"id": FIX, "code": CODE, "node_id": None}]
issues = [{"level": "error", "message": "a", "code": CODE, "node_id": "ask"},
{"level": "error", "message": "b", "code": "governed.agent_approval_never", "node_id": "ask"},
{"level": "error", "message": "c", "code": None, "node_id": "ask"}]
assert [i["fix"] for i in annotate(issues, fixes)] == [FIX, None, None]
# --------------------------------------------------------------------------
# 接口:发布前检查和自动修复都认,而且只读
# --------------------------------------------------------------------------
@pytest.fixture
async def client():
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
yield c
async def test_the_endpoints_report_and_fix_the_graph_default(client):
graph = add_agent(weekly(), tools=["db_query__shop"])
r = await client.post("/api/workflows", json={"name": "周报-全图默认", "graph": graph})
assert r.status_code == 201, r.text
wid = r.json()["id"]
stored = (await client.get(f"/api/workflows/{wid}")).json()
r = await client.post(f"/api/workflows/{wid}/publish-check", json={"level": "governed"})
body = r.json()
assert r.status_code == 200 and body["ok"] is False
assert {i["node_id"]: i["fix"] for i in body["issues"] if i["code"] == CODE} == {"ask": FIX, "ask2": FIX}
assert [f["id"] for f in body["fixes"]] == [FIX]
r = await client.post(f"/api/workflows/{wid}/autofix", json={"level": "governed", "apply": [FIX]})
out = r.json()
assert r.status_code == 200 and out["ok"] is True and out["applied"] == [FIX]
assert out["graph"]["defaults"]["approval"] == "dangerous"
# 只读:库里还是原来那张
after = (await client.get(f"/api/workflows/{wid}")).json()
assert after["graph"] == stored["graph"] and after["version"] == stored["version"]
# 真发布同一个口径:原图被拦,修好的图存下去能发成受管
blocked = (await client.post(f"/api/workflows/{wid}/publish", json={"level": "governed"})).json()
assert blocked["ok"] is False and CODE in {i.get("code") for i in blocked["issues"]}
await client.patch(f"/api/workflows/{wid}", json={"graph": out["graph"]})
assert (await client.post(f"/api/workflows/{wid}/publish", json={"level": "governed"})).json()["ok"] is True
def test_lint_reads_defaults_off_the_spec_not_the_node():
"""GraphSpec 的 defaults 缺省是空对象:老图没有这个键,门禁照旧。"""
spec = GraphSpec.model_validate({k: v for k, v in weekly().items() if k != "defaults"})
assert [i for i in lint_for_publish(spec, level="governed").issues if i.code == CODE] == []