Files
AgentLab/backend/tests/test_contract_cells.py
T
ThearchyHelios 9727c21d8d 可点击证据第四期:结论句由模型裁判;只引单元格的问数据图能发成受管
一、结论句裁判

- 报告节点新增 claims: judge:写完、核对完之后,把结论句和它引用的证据摘录交给裁判模型,
  判为支持 / 部分支持 / 不支持 / 不是结论,附一句理由;判定只标注,不改写正文
- 送裁判的句子按固定规则预筛:带引用的、含方向词或因果词的送去判,短句、过渡句不送
- 证据摘录:指标写名称、值和原式;查询给 SQL 前 300 字和被引用的行,被引用的列一定给到;遮罩列一律不给;
  规则里写明「数字已经由系统核对过,不要复算」
- 一份报告合并成一次调用,超出句数就分批;结构化输出失败时退回 JSON 解析,再失败记为未裁判
- 裁判模型的优先级:节点上写的 > 设置里的「证据裁判模型」> Copilot 的模型;思考关闭;
  和写作模型相同时给警告(等于自己审自己)
- rewrite_once(默认关):把不支持的句子和理由交回写作者只改这几句,再裁一次;
  改写稿冒出原稿没有的问题就不采用,保留原稿和原来的判定

二、成本上限

- 每份报告的句数、金额、时长,探索运行每次点击的金额,全局每日金额,每一项都可以自定义,也都可以设成不限;
  默认 40 句、$0.05、30 秒,每次点击 $0.01,每天 $2
- 调用前先估算,超出上限按优先级截断:先判带引用的,再判含方向词、因果词的,其余记为未裁判并写明「已到上限」;
  不报错,也不让报告失败
- 每日花费按本地日期计数,持久化在设置表里;重跑同一次运行不重复计费;价格目录里没有的模型只计次数,并写明估不出金额

三、正式运行与探索运行

- 正式运行在报告节点内裁判,判定写进报告文档、随文档封存;不支持的按 on_unsupported(degrade / withhold)降档或不予出具,
  裁判没跑成记缺口,不能判完整出具
- 探索运行按需裁判:POST /api/runs/{id}/evidence/judge,点开哪句判哪句;判定作为 evidence.judged 事件追加在封存之后,
  标「封存后追加」,封存核对仍然一致;只读封存范围内的工件;同一句不重复判;运行在裁判期间被接着跑时不记录
- 正式运行调这个接口返回 409,写明「正式运行的裁判在节点内完成」

四、治理与自动修复

- G3 放开 judge:受管级别写 judge 必须写预算(judge.max_cost_usd,或显式写 null 表示不限)
- 发布前自动修复:缺预算时给选项(默认金额 / 不限),不替人选;
  从 require_citation 改成 judge 不算降低要求,judge 改回 off、withhold 放宽成 degrade 算降低要求
- Copilot 手册:受管模板可以写 judge 并带预算;问数据按需裁判,不替人打开结论句策略

五、界面

- 结论句句末放徽标:支持 ◆、部分支持 ◇、不支持 ! 整句浅底、未裁判 ?;不用表示「已核对」的绿色;n / N 也跳到不支持的句子
- 证据面板里「模型的解释」:带「模型判断 · 模型名 · 非确定」,封存后追加的另外标出;
  「请模型判断这句」只在探索运行、还没判过时出现,触顶时写明已到上限和怎么调
- 出具横幅多一段结论句计数;设置页新增「证据裁判」一组(裁判模型、各项上限、今日花费),设成不限时写明费用受什么约束;
  报告节点可以配 judge;运行轨迹里裁判相关的事件都有中文标题

六、门禁修正

- 引用模式下,图里没有口径卡(只引查询单元格)的,出具契约不再要求 metrics_from,受管门禁也不再要求 required:
  strict 加 cells 已经保证每个数都点得开、对不上就不予出具;有口径卡的照旧要求。
  以前问数据的图怎么都发不成受管,Copilot 补上契约也会被判「冒出新的问题」作废
2026-09-29 11:10:53 +08:00

400 lines
18 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.
"""报告直接引用查询单元格 [[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"),
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