mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 05:21:57 +00:00
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 补上契约也会被判「冒出新的问题」作废
400 lines
18 KiB
Python
400 lines
18 KiB
Python
"""报告直接引用查询单元格 [[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
|