Files
AgentLab/backend/tests/test_ledger_certify.py
T
ThearchyHelios 9040289e1f 固化成口径草稿:口径卡用传入的名字、报告指令不写死代表那一轮的日期、指标名取目录中文名、已固化去重
验收报告里 certify.freeze 的四处:
- 口径卡的 caliber 用的是代表问题的原文,没用传入的 name。改成 name(去首尾空白)优先,没传才用代表问题。
- 报告的 instructions 写死成「回答:<代表问题>」,问题里带着那一轮的日期(「上周(10月5日到11日)」「九月第一周」),
  参数化之后这句就错了。有入口参数时改写运行时渲染的参数(「这次的参数:p1 = {{ input.p1 }},…;时间范围、筛选的
  取值以参数为准」),不写问题原文;没有参数时取数的字面量都是口径的常量,照旧写问题原文。
- 指标名直接用列名。现在能从数据目录取到中文名的用中文名:先看查询快照冻结的目录(schema_artifact,和口径比对用的
  同一份,ledger.match._frozen),老快照没冻结的看数据源现在的目录;只认列名本身就是目录里的一列(SELECT amount、
  SUM(amount) AS amount),gmv 这类算出来起的别名取不到,照旧用列名。
- 认证队列里 cluster 项的 frozen_into 把同一个工作流列了好几次:固化时同组的几次运行都记进 origin.sources,一项里
  的几次运行就各指一遍同一个工作流。shape 项同样会重。加 _frozen_of 按工作流去重(保持先后),三种项都用它。

测试(test_ledger_certify):原来的固化用例改成断言指令里没有代表问题和日期、有 {{ input.p1 }} {{ input.p2 }},
没传名字时口径卡照旧用代表问题,shape 和 cluster 两项的 frozen_into 都只有一个工作流;新增一条:传了名字的口径卡
用名字,目录里有中文名的列(amount → 金额)用中文名、取不到的照旧(n),没有参数时指令照旧写问题原文。去掉修复
回跑两条都挂。
2026-10-17 08:35:57 +08:00

273 lines
15 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 做法 8,app/ledger/certify.py)。
真的跑三次问数据(两段对话、同一个 SQL 形状、日期不同,虚构店铺库),等后台比对做完:
- 队列:确定性重复进队(3 次运行、2 段对话),变了的字面量是参数候选、不变的是常量,形状里不带取值;同一个问题问了 3 次
进问题聚类;导出过两次的、做成过应用的答案进「被反复用的答案」;点「不是口径」写审计后不再进队;
- 固化:报告引用的查询变成固定 SQL 的取数节点,跨轮变化的日期成为入口参数(缺省值取代表运行的),常量 'paid' 照写;单行
结果的数值列成口径卡指标;出口契约 report_from + metrics_from;过 validate_graph 和受管级别的发布门禁;origin.sources 记
三个来源问题;
- 按受管级别发布之后:来源运行在后台按它补比(trigger=certified),查询变成等价已证明、出了队;再问一次同样的形状,标
「等价已证明」;
- 统计 E3:认证队列的转化(固化、发布、不是口径)有数了。
数据是虚构店铺库。
"""
from __future__ import annotations
import pytest
from httpx import ASGITransport, AsyncClient
from app.db.base import SessionLocal
from app.db.models import Conversation, ConversationTurn, Run, Workflow
from app.engine.runner import run_manager
from app.engine.schema import GraphSpec, validate_graph
from app.ledger import certify
from app.ledger import match as ledger_match
from app.ledger import store
from app.main import app
from tests import ledger_samples as S
QUESTION = "九月第一周卖了多少"
WEEKS = [("2026-09-01", "2026-09-08"), ("2026-09-02", "2026-09-09"), ("2026-09-03", "2026-09-10")]
def weekly(start: str, end: str) -> str:
return (f"SELECT SUM(amount) AS gmv FROM orders WHERE status = 'paid' AND paid_at >= '{start}' "
f"AND paid_at < '{end}'")
@pytest.fixture(autouse=True)
async def engine_up(tmp_path):
await run_manager.setup()
await S.fresh_source(tmp_path / "shop.db")
yield
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 conversation() -> str:
async with SessionLocal() as session:
conv = Conversation(title="问销售", kind="chat")
session.add(conv)
await session.commit()
return conv.id
async def ask(monkeypatch, conversation_id: str, sql: str, question: str = QUESTION) -> Run:
"""问数据的一轮:建轮次、跑一次问数据运行(来源记着对话和轮次)、挂回轮次、等比对做完。"""
async with SessionLocal() as session:
turn = ConversationTurn(conversation_id=conversation_id, question=question)
session.add(turn)
await session.commit()
turn_id = turn.id
S.script(monkeypatch, "这一周销售额 [[v:Q1.r0.gmv]]。")
run = await run_manager.start(graph=S.explore_graph(sql), input_payload={"question": question}, origin_kind="chat",
origin={"conversation_id": conversation_id, "turn_id": turn_id})
row = await S.wait(run.id)
assert row.status == "succeeded", row.error
async with SessionLocal() as session:
(await session.get(ConversationTurn, turn_id)).run_id = row.id
await session.commit()
await ledger_match.drain()
return row
async def three_asks(monkeypatch) -> list[Run]:
a, b = await conversation(), await conversation()
return [await ask(monkeypatch, conv, weekly(*week)) for conv, week in zip((a, a, b), WEEKS)]
def shape_items(body: dict) -> list[dict]:
return [i for i in body["items"] if i["kind"] == "shape" and i["source"] == S.CURRENT["source"]]
# --------------------------------------------------------------------------
# 队列
# --------------------------------------------------------------------------
def test_varying_literals_are_parameters_and_the_template_puts_them_back():
shape = certify.Shape("shop", "SELECT SUM(amount) AS gmv FROM orders WHERE status = ? AND paid_at >= ? AND x = ?",
("'paid'", "'2026-09-01'", "3"))
assert certify.varying([shape.literals, ("'paid'", "'2026-09-02'", "4")]) == [1, 2]
template, fields = certify.template_of(shape, [1, 2], "sqlite", start=3)
assert template == ("SELECT SUM(amount) AS gmv FROM orders WHERE status = 'paid' AND paid_at >= '{{ input.p3 }}' "
"AND x = {{ input.p4 }}")
assert fields == [{"name": "p3", "type": "string", "required": True, "default": "2026-09-01"},
{"name": "p4", "type": "number", "required": True, "default": 3}]
async def test_repeated_shapes_questions_and_reused_answers_queue_up(client, monkeypatch):
runs = await three_asks(monkeypatch)
body = (await client.get("/api/ledger/certify-queue")).json()
[item] = shape_items(body)
assert item["runs"] == 3 and item["conversations"] == 2 and item["question"] == QUESTION
assert item["params"] == [{"index": 1, "distinct": 3}, {"index": 2, "distinct": 3}] and item["constants"] == 1
assert "2026-09" not in item["shape"] and "'paid'" not in item["shape"] and "?" in item["shape"]
assert {t["run_id"] for t in item["turns"]} == {r.id for r in runs}
assert all(t["turn_id"] and t["conversation_id"] for t in item["turns"])
assert item["representative"]["run_id"] in {r.id for r in runs} and len(item["peers"]) == 2
assert item["related"] == [] and item["frozen_into"] == []
clusters = [i for i in body["items"] if i["kind"] == "cluster" and i["question"] == QUESTION]
assert clusters and clusters[0]["count"] >= 3
# 被反复用的答案:导出过两次的、做成过应用的
from app.db.models_blueprints import Blueprint
async with SessionLocal() as session:
from app.auth.audit import record
for _ in range(2):
await record(session, "report.exported", "run", runs[0].id, data={"format": "html"})
session.add(Blueprint(name="周销售应用", source={"kind": "run", "id": runs[1].id}, spec={}))
await session.commit()
body = (await client.get("/api/ledger/certify-queue")).json()
reused = {i["representative"]["run_id"]: i for i in body["items"] if i["kind"] == "reused"}
assert reused[runs[0].id]["uses"]["exports"] == 2 and reused[runs[1].id]["uses"]["app"] is True
assert runs[2].id not in reused
# 「不是口径」:记审计,之后不再进队
res = await client.post("/api/ledger/certify-queue/dismiss", json={"item_id": item["id"], "kind": "shape"})
assert res.status_code == 200
assert shape_items((await client.get("/api/ledger/certify-queue")).json()) == []
stats = (await client.get("/api/ledger/stats")).json()["e3"]["certify"]
assert stats["available"] is True and stats["dismissed"] >= 1
async def test_two_runs_or_one_conversation_are_not_enough(client, monkeypatch):
conv = await conversation()
for week in WEEKS:
await ask(monkeypatch, conv, weekly(*week))
assert shape_items((await client.get("/api/ledger/certify-queue")).json()) == [], "只来自一段对话"
# --------------------------------------------------------------------------
# 固化、发布、再问
# --------------------------------------------------------------------------
async def test_freezing_makes_a_fixed_sql_draft_that_passes_the_governed_gate(client, monkeypatch):
from app.engine.governance import publish_issues
runs = await three_asks(monkeypatch)
[item] = shape_items((await client.get("/api/ledger/certify-queue")).json())
rep = item["representative"]["run_id"]
res = await client.post("/api/copilot/from-run", json={"run_id": rep, "freeze": True, "peers": item["peers"]})
assert res.status_code == 200, res.text
made = res.json()
assert made["frozen"] is True and made["params"] == ["p1", "p2"] and made["metrics"] == ["m1"]
async with SessionLocal() as session:
wf = await session.get(Workflow, made["workflow_id"])
assert wf.status == "draft" and "certified" in wf.tags
nodes = {n["id"]: n for n in wf.graph["nodes"]}
assert set(nodes) == {"start", "fetch", "card", "write", "out"}
rep_run = next(r for r in runs if r.id == rep)
start, end = WEEKS[[r.id for r in runs].index(rep)]
assert nodes["start"]["data"]["config"]["fields"] == [
{"name": "p1", "type": "string", "required": True, "default": start},
{"name": "p2", "type": "string", "required": True, "default": end}]
sql = nodes["fetch"]["data"]["config"]["args"]["sql"]
assert "'{{ input.p1 }}'" in sql and "'{{ input.p2 }}'" in sql and "'paid'" in sql
assert nodes["fetch"]["data"]["config"]["tool"] == S.tool()
assert nodes["card"]["data"]["config"]["metrics"] == [
{"id": "m1", "name": "gmv", "expression": "cell(nodes.fetch, 0, 'gmv')"}]
assert nodes["out"]["data"]["config"]["contract"] == {"report_from": "write", "metrics_from": "card",
"required": ["m1"]}
assert {k: nodes["write"]["data"]["config"].get(k) for k in ("numbers", "on_violation", "claims")} == {
"numbers": "strict", "on_violation": "fail", "claims": "require_citation"}
# 有入口参数:不写代表那一轮的问题原文(「九月第一周」参数化之后就不对了),写运行时渲染的参数
instructions = nodes["write"]["data"]["config"]["instructions"]
assert QUESTION not in instructions and "2026-09" not in instructions, instructions
assert "{{ input.p1 }}" in instructions and "{{ input.p2 }}" in instructions
assert nodes["card"]["data"]["config"]["caliber"] == QUESTION, "没传名字:口径卡照旧用代表问题"
spec = GraphSpec.model_validate(wf.graph)
assert not [i for i in validate_graph(spec).issues if i.level == "error"]
assert not [i for i in publish_issues(spec, level="governed") if i.level == "error"], \
[i.message for i in publish_issues(spec, level="governed") if i.level == "error"]
assert [s["run_id"] for s in wf.origin["sources"]][0] == rep and len(wf.origin["sources"]) == 3
assert all(s["question"] == QUESTION for s in wf.origin["sources"])
assert wf.origin["run_id"] == rep_run.id and wf.origin["freeze"] is True
# 队列上标着已经固化过:同一个工作流只列一次(三个来源运行都指着它)
body = (await client.get("/api/ledger/certify-queue")).json()
[again] = shape_items(body)
assert [w["workflow_id"] for w in again["frozen_into"]] == [wf.id]
[cluster] = [i for i in body["items"] if i["kind"] == "cluster" and i["question"] == QUESTION]
assert [w["workflow_id"] for w in cluster["frozen_into"]] == [wf.id], cluster["frozen_into"]
# 按受管级别发布:来源运行在后台补比,变成等价已证明、出了队
res = await client.post(f"/api/workflows/{wf.id}/publish", json={"level": "governed"})
assert res.status_code == 200, res.text
await ledger_match.drain()
for run in runs:
recorded = await store.latest(run.id)
assert recorded.trigger == "certified" and recorded.retro is True
[query] = recorded.content["queries"]
assert query["status"] == "proven" and query["candidates"][0]["caliber"] == f"{wf.id}@1"
body = (await client.get(f"/api/runs/{runs[0].id}/ledger")).json()
assert body["retro_note"] == "按之后发布的口径回看"
assert shape_items((await client.get("/api/ledger/certify-queue")).json()) == []
# 再问一次同样的形状:标等价已证明
later = await ask(monkeypatch, await conversation(), weekly("2026-09-05", "2026-09-12"))
[label] = (await store.latest(later.id)).content["reports"]["write"]["labels"].values()
assert label["kind"] == "caliber" and label["how"] == "independent" and label["caliber"] == f"{wf.id}@1"
assert label["binding"] == {"p1": "2026-09-05", "p2": "2026-09-12"}
stats = (await client.get("/api/ledger/stats")).json()["e3"]["certify"]
assert stats["frozen"] >= 1 and stats["published"] >= 1
async def test_freezing_uses_the_given_name_and_catalog_labels_and_no_dates_without_parameters(client, monkeypatch):
"""传了名字:口径卡用它(下一轮验收:以前用代表问题的原文)。指标名能从数据目录取到中文名的用中文名,取不到照旧用
列名。没有入口参数(字面量都没变过)时报告指令照旧写问题原文:那时问题里的日期就是口径的常量。"""
from app.data import catalog
from app.db.models import DataSource
from sqlalchemy import select
async with SessionLocal() as session:
source = (await session.execute(select(DataSource).where(DataSource.name == S.CURRENT["source"]))).scalar_one()
await catalog.write_entry(session, source.id, "orders", {"columns": {
"amount": {"label": catalog.make_item("金额", "human")}}}, if_version=0, actor="测试员")
sql = "SELECT SUM(amount) AS amount, COUNT(*) AS n FROM orders WHERE status = 'paid' AND paid_at >= '2026-09-01'"
S.script(monkeypatch, "金额 [[v:Q1.r0.amount]],[[v:Q1.r0.n]] 单。")
conv = await conversation()
async with SessionLocal() as session:
turn = ConversationTurn(conversation_id=conv, question=QUESTION)
session.add(turn)
await session.commit()
turn_id = turn.id
run = await run_manager.start(graph=S.explore_graph(sql), input_payload={"question": QUESTION}, origin_kind="chat",
origin={"conversation_id": conv, "turn_id": turn_id})
row = await S.wait(run.id)
assert row.status == "succeeded", row.error
async with SessionLocal() as session:
(await session.get(ConversationTurn, turn_id)).run_id = row.id
await session.commit()
await ledger_match.drain()
res = await client.post("/api/copilot/from-run", json={"run_id": row.id, "freeze": True, "name": "九月销售额口径"})
assert res.status_code == 200, res.text
made = res.json()
assert made["params"] == [] and made["name"] == "九月销售额口径"
async with SessionLocal() as session:
wf = await session.get(Workflow, made["workflow_id"])
nodes = {n["id"]: n for n in wf.graph["nodes"]}
card = nodes["card"]["data"]["config"]
assert card["caliber"] == "九月销售额口径"
assert [(m["name"], m["expression"]) for m in card["metrics"]] == [
("金额", "cell(nodes.fetch, 0, 'amount')"), ("n", "cell(nodes.fetch, 0, 'n')")]
assert nodes["write"]["data"]["config"]["instructions"] == f"回答:{QUESTION}"
async def test_freezing_needs_a_matched_successful_run(client, monkeypatch):
res = await client.post("/api/copilot/from-run", json={"run_id": "nope", "freeze": True})
assert res.status_code == 404
row = await S.explore(monkeypatch, S.ELSEWHERE, text="一共几家店,没有引用。")
res = await client.post("/api/copilot/from-run", json={"run_id": row.id, "freeze": True})
assert res.status_code == 409 and res.json()["detail"] == certify.NO_QUERIES