Files
AgentLab/backend/tests/test_contract_cells.py

480 lines
22 KiB
Python
Raw Permalink 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.
"""报告直接引用查询单元格 [[v:Qn.r<行>.<列>]]:受管级别的正式运行要在契约里显式声明 cells。
用户拍板:探索运行和已发布级别允许直接引用单元格;受管级别的正式运行,契约里没写
"cells": true 的,单元格引用按 unresolved 计,原因写「受管出具要在契约里声明 cells」。
- 出口复核(io.py)按这条规则独立判,不信报告节点的自查
- 报告节点写作时也照这条规则建目录:不允许时提示里只给 [[see:Qn]],写了单元格当场就被打回,
不必等到出口才发现
- 旧 narrative 契约的 matched 里,单元格引用的数字记成 cell: "Q1.r0.gmv",不是 input: None
"""
from __future__ import annotations
import asyncio
import sqlite3
import pytest
from httpx import ASGITransport, AsyncClient
from langchain_core.messages import AIMessage
from sqlalchemy import select
from app.data.engine import engines
from app.db.base import SessionLocal
from app.db.models import DataSource, Run, RunEvent, Workflow, WorkflowVersion
from app.engine.evidence import CELLS_REASON
from app.engine.runner import run_manager
from app.main import app
CELL_TEXT = "本周销售额 [[v:Q1.r0.gmv]],按口径卡是 [[m:gmv]]。"
@pytest.fixture(autouse=True)
async def engine_up():
await run_manager.setup()
yield
await run_manager.shutdown()
@pytest.fixture
async def client():
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
yield c
@pytest.fixture(autouse=True)
async def shop(tmp_path):
path = tmp_path / "shop.db"
db = sqlite3.connect(path)
db.executescript("CREATE TABLE orders (id INTEGER PRIMARY KEY, week TEXT, amount REAL);")
db.executemany("INSERT INTO orders VALUES (?, ?, ?)", [(1, "2026-W37", 100.5), (2, "2026-W37", 200.0)])
db.commit()
db.close()
async with SessionLocal() as session:
row = (await session.execute(select(DataSource).where(DataSource.name == "shop"))).scalar_one_or_none()
if row is None:
row = DataSource(name="shop", kind="sqlite", database=str(path), readonly=True, enabled=True)
session.add(row)
else:
row.database, row.kind, row.enabled, row.readonly, row.options = str(path), "sqlite", True, True, {}
await session.commit()
source_id = row.id
await engines.invalidate(source_id)
yield
await engines.invalidate(source_id)
def node(nid, ntype, **config):
return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, "data": {"label": nid, "config": config}}
def weekly(*, cells=None, on_violation="flag"):
contract = {"report_from": "write", "metrics_from": ["card"], "required": ["gmv"], "strict": True}
if cells is not None:
contract["cells"] = cells
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="周报口径", caliber_version="v2", metrics=[
{"id": "gmv", "name": "销售额", "unit": "元", "decimals": 1, "format": "thousands",
"expression": "cell(nodes.fetch, 0, 'gmv')"}]),
node("write", "report", instructions="写周报", on_violation=on_violation),
node("out", "output", fields=[{"name": "周报", "value": "{{ nodes.write.text }}"}], contract=contract),
]
return {"nodes": nodes, "edges": [{"source": a["id"], "target": b["id"]} for a, b in zip(nodes, nodes[1:])]}
class Writer:
"""报告写作者:每次都照 text 写;记下每次看到的提示。"""
def __init__(self, monkeypatch, text=CELL_TEXT):
from app.providers import mock_model
self.prompts: list[str] = []
def decide(model, messages):
self.prompts.append("\n".join(str(m.content) for m in messages))
return AIMessage(content=text)
monkeypatch.setattr(mock_model.MockChatModel, "_decide", decide)
async def wait(run_id: str) -> Run:
for _ in range(400):
await asyncio.sleep(0.05)
async with SessionLocal() as session:
row = await session.get(Run, run_id)
if row.status in ("succeeded", "failed") and row.manifest_seq is not None:
return row
raise AssertionError(f"run {run_id} 没跑完:{row.status}")
async def formal(client, graph, *, status: str) -> Run:
"""把这张图发布成 status 级别,再从发布版本发起正式运行。"""
async with SessionLocal() as session:
wf = Workflow(name=f"周报-{status}", graph=graph, status=status, published_version=1)
session.add(wf)
await session.flush()
session.add(WorkflowVersion(workflow_id=wf.id, version=1, graph=graph))
await session.commit()
wf_id = wf.id
res = await client.post("/api/runs", json={"workflow_id": wf_id, "run_class": "formal"})
assert res.status_code == 201, res.text
return await wait(res.json()["id"])
async def checked(run_id: str) -> dict:
async with SessionLocal() as session:
[event] = (await session.execute(select(RunEvent).where(
RunEvent.run_id == run_id, RunEvent.type == "report.checked"))).scalars()
return event.data
# --------------------------------------------------------------------------
# 出口复核
# --------------------------------------------------------------------------
async def test_governed_formal_run_without_cells_leaves_cell_refs_unresolved(client, monkeypatch):
Writer(monkeypatch)
row = await formal(client, weekly(), status="governed")
assert row.status == "succeeded", row.error
issuance = row.output["_issuance"]
assert issuance["mode"] == "citations" and issuance["tier"] == "withheld", issuance
[unresolved] = issuance["unresolved"]
assert unresolved["ref"] == "v:Q1.r0.gmv" and CELLS_REASON in unresolved["message"], unresolved
# 指标照常核对过;单元格那个数不算「有出处」
assert [m.get("metric") for m in issuance["matched"]] == ["gmv"]
async def test_governed_formal_run_with_cells_declared_passes(client, monkeypatch):
Writer(monkeypatch)
row = await formal(client, weekly(cells=True), status="governed")
assert row.status == "succeeded", row.error
issuance = row.output["_issuance"]
assert issuance["tier"] == "formal" and issuance["unresolved"] == [], issuance
cell = next(m for m in issuance["matched"] if m.get("cell"))
assert cell["cell"] == "Q1.r0.gmv" and cell["token"] == "300.5" and cell["metric"] is None
assert "input" not in cell and cell["eid"].startswith("ev:cell:")
@pytest.mark.parametrize("status", ["published"])
async def test_published_formal_runs_allow_cells(client, monkeypatch, status):
Writer(monkeypatch)
row = await formal(client, weekly(), status=status)
assert row.output["_issuance"]["tier"] == "formal", row.output["_issuance"]
async def test_exploratory_runs_are_not_affected(monkeypatch):
Writer(monkeypatch)
run = await run_manager.start(graph=weekly(), input_payload={})
row = await wait(run.id)
issuance = row.output["_issuance"]
assert issuance["tier"] == "formal" and issuance["unresolved"] == [], issuance
assert any(m.get("cell") == "Q1.r0.gmv" for m in issuance["matched"])
# --------------------------------------------------------------------------
# 报告节点写作时就照这条规则
# --------------------------------------------------------------------------
async def test_report_node_refuses_cells_while_writing_for_governed_formal_runs(client, monkeypatch):
writer = Writer(monkeypatch)
row = await formal(client, weekly(), status="governed")
data = await checked(row.id)
assert data["ok"] is False and data["repairs"] == 1
assert any(CELLS_REASON in v["message"] for v in data["violations"]), data["violations"]
# 提示里不再教它写单元格,只给依据写法
assert "[[v:Q1.r0." not in writer.prompts[0] and "[[see:Q1]]" in writer.prompts[0]
async def test_report_node_with_on_violation_fail_stops_before_the_exit(client, monkeypatch):
Writer(monkeypatch)
row = await formal(client, weekly(on_violation="fail"), status="governed")
assert row.status == "failed" and CELLS_REASON in (row.error or ""), row.error
async def test_report_node_teaches_cells_when_they_are_allowed(client, monkeypatch):
writer = Writer(monkeypatch)
row = await formal(client, weekly(cells=True), status="governed")
data = await checked(row.id)
assert data["ok"] is True and data["repairs"] == 0, data
assert "[[v:Q1.r0." in writer.prompts[0]
# --------------------------------------------------------------------------
# report_no_evidence:有任何一种可引用的来源就不报,报的时候说清缺的是哪种
# --------------------------------------------------------------------------
async def no_evidence_logs(run_id: str) -> list[dict]:
async with SessionLocal() as session:
rows = (await session.execute(select(RunEvent).where(
RunEvent.run_id == run_id, RunEvent.type == "log"))).scalars()
return [e.data for e in rows if e.data.get("code") == "report_no_evidence"]
def chain(*nodes):
return {"nodes": list(nodes), "edges": [{"source": a["id"], "target": b["id"]} for a, b in zip(nodes, nodes[1:])]}
FETCH = node("fetch", "tool", tool="db_query__shop", args={"sql": "SELECT SUM(amount) AS gmv FROM orders"})
WRITE = node("write", "report", instructions="回答问题", on_violation="flag")
OUT = node("out", "output", fields=[{"name": "答案", "value": "{{ nodes.write.text }}"}])
async def test_a_report_over_queries_only_is_not_warned(monkeypatch):
"""问数据的图(input → 取数 → report)只有查询条目、没有口径卡:单元格就是它的出处,不该报没有证据。"""
Writer(monkeypatch, "销售额 [[v:Q1.r0.gmv]]。")
row = await wait((await run_manager.start(graph=chain(node("start", "input"), FETCH, WRITE, OUT),
input_payload={})).id)
assert row.status == "succeeded", row.error
assert await no_evidence_logs(row.id) == []
async def test_a_report_with_nothing_upstream_names_every_missing_kind(monkeypatch):
Writer(monkeypatch, "什么都没有。")
row = await wait((await run_manager.start(graph=chain(node("start", "input"), WRITE, OUT),
input_payload={})).id)
[log] = await no_evidence_logs(row.id)
assert all(kind in log["message"] for kind in ("口径卡指标", "查询结果", "知识库检索", "运行输入")), log
async def test_queries_without_cells_in_a_governed_formal_run_are_named(client, monkeypatch):
Writer(monkeypatch, "销售额 [[v:Q1.r0.gmv]]。")
card = node("card", "metrics", caliber="周报口径", metrics=[
{"id": "gmv", "name": "销售额", "expression": "cell(nodes.fetch, 0, 'gmv')"}])
out = node("out", "output", fields=[{"name": "答案", "value": "{{ nodes.write.text }}"}],
contract={"report_from": "write", "metrics_from": ["card"], "required": ["gmv"]})
row = await formal(client, chain(node("start", "input"), FETCH, WRITE, card, out), status="governed")
[log] = await no_evidence_logs(row.id)
assert "只有查询结果" in log["message"] and CELLS_REASON in log["message"], log
# --------------------------------------------------------------------------
# 受管与否在发起时定下来:之后改图、改级别,这次运行都按发起时的算
# --------------------------------------------------------------------------
async def governance_notes(run_id: str) -> list[RunEvent]:
async with SessionLocal() as session:
rows = (await session.execute(select(RunEvent).where(
RunEvent.run_id == run_id, RunEvent.type == "log").order_by(RunEvent.seq))).scalars()
return [e for e in rows if e.data.get("code") == "run_governance"]
async def set_status(run_id: str, status: str) -> None:
async with SessionLocal() as session:
run = await session.get(Run, run_id)
wf = await session.get(Workflow, run.workflow_id)
wf.status = status
await session.commit()
@pytest.mark.parametrize("status, governed, later", [("governed", True, "draft"), ("published", False, "governed")])
async def test_the_governed_decision_is_recorded_at_start_and_does_not_follow_later_edits(
client, monkeypatch, status, governed, later):
from app.engine.governance import governed_formal
Writer(monkeypatch)
row = await formal(client, weekly(cells=True), status=status)
[note] = await governance_notes(row.id)
assert note.data["governed"] is governed and note.data["level"] == "info" and note.node_id is None
assert note.seq <= row.manifest_seq # 在封存范围里
assert await governed_formal(row.id) is governed
await set_status(row.id, later) # 改图退回草稿 / 事后升成受管
assert await governed_formal(row.id) is governed
async def test_exploratory_runs_record_nothing(monkeypatch):
Writer(monkeypatch)
row = await wait((await run_manager.start(graph=weekly(), input_payload={})).id)
assert await governance_notes(row.id) == []
async def test_runs_started_before_the_record_existed_follow_the_current_status(client, monkeypatch):
from app.engine.governance import governed_formal
Writer(monkeypatch)
row = await formal(client, weekly(cells=True), status="governed")
async with SessionLocal() as session:
for note in await governance_notes(row.id):
await session.delete(await session.get(RunEvent, note.id))
await session.commit()
assert await governed_formal(row.id) is True
await set_status(row.id, "draft")
assert await governed_formal(row.id) is False
async def test_the_report_node_and_the_exit_check_agree_when_the_status_changes_mid_run(client, monkeypatch):
"""报告节点写作时按受管拒了单元格,跑到出口前工作流被改回草稿:出口仍按受管判,两边说法一致。"""
from app.engine import governance
Writer(monkeypatch)
real, seen = governance.governed_formal, []
async def edit_after_first_look(run_id):
result = await real(run_id)
seen.append(result)
if len(seen) == 1:
await set_status(run_id, "draft")
return result
monkeypatch.setattr(governance, "governed_formal", edit_after_first_look)
row = await formal(client, weekly(), status="governed")
assert seen == [True, True], seen
issuance = row.output["_issuance"]
assert issuance["tier"] == "withheld" and CELLS_REASON in issuance["unresolved"][0]["message"], issuance
# --------------------------------------------------------------------------
# 只引单元格、没有口径卡:契约不用写 metrics_from
#
# 问数据的典型图是 input → 取数 → 报告 → 出口,报告直接引用查过的单元格。受管级别在契约里
# 写 cells: true 就够了(用户拍板的第 5 条),以前校验和门禁无条件要 metrics_from,
# 这张图怎么都发不成受管,Copilot 补上契约也会被判「冒出新的问题」作废
# --------------------------------------------------------------------------
def cells_only(**contract):
contract = {"report_from": "write", "strict": True, "cells": True, **contract}
nodes = [
node("start", "input"),
node("fetch", "tool", tool="db_query__shop",
args={"sql": "SELECT SUM(amount) AS gmv, COUNT(*) AS orders FROM orders"}),
node("write", "report", instructions="写周报", numbers="strict", on_violation="fail",
claims="require_citation", analysis_claims="strict"),
node("out", "output", fields=[{"name": "周报", "value": "{{ nodes.write.text }}"}], contract=contract),
]
return {"nodes": nodes, "edges": [{"source": a["id"], "target": b["id"]} for a, b in zip(nodes, nodes[1:])]}
def contract_codes(graph: dict, level: str = "governed") -> tuple[list[str], list[str]]:
from app.engine.governance import lint_for_publish
from app.engine.schema import GraphSpec, validate_graph
spec = GraphSpec.model_validate(graph)
return ([i.code for i in validate_graph(spec).issues if i.level == "error"],
[i.code for i in lint_for_publish(spec, level=level).issues if i.level == "error"])
def test_a_cells_only_contract_needs_no_metrics_from():
validate, gate = contract_codes(cells_only())
assert validate == [], validate
assert gate == [], gate # 受管门禁整张图一条 error 都没有
def test_a_cited_contract_that_lists_required_metrics_still_needs_metrics_from():
validate, gate = contract_codes(cells_only(required=["gmv"]))
assert "contract.metrics_from_missing" in validate and "contract.metrics_from_missing" in gate
validate, gate = contract_codes(cells_only(expected=["gmv"]))
assert "contract.metrics_from_missing" in validate and "contract.metrics_from_missing" in gate
def test_a_narrative_contract_still_needs_metrics_from():
graph = cells_only(report_from=None, narrative="{{ nodes.write.text }}")
graph["nodes"][-1]["data"]["config"]["contract"].pop("report_from")
validate, _ = contract_codes(graph, level="published") # 受管级别不收 narrative,只看校验
assert "contract.metrics_from_missing" in validate
async def test_a_governed_formal_run_over_cells_only_issues_formally(client, monkeypatch):
Writer(monkeypatch, text="本周销售额 [[v:Q1.r0.gmv]]。")
row = await formal(client, cells_only(), status="governed")
assert row.status == "succeeded", row.error
issuance = row.output["_issuance"]
assert issuance["mode"] == "citations" and issuance["tier"] == "formal", issuance
assert issuance["unresolved"] == [] and issuance["gaps"] == [], issuance
[cell] = [m for m in issuance["matched"] if m.get("cell")]
assert cell["cell"] == "Q1.r0.gmv" and cell["token"] == "300.5"
def test_a_graph_with_a_caliber_card_still_needs_required_even_without_metrics_from():
graph = weekly(cells=True)
contract = graph["nodes"][-1]["data"]["config"]["contract"]
contract.pop("metrics_from"), contract.pop("required")
validate, gate = contract_codes(graph)
assert "contract.required_missing" in gate, gate # 不写 metrics_from 绕不过必需指标
assert validate == [], validate
# --------------------------------------------------------------------------
# 没有口径卡、又没写 cells: true:受管门禁要拦下,发布弹窗里一键选
#
# 放开 metrics_from / required 之后留下的口子:这种契约以前被 metrics_from 挡着,现在能过门禁,
# 可受管正式运行里单元格引用一律不认,报告撰写节点(numbers strict、on_violation fail)每次都失败。
# 写不写 cells 要作者定(模型不许替人打开),所以修复是 choice:写 cells: true,或交给 Copilot 改成口径卡
# --------------------------------------------------------------------------
def _plan(graph: dict, level: str = "governed") -> dict[str, dict]:
from app.engine.autofix import plan_fixes
from app.engine.governance import publish_issues
from app.engine.schema import GraphSpec
spec = GraphSpec.model_validate(graph)
return {f["id"]: f for f in plan_fixes(spec, publish_issues(spec, level=level), level=level)}
def test_a_governed_graph_without_cards_must_declare_cells():
graph = cells_only()
graph["nodes"][-1]["data"]["config"]["contract"].pop("cells")
_, gate = contract_codes(graph)
assert gate == ["contract.cells_undeclared"], gate
_, gate = contract_codes(cells_only(cells=False))
assert gate == ["contract.cells_undeclared"], gate
_, gate = contract_codes(graph, level="published") # 已发布级别照样允许直接引用单元格
assert "contract.cells_undeclared" not in gate
def test_a_graph_with_a_caliber_card_does_not_need_cells():
_, gate = contract_codes(weekly()) # 有口径卡:数可以走 [[m:]],cells 是可选的
assert "contract.cells_undeclared" not in gate, gate
def test_the_fix_is_a_choice_and_picking_cells_is_not_loosening():
from app.engine.autofix import HANDOFF, apply_fixes
graph = cells_only()
graph["nodes"][-1]["data"]["config"]["contract"].pop("cells")
fid = "contract.cells_undeclared:out"
fix = _plan(graph)[fid]
assert fix["kind"] == "choice" and fix["field"] == "contract.cells"
assert [o["value"] for o in fix["options"]] == [True, HANDOFF]
assert [o.get("handoff") is True for o in fix["options"]] == [False, True]
assert apply_fixes(graph, [fid], level="governed")["applied"] == [] # 不替人选
out = apply_fixes(graph, [fid], {fid: True}, level="governed")
assert out["applied"] == [fid] and out["rejected"] == [], out["rejected"]
assert out["graph"]["nodes"][-1]["data"]["config"]["contract"]["cells"] is True and out["ok"] is True
out = apply_fixes(graph, [fid], {fid: HANDOFF}, level="governed")
assert out["handoff"] == [fid] and out["applied"] == []
def test_turning_cells_on_unasked_is_still_loosening():
from app.engine.autofix import forbidden_changes
before = cells_only()
before["nodes"][-1]["data"]["config"]["contract"].pop("cells")
after = cells_only()
[why] = forbidden_changes(before, after)
assert "单元格引用" in why
assert forbidden_changes(before, after, chosen={("out", "contract.cells")}) == []
def test_only_a_newly_raised_human_choice_error_is_let_through():
from app.engine.autofix import judge, worse
no_contract = {"level": "error", "code": "governed.no_contract", "node_id": "out", "message": "没有契约"}
cells = {"level": "error", "code": "contract.cells_undeclared", "node_id": "out", "field": "contract.cells",
"message": "要写 cells"}
other = {"level": "error", "code": "contract.strict_off", "node_id": "out", "message": "strict 关了"}
assert judge([no_contract], [cells]) is None # 补上契约、cells 留给人:算更好
assert worse([], [cells]) is None
assert "出现了新的问题" in (judge([no_contract], [other]) or "") # 别的新 error 照样作废
assert judge([no_contract], [cells, other]) is not None
# 原来就有的 cells 照常计数:修它的那一步,改完还在就是没修好
assert "仍然存在" in (judge([cells], [cells], target=("contract.cells_undeclared", "out")) or "")
assert judge([cells], [], target=("contract.cells_undeclared", "out")) is None
assert judge([cells], [cells]) == "修改后错误没有减少" # 原来就有、改完还在:Copilot 兜底不算修好