Files
AgentLab/backend/tests/test_eval_snapshots.py

268 lines
14 KiB
Python

"""目录快照与影子起草(app/eval/snapshots.py;细案 01 做法 6)。
- 当前目录的快照:按 data/catalog_view.py 的读法读得出、和拍的那一刻一样;之后线上目录改了,快照不变(内容寻址、不可变)。
- 影子起草:同一个起草引擎、只起草指定(或题集用得到)的表,写进内存里的副本、出快照;线上目录一项不动(版本不变、
不调 write_entry);不查业务库(开了剖析也不去数有效行过滤的排除比例);不占数据源的队;可以先去掉模型起草、还没人
审过的项;停止的不出快照;服务重启后没结束的标中断。开始前给估算。
库、表、目录内容都是虚构的。
"""
from __future__ import annotations
import asyncio
import pytest
from sqlalchemy import select
from app.core import artifact_store, job_runtime
from app.data import catalog, catalog_draft, catalog_profile, catalog_view
from app.data.catalog import make_item
from app.data.catalog_view import CatalogView
from app.db.base import SessionLocal
from app.db.models import CatalogNote, DataSource
from app.db.models_eval import EvalCatalogSnapshot, EvalSuite
from app.eval import snapshots
from tests.fixtures import dual_check_shop as shop
from tests.fixtures.catalog.fake_model import FakeModel
from tests.fixtures.sources import drop_source
LIVE_NOTES = {
"label": make_item("门店订单", "human"), # 人工确认
"grain": make_item("每笔订单一行", "llm"), # 模型起草、没人审过
"columns": {"amount": {"label": make_item("实收金额", "llm", "confirmed")}}, # 模型起草、人工确认过
}
REPLY = {"orders": {"label": "影子订单", "grain": "影子:每单一行", "kind": "fact",
"valid_filter": {"condition": "status <> 'refunded'", "reason": "状态列有退款"},
"columns": [{"name": "status", "label": "订单状态", "meaning": "paid 已支付,refunded 已退款"}]}}
@pytest.fixture
async def source(tmp_path):
name, sid = await shop.add_source(shop.build(tmp_path / "shop.db"),
options={catalog_profile.PROFILE_OPTION: {"enabled": True}})
async with SessionLocal() as session:
await catalog.write_entry(session, sid, "orders", LIVE_NOTES, if_version=0, actor="审阅人")
yield {"name": name, "id": sid}
await drop_source(sid)
async def live_rows(source_id: str) -> list[tuple[str, int, dict]]:
async with SessionLocal() as session:
rows = (await session.execute(select(CatalogNote).where(CatalogNote.source_id == source_id))).scalars().all()
return sorted((r.table_name, r.version, r.notes) for r in rows)
async def read_view(snapshot_artifact: str, source_id: str) -> dict:
view = CatalogView(mode="snapshot", snapshot=snapshot_artifact)
async with SessionLocal() as session:
found = await catalog_view.read_many(session, view, [source_id])
return {t: e.notes for t, e in found.get(source_id, {}).items()}
async def wait_done(snap_id: str) -> EvalCatalogSnapshot:
await snapshots.manager.wait(snap_id, timeout=30)
async with SessionLocal() as session:
return await session.get(EvalCatalogSnapshot, snap_id)
# --------------------------------------------------------------------------
# 当前目录
# --------------------------------------------------------------------------
async def test_a_live_snapshot_reads_back_through_the_catalog_view_and_never_changes(source):
async with SessionLocal() as session:
snap = await snapshots.take_live(session, [source["id"]], actor="评测人")
assert snap.status == "succeeded" and snap.origin == "live" and snap.created_by == "评测人"
assert snap.sources == [{"id": source["id"], "name": source["name"]}]
content = artifact_store.load(snap.artifact_id)
assert content["taken"]["origin"] == "live" and content["taken"]["by"] == "评测人"
assert artifact_store.content_hash(artifact_store.canonical_json(content)) == snap.artifact_id
assert (await read_view(snap.artifact_id, source["id"]))["orders"] == LIVE_NOTES
# 之后线上目录改了:快照还是拍的那一份
async with SessionLocal() as session:
await catalog.write_entry(session, source["id"], "orders", {"label": make_item("改过的", "human")},
if_version=1, actor="审阅人")
assert (await read_view(snap.artifact_id, source["id"]))["orders"]["label"]["value"] == "门店订单"
# 只用已核实的项:读的时候按视图筛,快照本身照原样
reviewed = CatalogView(mode="snapshot", statuses="reviewed", snapshot=snap.artifact_id)
async with SessionLocal() as session:
notes = (await catalog_view.read_many(session, reviewed, [source["id"]]))[source["id"]]["orders"].notes
assert "grain" not in notes and notes["label"]["value"] == "门店订单"
async def test_a_live_snapshot_needs_existing_sources():
async with SessionLocal() as session:
with pytest.raises(snapshots.SnapshotInvalid, match="不存在"):
await snapshots.take_live(session, ["nope"], actor=None)
with pytest.raises(snapshots.SnapshotInvalid, match="请选择"):
await snapshots.take_live(session, [], actor=None)
# --------------------------------------------------------------------------
# 影子起草
# --------------------------------------------------------------------------
async def test_shadow_drafting_writes_only_the_copy(source, monkeypatch):
model = FakeModel(REPLY)
monkeypatch.setattr(snapshots.ShadowDraft, "model_override", model)
async def no_business_queries(*_a, **_k):
raise AssertionError("影子起草不该查业务库")
monkeypatch.setattr(catalog_profile, "check_valid_filter", no_business_queries)
before = await live_rows(source["id"])
async with SessionLocal() as session:
plan = await snapshots.plan_shadow(session, {"sources": [{"id": source["id"], "tables": ["orders"]}],
"guidance": "订单表按支付日统计"}, model_id="mock-fast")
snap = await snapshots.start_shadow(session, plan, actor="评测人")
assert snap.status in ("queued", "running")
# 不占数据源的队:同一个源照样能马上起草、剖析
await asyncio.sleep(0)
assert await job_runtime.lanes.holders(source["id"]) == []
done = await wait_done(snap.id)
assert done.status == "succeeded", (done.error, done.progress)
# 线上目录一项不动
assert await live_rows(source["id"]) == before
notes = (await read_view(done.artifact_id, source["id"]))["orders"]
assert notes["label"]["value"] == "门店订单" # 人工确认的不被起草覆盖
assert notes["grain"]["value"] == "影子:每单一行" # 没人审过的模型项换成这次起草的
assert notes["columns"]["status"]["label"]["value"] == "订单状态"
# 开了剖析也不去业务库数排除比例:照收,写明没有核对
assert catalog_draft.SHADOW_NOT_CHECKED in notes["valid_filter"]["note"]
# 起草要求交给了模型;只起草了点名的表
assert len(model.calls) == 1 and "订单表按支付日统计" in model.calls[0]
content = artifact_store.load(done.artifact_id)
assert content["taken"]["origin"] == "shadow" and content["taken"]["tables"] == {source["id"]: ["orders"]}
assert done.usage["calls"] == 1 and done.progress["tables_done"] == 1
assert done.estimate["tables"] == 1 and done.estimate["calls"] == 1
async def test_shadow_drafting_can_start_without_unreviewed_model_items(source, monkeypatch):
monkeypatch.setattr(snapshots.ShadowDraft, "model_override", FakeModel({"orders": {"kind": "fact"}}))
async with SessionLocal() as session:
plan = await snapshots.plan_shadow(session, {"sources": [{"id": source["id"], "tables": ["orders"]}],
"strip_llm": True})
snap = await snapshots.start_shadow(session, plan, actor=None)
done = await wait_done(snap.id)
notes = (await read_view(done.artifact_id, source["id"]))["orders"]
assert "grain" not in notes # 没人审过的模型项去掉了
assert notes["columns"]["amount"]["label"]["value"] == "实收金额" # 人工确认过的模型项留着
assert notes["label"]["value"] == "门店订单" and notes["kind"]["value"] == "fact"
async def test_tables_come_from_the_suite_when_not_listed(source):
from app.db.models_eval import EvalCase
async with SessionLocal() as session:
suite = EvalSuite(name="虚构题集")
session.add(suite)
await session.flush()
session.add(EvalCase(suite_id=suite.id, seq=1, question="q", expectations=[], gold={
"kind": "sql", "datasource_id": source["id"],
"sql": "SELECT SUM(amount) FROM orders WHERE paid_date = '{{ schedule.yesterday }}'"}))
await session.commit()
found = await snapshots.suite_tables(session, suite.id)
assert found == {source["id"]: ["orders"]}
plan = await snapshots.plan_shadow(session, {"sources": [{"id": source["id"]}], "suite_id": suite.id})
assert plan.sources[0]["tables"] == ["orders"]
with pytest.raises(snapshots.SnapshotInvalid, match="没有指定要起草的表"):
await snapshots.plan_shadow(session, {"sources": [{"id": source["id"]}]})
with pytest.raises(snapshots.SnapshotInvalid, match="表结构里没有"):
await snapshots.plan_shadow(session, {"sources": [{"id": source["id"], "tables": ["ghost"]}]})
def test_the_estimate_is_said_to_be_an_estimate():
from types import SimpleNamespace
cols = lambda n: [{"name": f"c{i}", "type": "TEXT"} for i in range(n)] # noqa: E731
src = SimpleNamespace(schema_cache={"tables": {"a": {"columns": cols(10)}, "b": {"columns": cols(10)},
"wide": {"columns": cols(100)}}})
est = snapshots.estimate([(src, ["a", "b", "wide"])], "no-such-model")
# 窄表拼一段;宽表是表级一段 + 列分段(100 列按 40 列一段是 3 段)
assert est["calls"] == 1 + (1 + 3) and est["tables"] == 3 and est["columns"] == 120
assert est["unpriced"] is True and est["cost_usd"] is None and "不含思考" in est["note"]
assert est["output_tokens"] == 3 * snapshots.EST_OUTPUT_PER_TABLE + 120 * snapshots.EST_OUTPUT_PER_COLUMN
async def test_a_stopped_shadow_draft_leaves_no_snapshot(source, monkeypatch):
monkeypatch.setattr(snapshots.ShadowDraft, "model_override", FakeModel(REPLY, hang=5))
async with SessionLocal() as session:
plan = await snapshots.plan_shadow(session, {"sources": [{"id": source["id"], "tables": ["orders"]}]})
snap = await snapshots.start_shadow(session, plan, actor=None)
for _ in range(100):
await asyncio.sleep(0.02)
async with SessionLocal() as session:
if (await session.get(EvalCatalogSnapshot, snap.id)).status == "running":
break
assert await snapshots.stop(snap.id)
done = await wait_done(snap.id)
assert done.status == "cancelled" and done.artifact_id is None and done.error == snapshots.STOPPED
assert not await snapshots.stop(snap.id)
async def test_unfinished_shadow_drafts_are_marked_interrupted_after_a_restart(source):
async with SessionLocal() as session:
row = EvalCatalogSnapshot(origin="shadow", status="running", sources=[{"id": source["id"], "name": "x"}])
session.add(row)
await session.commit()
await snapshots.reconcile_stale(session, [row])
assert row.status == "interrupted" and row.error == snapshots.INTERRUPTED
other = EvalCatalogSnapshot(origin="shadow", status="queued", sources=[])
session.add(other)
await session.commit()
other_id = other.id
assert await snapshots.startup() >= 1
async with SessionLocal() as session:
assert (await session.get(EvalCatalogSnapshot, other_id)).status == "interrupted"
async def test_a_mock_provider_is_not_a_drafting_model():
from app.db.models import Provider
async with SessionLocal() as session:
session.add(Provider(name="演示接入_快照", kind="mock", default_model="mock-fast", enabled=True))
await session.commit()
try:
with pytest.raises(snapshots.SnapshotInvalid, match="演示用的模型接入"):
await snapshots.resolve_model(session, "演示接入_快照", None)
finally:
row = (await session.execute(select(Provider).where(Provider.name == "演示接入_快照"))).scalar_one()
await session.delete(row)
await session.commit()
async def test_a_partly_failed_shadow_draft_still_snapshots_and_says_which_table(tmp_path, monkeypatch):
import sqlite3
from app.data import introspect
path = tmp_path / "two.db"
with sqlite3.connect(path) as db:
db.executescript("CREATE TABLE orders (id INTEGER PRIMARY KEY, amount REAL);"
"CREATE TABLE stores (id INTEGER PRIMARY KEY, name TEXT);")
async with SessionLocal() as session:
row = DataSource(name=f"two_{id(path) % 100000}", kind="sqlite", database=str(path))
session.add(row)
await session.flush()
row.schema_cache = await introspect.introspect(row)
await session.commit()
sid = row.id
try:
# stores 那一段一直出错:起草引擎拆小重试之后仍没写出来
monkeypatch.setattr(snapshots.ShadowDraft, "model_override",
FakeModel({"orders": {"label": "订单"}, "stores": {"label": "门店"}}, fail={"stores"}))
monkeypatch.setattr(catalog, "MODEL_RETRIES", 0)
monkeypatch.setattr(catalog, "MODEL_BATCH_TABLES", 1) # 每张表一段:一段出错只影响那一张
async with SessionLocal() as session:
plan = await snapshots.plan_shadow(session, {"sources": [{"id": sid, "tables": ["orders", "stores"]}]})
snap = await snapshots.start_shadow(session, plan, actor=None)
done = await wait_done(snap.id)
assert done.status == "partial" and done.artifact_id, (done.status, done.error)
assert list(done.progress["failures"]) == [f"{row.name}.stores"] and done.progress["tables_failed"] == 1
notes = await read_view(done.artifact_id, sid)
assert notes["orders"]["label"]["value"] == "订单" and "stores" not in notes
finally:
await drop_source(sid)