mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 17:32:00 +00:00
d071bf119f
一、表结构、实体与引文
- 数据源工具查库时顺带存一份表结构快照(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 … 这类写法能骗过多语句拦截
- 看不到服务器设置时每种读法都扫一遍,读法之间切分不一致就拒绝,任何一种读法是写操作就按写操作处理
- 执行查询和工具危险判定都按数据源的方言检查
六、其他
- 修掉检查脚本里画布检查的一处竞态,并行估时按实测更新
253 lines
13 KiB
Python
253 lines
13 KiB
Python
"""受管门禁看全图默认的审批策略,自动修复改的也是全图默认。
|
||
|
||
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] == []
|