mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 04:51:57 +00:00
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),没有参数时指令照旧写问题原文。去掉修复
回跑两条都挂。
273 lines
15 KiB
Python
273 lines
15 KiB
Python
"""沉淀:可沉淀的问题(认证队列)和「固化成口径草稿」(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
|