Files
AgentLab/backend/tests/test_ledger_descriptor.py
T
ThearchyHelios 2f90f0eb3a 口径台账:固定执行、验证审阅发布与增量重导入
- QueryCore 固定执行(K09):按已发布的不可变口径跑一次,交回类型化标量、发布/manifest 引用与逐查询证据
- 依赖闭包(K08):按选中的指标算出要拷的取数节点、要的入口字段,接进 derive / compose / metrics
- SQL 模板参数完整性(K01):试跑、组合问数、派生图、运行时取数共用一套语义,缺参数在渲染前就拒
- 受限字段变更服务(K03)与口径领域边界(K01):改动打在整图深拷贝上,身份与证据归属不可改
- 版本化受控模板绑定(K04):业务口径按显式绑定落到 SQL,对不上就报 unsupported / needs_update
- 验证记录(K05)与最小审阅:样例匹配不等于正式批准,审批人只从服务端身份取
- 发布依据清单(K07):发布时按不可变图物化内容寻址清单,正式用途按它判
- 增量重导入(K11)与 B/L/R 三方合并、受限自然语言修改提案(K12):只产候选,落图仍走既有写路径
- 接口与前端:口径草稿页接上字段变更、绑定状态、验证与重导入,新增 caliber-drafts / studio 检查脚本

验证:前端 tsc -b 通过。
2026-11-10 15:05:18 +08:00

274 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.
"""口径可调用描述(app/ledger/descriptor.py、GET /api/ledger/calibers):目录页、应用表单、外部集成用的机器可读契约。
- 只列每个工作流**推荐发布版**(Workflow.published_version,K07:默认调用读推荐版,不机械取最高已发布版)里的源头
口径卡:入口字段成 JSON Schema、指标照配置、依赖只报数据源和表名;
- 能不能组合按 look.calibers.metric_options 的**每个指标**那份判定(K08 S4):B 独有那条查询坏了、绑定对不上只让 B
不可以选;另给这一版两份闭包摘要(closure 与 selectable_metrics)和 computes_unselected 的共享声明,摘要里没有 SQL;
- 出具只描述版本策略:受管级别、图里没有人工节点或写外部系统的节点才自动出具,原因照写;
- 转抄别处口径卡的卡不列;响应里没有 SQL、没有参数值。
数据是虚构店铺库。
"""
from __future__ import annotations
import json
import pytest
from httpx import ASGITransport, AsyncClient
from app.main import app
from tests import ledger_samples as S
@pytest.fixture(autouse=True)
async def shop(tmp_path):
await S.fresh_source(tmp_path / "shop.db")
yield
@pytest.fixture
async def client():
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
yield c
async def descriptors(client: AsyncClient, wf: str) -> list[dict]:
body = (await client.get("/api/ledger/calibers", params={"workflow_id": wf})).json()
assert set(body) == {"calibers"}, body
return body["calibers"]
def with_human() -> dict:
graph = S.caliber_graph()
graph["nodes"].append(S.node("approve", "human", assignee="运营"))
return graph
def with_copy() -> dict:
graph = S.caliber_graph()
graph["nodes"].append(S.node(
"copy", "metrics", caliber="抄来的周销售", caliber_version="v1",
caliber_from={"workflow_id": "elsewhere", "workflow_version": 1, "node_id": "card"},
metrics=[{"id": "gmv", "name": "销售额", "expression": "cell(nodes.fetch, 0, 'gmv')"}]))
return graph
async def test_a_source_caliber_is_described_as_a_callable_contract(client):
wf = await S.publish()
[made] = await descriptors(client, wf)
assert (made["workflow_id"], made["workflow_name"], made["version"]) == (wf, "周销售", 1)
assert made["level"] == "governed" and made["graph_hash"]
assert made["card"] == {"node_id": "card", "title": "card", "caliber": "周销售", "caliber_version": "v1"}
assert made["metrics"] == [{"id": "gmv", "name": "销售额", "unit": "元", "decimals": 2, "format": "thousands"}]
schema = made["entry_schema"]
assert schema["format"] == "agentlab.form/1" and schema["type"] == "object"
assert schema["required"] == ["start", "end"]
assert schema["properties"]["store"]["default"] == "all"
assert made["composable"] is True and made["compose_reason"] is None
assert made["dependencies"]["sources"] == [S.CURRENT["source"]]
assert made["dependencies"]["tables"] == ["orders"]
assert made["dependencies"]["catalog_versions"] is None and made["dependencies"]["schema_versions"] is None
issuance = made["issuance"]
assert issuance["level"] == "governed" and issuance["auto_issue"] is True
assert issuance["requires_independent_proof"] is True
assert issuance["side_effects"] is None and issuance["reasons"] == []
# 单条接口交同一份描述;转抄卡、没发布过的版本、不存在的节点都是 404
single = (await client.get(f"/api/ledger/calibers/{wf}/1/card")).json()
assert single == made
for path in (f"/api/ledger/calibers/{wf}/1/nope", f"/api/ledger/calibers/{wf}/9/card",
"/api/ledger/calibers/nope/1/card"):
assert (await client.get(path)).status_code == 404, path
# 契约里没有取数 SQL、没有参数值
text = json.dumps((await client.get("/api/ledger/calibers", params={"workflow_id": wf})).json())
assert "SELECT" not in text and '"sql"' not in text
assert "SUM(" not in text and "paid" not in text
async def test_a_human_node_turns_auto_issue_off_with_a_reason(client):
wf = await S.publish(name="审批周销售", graph=with_human(), level="governed")
[made] = await descriptors(client, wf)
assert made["level"] == "governed"
assert made["issuance"]["auto_issue"] is False
assert "approve" in made["issuance"]["side_effects"]
assert any("人工节点" in r for r in made["issuance"]["reasons"])
assert made["issuance"]["requires_independent_proof"] is True
async def test_a_published_level_version_only_supports_one_click_issuance(client):
wf = await S.publish(name="已发布周销售", graph=S.caliber_graph(), level="published")
[made] = await descriptors(client, wf)
assert made["level"] == "published" and made["issuance"]["auto_issue"] is False
assert any("已发布级别" in r and "一键出具" in r for r in made["issuance"]["reasons"])
async def test_a_card_that_copies_another_caliber_is_not_listed(client):
wf = await S.publish(graph=with_copy())
listed = await descriptors(client, wf)
assert [d["card"]["node_id"] for d in listed] == ["card"]
assert (await client.get(f"/api/ledger/calibers/{wf}/1/copy")).status_code == 404
async def test_metric_definition_and_params_are_described(client):
graph = S.caliber_graph()
metric = graph["nodes"][2]["data"]["config"]["metrics"][0]
metric["definition"] = {"text": "已支付订单的销售额", "scope": "全部门店", "owner": "运营"}
metric["params"] = [{"name": "store", "type": "string", "default": "all"}]
wf = await S.publish(graph=graph)
[made] = await descriptors(client, wf)
[m] = made["metrics"]
assert m["definition"]["text"] == "已支付订单的销售额" and m["definition"]["owner"] == "运营"
assert m["params"] == [{"name": "store", "type": "string", "default": "all"}]
async def test_an_external_only_card_is_not_promised_automatic_issuance(client):
graph = S.chain(
S.node("start", "input"),
S.node("card", "metrics", caliber="年度目标", caliber_version="v1", metrics=[{
"id": "target", "name": "年度目标", "unit": "元", "kind": "external",
"external": {"value": 100, "source": "预算", "as_of": "2026-01-01"}}]),
S.node("out", "output", fields=[{"name": "v", "value": "{{ nodes.card.metrics[0].value }}"}]))
wf = await S.publish(name="只有外部常量", graph=graph)
[made] = await descriptors(client, wf)
assert made["composable"] is True
assert made["issuance"]["auto_issue"] is False
assert any("没有取数节点" in r for r in made["issuance"]["reasons"])
async def test_metric_synonyms_reach_the_descriptor(client):
"""文档带来的同义词要出现在机器可读描述里:按名字找口径时,「月活」和指标名一样是入口。"""
graph = S.caliber_graph()
for node in graph["nodes"]:
if node["type"] == "metrics":
node["data"]["config"]["metrics"][0]["synonyms"] = ["月活", "MAU"]
wf = await S.publish(graph=graph)
[made] = await descriptors(client, wf)
[metric] = made["metrics"]
assert metric["synonyms"] == ["月活", "MAU"]
async def test_metric_constraints_and_semantic_identity_reach_the_descriptor(client):
graph = S.caliber_graph()
definition = {"shape": "scalar", "minimum": 0, "unit": "元"}
for node in graph["nodes"]:
if node["type"] == "metrics":
node["data"]["config"]["metrics"][0].update(contract=definition, grain="汇总", time_field="支付时间")
[made] = await descriptors(client, await S.publish(graph=graph))
[metric] = made["metrics"]
assert metric["contract"] == definition and metric["grain"] == "汇总"
assert metric["time_field"] == "支付时间" and made["semantic_hash"]
# --------------------------------------------------------------------------
# 按指标判可组合性(K08 S4):闭包摘要、每个指标的可用性、共享声明
# --------------------------------------------------------------------------
SQL_A = ("SELECT SUM(amount) AS gmv FROM orders WHERE status = 'paid' "
"AND paid_at >= '{{ input.start }}' AND paid_at < '{{ input.end }}'")
SQL_B = SQL_A + " AND region = '{{ input.buyer }}'"
def two_metric_graph(a_sql: str = SQL_A, b_sql: str = SQL_B) -> dict:
"""一张卡两个指标、两条各自独立的查询;buyer 只有 B 那条用。"""
return S.chain(
S.node("start", "input", fields=[{"name": "start", "required": True}, {"name": "end", "required": True},
{"name": "buyer", "required": True}]),
S.node("fetch_a", "tool", tool=S.tool(), args={"sql": a_sql}),
S.node("fetch_b", "tool", tool=S.tool(), args={"sql": b_sql}),
S.node("card", "metrics", caliber="双指标", caliber_version="v1",
metrics=[{"id": "gmv", "name": "销售额", "expression": "cell(nodes.fetch_a, 0, 'gmv')"},
{"id": "n", "name": "单量", "expression": "cell(nodes.fetch_b, 0, 'n')"}]),
S.node("out", "output", fields=[{"name": "r", "value": "{{ nodes.card.text }}"}]))
def shared_sql_graph() -> dict:
"""两个指标读同一条取数(共享 SQL):选一个时那条还在算另一个。"""
graph = S.caliber_graph()
next(n for n in graph["nodes"] if n["type"] == "metrics")["data"]["config"]["metrics"].append(
{"id": "n", "name": "单量", "expression": "cell(nodes.fetch, 0, 'gmv')"})
return graph
def leaks_sql(text: str) -> list[str]:
"""描述里不该出现的 SQL 痕迹:语句/列名/参数模板/参数值。
表名是**照实列出来的**(``dependencies.tables`` 的契约就是报数据源和表名),所以不含在禁列里;禁的是 SQL 正文的
片段与渲染模板。"""
return [n for n in ("SUM(", "paid_at", "{{", '"sql"') if n in text]
async def test_the_descriptor_carries_the_default_and_per_metric_closures(client):
"""描述里给这一版两份闭包摘要:closure(不点名、整卡那份)与 selectable_metrics(每个指标各自的那份)。"""
wf = await S.publish(graph=two_metric_graph())
[made] = await descriptors(client, wf)
default = made["closure"]
assert default["whole_card"] is True and default["query_ids"] == [f"{wf}:fetch_a", f"{wf}:fetch_b"]
assert default["required_entry_fields"] == ["start", "end", "buyer"]
assert default["metric_ids"] == ["gmv", "n"] and default["resolver_version"]
assert [m["metric_id"] for m in made["selectable_metrics"]] == ["gmv", "n"]
assert made["unavailable_metrics"] == [] and made["computes_unselected"] == []
by_metric = {m["metric_id"]: m for m in made["selectable_metrics"]}
assert by_metric["gmv"]["query_ids"] == [f"{wf}:fetch_a"]
assert by_metric["gmv"]["required_entry_fields"] == ["start", "end"]
assert by_metric["gmv"]["queries"][0]["sql_hash"] and "SELECT" not in json.dumps(made)
assert made["composable"] is True and made["compose_reason"] is None
assert leaks_sql(json.dumps(made, ensure_ascii=False)) == []
async def test_a_broken_metric_does_not_close_the_whole_card(client):
"""B 那条查询坏了:B 进 unavailable(带原因),A 照样可选、整卡照样可组合;不点名那一份照实说算不出来。"""
wf = await S.publish(graph=two_metric_graph(b_sql=SQL_B + " AND note = '{{ input.nope }}'"))
[made] = await descriptors(client, wf)
assert made["composable"] is True
assert [m["metric_id"] for m in made["selectable_metrics"]] == ["gmv"]
[bad] = made["unavailable_metrics"]
assert bad["metric_id"] == "n" and "undeclared_parameter" in bad["reason"]
assert made["closure"] is None and "undeclared_parameter" in made["compose_reason"]
assert leaks_sql(json.dumps(made, ensure_ascii=False)) == []
async def test_a_card_whose_metrics_all_fail_is_not_composable(client):
"""两个指标都用不了:不能组合,原因照实给(描述里每个指标各写各的)。"""
wf = await S.publish(graph=two_metric_graph(a_sql=SQL_A + " AND note = '{{ input.nope }}'",
b_sql=SQL_B + " AND note = '{{ input.nope }}'"))
[made] = await descriptors(client, wf)
assert made["composable"] is False
assert [m["metric_id"] for m in made["unavailable_metrics"]] == ["gmv", "n"]
assert made["closure"] is None and "undeclared_parameter" in made["compose_reason"]
assert leaks_sql(json.dumps(made, ensure_ascii=False)) == []
async def test_a_shared_query_is_declared_as_still_computing_the_other_metric(client):
"""A 和 B 共用一条 SQL:按指标那一份摘要声明它还在算没选中的那个指标(computes_unselected,顶层也按选择给一份)。"""
wf = await S.publish(graph=shared_sql_graph())
[made] = await descriptors(client, wf)
by_metric = {m["metric_id"]: m for m in made["selectable_metrics"]}
[query] = by_metric["gmv"]["queries"]
assert query["query_id"] == f"{wf}:fetch" and query["node_id"] == "fetch" and query["tool"] == S.tool()
assert query["metric_ids"] == ["gmv"] and query["computes_unselected"] == ["n"]
assert query["required_entry_fields"] == ["start", "end"] and query["sql_hash"]
# 顶层按「选谁、还在算谁」两组各一条(选 gmv 时还在算 n,反过来也一样)
assert made["computes_unselected"] == [
{"query_id": f"{wf}:fetch", "node_id": "fetch", "metric_ids": ["gmv"], "computes_unselected": ["n"]},
{"query_id": f"{wf}:fetch", "node_id": "fetch", "metric_ids": ["n"], "computes_unselected": ["gmv"]},
]
assert leaks_sql(json.dumps(made, ensure_ascii=False)) == []
async def test_the_descriptor_follows_the_working_versions_recommendation(client):
"""K07:列出的是**推荐发布版**(Workflow.published_version),不是机械取最高已发布版。"""
from app.db.base import SessionLocal
from app.db.models import Workflow
wf = await S.publish(name="推荐版周销售") # v1
changed = S.caliber_graph()
next(n for n in changed["nodes"] if n["type"] == "metrics")["data"]["config"]["metrics"][0]["name"] = "销售额(改)"
await S.publish(name="推荐版周销售", version=2, workflow_id=wf, graph=changed)
async with SessionLocal() as session:
row = await session.get(Workflow, wf)
row.published_version = 1 # 把推荐版推回 v1
await session.commit()
[made] = await descriptors(client, wf)
assert made["version"] == 1 and made["metrics"][0]["name"] == "销售额"
assert made["closure"]["query_ids"] == [f"{wf}:fetch"]