"""受管门禁看全图默认的审批策略,自动修复改的也是全图默认。 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] == []