Files
AgentLab/backend/tests/test_evidence_judge_api.py
T
ThearchyHelios 2e7d6103ea 应用库可移植到 PostgreSQL:AGENTLAB_DATABASE_URL 设成 postgresql+asyncpg://… 时整个应用库用 PostgreSQL,断点用官方的 langgraph-checkpoint-postgres(同一个库,AGENTLAB_CHECKPOINT_URL 可另给);不设时照旧是数据目录里的 SQLite,行为不变
- 配置:database_url、checkpoint_url(启动时校验写法,只认 PostgreSQL)、连接池四项;启动日志写用的是哪种库(主机、库名、用户,不写密码)
- 引擎(db/base.make_engine):SQLite 照旧 WAL、外键、忙等;PostgreSQL 用连接池、探活、会话时区 UTC,装上方言调整(db/postgres.py):建表时 String(n) 是不限长的 VARCHAR(SQLite 从不查长度,导过来的存量也可能超长)、ORDER BY 里可空列的 NULL 位置照 SQLite 补写(升序在前、降序在后,窗口函数里同样);Integer 列在 PostgreSQL 上是 BIGINT(列挂到表上时换成 with_variant,绑定参数跟着列按 BIGINT 转换);JSON 列两边都存原文(PostgreSQL 的 json,不用 JSONB:JSONB 重排对象里的键,快照的列序、表的先后读回来就变了);JSON 里的 NaN 写成 null、NUL 去掉(PostgreSQL 不收)
- 迁移按方言:已有的列和索引用 inspector 认;PostgreSQL 上补列的类型取模型里这一列的、缺省值取迁移表里的(布尔 0/1 换成 false/true),ADD COLUMN IF NOT EXISTS;去重语句里的布尔按方言写;部分唯一索引补 postgresql_where;init_db 在 PostgreSQL 上拿咨询锁(几个进程同时启动不撞)
- 应用库上的 SQLite 专用写法改成按方言编译的表达式(db/portable.py):json_extract 的十几处(口径召回、出具、对账、个人应用、幂等键、访问控制、数据源变化通知、剖析任务、待办超时)换成 json_eq / json_flag / json_text,json_type 换成 json_is_string(两种库同一套带类型的比较);能力登记的 sqlite insert 换成按会话方言的 insert_ignore;能力使用次数的 json_each 按方言分支;附件「没绑记录」的条件不靠 json 的等号;按报错文字认唯一约束的地方换成 unique_violation(按表和列认,两种库的报错都认得)
- 断点(engine/checkpoints.py):PostgreSQL 时开 psycopg 连接池、在咨询锁里 setup;runner 只认 BaseCheckpointSaver,读断点仍只走 saver 的公开接口
- 依赖:新 extra postgres(asyncpg、psycopg[binary] 3、psycopg-pool、langgraph-checkpoint-postgres 3.1.2);langgraph-checkpoint 卡在 <4.3(断点格式就在这个包里,postgres 包新版要 4.3,不动已装的 4.2.0);锁文件补上新包
- 运行收尾:记 action.unknown、安排核实做完才把运行摘掉(以前先摘掉,等「不在跑了」再读事件的一方在库慢时读不到这一条)
- 测试:AGENTLAB_TEST_DATABASE_URL 时跑在 PostgreSQL 上(每个 xdist 进程建自己的库、按事件循环分的连接池 tests/pgpool.py),不设时盖掉环境里的数据库地址照旧用 SQLite;scripts/test-postgres.sh 起临时的 postgres:16 容器跑全量;sqlite_only 标记;另开连接改库的几个竞态测试、触发器测试改成两种库都能跑(tests/appdb.py);新增 test_db_portable 守着方言调整、JSON 键序、超长字符串、大整数、NULL 排序、时区、PostgreSQL 老库迁移
2026-10-19 09:52:09 +08:00

696 lines
35 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.
"""按需裁判接口 POST /api/runs/{id}/evidence/judge(四期)。
探索运行不在节点里花裁判的钱:点开哪句才判哪句。
- 判定作为批注追加在封存之后(evidence.judged 事件,post_seal: true):封存核对只覆盖 manifest_seq 之前的
事件,所以追加之后 verify 照旧一致;封存的报告文档一个字都不改
- 只在探索运行里可用:正式运行的裁判在节点内完成,按需裁判返回 409;还没封存的、升级前发起的、封存链断了的
也不判
- 每次点击的上限、全局每日上限按设置控制;触顶不是报错,照实说「已到上限」和怎么调
- 同一次运行里同一句判过就直接给已有的判定,不再调用、不再花钱(连点两下也一样)
- 裁判只看得到封存范围内的证据:人为插进工件表的、封存之后才追加事件引用的快照,一行都不进摘录;
数据源遮罩的列同样不给
- 片段接口和证据图带上判定,封存内的和封存后追加的分得开
所有裁判都用 mock 模型给剧本,不调真接口。
"""
from __future__ import annotations
import asyncio
import json
import re
import sqlite3
from types import SimpleNamespace
import pytest
from httpx import ASGITransport, AsyncClient
from langchain_core.messages import AIMessage
from sqlalchemy import delete, select, update
from app.core import artifact_store
from app.core.events import EventType
from app.data.engine import engines
from app.db.base import SessionLocal
from app.db.models import DataSource, Run, RunEvent, Setting
from app.engine import judge as judge_mod
from app.engine import runner as runner_mod
from app.engine.evidence import iter_segments, iter_units
from app.engine.judge import JUDGE_ROLE, SPEND_KEY, daily_spend
from app.engine.runner import run_manager, verify_manifest
from app.main import app
from app.providers.mock_model import MockChatModel
PRICED = "claude-sonnet-5"
METRICS = [{"id": "gmv", "name": "销售额", "unit": "元", "decimals": 1, "expression": "vars.kpi.gmv"}]
#: 一句挂了依据的结论、一句没挂依据的归因、一句连接性的话
TEXT = "## 本周\n\n本周销售额 [[m:gmv]]。[[see:m:gmv]]增长主要来自新客首单。下面看细节。"
CITED, CAUSAL, LINK = "本周销售额 45,678.5元。", "增长主要来自新客首单。", "下面看细节。"
@pytest.fixture(autouse=True)
async def engine_up():
await run_manager.setup()
async with SessionLocal() as session:
await session.execute(delete(Setting).where(Setting.key.in_([SPEND_KEY, "judge", "copilot"])))
await session.commit()
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
def node(nid, ntype, **config):
return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, "data": {"label": nid, "config": config}}
def chain(*nodes) -> dict:
ids = [n["id"] for n in nodes]
return {"nodes": list(nodes), "edges": [{"source": a, "target": b} for a, b in zip(ids, ids[1:])]}
def weekly(*, gate=False, **report) -> dict:
return chain(
node("start", "input", fields=[{"name": "week", "default": "2026-W37"}]),
node("fetch", "transform", mode="expression", expression="{'gmv': 45678.5}", assign_to="kpi"),
node("caliber", "metrics", caliber="周报口径", caliber_version="v2", metrics=METRICS),
node("write", "report", instructions="写周报", **report),
*([node("gate", "human", mode="approve", title="确认")] if gate else []),
node("out", "output", fields=[{"name": "周报", "value": "{{ nodes.write.text }}"}]),
)
class Models:
"""写作者回 text;裁判按句子里的字给判定(verdicts:{句中片段: 判定},没对上的是 supported)。
judges 记下每次交给裁判的请求原文,specs 记下每次要的裁判模型。"""
def __init__(self, monkeypatch, text=TEXT, verdicts=None, *, model=PRICED, fail_judge=None, delay=0.0):
self.judges: list[str] = []
self.specs: list = []
verdicts = verdicts or {}
def decide(chat, messages):
if JUDGE_ROLE in str(messages[0].content):
request = str(messages[-1].content)
self.judges.append(request)
if fail_judge:
raise RuntimeError(fail_judge)
items = [{"unit": uid,
"verdict": next((s for frag, s in verdicts.items() if frag in line), "supported"),
"rationale": f"判的是:{line.strip()[:16]}", "used": []}
for uid, line in re.findall(r"^\[(u\d+)\]([^\n]*)$", request, re.M)]
return AIMessage(content=json.dumps({"items": items}, ensure_ascii=False))
return AIMessage(content=text)
monkeypatch.setattr(MockChatModel, "_decide", decide)
async def judge_model_of(session, spec):
self.specs.append(spec)
if delay:
await asyncio.sleep(delay)
return MockChatModel(model_name=spec.model or model), spec.model or model
monkeypatch.setattr(judge_mod, "get_chat_model", judge_model_of)
async def wait(run_id: str, statuses=("succeeded", "failed")) -> 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 statuses and (row.status == "interrupted" or row.manifest_seq is not None):
return row
raise AssertionError(f"run {run_id} 没跑完:{row.status}")
async def finish(graph, *, run_class="exploratory", statuses=("succeeded", "failed")) -> Run:
run = await run_manager.start(graph=graph, input_payload={}, run_class=run_class)
row = await wait(run.id, statuses)
return row
def doc_of(row: Run) -> dict:
return artifact_store.load(row.output["_evidence"]["doc_artifact"])
def unit_ids(doc: dict) -> dict[str, str]:
md = doc["markdown"]
return {md[u["span"][0]:u["span"][1]].strip(): u["id"] for _, u in iter_units(doc)}
def unit_starting(doc: dict, prefix: str) -> str:
return next(uid for text, uid in unit_ids(doc).items() if text.startswith(prefix))
def seg_in(doc: dict, uid: str) -> str:
return next(s["id"] for _, u in iter_units(doc) if u["id"] == uid for s in u["segments"] if s["kind"] == "text")
async def judged_events(run_id: str) -> list[RunEvent]:
async with SessionLocal() as session:
return list((await session.execute(select(RunEvent).where(
RunEvent.run_id == run_id, RunEvent.type == EventType.EVIDENCE_JUDGED).order_by(RunEvent.seq))).scalars())
async def judge(client, run_id: str, units, **extra):
return await client.post(f"/api/runs/{run_id}/evidence/judge", json={"units": units, **extra})
async def save_setting(key: str, value) -> None:
async with SessionLocal() as session:
row = await session.get(Setting, key)
if row is None:
session.add(Setting(key=key, value=value))
else:
row.value = value
await session.commit()
# --------------------------------------------------------------------------
# 判定落在封存之后,封存核对照旧一致
# --------------------------------------------------------------------------
async def test_an_on_demand_verdict_lands_after_the_seal_and_the_seal_still_holds(client, monkeypatch):
models = Models(monkeypatch, verdicts={"新客": "contradicted"})
row = await finish(weekly(claims="judge"))
assert row.status == "succeeded" and models.judges == [], "探索运行节点里不判"
doc = doc_of(row)
ids = unit_ids(doc)
res = await judge(client, row.id, [ids[CAUSAL]])
assert res.status_code == 200, res.text
body = res.json()
verdict = body["verdicts"][ids[CAUSAL]]
assert verdict["status"] == "contradicted" and verdict["post_seal"] is True and verdict["judge"] == PRICED
assert verdict["rationale"] and body["judged"] == [ids[CAUSAL]] and body["reused"] == []
assert body["report"] == {"node_id": "write", "doc_artifact": row.output["_evidence"]["doc_artifact"]}
assert body["post_seal"] is True and body["limited"] is False and body["calls"] == 1
assert len(models.judges) == 1 and CAUSAL in models.judges[0] and CITED not in models.judges[0]
[event] = await judged_events(row.id)
assert event.seq > row.manifest_seq and event.node_id == "write" and body["event"] == {"seq": event.seq}
data = event.data
assert data["post_seal"] is True and data["report"] == "write"
assert data["doc_artifact"] == body["report"]["doc_artifact"]
assert data["verdicts"] == {ids[CAUSAL]: verdict} and data["model"] == PRICED and data["calls"] == 1
assert set(data["keys"]) == {ids[CAUSAL]} and data["units"] == [ids[CAUSAL]]
check = await verify_manifest(row.id)
assert check["ok"] is True and check["sealed_at"] == row.manifest_seq
assert (await client.get(f"/api/runs/{row.id}/verify")).json()["ok"] is True
async with SessionLocal() as session:
after = await session.get(Run, row.id)
assert after.manifest_seq == row.manifest_seq and after.last_seq == event.seq
assert doc_of(row) == doc, "封存的文档一个字都不改"
csv = await client.get(f"/api/runs/{row.id}/evidence/audit", params={"format": "csv"})
assert csv.headers["x-evidence-seal"] == "ok"
async def test_any_claim_in_an_explore_report_can_be_judged(client, monkeypatch):
"""报告节点没开 claims: judge 也能按需判:结论句都是候选。"""
models = Models(monkeypatch)
row = await finish(weekly())
ids = unit_ids(doc_of(row))
body = (await judge(client, row.id, [ids[CITED], ids[CAUSAL]])).json()
assert {u: v["status"] for u, v in body["verdicts"].items()} == {ids[CITED]: "supported", ids[CAUSAL]: "supported"}
assert len(models.judges) == 1, "一次点击的几句合成一次调用"
assert "【m:gmv】" in models.judges[0], "挂的依据摘给裁判"
async def test_sequence_numbers_continue_after_a_restart(client, monkeypatch):
"""进程重启后内存里的序号从 0 起:追加的事件要接在库里最后一条后面,不能和已有的撞号。"""
from app.core.bus import bus
Models(monkeypatch)
row = await finish(weekly())
ids = unit_ids(doc_of(row))
bus._seq.pop(row.id, None)
await judge(client, row.id, [ids[CAUSAL]])
[event] = await judged_events(row.id)
async with SessionLocal() as session:
seqs = [s for (s,) in await session.execute(select(RunEvent.seq).where(RunEvent.run_id == row.id))]
assert event.seq == max(seqs) and seqs.count(event.seq) == 1 and event.seq > row.manifest_seq
# --------------------------------------------------------------------------
# 同一句不重复判
# --------------------------------------------------------------------------
async def test_asking_again_returns_the_verdict_without_another_call(client, monkeypatch):
models = Models(monkeypatch, verdicts={"新客": "partial"})
row = await finish(weekly(claims="judge"))
ids = unit_ids(doc_of(row))
first = (await judge(client, row.id, [ids[CAUSAL]])).json()
spent = (await daily_spend())["calls"]
again = (await judge(client, row.id, [ids[CAUSAL]])).json()
assert len(models.judges) == 1 and (await daily_spend())["calls"] == spent, "不再调用、不再计费"
assert again["verdicts"] == first["verdicts"] and again["reused"] == [ids[CAUSAL]] and again["judged"] == []
assert again["calls"] == 0 and again["event"] is None and again["message"] is None
assert len(await judged_events(row.id)) == 1
# 判过的和没判过的一起点:只判没判过的
both = (await judge(client, row.id, [ids[CAUSAL], ids[CITED]])).json()
assert both["reused"] == [ids[CAUSAL]] and both["judged"] == [ids[CITED]]
assert len(models.judges) == 2 and CAUSAL not in models.judges[1]
async def test_a_double_click_is_judged_once(client, monkeypatch):
models = Models(monkeypatch, delay=0.05)
row = await finish(weekly(claims="judge"))
uid = unit_ids(doc_of(row))[CAUSAL]
one, two = await asyncio.gather(judge(client, row.id, [uid]), judge(client, row.id, [uid]))
assert one.status_code == two.status_code == 200
assert len(models.judges) == 1 and len(await judged_events(row.id)) == 1
assert sorted([one.json()["reused"], two.json()["reused"]]) == [[], [uid]]
async def test_a_failed_attempt_can_be_retried(client, monkeypatch):
Models(monkeypatch, fail_judge="网关 502")
row = await finish(weekly(claims="judge"))
uid = unit_ids(doc_of(row))[CAUSAL]
failed = (await judge(client, row.id, [uid])).json()
assert failed["verdicts"][uid]["status"] == "unjudged" and failed["verdicts"][uid]["reason"] == "error"
assert "网关 502" in failed["message"] and failed["limited"] is False
models = Models(monkeypatch, verdicts={"新客": "contradicted"})
retried = (await judge(client, row.id, [uid])).json()
assert retried["verdicts"][uid]["status"] == "contradicted" and retried["reused"] == [] and len(models.judges) == 1
# --------------------------------------------------------------------------
# 上限
# --------------------------------------------------------------------------
async def test_hitting_the_click_limit_is_a_result_not_an_error(client, monkeypatch):
models = Models(monkeypatch)
await save_setting("judge", {"click_max_cost_usd": 1e-9})
row = await finish(weekly(claims="judge"))
uid = unit_ids(doc_of(row))[CAUSAL]
res = await judge(client, row.id, [uid])
assert res.status_code == 200
body = res.json()
assert body["limited"] is True and body["limits_hit"] == ["max_cost_usd"]
assert body["unjudged"] == {"max_cost_usd": 1}
assert body["message"].startswith("已达上限") and "这次点击" in body["message"]
assert any("设置" in how and "不限" in how for how in body["adjust"])
verdict = body["verdicts"][uid]
assert verdict["status"] == "unjudged" and verdict["reason"] == "max_cost_usd" and verdict["post_seal"] is True
assert models.judges == [] and body["calls"] == 0 and body["budget"]["max_cost_usd"] == 1e-9
assert body["event"] is None and await judged_events(row.id) == [], "没问模型就没有判定可记"
async def test_the_daily_limit_applies_to_clicks_too(client, monkeypatch):
models = Models(monkeypatch)
today = (await daily_spend())["date"]
await save_setting(SPEND_KEY, {"date": today, "usd": 2.0, "calls": 9, "unpriced_calls": 0})
row = await finish(weekly(claims="judge"))
body = (await judge(client, row.id, [unit_ids(doc_of(row))[CAUSAL]])).json()
assert body["limits_hit"] == ["daily_max_usd"] and "每日" in body["message"] and models.judges == []
assert body["spend"]["usd"] == 2.0 and body["spend"]["daily_max_usd"] == 2.0
async def test_unlimited_click_budget_judges(client, monkeypatch):
models = Models(monkeypatch)
await save_setting("judge", {"click_max_cost_usd": None, "daily_max_usd": None})
row = await finish(weekly(claims="judge"))
body = (await judge(client, row.id, [unit_ids(doc_of(row))[CAUSAL]])).json()
assert body["limited"] is False and len(models.judges) == 1
assert body["budget"]["max_cost_usd"] is None and body["budget"]["daily_max_usd"] is None
assert body["spend"]["calls"] == 1 and body["spend"]["daily_max_usd"] is None
async def test_the_sentence_limit_keeps_what_was_judged(client, monkeypatch):
models = Models(monkeypatch)
row = await finish(weekly(claims="judge", judge={"max_claims": 1}))
ids = unit_ids(doc_of(row))
body = (await judge(client, row.id, [ids[CITED], ids[CAUSAL]])).json()
statuses = {u: v["status"] for u, v in body["verdicts"].items()}
assert statuses == {ids[CITED]: "supported", ids[CAUSAL]: "unjudged"} and len(models.judges) == 1
assert body["limits_hit"] == ["max_claims"] and body["verdicts"][ids[CAUSAL]]["reason"] == "max_claims"
[event] = await judged_events(row.id)
assert set(event.data["verdicts"]) == {ids[CITED], ids[CAUSAL]}, "问过模型的这一次连同没判的一起记下"
# 没判的那句还能再点
again = (await judge(client, row.id, [ids[CAUSAL]])).json()
assert again["judged"] == [ids[CAUSAL]] and again["reused"] == []
# --------------------------------------------------------------------------
# 用哪个模型
# --------------------------------------------------------------------------
async def test_the_judge_model_follows_node_then_settings(client, monkeypatch):
models = Models(monkeypatch)
await save_setting("judge", {"model": "judge-from-settings"})
row = await finish(weekly(claims="judge"))
await judge(client, row.id, [unit_ids(doc_of(row))[CAUSAL]])
[spec] = models.specs
assert spec.model == "judge-from-settings" and spec.thinking == "off" and spec.max_tokens == 16384
row = await finish(weekly(claims="judge", judge={"model": "judge-from-node"}))
body = (await judge(client, row.id, [unit_ids(doc_of(row))[CAUSAL]])).json()
assert models.specs[-1].model == "judge-from-node" and body["model"] == "judge-from-node"
# --------------------------------------------------------------------------
# 什么时候不判
# --------------------------------------------------------------------------
async def test_formal_runs_are_judged_inside_the_node_not_on_demand(client, monkeypatch):
models = Models(monkeypatch)
row = await finish(weekly(claims="judge", judge={"max_cost_usd": 0.05}), run_class="formal")
assert row.status == "succeeded", row.error
calls = len(models.judges)
res = await judge(client, row.id, [unit_ids(doc_of(row))[CAUSAL]])
assert res.status_code == 409 and res.json()["code"] == "evidence_judge_formal"
assert "正式运行的结论句已在报告撰写节点内完成裁判" in res.json()["detail"]
assert len(models.judges) == calls and await judged_events(row.id) == []
async def test_a_run_paused_at_an_approval_is_not_judged(client, monkeypatch):
"""停在审批上的运行还没封存,按需裁判不接。交给审批的报告探索运行也在节点里判过了
(test_approval_report_check),这里只看点击:不再多一次裁判调用,也不追加判定事件。"""
models = Models(monkeypatch)
row = await finish(weekly(claims="judge", gate=True), statuses=("interrupted",))
judged_in_node = list(models.judges)
assert judged_in_node
res = await judge(client, row.id, ["u1"])
assert res.status_code == 409 and res.json()["code"] == "evidence_judge_unsealed"
assert models.judges == judged_in_node and await judged_events(row.id) == []
async def test_runs_from_before_the_upgrade_are_not_judged(client, monkeypatch):
async def no_snapshot(session):
return None
monkeypatch.setattr(runner_mod, "_agent_limits", no_snapshot)
models = Models(monkeypatch)
row = await finish(weekly())
res = await judge(client, row.id, [unit_ids(doc_of(row))[CAUSAL]])
assert res.status_code == 409 and res.json()["code"] == "evidence_judge_legacy"
assert models.judges == [] and await judged_events(row.id) == []
async def test_a_broken_seal_is_not_judged(client, monkeypatch):
models = Models(monkeypatch)
row = await finish(weekly(claims="judge"))
async with SessionLocal() as session:
event = (await session.execute(select(RunEvent).where(
RunEvent.run_id == row.id, RunEvent.type == "node.finished", RunEvent.node_id == "fetch"))).scalar_one()
event.data = {**event.data, "tampered": True}
await session.commit()
res = await judge(client, row.id, [unit_ids(doc_of(row))[CAUSAL]])
assert res.status_code == 409 and res.json()["code"] == "evidence_seal_broken" and models.judges == []
@pytest.mark.parametrize("meanwhile", [
# 裁判的这几秒里有人点了「继续运行」(失败的运行可以接着跑):运行又活了
{"status": "running", "error": None},
# 接着跑完、重新封存了:manifest_seq 和清单哈希都换了,这次点击依据的那份封存已经不是最新的
{"manifest_seq": 10_000, "manifest_hash": "0" * 64},
])
async def test_a_run_continued_during_the_judge_call_records_no_event(client, monkeypatch, meanwhile):
"""判定只接在点击开始时的那份封存后面。运行在裁判期间被接着跑了,再追加就落进一次活着的运行中间,
还会被封进下一份清单:不记进运行记录,判定照样交回给点的人,并说明为什么没记。"""
models = Models(monkeypatch, verdicts={"新客": "contradicted"})
row = await finish(weekly(claims="judge"))
uid = unit_ids(doc_of(row))[CAUSAL]
ask_model = judge_mod.get_chat_model
async def continued_while_judging(session, spec):
async with SessionLocal() as other:
await other.execute(update(Run).where(Run.id == row.id).values(**meanwhile))
await other.commit()
return await ask_model(session, spec)
monkeypatch.setattr(judge_mod, "get_chat_model", continued_while_judging)
res = await judge(client, row.id, [uid])
assert res.status_code == 200, res.text
body = res.json()
assert len(models.judges) == 1 and body["calls"] == 1, "模型问过了"
assert body["verdicts"][uid]["status"] == "contradicted" and body["judged"] == [uid], "判定照样交回"
assert body["event"] is None and "运行已继续执行" in body["message"] and "未写入运行记录" in body["message"]
assert await judged_events(row.id) == []
async with SessionLocal() as session:
after = await session.get(Run, row.id)
assert after.last_seq == row.last_seq, "运行记录一条都没多"
async def test_a_continue_that_wins_the_row_between_check_and_write_gets_no_event(client, monkeypatch):
"""核对过状态、还没写入的那一下,接着跑的条件更新(runner._claim)先提交了:追加判定的条件更新落空,
照样不记——核对和写入是同一行上的条件更新,不是先读后写。"""
from app.core.bus import bus
from tests import appdb
Models(monkeypatch, verdicts={"新客": "contradicted"})
row = await finish(weekly(claims="judge"))
uid = unit_ids(doc_of(row))[CAUSAL]
set_seq = bus.set_seq
def claimed_in_between(run_id, value):
# _append_judged 读完运行、分配序号之前正好调用它:在这里用另一条连接把运行占成 running
if run_id == row.id:
appdb.execute(("UPDATE runs SET status = 'running' WHERE id = ?", (row.id,)))
set_seq(run_id, value)
monkeypatch.setattr(bus, "set_seq", claimed_in_between)
body = (await judge(client, row.id, [uid])).json()
assert body["verdicts"][uid]["status"] == "contradicted" and body["event"] is None
assert "未写入运行记录" in body["message"] and await judged_events(row.id) == []
async with SessionLocal() as session:
after = await session.get(Run, row.id)
assert after.status == "running" and after.last_seq == row.last_seq
async def test_missing_runs_and_reports(client, monkeypatch):
Models(monkeypatch)
res = await judge(client, "nope", ["u1"])
assert res.status_code == 404 and res.json()["code"] == "run_not_found"
row = await finish(chain(node("start", "input"), node("out", "output", fields=[{"name": "a", "value": "x"}])))
res = await judge(client, row.id, ["u1"])
assert res.status_code == 404 and res.json()["code"] == "evidence_report_not_found"
@pytest.mark.parametrize("payload", [{}, {"units": []}, {"units": "u1"}, {"units": [1]}, {"units": [""]},
{"units": [f"u{i}" for i in range(201)]}, {"units": ["u1"], "report": 3}])
async def test_bad_requests_are_422(client, monkeypatch, payload):
Models(monkeypatch)
row = await finish(weekly())
res = await client.post(f"/api/runs/{row.id}/evidence/judge", json=payload)
assert res.status_code == 422 and res.json()["code"] == "evidence_judge_bad_units"
async def test_sentences_that_are_not_claims_are_skipped(client, monkeypatch):
models = Models(monkeypatch)
row = await finish(weekly(claims="judge"))
ids = unit_ids(doc_of(row))
body = (await judge(client, row.id, [ids["本周"], ids[LINK], "u999"])).json()
assert body["skipped"] == {ids["本周"]: "not_a_claim", ids[LINK]: "not_a_claim", "u999": "not_found"}
assert body["verdicts"] == {} and models.judges == [] and body["event"] is None
assert "不是结论句" in body["message"]
# --------------------------------------------------------------------------
# 片段接口、证据图带上判定
# --------------------------------------------------------------------------
async def test_the_segment_shows_the_verdict_and_whether_it_can_still_be_asked(client, monkeypatch):
Models(monkeypatch, verdicts={"新客": "contradicted"})
row = await finish(weekly(claims="judge"))
doc = doc_of(row)
ids = unit_ids(doc)
before = (await client.get(f"/api/runs/{row.id}/evidence/segments/{seg_in(doc, ids[CAUSAL])}")).json()
assert before["unit"]["verdict"]["status"] == "unjudged" and before["unit"]["verdict"]["reason"] == "on_demand"
assert before["unit"]["on_demand"] == {"available": True, "reason": None, "message": None}
await judge(client, row.id, [ids[CAUSAL]])
after = (await client.get(f"/api/runs/{row.id}/evidence/segments/{seg_in(doc, ids[CAUSAL])}")).json()
assert after["unit"]["verdict"]["status"] == "contradicted" and after["unit"]["verdict"]["post_seal"] is True
assert after["unit"]["on_demand"]["available"] is False and after["unit"]["on_demand"]["reason"] == "judged"
assert "模型" in after["note"] and "后续版本" not in after["note"]
other = (await client.get(f"/api/runs/{row.id}/evidence/segments/{seg_in(doc, ids[CITED])}")).json()
assert other["unit"]["on_demand"]["available"] is True
assert "附有依据" in other["note"] and "模型" in other["note"] and "后续版本" not in other["note"]
link = (await client.get(f"/api/runs/{row.id}/evidence/segments/{seg_in(doc, ids[LINK])}")).json()
assert link["unit"]["on_demand"]["reason"] == "not_a_claim" and "verdict" not in link["unit"]
graph = (await client.get(f"/api/runs/{row.id}/evidence")).json()
[report] = graph["reports"]
assert report["claims"] == "judge" and report["judge"]["mode"] == "on_demand"
assert report["post_seal_verdicts"] == {ids[CAUSAL]: after["unit"]["verdict"]}
audit = (await client.get(f"/api/runs/{row.id}/evidence/audit")).json()
assert audit["reports"][0]["post_seal_verdicts"] == report["post_seal_verdicts"]
async def test_sealed_verdicts_of_a_formal_run_are_shown_as_sealed(client, monkeypatch):
Models(monkeypatch, verdicts={"新客": "contradicted"})
row = await finish(weekly(claims="judge", judge={"max_cost_usd": 0.05}), run_class="formal")
doc = doc_of(row)
body = (await client.get(f"/api/runs/{row.id}/evidence/segments/{seg_in(doc, unit_ids(doc)[CAUSAL])}")).json()
assert body["unit"]["verdict"]["status"] == "contradicted" and body["unit"]["verdict"]["post_seal"] is False
assert body["unit"]["on_demand"]["reason"] == "formal"
graph = (await client.get(f"/api/runs/{row.id}/evidence")).json()
assert graph["reports"][0]["post_seal_verdicts"] == {} and graph["reports"][0]["judge"]["mode"] == "inline"
async def test_a_forged_event_cannot_overwrite_a_sealed_verdict(client, monkeypatch):
"""封存后追加的判定只填补没判过的句子:封存内判过的,后面追加什么都盖不掉。"""
Models(monkeypatch, verdicts={"新客": "contradicted"})
row = await finish(weekly(claims="judge", judge={"max_cost_usd": 0.05}), run_class="formal")
doc = doc_of(row)
uid = unit_ids(doc)[CAUSAL]
doc_artifact = row.output["_evidence"]["doc_artifact"]
await run_manager.note(row.id, EventType.EVIDENCE_JUDGED, node_id="write", report="write",
doc_artifact=doc_artifact, post_seal=True,
verdicts={uid: {"status": "supported", "rationale": "改好了", "judge": "x", "post_seal": True}})
body = (await client.get(f"/api/runs/{row.id}/evidence/segments/{seg_in(doc, uid)}")).json()
assert body["unit"]["verdict"]["status"] == "contradicted"
async def test_appended_verdicts_always_read_as_post_seal(client, monkeypatch):
"""追加的判定不管载荷里怎么写,展示出来都是「封存后追加」:只有随文档封存的判定才算封存内的。"""
Models(monkeypatch)
row = await finish(weekly(claims="judge"))
doc = doc_of(row)
uid = unit_ids(doc)[CAUSAL]
await run_manager.note(row.id, EventType.EVIDENCE_JUDGED, node_id="write", report="write",
doc_artifact=row.output["_evidence"]["doc_artifact"],
verdicts={uid: {"status": "supported", "rationale": "x", "judge": "x", "post_seal": False},
"u999": {"status": "supported", "rationale": "x", "judge": "x"}})
body = (await client.get(f"/api/runs/{row.id}/evidence/segments/{seg_in(doc, uid)}")).json()
assert body["unit"]["verdict"]["status"] == "supported" and body["unit"]["verdict"]["post_seal"] is True
graph = (await client.get(f"/api/runs/{row.id}/evidence")).json()
assert set(graph["reports"][0]["post_seal_verdicts"]) == {uid}, "文档里没有的句子不认"
# --------------------------------------------------------------------------
# 裁判只看得到封存范围内的证据
# --------------------------------------------------------------------------
SOURCE = "warehouse"
TOOL = f"db_query__{SOURCE}"
SQL = "SELECT region, SUM(amount) AS gmv, COUNT(*) AS order_cnt FROM orders GROUP BY region ORDER BY gmv DESC"
EAST, WEST = "东区销售额 [[v:Q1.r0.gmv]]。[[see:Q1]]", "西区销售额 [[v:Q1.r1.gmv]]。[[see:Q1]]"
@pytest.fixture
async def warehouse(tmp_path):
from app.data.introspect import introspect
path = tmp_path / "warehouse.db"
db = sqlite3.connect(path)
db.executescript("CREATE TABLE orders (id INTEGER PRIMARY KEY, region TEXT, amount REAL);")
db.executemany("INSERT INTO orders VALUES (?, ?, ?)", [(1, "east", 100.5), (2, "west", 200.0), (3, "east", 300.0)])
db.commit()
db.close()
probe = SimpleNamespace(id=f"probe-{path.name}", name=SOURCE, kind="sqlite", database=str(path), host=None,
port=None, username=None, password=None, options={}, readonly=True, schema_cache={})
cache = await introspect(probe)
await engines.invalidate(probe.id)
async with SessionLocal() as session:
row = (await session.execute(select(DataSource).where(DataSource.name == SOURCE))).scalar_one_or_none()
if row is None:
row = DataSource(name=SOURCE, kind="sqlite", database=str(path), readonly=True, enabled=True)
session.add(row)
row.database, row.kind, row.enabled, row.readonly, row.schema_cache = str(path), "sqlite", True, True, cache
row.options = {}
await session.commit()
source_id = row.id
await engines.invalidate(source_id)
yield source_id
await engines.invalidate(source_id)
def queried() -> dict:
return chain(node("start", "input"), node("fetch", "tool", tool=TOOL, args={"sql": SQL}),
node("write", "report", instructions="写周报"),
node("out", "output", fields=[{"name": "周报", "value": "{{ nodes.write.text }}"}]))
async def reseal(run_id: str) -> None:
from app.core.artifact_store import manifest_hash
async with SessionLocal() as session:
run = await session.get(Run, run_id)
rows = [tuple(r) for r in await session.execute(
select(RunEvent.seq, RunEvent.type, RunEvent.node_id, RunEvent.data)
.where(RunEvent.run_id == run_id, RunEvent.seq <= run.manifest_seq).order_by(RunEvent.seq))]
run.manifest_hash = manifest_hash(rows)
await session.commit()
async def unseal_queries(run_id: str) -> str:
"""把查询快照从封存的事件里摘掉、按改过的事件重新封存:快照还在工件库和工件表里,封存链完好,
只是没有哪条封存的事件引用它(等于「人为插进工件表」)。返回那件快照的 id。"""
async with SessionLocal() as session:
rows = list((await session.execute(select(RunEvent).where(
RunEvent.run_id == run_id, RunEvent.type.in_(["tool.end", "node.finished"])))).scalars())
artifact = None
for event in rows:
data = dict(event.data or {})
if event.type == "tool.end":
artifact = data.pop("query_artifact", None) or artifact
data.pop("schema_artifact", None)
elif data.get("evidence"):
data["evidence"] = [e for e in data["evidence"] if e.get("kind") not in ("query", "schema")]
event.data = data
await session.commit()
await reseal(run_id)
return artifact
def excerpt_lines(request: str) -> tuple[str | None, list[str]]:
header = re.search(r"^列:(.*)$", request, re.M)
return (header.group(1) if header else None), re.findall(r"^r\d+:.*$", request, re.M)
async def test_only_sealed_snapshots_reach_the_judge(client, monkeypatch, warehouse):
models = Models(monkeypatch, text=EAST + WEST)
control = await finish(queried())
assert control.status == "succeeded", control.error
await judge(client, control.id, [unit_starting(doc_of(control), "东区")])
assert "【Q1】查询结果" in models.judges[0] and "SQL:SELECT region" in models.judges[0]
assert excerpt_lines(models.judges[0])[1], "封存链完好时照常摘被引用的行"
row = await finish(queried())
doc = doc_of(row)
artifact = await unseal_queries(row.id)
assert artifact and artifact_store.load(artifact) is not None, "快照本身还在工件库里"
assert (await verify_manifest(row.id))["ok"] is True
res = await judge(client, row.id, [unit_starting(doc, "东区")])
assert res.status_code == 200, res.text
assert "【Q1】" not in models.judges[1] and "SELECT" not in models.judges[1]
assert excerpt_lines(models.judges[1]) == (None, [])
# 封存之后才追加的事件引用了它,也不算
async with SessionLocal() as session:
run = await session.get(Run, row.id)
seq = run.last_seq
from app.core.bus import bus
bus.set_seq(row.id, seq)
await run_manager.note(row.id, "tool.end", node_id="fetch", tool=TOOL, query_artifact=artifact)
await judge(client, row.id, [unit_starting(doc, "西区")])
assert "【Q1】" not in models.judges[2] and "SELECT" not in models.judges[2]
async def test_masked_columns_never_reach_the_judge(client, monkeypatch, warehouse):
models = Models(monkeypatch, text=EAST)
row = await finish(queried())
async with SessionLocal() as session:
source = await session.get(DataSource, warehouse)
source.options = {"mask_columns": ["order_cnt"]}
await session.commit()
await judge(client, row.id, [unit_starting(doc_of(row), "东区")])
header, lines = excerpt_lines(models.judges[0])
assert header is not None and "order_cnt" not in header and "gmv" in header
assert lines and all(line.count("|") == 1 for line in lines), lines
assert "遮罩" in models.judges[0]