Files
AgentLab/backend/tests/test_autofix_rules.py
T
ThearchyHelios d071bf119f 可点击证据第三期:表名字段名和引文也点得开、可疑实体与结论句、受管门禁 G1–G5、证据审计导出
一、表结构、实体与引文

- 数据源工具查库时顺带存一份表结构快照(schema_snapshot),和查询快照一起进台账、进封存;
  台账里的查询记下 SQL 用到的表
- 报告里的表名、字段名可以点:反引号里的名字一律核对,正文里的裸名字只在明显是标识符时才自动链接,
  常见英文单词不链接;点开看到它出现在哪次查询、类型、所属的表,快照被截断时写明
- 找不到的名字标成可疑实体:在快照、SQL、结果列里都没有的记「可能是编造的名字」并给出最接近的 3 个;
  快照被截断、核对不了的记「核对不了」,不冤枉成编造
- 检索引文 [[q:K1|原文]]:原文在检索快照的命中片段里逐字出现(空白归一化后)才算有出处,点开高亮原文位置
- 自动链接的名字不算句子的引用,只有写作者写的引用才算

二、结论句与出具

- 报告节点新增 claims:off(默认,保持原样)或 require_citation(没有引用的结论句计入缺口)
- 受管级别的正式运行里,可疑实体和没有引用的结论句按「有缺口」降档,不拦截;探索运行和已发布级别只标注
- 契约的 claims 和报告节点的设置取更严的那个;受管正式运行里写 ignore 也按降档处理,并给出警告
- 出具横幅除了数字,也写出可疑实体和没有引用的结论句

三、受管门禁 G1–G5(只看图和配置)

- G1 带契约的出口,成果字段的文字只能来自报告撰写节点
- G2 模型写的文字不绕过报告撰写节点直接流进出口
- G3 报告撰写节点写明 numbers: strict、on_violation: fail、claims: require_citation
- G4 口径卡的输入不来自计算角色的沙箱代码
- G5 给口径卡供数的 agent 配 output_schema 并开 cite_fields
- 受管级别是错误、挡住发布;已发布级别只给警告;已经发布的版本照常运行,下次发布时按新规则检查
- 每条规则都接进发布前自动修复(一键修复 / 给选项 / 交给 Copilot);禁止的改动补上关 cite_fields、
  删 output_schema、放宽契约 claims;Copilot 兜底不许自己把代码节点标成 source、不许放宽报告节点
- 发布弹窗里选「交给 Copilot」时明确写「正在交给 Copilot」

四、旧运行与审计

- 没有契约的旧答案:数字和已封存的查询单元格、口径卡值按数值比对,每个数最多 3 个候选,
  默认折叠,写明「猜测的来源,不能当证据」
- 有契约的旧运行:候选只从已封存的口径卡里找,人为插进工件表的行不当证据
- 证据审计:GET /api/runs/{id}/evidence/audit 按「无证据 / 可疑实体 / 有出处 / 旧运行猜测」分组,
  可导出 JSON 和 CSV(带封存状态);记录页和画布上的报告节点显示「有出处 N · 无证据 M」

五、SQL 守卫修正(安全)

- 去注释改成单遍扫描,认得字符串和各方言的引号(单引号转义、MySQL 反斜杠与 #、PostgreSQL 的 $$ 与嵌套注释、
  Oracle 的 q'[…]'、SQLite 的方括号);以前字符串里的 -- 会被当成注释,SELECT '--'; DROP … 这类写法能骗过多语句拦截
- 看不到服务器设置时每种读法都扫一遍,读法之间切分不一致就拒绝,任何一种读法是写操作就按写操作处理
- 执行查询和工具危险判定都按数据源的方言检查

六、其他

- 修掉检查脚本里画布检查的一处竞态,并行估时按实测更新
2026-09-29 05:26:34 +08:00

520 lines
26 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.
"""发布前自动修复的确定性规则(engine/autofix.py)。
原则:只修「答案唯一、不降低要求」的;要人拿主意的给候选、不替人选;修完重跑 validate 和门禁,
错误必须变少、不能冒出新错误;删节点连线、删空契约、审批改成全部放行、strict 改成 false
这些降低要求的操作一律拒绝。
"""
from __future__ import annotations
import copy
import pytest
from app.engine import autofix
from app.engine.autofix import apply_fixes, forbidden_changes, judge, plan_fixes
from app.engine.governance import publish_issues
from app.engine.schema import GraphSpec, ValidationIssue
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(**contract_overrides) -> dict:
"""input → 查库 → 口径卡 → 报告撰写 → 出口(引用模式契约),受管要求都满足。"""
contract = {"report_from": "write", "metrics_from": ["card"], "required": ["gmv"], "strict": True}
contract.update(contract_overrides)
nodes = [
node("start", "input", "周期"),
node("fetch", "tool", "取数", tool="db_query__shop",
args={"sql": "SELECT SUM(amount) AS gmv, COUNT(*) AS orders FROM orders"}),
node("card", "metrics", "周报口径卡", caliber="周报口径", metrics=[
{"id": "gmv", "name": "销售额", "unit": "元", "expression": "cell(nodes.fetch, 0, 'gmv')"},
{"id": "orders", "name": "订单数", "unit": "单", "expression": "cell(nodes.fetch, 0, 'orders')"}]),
node("write", "report", "报告撰写", instructions="写周报",
numbers="strict", on_violation="fail", claims="require_citation"),
node("done", "output", "出具", fields=[{"name": "周报", "value": "{{ nodes.write.text }}"}],
contract={k: v for k, v in contract.items() if v is not None}),
]
edges = [{"id": f"e{i}", "source": a["id"], "target": b["id"]} for i, (a, b) in enumerate(zip(nodes, nodes[1:]))]
return {"nodes": nodes, "edges": edges, "viewport": {"x": 1, "y": 2, "zoom": 1}}
def find(graph: dict, nid: str) -> dict:
return next(n for n in graph["nodes"] if n["id"] == nid)
def config(graph: dict, nid: str) -> dict:
return find(graph, nid)["data"]["config"]
def plan(graph: dict, level: str = "governed", **kw) -> dict[str, dict]:
spec = GraphSpec.model_validate(graph)
return {f["id"]: f for f in plan_fixes(spec, publish_issues(spec, level=level), level=level, **kw)}
def errors(graph: dict, level: str = "governed") -> list:
return [i for i in publish_issues(GraphSpec.model_validate(graph), level=level) if i.level == "error"]
def codes(issues) -> set[str]:
return {i["code"] if isinstance(i, dict) else i.code for i in issues}
def add_card(graph: dict, nid: str = "card2") -> dict:
"""第二张口径卡:接在查库后面、报告前面。"""
graph["nodes"].insert(3, node(nid, "metrics", "第二张口径卡", caliber="周报口径", metrics=[
{"id": "refunds", "name": "退款数", "expression": "cell(nodes.fetch, 0, 'orders')"}]))
graph["edges"] += [{"source": "fetch", "target": nid}, {"source": nid, "target": "write"}]
return graph
def add_report(graph: dict, nid: str = "write2") -> dict:
graph["nodes"].insert(4, node(nid, "report", "第二份报告", instructions="写月报",
numbers="strict", on_violation="fail", claims="require_citation"))
graph["edges"] += [{"source": "card", "target": nid}, {"source": nid, "target": "done"}]
return graph
def test_the_baseline_graph_passes_the_governed_gate():
assert errors(weekly()) == []
assert plan(weekly()) == {}
# --------------------------------------------------------------------------
# auto:一键修好
# --------------------------------------------------------------------------
def test_agent_approval_never_becomes_dangerous():
graph = weekly()
graph["nodes"].insert(2, node("ask", "agent", "查数员", tools=["db_query__shop"], approval="never",
prompt="用 db_query__shop 查"))
graph["edges"] += [{"source": "start", "target": "ask"}, {"source": "ask", "target": "card"}]
fid = "governed.agent_approval_never:ask"
fix = plan(graph)[fid]
assert fix["kind"] == "auto" and fix["node_id"] == "ask"
assert fix["preview"] == {"field": "approval", "before": "never", "after": "dangerous"}
out = apply_fixes(graph, [fid], level="governed")
assert out["applied"] == [fid] and out["rejected"] == []
assert config(out["graph"], "ask")["approval"] == "dangerous"
assert out["ok"] is True and "governed.agent_approval_never" not in codes(out["remaining"])
assert out["changes"] == [{"fix_id": fid, "node_id": "ask", "node_title": "查数员", "field": "approval",
"before": "never", "after": "dangerous", "label": fix["label"]}]
assert out["ops"] == [{"op": "update_node", "id": "ask", "config": {"approval": "dangerous"}}]
def test_an_unpinned_subgraph_is_pinned_to_the_published_version():
graph = weekly()
graph["nodes"].insert(1, node("lib", "subgraph", "方法卡", workflow_id="wf-lib"))
graph["edges"] += [{"source": "start", "target": "lib"}, {"source": "lib", "target": "fetch"}]
fid = "governed.subgraph_unpinned:lib"
fix = plan(graph, versions={"wf-lib": 3})[fid]
assert fix["kind"] == "auto" and fix["preview"] == {"field": "workflow_version", "before": None, "after": 3}
out = apply_fixes(graph, [fid], level="governed", versions={"wf-lib": 3})
assert config(out["graph"], "lib")["workflow_version"] == 3 and out["ok"] is True
# 上游没有发布过:不替人挑一个草稿版本
assert plan(graph, versions={"wf-lib": None})[fid]["kind"] == "assist"
assert plan(graph)[fid]["kind"] == "assist"
def test_a_missing_metrics_from_takes_the_only_upstream_card():
graph = weekly(metrics_from=None)
fid = "contract.metrics_from_missing:done"
fix = plan(graph)[fid]
assert fix["kind"] == "auto" and fix["preview"]["after"] == ["card"]
out = apply_fixes(graph, [fid], level="governed")
assert config(out["graph"], "done")["contract"]["metrics_from"] == ["card"]
assert out["ok"] is True and len(errors(graph)) >= 2 # validate 和门禁各报一条,都消掉了
def test_several_cards_make_metrics_from_a_choice():
graph = add_card(weekly(metrics_from=None))
fid = "contract.metrics_from_missing:done"
fix = plan(graph)[fid]
assert fix["kind"] == "choice" and fix["multiple"] is True and "preview" not in fix
assert [o["value"] for o in fix["options"]] == ["card", "card2"]
assert fix.get("default") is None
# 没选就不应用
out = apply_fixes(graph, [fid], level="governed")
assert out["applied"] == [] and out["rejected"][0]["fix_id"] == fid
assert "metrics_from" not in config(out["graph"], "done")["contract"]
out = apply_fixes(graph, [fid], {fid: ["card2"]}, level="governed")
assert config(out["graph"], "done")["contract"]["metrics_from"] == ["card2"] and out["applied"] == [fid]
# 选了候选之外的
out = apply_fixes(graph, [fid], {fid: ["ghost"]}, level="governed")
assert out["applied"] == [] and "候选" in out["rejected"][0]["reason"]
def test_a_missing_report_from_takes_the_only_upstream_report():
graph = weekly(report_from=None)
fid = "contract.report_from_missing:done"
fix = plan(graph)[fid]
assert fix["kind"] == "auto" and fix["preview"] == {"field": "contract.report_from", "before": None,
"after": "write"}
out = apply_fixes(graph, [fid], level="governed")
assert config(out["graph"], "done")["contract"]["report_from"] == "write" and out["ok"] is True
def test_several_reports_make_report_from_a_choice():
graph = add_report(weekly(report_from="fetch"))
fid = "contract.report_from_invalid:done"
fix = plan(graph)[fid]
assert fix["kind"] == "choice" and not fix.get("multiple")
assert [o["value"] for o in fix["options"]] == ["write", "write2"]
out = apply_fixes(graph, [fid], {fid: "write2"}, level="governed")
assert config(out["graph"], "done")["contract"]["report_from"] == "write2" and out["ok"] is True
def test_a_wrong_report_from_takes_the_only_upstream_report():
graph = weekly(report_from="fetch")
fid = "contract.report_from_invalid:done"
fix = plan(graph)[fid]
assert fix["kind"] == "auto" and fix["preview"]["before"] == "fetch" and fix["preview"]["after"] == "write"
out = apply_fixes(graph, [fid], level="governed")
assert out["ok"] is True and config(out["graph"], "done")["contract"]["report_from"] == "write"
def test_without_a_report_node_report_from_goes_to_copilot():
# 以前这里让人在模型节点里选一段 narrative。受管级别的叙述模式过不了门禁 G1
# (governed.report_from_required),选了也发不出去:没有报告撰写节点就交给 Copilot 加一个
graph = weekly(report_from=None)
write = find(graph, "write")
write.update(type="llm", data={"label": "写周报", "config": {"prompt": "写周报", "assign_to": "report"}})
fid = "contract.report_from_missing:done"
fix = plan(graph)[fid]
assert fix["kind"] == "assist" and fix["field"] == "contract.report_from"
out = apply_fixes(graph, [fid], {fid: "{{ nodes.write.text }}"}, level="governed")
assert out["applied"] == [] and "narrative" not in config(out["graph"], "done")["contract"]
def test_strict_off_is_turned_on():
graph = weekly(strict=False)
fid = "contract.strict_off:done"
fix = plan(graph)[fid]
assert fix["kind"] == "auto" and fix["preview"] == {"field": "contract.strict", "before": False, "after": True}
out = apply_fixes(graph, [fid], level="governed")
assert config(out["graph"], "done")["contract"]["strict"] is True
assert "contract.strict_off" not in codes(out["remaining"])
def test_report_metrics_from_drops_bad_entries_and_adds_the_only_card():
graph = weekly()
config(graph, "write")["metrics_from"] = ["ghost", "fetch"]
fid = "report.metrics_from_invalid:write"
fix = plan(graph)[fid]
assert fix["kind"] == "auto" and fix["preview"] == {"field": "metrics_from", "before": ["ghost", "fetch"],
"after": ["card"]}
out = apply_fixes(graph, [fid], level="governed")
assert config(out["graph"], "write")["metrics_from"] == ["card"] and out["ok"] is True
# 留下有效的那几项,不重复补
graph = add_card(weekly())
config(graph, "write")["metrics_from"] = ["card2", "ghost"]
fix = plan(graph)[fid]
assert fix["preview"]["after"] == ["card2"]
def test_a_contract_metrics_from_pointing_nowhere_is_repaired():
graph = weekly(metrics_from=["ghost"])
fid = "contract.metrics_from_invalid:done"
fix = plan(graph)[fid]
assert fix["kind"] == "auto" and fix["preview"]["after"] == ["card"]
assert apply_fixes(graph, [fid], level="governed")["ok"] is True
# --------------------------------------------------------------------------
# choice:要人拿主意,不替人选
# --------------------------------------------------------------------------
def test_required_is_a_choice_over_the_contract_cards_metrics():
graph = weekly(required=None)
fid = "contract.required_missing:done"
fix = plan(graph)[fid]
assert fix["kind"] == "choice" and fix["multiple"] is True and fix.get("default") is None
assert [o["value"] for o in fix["options"]] == ["gmv", "orders"]
assert all("周报口径卡" in (o.get("hint") or "") for o in fix["options"])
# 「一键修复」只应用 auto 的:required 不会被顺手填上
autos = [f for f, v in plan(graph).items() if v["kind"] == "auto"]
assert fid not in autos
out = apply_fixes(graph, autos + [fid], level="governed")
assert "required" not in config(out["graph"], "done")["contract"]
assert any(r["fix_id"] == fid for r in out["rejected"])
out = apply_fixes(graph, [fid], {fid: ["gmv"]}, level="governed")
assert config(out["graph"], "done")["contract"]["required"] == ["gmv"] and out["ok"] is True
# 空选择不算选了
out = apply_fixes(graph, [fid], {fid: []}, level="governed")
assert out["applied"] == []
def test_required_candidates_follow_pinned_cards():
graph = weekly(required=None)
card = config(graph, "card")
card.pop("metrics")
card["caliber_from"] = {"workflow_id": "wf-lib", "workflow_version": 2, "node_id": "kpi"}
fid = "contract.required_missing:done"
fix = plan(graph, cards={"card": [{"id": "net", "name": "净额"}]})[fid]
assert fix["kind"] == "choice" and [o["value"] for o in fix["options"]] == ["net"]
# 查不到钉住那张卡的指标:列不出候选,交给 Copilot(它只能提问,不能编)
assert plan(graph)[fid]["kind"] == "assist"
def test_a_governed_graph_without_a_contract_gets_a_skeleton():
graph = weekly()
config(graph, "done").pop("contract")
fid = "governed.no_contract:done"
fix = plan(graph)[fid]
assert fix["kind"] == "choice" and fix["node_id"] == "done" and fix["multiple"] is True
assert [o["value"] for o in fix["options"]] == ["gmv", "orders"]
out = apply_fixes(graph, [fid], {fid: ["orders"]}, level="governed")
assert config(out["graph"], "done")["contract"] == {
"report_from": "write", "metrics_from": ["card"], "required": ["orders"], "strict": True}
assert out["ok"] is True
# 口径卡不止一张:骨架该指向哪张说不准,交给 Copilot
graph = add_card(graph)
assert plan(graph)[fid]["kind"] == "assist"
def test_a_skeleton_issue_carries_the_fix_on_the_graph_level_issue():
graph = weekly()
config(graph, "done").pop("contract")
spec = GraphSpec.model_validate(graph)
issues = publish_issues(spec, level="governed")
marked = autofix.annotate(issues, plan_fixes(spec, issues, level="governed"))
[issue] = [i for i in marked if i["code"] == "governed.no_contract"]
assert issue["node_id"] is None and issue["fix"] == "governed.no_contract:done"
def test_structural_problems_go_to_copilot():
graph = weekly()
graph["nodes"].insert(1, node("team", "supervisor", "协作团队", goal="查数", agents=[{"name": "a"}]))
graph["edges"] += [{"source": "start", "target": "team"}, {"source": "team", "target": "fetch"}]
fix = plan(graph)["governed.supervisor:team"]
assert fix["kind"] == "assist" and "固定编排" in fix["label"] and fix["node_id"] == "team"
out = apply_fixes(graph, ["governed.supervisor:team"], level="governed")
assert out["applied"] == [] and out["rejected"][0]["fix_id"] == "governed.supervisor:team"
# --------------------------------------------------------------------------
# 修完必须更好;降低要求的一律拒
# --------------------------------------------------------------------------
def broken() -> dict:
graph = weekly(metrics_from=None, report_from="fetch", strict=False)
graph["nodes"].insert(2, node("ask", "agent", "查数员", tools=["db_query__shop"], approval="never",
prompt="用 db_query__shop 查"))
graph["edges"] += [{"source": "start", "target": "ask"}, {"source": "ask", "target": "card"}]
return graph
def test_all_auto_fixes_together_leave_fewer_errors_and_keep_the_rest_of_the_graph():
graph = broken()
before = errors(graph)
autos = [f for f, v in plan(graph).items() if v["kind"] == "auto"]
assert len(autos) == 4, autos
out = apply_fixes(graph, autos, level="governed")
assert sorted(out["applied"]) == sorted(autos) and out["rejected"] == []
assert len(errors(out["graph"])) < len(before) and out["ok"] is True
# 只动了修复涉及的字段:坐标、宽度、连线 id、视口原样
assert out["graph"]["viewport"] == graph["viewport"]
assert [e.get("id") for e in out["graph"]["edges"]] == [e.get("id") for e in graph["edges"]]
assert all(n.get("width") == 240 and n["position"] == {"x": 10, "y": 20} for n in out["graph"]["nodes"])
# 原图没被就地改掉
assert graph == broken()
def test_a_stale_fix_id_is_rejected():
out = apply_fixes(weekly(), ["contract.strict_off:done"], level="governed")
assert out["applied"] == [] and out["rejected"][0]["fix_id"] == "contract.strict_off:done"
def test_an_edit_that_makes_things_worse_is_discarded(monkeypatch):
"""修复规则自己出错(比如顺手把 metrics_from 删了):复核发现冒出新错误,整条丢弃。"""
real = autofix._EDITORS["contract.strict_off"]
def sloppy(graph, fix, value):
edits = real(graph, fix, value)
contract = autofix._node(graph, fix["node_id"])["data"]["config"]["contract"]
contract.pop("metrics_from")
return edits
monkeypatch.setitem(autofix._EDITORS, "contract.strict_off", sloppy)
graph = weekly(strict=False)
out = apply_fixes(graph, ["contract.strict_off:done"], level="governed")
assert out["applied"] == [] and out["graph"] == graph
[rejected] = out["rejected"]
assert "新" in rejected["reason"], rejected
def test_judge_wants_fewer_errors_and_nothing_new():
a = ValidationIssue(level="error", message="a", node_id="n", code="x.a")
b = ValidationIssue(level="error", message="b", node_id="n", code="x.b")
c = ValidationIssue(level="error", message="c", node_id="m", code="x.c")
w = ValidationIssue(level="warning", message="w", node_id="n", code="x.w")
assert judge([a, b], [b]) is None
assert judge([a, b], [a, b]) is not None # 没变少
assert judge([a, b], [b, c]) is not None # 冒出新的
assert judge([a, w], [a], target=("x.w", "n")) is None # 只修警告:错误不增加、那条警告没了
assert judge([a, w], [a, w], target=("x.w", "n")) is not None
def test_judge_knows_an_issue_by_its_code_not_its_wording():
"""节点改了名,它上面没修好的 error 文案跟着变:有编号的按编号认,还是同一处问题。"""
a = ValidationIssue(level="error", message="「复核团队」不允许", node_id="n", code="x.a")
renamed = ValidationIssue(level="error", message="「复核团队(待拆)」不允许", node_id="n", code="x.a")
b = ValidationIssue(level="error", message="b", node_id="m", code="x.b")
assert judge([a, b], [renamed]) is None
assert judge([a, b], [renamed, ValidationIssue(level="error", message="c", node_id="n", code="x.c")]) is not None
# 没编号的问题只能按文案认
plain = ValidationIssue(level="error", message="旧文案", node_id="n")
assert judge([plain, b], [ValidationIssue(level="error", message="新文案", node_id="n")]) is not None
def raised(default: str = "dangerous") -> dict:
"""要求拧得比较高的基线:取数节点每次调用都审批,查数员仅危险工具需要审批,全图默认也写明了,
契约只放行 7 这一个不带出处的数字。"""
graph = weekly(allow_numbers=["7"])
config(graph, "fetch")["approval"] = "always"
graph["nodes"].insert(2, node("ask", "agent", "查数员", tools=["db_query__shop"], approval="dangerous",
prompt="用 db_query__shop 查"))
graph["defaults"] = {"approval": default}
return graph
@pytest.mark.parametrize("mutate, word", [
(lambda g: g["nodes"].pop(1), "删"),
(lambda g: g["edges"].pop(0), "连线"),
(lambda g: config(g, "done").pop("contract"), "契约"),
(lambda g: config(g, "done").update(contract={}), "契约"),
(lambda g: config(g, "done")["contract"].update(strict=False), "strict"),
(lambda g: config(g, "done")["contract"].update(required=[]), "required"),
(lambda g: config(g, "write").update(approval="never"), "全部自动放行"),
(lambda g: g.setdefault("defaults", {}).update(approval="never"), "全部自动放行"),
# 审批按「每次调用都审批 > 仅危险工具需要审批 > 全部自动放行」排,往宽里改、删掉都算降低要求
(lambda g: config(g, "fetch").update(approval="dangerous"), "放宽"),
(lambda g: config(g, "fetch").update(approval="Always"), "不认识"),
# 删掉写明的审批,运行时退回全图默认(这里是仅危险工具需要审批),比原来的每次调用都审批宽
(lambda g: config(g, "fetch").pop("approval"), "去掉了"),
(lambda g: config(g, "fetch").update(approval=None), "去掉了"),
(lambda g: config(g, "fetch").update(approval=""), "去掉了"),
# 全图默认删掉,退回全局设置:可能是全部自动放行
(lambda g: g["defaults"].pop("approval"), "全图默认"),
# 白名单里的数字不用引用也能过;cells 要作者显式声明,不由模型替人打开
(lambda g: config(g, "done")["contract"]["allow_numbers"].append("120"), "allow_numbers"),
(lambda g: config(g, "done")["contract"].update(allow_numbers=["7", "37"]), "allow_numbers"),
(lambda g: config(g, "done")["contract"].update(cells=True), "cells"),
# 同一个 id 换了类型:等于删了重建
(lambda g: find(g, "write").update(type="llm"), "换成"),
])
def test_lowering_the_bar_is_forbidden(mutate, word):
before = raised()
after = copy.deepcopy(before)
mutate(after)
reasons = forbidden_changes(before, after)
assert reasons and any(word in r for r in reasons), reasons
def test_unpinning_a_subgraph_is_forbidden():
before = weekly()
before["nodes"].insert(1, node("lib", "subgraph", "方法卡", workflow_id="wf-lib", workflow_version=2))
after = copy.deepcopy(before)
config(after, "lib").pop("workflow_version")
assert any("版本" in r for r in forbidden_changes(before, after))
@pytest.mark.parametrize("op", [{"op": "remove_node", "id": "fetch"},
{"op": "remove_edge", "source": "start", "target": "fetch"},
{"op": "publish", "level": "published"}])
def test_forbidden_ops_are_named(op):
assert forbidden_changes(weekly(), weekly(), [op])
@pytest.mark.parametrize("mutate, word", [
(lambda g: config(g, "card").update(approval="dangerous"), "跟随全图默认"), # 原来跟全图默认:每次调用都审批
(lambda g: g["defaults"].update(approval="dangerous"), "全图默认"),
(lambda g: (g["defaults"].pop("approval"), config(g, "card").update(approval="dangerous")), "放宽"),
])
def test_lowering_a_strict_graph_default_is_forbidden(mutate, word):
before = raised("always")
after = copy.deepcopy(before)
mutate(after)
reasons = forbidden_changes(before, after)
assert reasons and any(word in r for r in reasons), reasons
def test_dropping_a_node_approval_under_a_stricter_default_is_fine():
"""全图默认是每次调用都审批:删掉节点上的「仅危险工具需要审批」,运行时反而更严。"""
before = raised("always")
after = copy.deepcopy(before)
config(after, "ask").pop("approval")
assert forbidden_changes(before, after) == []
def test_a_new_contract_cannot_open_cells_or_whitelist_numbers():
"""原来没有契约的出口(或者新加的出口)补上契约:骨架没问题,但 cells、allow_numbers 不许替人写。"""
before = weekly()
config(before, "done").pop("contract")
skeleton = {"report_from": "write", "metrics_from": ["card"], "required": ["gmv"], "strict": True}
after = copy.deepcopy(before)
config(after, "done")["contract"] = dict(skeleton)
assert forbidden_changes(before, after) == []
config(after, "done")["contract"].update(cells=True, allow_numbers=["2026"])
reasons = forbidden_changes(before, after)
assert any("cells" in r for r in reasons) and any("allow_numbers" in r for r in reasons), reasons
fresh = copy.deepcopy(before)
fresh["nodes"].append(node("extra", "output", "另一个出口", contract={**skeleton, "cells": True}))
assert any("cells" in r for r in forbidden_changes(before, fresh))
def test_overwriting_an_existing_node_is_forbidden():
"""add_node 用了图里已有的 id:Copilot 的改图流程会拿新节点整个盖掉旧的。"""
before = weekly()
op = {"op": "add_node", "node": {"id": "write", "type": "report", "label": "报告撰写", "config": {}}}
assert any("覆盖" in r and "报告撰写" in r for r in forbidden_changes(before, before, [op]))
# 新 id 的 add_node 没问题
assert forbidden_changes(before, before, [{**op, "node": {**op["node"], "id": "write2"}}]) == []
after = copy.deepcopy(before)
find(after, "write")["type"] = "llm"
[reason] = forbidden_changes(before, after)
assert "报告撰写" in reason and "换成" in reason and "删了重建" in reason, reason
def test_a_type_change_shows_in_the_preview():
before = weekly()
after = copy.deepcopy(before)
find(after, "write")["type"] = "llm"
changes = autofix.diff_changes(before, after, fix_id="assist", label="Copilot")
assert {"fix_id": "assist", "node_id": "write", "node_title": "报告撰写", "field": "type", "before": "report",
"after": "llm", "label": "Copilot"} in changes
def test_raising_the_bar_is_fine():
before = weekly(strict=False)
after = copy.deepcopy(before)
config(after, "done")["contract"].update(strict=True, required=["gmv", "orders"])
assert forbidden_changes(before, after, [{"op": "update_node", "id": "done", "config": {}}]) == []
# 审批往严里改、去掉一条「全部自动放行」、白名单收窄、cells 关掉,都不算降低要求
before = raised()
config(before, "write")["approval"] = "never"
config(before, "done")["contract"].update(cells=True, allow_numbers=["7", "37"])
after = copy.deepcopy(before)
config(after, "ask")["approval"] = "always"
config(after, "write").pop("approval")
config(after, "done")["contract"].update(allow_numbers=["37"])
config(after, "done")["contract"].pop("cells")
assert forbidden_changes(before, after) == []
# 原来跟随全局默认(至多「仅危险工具需要审批」)的节点,写明「仅危险工具需要审批」不算放宽
before, after = weekly(), weekly()
config(after, "fetch")["approval"] = "dangerous"
assert forbidden_changes(before, after) == []
def test_published_level_only_fixes_errors():
"""已发布级别门禁只给警告:不借「一键修复」替人把审批、strict 这些往受管的要求上拧。"""
graph = broken()
fixes = plan(graph, level="published")
assert "governed.agent_approval_never:ask" not in fixes and "contract.strict_off:done" not in fixes
assert {"contract.metrics_from_missing:done", "contract.report_from_invalid:done"} <= set(fixes)