Files
AgentLab/backend/tests/test_evidence_challenge_marks.py

344 lines
18 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.
"""证据图上的反方审稿标记(期 4 整合 J3):GET /api/runs/{id}/evidence 的 reports[] 带 challenge_verdicts({句子: 判断})。
正文(前端 EvidenceDoc)据此给被反方审过的句子挂句末标记、点开整句面板,不逐句请求片段接口。有的被审句子既没有可点的
片段、也没有裁判判定(没开裁判的报告里一句纯文字的结论),不靠这张表,正文里就打不开它的整句面板。
- 只含被反方审过的句子;读法和片段接口的 unit.challenge(_challenge_of)是同一个来源:审稿记录里记着审的哪份报告文档,
和这一份对上才算;记录没存上时按画布上审的报告、和它跑完之前那份报告最后一次自查的文档认,判断取产出里列的那几句;
节点「报错并停止运行」时按审完那一刻的日志认。几个反方审稿审了同一份文档时,每一句取最后跑完、审到了它的那个
- 每一句的判断和点开它时片段接口答的 unit.challenge.verdict 一字不差
- 没有反方审稿(或者它直通了、一句都没审)的报告没有这个键,老运行和以前一字不差
反方审稿真跑一遍图(虚构的 SQLite 库,模型是 MockChatModel 剧本);认「哪一次」的边界情形用手写的封存事件。
"""
from __future__ import annotations
import asyncio
import json
import sqlite3
import uuid
import pytest
from httpx import ASGITransport, AsyncClient
from langchain_core.messages import AIMessage, ToolMessage
from sqlalchemy import delete
from app.api import evidence
from app.core import artifact_store
from app.core.artifact_store import put_json
from app.data import introspect
from app.data.engine import engines
from app.db.base import SessionLocal
from app.db.models import DataSource, Run, Setting
from app.engine import spend, verify_agent
from app.engine.evidence import iter_units
from app.engine.judge import SPEND_KEY as JUDGE_SPEND_KEY
from app.engine.nodes import challenge as challenge_mod
from app.engine.runner import run_manager
from app.main import app
from app.providers.mock_model import MockChatModel
@pytest.fixture(autouse=True)
async def engine_up():
await run_manager.setup()
async def clear() -> None:
async with SessionLocal() as session:
await session.execute(delete(Setting).where(Setting.key.in_(
["verify", "judge", spend.SPEND_KEY, JUDGE_SPEND_KEY])))
await session.commit()
await clear()
yield
await clear()
await run_manager.shutdown()
@pytest.fixture
async def client():
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
yield c
def node(nid, ntype, label=None, **config):
return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, "data": {"label": label or nid, "config": config}}
def chain(*nodes):
return {"nodes": list(nodes), "edges": [{"source": a["id"], "target": b["id"]} for a, b in zip(nodes, nodes[1:])]}
async def start(spec: dict) -> Run:
run_id = (await run_manager.start(graph=spec, input_payload={}, run_class="formal")).id
for _ in range(600):
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:
await run_manager.wait_idle(run_id)
return row
raise AssertionError(f"运行 {run_id} 停在 {row.status}:{row.error}")
# --------------------------------------------------------------------------
# 真跑:虚构的区域月报
# --------------------------------------------------------------------------
PRICED = "claude-sonnet-5"
#: 各区销售额(含退款):北区 1600、南区 700、西区 300
QUERY = "SELECT region AS 区域, SUM(amount) AS 销售额 FROM sales GROUP BY region ORDER BY 销售额 DESC"
NET = "SELECT region AS 区域, SUM(amount) AS 净销售额 FROM sales WHERE refunded = 0 GROUP BY region ORDER BY 净销售额 DESC"
REFUNDS = "SELECT COUNT(*) AS 退款笔数 FROM sales WHERE refunded = 1"
#: 标题、一句有依据的结论、一句预埋的错误结论(南区其实低于北区)。后一句只有文字:没有可点的片段
TEXT = "## 区域月报\n\n北区销售额 [[v:Q1.r0.销售额]],居各区之首。[[see:Q1]]南区销售额超过北区。[[see:Q1]]"
NORTH, SOUTH = "北区销售额 1,600,居各区之首。", "南区销售额超过北区。"
PLAYS = {
"南区销售额超过北区": ([NET], "refuted", "扣除退款后南区为 [[v:Q1.r1.净销售额]],仍低于北区的 [[v:Q1.r0.净销售额]]。[[see:Q1]]"),
"居各区之首": ([REFUNDS], "weakened", "北区含 [[v:Q1.r0.退款笔数]] 笔退款,扣除后仍居首,但销售额被高估。"),
}
@pytest.fixture
async def sales(tmp_path):
path = tmp_path / f"{uuid.uuid4().hex[:8]}.db"
db = sqlite3.connect(path)
db.executescript(
"CREATE TABLE sales (id INTEGER PRIMARY KEY, region TEXT, amount INTEGER, refunded INTEGER);"
"INSERT INTO sales VALUES (1, '北区', 1250, 0), (2, '北区', 350, 1), (3, '南区', 700, 0), (4, '西区', 300, 0);")
db.commit()
db.close()
name = f"months_{uuid.uuid4().hex[:8]}"
async with SessionLocal() as session:
row = DataSource(name=name, kind="sqlite", database=str(path), readonly=True, enabled=True, options={})
session.add(row)
await session.flush()
row.schema_cache = await introspect.introspect(row)
await session.commit()
source_id = row.id
yield name
await engines.invalidate(source_id)
def reviewer(monkeypatch, source: str) -> None:
"""写作者交 TEXT;反方 agent 按任务里的结论认出是哪一句,照 PLAYS 查询、交结论和理由。"""
def decide(model, messages):
if challenge_mod.CHALLENGE_SYSTEM not in str(messages[0].content):
return AIMessage(content=TEXT)
task = str(messages[1].content) if len(messages) > 1 else ""
queries, verdict, rationale = next((p for k, p in PLAYS.items() if k in task), ([], "stands", "没有找到反例。"))
if model.response_format:
return AIMessage(content=json.dumps({"verdict": verdict, "rationale": rationale}, ensure_ascii=False))
done = sum(1 for m in messages if isinstance(m, ToolMessage))
if done < len(queries):
call = {"name": f"db_query__{source}", "args": {"sql": queries[done]},
"id": f"q{done}-{uuid.uuid4().hex[:6]}"}
return AIMessage(content="查一下。", tool_calls=[call])
return AIMessage(content="查完了。")
monkeypatch.setattr(MockChatModel, "_decide", decide)
async def verify_model(session, spec):
return MockChatModel(model_name=PRICED, latency=0.0), PRICED
monkeypatch.setattr(verify_agent, "get_chat_model", verify_model)
def challenge_graph(source: str, *, challenge: bool = True, **config) -> dict:
return chain(
node("start", "input"),
node("fetch", "tool", tool=f"db_query__{source}", args={"sql": QUERY}),
node("write", "report", "写月报", instructions="写月报"),
*([node("chal", "challenge", "审稿", report="write", **config)] if challenge else []),
node("out", "output", fields=[{"name": "月报", "value": "{{ nodes.write.text }}"}],
contract={"report_from": "write"}),
)
async def graph_report(client, run_id: str) -> dict:
res = await client.get(f"/api/runs/{run_id}/evidence")
assert res.status_code == 200, res.text
[report] = res.json()["reports"]
return report
def units_of(doc: dict) -> dict[str, dict]:
"""句子编号 → (原文, 第一个不是结构性的片段):点开整句时前端请求的就是这个片段。"""
md = doc["markdown"]
out = {}
for _, unit in iter_units(doc):
first = next((s for s in unit.get("segments") or [] if s.get("kind") != "structural"), None)
start, end = unit["span"]
out[unit["id"]] = {"text": md[start:end].strip(), "kind": unit.get("kind"), "first": first}
return out
async def same_as_segments(client, run_id: str, report: dict) -> dict[str, str]:
"""每一句的标记和片段接口答的 unit.challenge.verdict 一字不差(没审到的两边都没有)。返回 原文 → 判断。"""
doc = artifact_store.load(report["doc_artifact"])
marks = report.get("challenge_verdicts", {})
said = {}
for uid, unit in units_of(doc).items():
if unit["first"] is None:
continue
res = await client.get(f"/api/runs/{run_id}/evidence/segments/{unit['first']['id']}")
assert res.status_code == 200, res.text
verdict = (res.json()["unit"].get("challenge") or {}).get("verdict")
assert marks.get(uid) == verdict, f"{unit['text']}:证据图 {marks.get(uid)!r} ≠ 片段接口 {verdict!r}"
if verdict:
said[unit["text"]] = verdict
assert set(marks) <= set(units_of(doc)), "证据图标了文档里没有的句子"
return said
async def test_the_graph_marks_every_challenged_sentence(client, monkeypatch, sales):
reviewer(monkeypatch, sales)
run = await start(challenge_graph(sales))
assert run.status == "succeeded", run.error
report = await graph_report(client, run.id)
doc = artifact_store.load(report["doc_artifact"])
units = units_of(doc)
by_text = {u["text"]: uid for uid, u in units.items()}
# 只含被审过的两句:标题、没被挑来审的句子不在里面
assert report["challenge_verdicts"] == {by_text[NORTH]: "weakened", by_text[SOUTH]: "refuted"}
heading = next(uid for uid, u in units.items() if u["kind"] == "heading")
assert heading not in report["challenge_verdicts"]
# 被推翻的那句只有文字:没有一个可点的片段,正文要靠这张表才挂得出入口
south = next(u for b in doc["blocks"] for u in b["units"] if u["id"] == by_text[SOUTH])
assert all(seg["kind"] in ("text", "structural") for seg in south["segments"])
assert await same_as_segments(client, run.id, report) == {NORTH: "weakened", SOUTH: "refuted"}
async def test_a_lost_review_record_marks_the_listed_sentences(client, monkeypatch, sales):
"""审稿记录没存上:按产出里列的那几句标,和片段接口同一个来源。"""
async def lost(self, record):
return None
monkeypatch.setattr(challenge_mod._Review, "_store", lost)
reviewer(monkeypatch, sales)
run = await start(challenge_graph(sales))
assert run.status == "succeeded", run.error
report = await graph_report(client, run.id)
assert sorted(report["challenge_verdicts"].values()) == ["refuted", "weakened"]
assert await same_as_segments(client, run.id, report) == {NORTH: "weakened", SOUTH: "refuted"}
async def test_a_review_that_stopped_the_run_still_marks(client, monkeypatch, sales):
"""「结论被推翻时」设为报错并停止运行:节点报错、运行失败,按审完那一刻的日志认审稿记录,被审的句子照样有标记。"""
reviewer(monkeypatch, sales)
run = await start(challenge_graph(sales, on_refuted="fail"))
assert run.status == "failed" and "推翻" in (run.error or ""), run.error
report = await graph_report(client, run.id)
assert await same_as_segments(client, run.id, report) == {NORTH: "weakened", SOUTH: "refuted"}
@pytest.mark.parametrize("challenge", [False, True])
async def test_reports_nobody_reviewed_have_no_marks(client, monkeypatch, sales, challenge):
"""没有反方审稿、或者反方审稿关着(直通,一句都没审):没有这个键,和以前一字不差。"""
reviewer(monkeypatch, sales)
run = await start(challenge_graph(sales, challenge=challenge, **({"enabled": False} if challenge else {})))
assert run.status == "succeeded", run.error
assert "challenge_verdicts" not in await graph_report(client, run.id)
# --------------------------------------------------------------------------
# 认「哪一次」:手写的封存事件
# --------------------------------------------------------------------------
def sealed_of(graph: dict, events: list[tuple]) -> evidence._Sealed:
return evidence._Sealed(run_id="syn-run", graph=graph, events=events,
seal={"sealed": True, "ok": True, "manifest_seq": events[-1][0], "legacy": False})
def report(doc_artifact: str) -> evidence._Report:
return evidence._Report(node_id="write", doc_artifact=doc_artifact, ok=True, repairs=0, stats=None, doc=None,
hash_ok=True, fields=[])
def claim(unit, verdict, text="一句结论。"):
return {"unit": unit, "text": text, "verdict": verdict, "rationale": "", "rationale_doc": None, "queries": []}
async def review(doc: str, claims: list[dict], *, saved: bool = True, on_refuted: str = "degrade") -> str:
"""一次反方审稿的产出工件(审稿记录存上了的带 challenge_artifact)。"""
record = await put_json({"report": "write", "doc_artifact": doc, "model": "mock", "claims": claims},
kind="challenge") if saved else None
counts = {v: sum(1 for c in claims if c["verdict"] == v) for v in ("stands", "weakened", "refuted", "inconclusive")}
items = [{k: c[k] for k in ("unit", "text", "verdict")} for c in claims]
return await put_json({"challenge_artifact": record, "counts": counts, "on_refuted": on_refuted, "items": items},
kind="node_output")
def consistent(sealed: evidence._Sealed, doc: str, units: list[str]) -> dict[str, str]:
"""标记表和逐句的 _challenge_of 一致:每一句的判断相同,没审到的两边都没有。"""
marks = evidence._challenge_verdicts(sealed, report(doc))
for uid in units:
one = evidence._challenge_of(sealed, report(doc), uid)
assert marks.get(uid) == (one or {}).get("verdict"), f"{uid}:{marks.get(uid)!r} ≠ {one!r}"
return marks
@pytest.mark.parametrize("saved", [True, False])
async def test_a_review_of_an_earlier_draft_does_not_mark_the_rewritten_report(saved):
"""报告在反方审稿之后又重写了一次:审的是前一稿,新的一稿上同一个句子编号说的可能是另一句话,不标。"""
draft = await put_json({"markdown": "第一稿", "n": 11}, kind="report_doc")
final = await put_json({"markdown": "第二稿", "n": 12}, kind="report_doc")
out = await review(draft, [claim("u1", "refuted"), claim("u2", "stands")], saved=saved)
graph = chain(node("write", "report", "写月报"), node("chal", "challenge", "审稿", report="write"))
events = [
(1, "report.checked", "write", {"doc_artifact": draft, "ok": True}),
(2, "node.finished", "write", {}),
(3, "node.finished", "chal", {"artifact": out}),
(4, "report.checked", "write", {"doc_artifact": final, "ok": True}),
]
sealed = sealed_of(graph, events)
assert consistent(sealed, draft, ["u1", "u2", "u3"]) == {"u1": "refuted", "u2": "stands"}
assert consistent(sealed, final, ["u1", "u2"]) == {}
async def test_each_sentence_takes_the_last_review_that_reached_it():
"""两个反方审稿审了同一份文档:每一句取最后跑完、审到了它的那个(同片段接口);只有前一个审到的句子照样标。"""
doc = await put_json({"markdown": "月报", "n": 13}, kind="report_doc")
first = await review(doc, [claim("u1", "refuted"), claim("u2", "stands")])
second = await review(doc, [claim("u2", "weakened"), claim("u3", "inconclusive")])
graph = chain(node("write", "report", "写月报"), node("chal1", "challenge", "审稿一", report="write"),
node("chal2", "challenge", "审稿二", report="write"))
events = [
(1, "report.checked", "write", {"doc_artifact": doc, "ok": True}),
(2, "node.finished", "chal1", {"artifact": first}),
(3, "node.finished", "chal2", {"artifact": second}),
]
marks = consistent(sealed_of(graph, events), doc, ["u1", "u2", "u3", "u4"])
assert marks == {"u1": "refuted", "u2": "weakened", "u3": "inconclusive"}
async def test_a_review_that_failed_the_node_marks_from_its_record():
"""节点报错:按审完那一刻的日志认审稿记录;重跑之后没再审完的不算,日志里没有审稿记录的认不出审的是哪一稿,也不算。"""
doc = await put_json({"markdown": "月报", "n": 14}, kind="report_doc")
record = await put_json({"report": "write", "doc_artifact": doc, "model": "mock",
"claims": [claim("u1", "refuted"), claim("u2", "weakened")]}, kind="challenge")
graph = chain(node("write", "report", "写月报"), node("chal", "challenge", "审稿", report="write",
on_refuted="fail"))
counts = {"stands": 0, "weakened": 1, "refuted": 1, "inconclusive": 0}
head = [(1, "report.checked", "write", {"doc_artifact": doc, "ok": True}), (2, "node.started", "chal", {})]
done = (3, "log", "chal", {"code": "challenge_done", "counts": counts, "artifact": record})
failed = (4, "node.failed", "chal", {"error": "反方审稿推翻了 1 句结论"})
assert consistent(sealed_of(graph, [*head, done, failed]), doc, ["u1", "u2"]) == {"u1": "refuted", "u2": "weakened"}
retried = [*head, done, (4, "node.started", "chal", {}), (5, "node.failed", "chal", {"error": "模型不可用"})]
assert consistent(sealed_of(graph, retried), doc, ["u1", "u2"]) == {}
lost = [*head, (3, "log", "chal", {"code": "challenge_done", "counts": counts, "artifact": None}), failed]
assert consistent(sealed_of(graph, lost), doc, ["u1", "u2"]) == {}
async def test_unreadable_verdicts_are_left_out():
"""判断不是一个词的(缺了、空的、不是字符串)不标;同一次审稿里一句列了两遍时取前一条(同片段接口)。"""
doc = await put_json({"markdown": "月报", "n": 15}, kind="report_doc")
out = await review(doc, [claim("u1", None), claim("u2", ""), claim("u3", 3), claim("u4", "stands"),
claim("u4", "refuted"), claim("", "refuted"), claim(None, "refuted")])
graph = chain(node("write", "report", "写月报"), node("chal", "challenge", "审稿", report="write"))
events = [(1, "report.checked", "write", {"doc_artifact": doc, "ok": True}),
(2, "node.finished", "chal", {"artifact": out})]
assert consistent(sealed_of(graph, events), doc, ["u1", "u2", "u3", "u4", ""]) == {"u4": "stands"}