Files
AgentLab/backend/tests/test_ledger_api.py
T
ThearchyHelios b3dcddb52e 召回复核修正(后端):人工节点不再挡召回、读不出召回卡记失败、旧版不再接着算、团队渠道一次召回只推一条、目录核对按数据源合并排队;账本接口带上召回标记,对账结果可以反馈
- W4R-01:召回只查新版图里会不会写外部系统(apps/backfill 抽出 graph_external_write,graph_side_effect 复用它)。召回图只有两版
  的取数和口径卡,图里别处的人工节点、按流程使用的问题碰不到它;口径卡上游的人工节点 derive 本来就认作数据部分不能复用。
  「按新版重新出具」走完整的正式运行,仍按 graph_side_effect 全套拒绝。原来带审批的口径升版后一组都不召回,L6 对它们失效。
- W4R-09:收尾时召回卡读不出、或缺了某个比较的旧值 / 新值,照实记重算失败(读不出召回卡),原答案一个不动。原来两边都是空,
  按渲染比被当成「没变」,给每个原答案永久盖上「已核对,不影响」,收过尾之后再也不核对。
- W4R-12:这一版已经不是当前发布版(之后又发布了更新的版本)时不再发起召回、不给接着算,批次里写 continue_reason,接着算的
  接口 409 带同一句。同启动时接着做只认当前发布版:新版本的召回覆盖更早的所有版本,再按旧版开「口径升到 vN」的待办只是添乱。
- W4R-13:开登录时一组答案几个主人就几条待办,全局渠道却按这一批(recall.batch_of_key:同一次召回、同一个来由)只在最早开的
  那条上推一次,答案、待办按这一批汇总,几条的链到待办箱;别的几条连 task.opened 也不再推全局渠道。个人渠道照旧每人一条。
- W4R-11:目录审阅之后的核对按数据源排队、批量审阅的几张表合成一遍(catalog_pass,同一数据源一把锁);查询快照按数据源在库里
  筛、按运行结束时间分页、每张表凑够 BASIS_MAX_RUNS 次运行就停;冻结的表结构快照这一遍里按工件 id 只读一次。原来批量审阅
  几十张表就同时起几十个后台任务,每个都全表扫查询快照、反复读同一份几 MB 的表结构快照。
- W4R-03(本包那一半):GET /api/runs/{id}/ledger 加 recall:checked(已按 vN 核对,不影响)、basis(依据有变:哪一种、几项、
  新出现几条错误级问题)、tasks(看的这个人手上说的是这个答案、还没处理的 recall 待办)。不放数、不放表名。几种状态都带。
- W4R-10(本包那一半):新事件 ledger.feedback(对账运行上的封存后事件),POST /api/ledger/answers/{run}/reconcile/{rid}/feedback
  {verdict: useful | unclear} 记反馈,同一个人改主意只算最后一次(app/ledger/feedback.py);账本接口的 reconciled 每一项带
  feedback {mine, useful, unclear};统计 E2 的 feedback 数到它,去掉「第 4 波接上」的占位说法。离线核对页、封存后事件摘要
  都认得这个事件。

测试:test_ledger_recall 新增七个用例、改了人工节点那一个(召回照算、重新出具照实拒绝);新增 test_ledger_feedback;
test_ledger_api 的 E2 断言跟上。每个修复都先写失败的测试、去掉修复回跑确认抓得住。后端全量 14237 通过。
2026-10-16 22:16:58 +08:00

255 lines
14 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.
"""口径账本的读接口(next-round 02 做法 3、9、10):GET /api/runs/{id}/ledger、证据片段的 ledger 键、报告导出的「口径」一步、
导出留痕、GET /api/ledger/stats。
- 比对过的运行:标签、整题判断、每条查询的比对,写明封存后比对、不在封存范围内;工作流名按现在的取;
- 没比对过的:能比的排上按需比对(pending → 比完 trigger=on_demand、写明按现在的口径回看),不比的照实说为什么;
- 遮罩:绑定的值是这次运行查到过的遮罩值时不给值,差异条目里的 SQL 片段换成「已遮罩」,整份答复里找不到那个号码;
- 证据片段:比对过的带 ledger 键,没比对过的没有这个键;
- 报告导出:数据块带同一份标签和那句说明;没比对过的没有这个键;每次导出(报告、可验证报告)各记一条审计 report.exported;
- 统计:E1–E4 的口径和夹具上手算的一致,skipped 单独计。
"""
from __future__ import annotations
import json
import re
import pytest
from httpx import ASGITransport, AsyncClient
from sqlalchemy import select
from app.db.base import SessionLocal
from app.db.models import AuditEvent, Workflow
from app.engine.runner import run_manager
from app.ledger import match as ledger_match
from app.main import app
from tests import ledger_samples as S
@pytest.fixture(autouse=True)
async def engine_up(tmp_path):
await run_manager.setup()
path = tmp_path / "shop.db"
await S.fresh_source(path)
yield path
await ledger_match.drain()
await run_manager.shutdown()
@pytest.fixture
async def client():
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
yield c
async def ledger_of(client, run_id: str) -> dict:
res = await client.get(f"/api/runs/{run_id}/ledger")
assert res.status_code == 200, res.text
return res.json()
def first_segment(row, text: str) -> str:
from app.core.artifact_store import load
from app.engine.evidence import iter_segments
doc = load(row.output["_evidence"]["doc_artifact"])
return next(s["id"] for s in iter_segments(doc) if s.get("text") == text and s.get("cite"))
async def test_a_matched_run_gives_labels_and_the_whole_judgement(client, monkeypatch):
wf = await S.publish()
row = await S.explore(monkeypatch, S.EQUIVALENT, text="本周销售额 [[v:Q1.r0.销售额]]。")
body = await ledger_of(client, row.id)
assert body["state"] == "matched" and body["note"] == "封存后比对,不在封存范围内" and body["post_seal"] is True
assert body["trigger"] == "sealed" and body["retro"] is False and "retro_note" not in body
# 第 3 波起整题等价时有「按口径出具」这一项(详细的在 tests/test_ledger_issue.py)
assert [a["kind"] for a in body["actions"]] == ["issue"] and body["primary"] == "write"
[caliber] = body["calibers"]
assert caliber["workflow_name"] == "周销售" and caliber["level_text"] == "受管" and caliber["caliber"] == f"{wf}@1"
[label] = body["reports"]["write"]["labels"].values()
assert label["kind"] == "caliber" and label["how"] == "independent" and label["workflow_name"] == "周销售"
# 工件是规范 JSON(键排好序),绑定按字段名的先后给
assert label["binding"] == [{"field": "end", "value": "2026-09-08"}, {"field": "start", "value": "2026-09-01"}]
assert body["whole"]["equivalent"] is True and body["whole"]["missing"] == ["store"]
[query] = body["queries"]
assert query["status"] == "proven" and query["candidates"][0]["status"] == "proven"
# 工作流改了名:按现在的名字给
async with SessionLocal() as session:
(await session.get(Workflow, wf)).name = "周销售(新)"
await session.commit()
assert (await ledger_of(client, row.id))["calibers"][0]["workflow_name"] == "周销售(新)"
async def test_an_unmatched_run_is_matched_on_demand_and_says_it_looks_back(client, monkeypatch):
await S.publish()
real = ledger_match.schedule
monkeypatch.setattr(ledger_match, "schedule", lambda run_id, **kw: False)
row = await S.explore(monkeypatch, S.EQUIVALENT, text="本周销售额 [[v:Q1.r0.销售额]]。")
monkeypatch.setattr(ledger_match, "schedule", real)
body = await ledger_of(client, row.id)
assert body["state"] == "pending"
await ledger_match.drain()
body = await ledger_of(client, row.id)
assert body["state"] == "matched" and body["trigger"] == "on_demand" and body["retro"] is True
assert body["retro_note"] == "按现在的口径回看"
async def test_runs_that_are_not_compared_say_why(client, monkeypatch):
row = await S.explore(monkeypatch, S.EQUIVALENT, origin="api", text="本周销售额 [[v:Q1.r0.销售额]]。")
body = await ledger_of(client, row.id)
assert body["state"] == "ineligible" and body["reason"] == ledger_match.NOT_EXPLORE
res = await client.get("/api/runs/nope/ledger")
assert res.status_code == 404 and res.json()["code"] == "run_not_found"
async def test_masked_binding_values_and_sql_fragments_are_withheld(client, monkeypatch, engine_up):
"""口径按手机号筛({{ input.phone }});这次运行先查过手机号(数据源设了遮罩),再按其中一个号码筛:绑定的值是遮罩值,
不给值;差异条目、逐行对照里的那个号码也换成「已遮罩」。"""
await S.set_source(engine_up, options={"mask_columns": ["phone"]})
template = "SELECT SUM(amount) AS gmv FROM orders WHERE status = 'paid' AND phone = '{{ input.phone }}'"
await S.publish(graph=S.caliber_graph(template))
graph = S.chain(
S.node("start", "input"),
S.node("p", "tool", tool=S.tool(), args={"sql": "SELECT phone FROM orders ORDER BY id"}),
S.node("q", "tool", tool=S.tool(),
args={"sql": "SELECT SUM(amount) AS gmv FROM orders WHERE phone = '13800000001'"}),
S.node("write", "report", instructions="写答案"),
S.node("out", "output", fields=[{"name": "答案", "value": "{{ nodes.write.text }}"}]))
S.script(monkeypatch, "这位会员的消费 [[v:Q2.r0.gmv]]。")
run = await run_manager.start(graph=graph, input_payload={}, origin_kind="canvas")
row = await S.wait(run.id)
assert row.status == "succeeded", row.error
await ledger_match.drain()
body = await ledger_of(client, row.id)
assert body["state"] == "matched"
[label] = body["reports"]["write"]["labels"].values()
assert label["kind"] == "related"
[related] = label["related"]
assert related["binding"] == [{"field": "phone", "masked": True, "value": "已遮罩"}]
assert "13800000001" not in json.dumps(body, ensure_ascii=False)
assert related["diff"]["items"] and "13800000001" not in json.dumps(related["diff"], ensure_ascii=False)
def test_diff_fragments_go_through_the_same_mask_as_the_evidence_panel():
"""差异条目、逐行对照里的 SQL 片段走证据面板同一套换法(api/evidence._Shown):这次运行查到过的遮罩值换成「已遮罩」。"""
from app.ledger.labels import view_label
from app.ledger.view import _Hide
class Secrets:
def hit(self, value):
return str(value) == "13800000001"
def weak_hit(self, text):
return text == "13800000001"
def scrub(self, text, *, prose=False, loose=None):
return text.replace("13800000001", "已遮罩")
hider = _Hide(Secrets())
label = {"kind": "related", "related": [{
"caliber": "wf@1", "workflow_id": "wf", "version": 1, "level": "published", "binding": {"phone": "13800000001"},
"diff": {"items": [{"aspect": "filter", "a": "phone = '13800000001'", "b": None,
"text": "这次只保留 phone = '13800000001' 的行,口径没有这个条件"}],
"fallback": False, "text_diff": ["- WHERE phone = '13800000001'", "+ WHERE TRUE"]}}]}
shown = view_label(label, hide=hider.hide, shown=hider.shown, names={"wf": "周销售"})
text = json.dumps(shown, ensure_ascii=False)
assert "13800000001" not in text and text.count("已遮罩") >= 4
assert shown["related"][0]["workflow_name"] == "周销售" and shown["related"][0]["level_text"] == "已发布"
async def test_the_evidence_segment_carries_the_label_only_when_matched(client, monkeypatch):
await S.publish()
row = await S.explore(monkeypatch, S.EQUIVALENT, text="本周销售额 [[v:Q1.r0.销售额]]。")
seg = first_segment(row, "170.5")
res = await client.get(f"/api/runs/{row.id}/evidence/segments/{seg}")
assert res.status_code == 200, res.text
ledger = res.json()["ledger"]
assert ledger["label"]["kind"] == "caliber" and ledger["note"] == "封存后比对,不在封存范围内"
assert ledger["post_seal"] is True and ledger["artifact"]
monkeypatch.setattr(ledger_match, "schedule", lambda run_id, **kw: False)
other = await S.explore(monkeypatch, S.EQUIVALENT, text="本周销售额 [[v:Q1.r0.销售额]]。")
res = await client.get(f"/api/runs/{other.id}/evidence/segments/{first_segment(other, '170.5')}")
assert res.status_code == 200 and "ledger" not in res.json()
def _data_block(page: str) -> dict:
found = re.search(r'<script type="application/json" id="report-data">(.*?)</script>', page, re.S)
assert found, "页面里没有数据块"
return json.loads(found.group(1))
async def exported(run_id: str) -> list[AuditEvent]:
async with SessionLocal() as session:
return list((await session.execute(select(AuditEvent).where(
AuditEvent.action == "report.exported", AuditEvent.subject_id == run_id).order_by(AuditEvent.at))).scalars())
async def test_report_export_shows_the_label_and_records_the_export(client, monkeypatch):
await S.publish()
row = await S.explore(monkeypatch, S.EQUIVALENT, text="本周销售额 [[v:Q1.r0.销售额]]。")
res = await client.get(f"/api/runs/{row.id}/report.html")
assert res.status_code == 200, res.text
data = _data_block(res.text)
assert data["ledger"]["note"] == "封存后比对,不在封存范围内"
[label] = data["ledger"]["labels"].values()
assert label["kind"] == "caliber"
[audit] = await exported(row.id)
assert audit.subject_kind == "run" and audit.data["format"] == "html"
assert audit.data["report_node"] == "write" and audit.data["doc_artifact"] == row.output["_evidence"]["doc_artifact"]
res = await client.get(f"/api/runs/{row.id}/bundle.html")
assert res.status_code == 200, res.text
audits = await exported(row.id)
assert [a.data["format"] for a in audits] == ["html", "bundle"]
assert audits[1].data["reports"] == [{"report_node": "write", "doc_artifact": audit.data["doc_artifact"]}]
# 可验证报告把封存之后追加的比对列出来,只说几条,不带绑定
assert "封存后比对已发布口径:1 条等价已证明" in res.text
async def test_an_unmatched_report_export_has_no_ledger_block(client, monkeypatch):
monkeypatch.setattr(ledger_match, "schedule", lambda run_id, **kw: False)
row = await S.explore(monkeypatch, S.EQUIVALENT, text="本周销售额 [[v:Q1.r0.销售额]]。")
res = await client.get(f"/api/runs/{row.id}/report.html")
assert res.status_code == 200 and "ledger" not in _data_block(res.text)
async def test_stats_count_what_the_fixtures_did(client, monkeypatch):
from datetime import datetime, timezone
since = datetime.now(timezone.utc)
wf = await S.publish()
await S.explore(monkeypatch, S.EQUIVALENT, text="本周销售额 [[v:Q1.r0.销售额]]。")
await S.explore(monkeypatch, S.WITH_REFUNDS, text="含退款 [[v:Q1.r0.gmv]]。")
await S.explore(monkeypatch, S.ELSEWHERE, text="一共 [[v:Q1.r0.n]] 家店。")
monkeypatch.setattr(ledger_match, "schedule", lambda run_id, **kw: False)
timed = await S.explore(monkeypatch, S.EQUIVALENT + " -- 超时用", text="本周销售额 [[v:Q1.r0.销售额]]。")
await ledger_match.match_run(timed.id, timeout=0)
res = await client.get("/api/ledger/stats", params={"since": since.isoformat()})
assert res.status_code == 200, res.text
body = res.json()
e1 = body["e1"]
assert e1["runs"] == 4 and e1["queries"] == 4
assert e1["status"] == {"proven": 1, "composed": 0, "related": 1, "none": 1, "skipped": 1}
assert e1["with_candidates"] == 3 and e1["rates"]["proven"] == round(1 / 3, 4)
assert e1["diff_aspects"]["filter"] == 1 and e1["labels"]["caliber"] == 1 and e1["labels"]["unknown"] == 1
assert e1["whole_equivalent"] == 1
assert body["e2"]["launched"] == 0 and body["e2"]["feedback"] == {"available": True, "useful": 0, "unclear": 0,
"runs": 0, "useful_rate": None}
assert body["e3"]["chat_runs"] == 0 and body["e3"]["threshold"] == {"runs": 3, "conversations": 2}
usage = {u["caliber"]: u for u in body["e4"]["usage"]}
assert usage[f"{wf}@1"]["proven_runs"] == 1
# 召回(第 4 波):这一段时间里没有发布新版本,没有召回
assert body["e4"]["per_publish"]["available"] is True and body["e4"]["per_publish"]["groups"] == 0
async def test_stats_take_the_run_ids_the_caller_names(monkeypatch):
"""评测(第 3 波)直接调 compute_stats,点名自己的运行:只算这几次。"""
from app.ledger.stats import compute_stats
await S.publish()
row = await S.explore(monkeypatch, S.EQUIVALENT, text="本周销售额 [[v:Q1.r0.销售额]]。")
await S.explore(monkeypatch, S.WITH_REFUNDS, text="含退款 [[v:Q1.r0.gmv]]。")
async with SessionLocal() as session:
body = await compute_stats(session, run_ids=[row.id])
assert body["e1"]["runs"] == 1 and body["e1"]["status"]["proven"] == 1 and body["window"]["runs"] == [row.id]