mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 21: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 … 这类写法能骗过多语句拦截
- 看不到服务器设置时每种读法都扫一遍,读法之间切分不一致就拒绝,任何一种读法是写操作就按写操作处理
- 执行查询和工具危险判定都按数据源的方言检查
六、其他
- 修掉检查脚本里画布检查的一处竞态,并行估时按实测更新
216 lines
10 KiB
Python
216 lines
10 KiB
Python
"""证据门禁 G1–G5 的收尾:验收留下的小问题和 B 段的交叉请求。
|
|
|
|
- G1 和 G2 对开了 cite_fields 的 agent 字段看法一致:逐格核对过的字段是数据,不是模型写的话;
|
|
- 选项里只有标了 handoff 的那一项才是「交给 Copilot」,节点 id 恰好叫 copilot 的报告照常应用;
|
|
- 自动修复接口读不懂图时也带 handoff 键;
|
|
- 已发布级别的 G2 对每个出口都给警告(用户拍板:G 规则受管挡、已发布警告,范围一样);
|
|
- G4、G5 挡住的地方,validate 那条同义的提示不再重复;
|
|
- 删掉开了 cite_fields 的 agent 的 output_schema 算降低要求;
|
|
- Copilot 自查带上全图默认,和真正的门禁口径一致;
|
|
- 受管契约的 claims 写 on_uncited: ignore 给警告,并能一键改成 degrade。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import copy
|
|
import json
|
|
|
|
import pytest
|
|
from httpx import ASGITransport, AsyncClient
|
|
|
|
from app.engine.autofix import HANDOFF, apply_fixes, forbidden_changes
|
|
from app.engine.governance import publish_issues
|
|
from app.engine.schema import GraphSpec, validate_graph
|
|
from app.main import app
|
|
from tests.test_autofix_governance import (
|
|
SCHEMA, STRICT, codes, compute_feeding, config, create, errors, insert, node, plan, script, traceable,
|
|
with_story,
|
|
)
|
|
|
|
|
|
def issues(graph: dict, level: str = "governed") -> list:
|
|
return publish_issues(GraphSpec.model_validate(graph), level=level)
|
|
|
|
|
|
def cited_agent(graph: dict, *, cite: bool = True) -> dict:
|
|
ask = node("ask", "agent", "查数助手", prompt="查一下", tools=["db_query__shop"], approval="dangerous",
|
|
output_schema=SCHEMA, cite_fields=cite, assign_to="facts")
|
|
return insert(graph, ask, before="done", after="write")
|
|
|
|
|
|
# ---------------------------------------------------------------- G1 / G2 一致
|
|
|
|
|
|
@pytest.mark.parametrize("ref", ["{{ vars.facts.gmv }}", "{{ nodes.ask.data.gmv }}"])
|
|
def test_a_cited_agent_field_in_a_contract_exit_is_data(ref):
|
|
graph = cited_agent(traceable())
|
|
config(graph, "done")["fields"].append({"name": "GMV", "value": ref})
|
|
found = codes(errors(graph))
|
|
assert "governed.exit_text_source" not in found and "governed.text_bypass" not in found, found
|
|
assert not [f for f in plan(graph) if f.startswith("governed.exit_text_source")]
|
|
|
|
|
|
def test_an_uncited_agent_field_in_a_contract_exit_is_still_text():
|
|
graph = cited_agent(traceable(), cite=False)
|
|
config(graph, "done")["fields"].append({"name": "GMV", "value": "{{ vars.facts.gmv }}"})
|
|
assert "governed.exit_text_source" in codes(errors(graph))
|
|
|
|
|
|
# ---------------------------------------------------------------- handoff 只认标记
|
|
|
|
|
|
def renamed(graph: dict, old: str, new: str) -> dict:
|
|
text = json.dumps(graph, ensure_ascii=False)
|
|
return json.loads(text.replace(f'"{old}"', f'"{new}"').replace(f"nodes.{old}.", f"nodes.{new}."))
|
|
|
|
|
|
def test_the_handoff_option_is_marked():
|
|
fix = plan(compute_feeding())["governed.caliber_compute_input:calc"]
|
|
marked = [o for o in fix["options"] if o.get("handoff")]
|
|
assert [o["value"] for o in marked] == [HANDOFF]
|
|
|
|
|
|
def test_a_report_whose_id_is_copilot_is_applied_not_handed_off():
|
|
graph = renamed(with_story(traceable()), "write", HANDOFF)
|
|
fid = "governed.exit_text_source:done"
|
|
assert [o["value"] for o in plan(graph)[fid]["options"]] == [HANDOFF]
|
|
out = apply_fixes(graph, [fid], {fid: HANDOFF}, level="governed")
|
|
assert out["applied"] == [fid] and out["handoff"] == [], out
|
|
assert config(out["graph"], "done")["fields"][2]["value"] == "{{ nodes.copilot.text }}"
|
|
|
|
|
|
# ---------------------------------------------------------------- 接口的早退回包
|
|
|
|
|
|
@pytest.fixture
|
|
async def client():
|
|
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
|
|
yield c
|
|
|
|
|
|
async def test_autofix_on_an_unreadable_graph_still_has_handoff(client):
|
|
wf = await create(client, traceable())
|
|
body = (await client.post(f"/api/workflows/{wf}/autofix", json={
|
|
"level": "governed", "graph": {"nodes": "坏的"}, "apply": ["x"]})).json()
|
|
assert body["ok"] is False and body["handoff"] == []
|
|
|
|
|
|
# ---------------------------------------------------------------- G2 已发布级别的范围
|
|
|
|
|
|
def test_published_g2_warns_on_every_exit():
|
|
graph = {"nodes": [node("start", "input", "问题"), node("chat", "llm", "回答", prompt="{{ input.q }}"),
|
|
node("out", "output", "回答出口", fields=[{"name": "回答", "value": "{{ nodes.chat.text }}"}])],
|
|
"edges": [{"source": "start", "target": "chat"}, {"source": "chat", "target": "out"}]}
|
|
published = [i for i in issues(graph, "published") if i.code == "governed.text_bypass"]
|
|
assert [i.level for i in published] == ["warning"] and published[0].node_id == "chat"
|
|
governed = [i for i in issues(graph, "governed") if i.code == "governed.text_bypass"]
|
|
assert [i.level for i in governed] == ["error"]
|
|
|
|
|
|
# ---------------------------------------------------------------- G4 / G5 不重复
|
|
|
|
|
|
def test_the_g4_error_is_not_repeated_by_a_validate_warning():
|
|
graph = compute_feeding()
|
|
spec = GraphSpec.model_validate(graph)
|
|
# validate 自己照样提示(画布上没发布也看得到),带编号
|
|
assert "evidence.caliber_compute_input" in {i.code for i in validate_graph(spec).issues}
|
|
for level in ("governed", "published"):
|
|
found = issues(graph, level)
|
|
assert "governed.caliber_compute_input" in {i.code for i in found}
|
|
assert "evidence.caliber_compute_input" not in {i.code for i in found}, level
|
|
|
|
|
|
def test_the_g5_error_is_not_repeated_by_a_validate_warning():
|
|
graph = insert(traceable(), node("ask", "agent", "查数助手", prompt="查", tools=["db_query__shop"],
|
|
approval="dangerous", output_schema=SCHEMA, assign_to="facts"),
|
|
before="card", after="fetch")
|
|
config(graph, "card")["metrics"].append({"id": "n", "expression": "vars.facts.gmv"})
|
|
spec = GraphSpec.model_validate(graph)
|
|
assert "evidence.cite_fields_off" in {i.code for i in validate_graph(spec).issues}
|
|
found = issues(graph, "governed")
|
|
assert "governed.caliber_agent_cite_fields" in {i.code for i in found}
|
|
assert "evidence.cite_fields_off" not in {i.code for i in found}
|
|
|
|
|
|
def test_the_validate_warning_stays_where_no_gate_rule_covers_it():
|
|
graph = cited_agent(traceable(), cite=False) # 不给口径卡供数:G5 不管,validate 的提示留着
|
|
assert "evidence.cite_fields_off" in {i.code for i in issues(graph, "governed")}
|
|
|
|
|
|
# ---------------------------------------------------------------- 删 output_schema 算降低要求
|
|
|
|
|
|
def test_removing_the_schema_of_a_cited_agent_is_refused():
|
|
before = cited_agent(traceable())
|
|
after = copy.deepcopy(before)
|
|
config(after, "ask").pop("output_schema")
|
|
[reason] = forbidden_changes(before, after)
|
|
assert "「查数助手」" in reason and "output_schema" in reason
|
|
config(after, "ask")["output_schema"] = {}
|
|
assert forbidden_changes(before, after)
|
|
|
|
|
|
# ---------------------------------------------------------------- Copilot 自查带全图默认
|
|
|
|
|
|
async def test_copilot_self_check_sees_graph_defaults(client, monkeypatch):
|
|
graph = compute_feeding()
|
|
config(graph, "write").clear()
|
|
config(graph, "write")["instructions"] = "写周报"
|
|
graph["defaults"] = dict(STRICT)
|
|
assert "governed.report_policy" not in codes(errors(graph)) # 真正的门禁认全图默认
|
|
model = script(monkeypatch, [])
|
|
wf = await create(client, graph)
|
|
await client.post(f"/api/workflows/{wf}/autofix", json={"level": "governed", "apply": [], "assist": True})
|
|
human = next(text for role, text in model.calls[0] if role == "human")
|
|
assert "「计算」" in human and "要写明" not in human
|
|
|
|
|
|
async def test_copilot_self_check_still_sees_report_policy_without_defaults(client, monkeypatch):
|
|
graph = compute_feeding()
|
|
config(graph, "write").clear()
|
|
config(graph, "write")["instructions"] = "写周报"
|
|
model = script(monkeypatch, [])
|
|
wf = await create(client, graph)
|
|
await client.post(f"/api/workflows/{wf}/autofix", json={"level": "governed", "apply": [], "assist": True})
|
|
human = next(text for role, text in model.calls[0] if role == "human")
|
|
assert "要写明" in human
|
|
|
|
|
|
def test_the_assist_rules_forbid_marking_sources_and_loosening_reports():
|
|
from app.api.copilot import _ASSIST_RULES
|
|
|
|
assert "evidence_role" in _ASSIST_RULES and "source" in _ASSIST_RULES
|
|
for key in ("numbers", "on_violation", "claims", "cite_fields", "output_schema"):
|
|
assert key in _ASSIST_RULES, key
|
|
|
|
|
|
# ---------------------------------------------------------------- 契约 claims 写 ignore
|
|
|
|
|
|
def ignoring() -> dict:
|
|
return traceable(claims={"policy": "require_citation", "on_uncited": "ignore"})
|
|
|
|
|
|
def test_contract_ignore_warns_under_governed_only():
|
|
found = [i for i in issues(ignoring()) if i.code == "contract.claims_ignored"]
|
|
assert [(i.level, i.node_id, i.field) for i in found] == [("warning", "done", "contract.claims")]
|
|
assert not [i for i in issues(ignoring(), "published") if i.code == "contract.claims_ignored"]
|
|
assert not [i for i in issues(traceable(claims={"policy": "require_citation", "on_uncited": "withhold"}))
|
|
if i.code == "contract.claims_ignored"]
|
|
|
|
|
|
def test_contract_ignore_is_fixed_to_degrade():
|
|
fid = "contract.claims_ignored:done"
|
|
fix = plan(ignoring())[fid]
|
|
assert fix["kind"] == "auto" and fix["preview"] == {"field": "contract.claims.on_uncited",
|
|
"before": "ignore", "after": "degrade"}
|
|
out = apply_fixes(ignoring(), [fid], level="governed")
|
|
assert out["applied"] == [fid], out["rejected"]
|
|
assert config(out["graph"], "done")["contract"]["claims"] == {"policy": "require_citation",
|
|
"on_uncited": "degrade"}
|
|
assert "contract.claims_ignored" not in {i["code"] for i in out["remaining"]}
|
|
# 反方向(degrade 改回 ignore)是放宽,要拒
|
|
assert forbidden_changes(out["graph"], ignoring())
|