Files
AgentLab/backend/tests/test_ledger_compose.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

655 lines
39 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.
"""组合式问数:use_caliber 选用已发布口径,服务端按那一版确定性内联实例化(next-round 02 做法 4,ledger/compose.py)。
- 实例化是确定的:同一版、同一个操作得到一模一样的节点;取数节点照抄、id 加前缀、模板按参数渲染成字面 SQL,口径卡 id
就是操作 id、caliber_from 带改名映射;取数首尾相接再接口径卡,入口连到第一个取数节点;
- 参数按口径入口的字段校验(未知字段、缺必填、类型、引号里的值带引号、裸写的值不是数),不合规拒收、原因交回模型;
只能选这一轮查看到的那一版、能组合的卡、卡上的指标,id 不能撞;
- 问数据的查看对象多了口径:给入口字段和「能不能组合」,不给 SQL;「什么时候该看」换成问数据那一份;使用者不给;
- K08 S4:op.metrics 按**源卡顺序**归一(Composed.metrics、口径卡上记的 metrics、selection_hash 与执行数组都按那一份,
调用方传的顺序与重复项都不影响);选用选项按**每个指标**判能不能组合(B 独有那条查询坏了只让 B 不可选,A 照旧给组合
写法),真正的实例化仍按同一条选择逐项硬校验;
- 走一遍问数据的生成(剧本模型)再真跑出来:比对器把这些数标成 composed「助手选用」,整题 how=composed。
数据是虚构店铺库。
"""
from __future__ import annotations
import hashlib
import json
import pytest
from httpx import ASGITransport, AsyncClient
from app.api import copilot_context
from app.api.copilot_gen import GraphApplier, GraphChecker
from app.db.base import SessionLocal
from app.engine.runner import run_manager
from app.engine.schema import GraphSpec, validate_graph
from app.ledger import compose
from app.ledger import match as ledger_match
from app.ledger import params, store
from app.look.calibers import CaliberKind
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()
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
def use(wf: str, **extra) -> dict:
return {"op": "use_caliber", "id": "c1", "workflow_id": wf, "version": 1, "metrics": ["gmv"],
"inputs": {"start": "2026-09-01", "end": "2026-09-08"}, **extra}
async def composer() -> compose.Composer:
async with SessionLocal() as session:
kind = await CaliberKind.load(session, compose=True)
return compose.Composer(list(kind.options))
# --------------------------------------------------------------------------
# 实例化
# --------------------------------------------------------------------------
async def test_instantiation_is_deterministic_and_copies_the_pinned_fetch():
wf = await S.publish()
made = (await composer()).compose(use(wf), taken={"start"})
again = (await composer()).compose(use(wf), taken={"start"})
assert made.nodes == again.nodes and made.edges == again.edges
fetch, card = made.nodes
assert fetch["id"] == "c1_fetch" and fetch["type"] == "tool"
assert fetch["data"]["config"]["tool"] == S.tool()
assert fetch["data"]["config"]["args"]["sql"] == S.TEMPLATE.replace("{{ input.start }}", "2026-09-01").replace(
"{{ input.end }}", "2026-09-08")
# 值渲染进去的那一次已经发生过:节点上记一份参数契约(ledger/params 的 marker),运行时的模板渲染不再碰 args——
# 不然值里的 {{ }} 会被再解释一遍(K01 的二次渲染反例)
marker = fetch["data"]["config"]["parameters"]
assert marker["mode"] == params.LITERAL_ARGS and marker["adapter"] == params.ADAPTER == "inline_text"
# 源模板正文不写在节点上(engine/variables 会扫节点配置里的 {{ }},把占位符报成没人产出):记哈希 + 出处
assert "template" not in marker
assert marker["template_hash"] == hashlib.sha256(S.TEMPLATE.encode("utf-8")).hexdigest()
# 契约与它注解的 args 绑定:哈希对得上才算(节点被复制、args 改过就作废,运行时回退正常渲染)
assert marker["rendered_hash"] == params.rendered_hash(fetch["data"]["config"]["args"])
assert params.literal_keys(fetch["data"]["config"], "tool") == ("args",)
assert marker["parameters"] == [{"name": "start", "kind": "text", "declared": "string", "slot": "quoted"},
{"name": "end", "kind": "text", "declared": "string", "slot": "quoted"}]
assert [d["name"] for d in marker["declared"]] == ["start", "end", "store"]
assert marker["source"]["workflow_id"] == wf and marker["source"]["node_id"] == "fetch"
assert card["id"] == "c1" and card["type"] == "metrics" and card["data"]["label"] == "card"
# 这张卡这次算哪个指标也记在 口径卡来源 上(K08 S2:metrics 是这次的选择;整卡时这一项不写)
assert card["data"]["config"]["caliber_from"] == {"workflow_id": wf, "workflow_version": 1, "node_id": "card",
"nodes": {"fetch": "c1_fetch"}, "metrics": ["gmv"]}
assert made.edges == [{"source": "c1_fetch", "target": "c1"}]
# 这次要执行的那条取数只用到 start/end:没用到的 store 不进这次的值(也不填它的缺省值)
assert made.inputs == {"start": "2026-09-01", "end": "2026-09-08"}
result = made.result()
assert result["status"] == "ok" and result["card_id"] == "c1" and result["fetches"] == ["c1_fetch"]
assert result["level_text"] == "受管" and result["metrics"] == [{"id": "gmv", "name": "销售额"}]
assert "sql" not in json.dumps(result).lower()
@pytest.mark.parametrize(("change", "words"), [
({"workflow_id": "nope"}, "只能选用这一轮查看到的已发布口径"),
({"version": 2}, "能选用的是 v1"),
({"card": "other"}, "没有口径卡「other」"),
({"id": "C 1"}, "简短的英文小写"),
({"metrics": []}, "metrics 要写"),
({"metrics": ["profit"]}, "没有指标「profit」"),
({"inputs": {"start": "2026-09-01", "end": "2026-09-08", "city": "x"}}, "入口没有字段「city」"),
# 这一版入口声明过、只是不在这次选中的指标的范围里:说法不一样(K08 复核),照样拒
({"inputs": {"start": "2026-09-01", "end": "2026-09-08", "store": "east"}}, "不在这次选用的范围里"),
({"inputs": {"start": "2026-09-01"}}, "请填写「end」"),
({"inputs": {"start": "2026-09-01' OR '1'='1", "end": "2026-09-08"}}, "引号里,值里不能有引号"),
({"inputs": "start=2026-09-01"}, "inputs 要写成"),
])
async def test_bad_choices_are_refused_with_a_reason(change, words):
wf = await S.publish()
op = use(wf, **change) if change.get("workflow_id") != "nope" else {**use(wf), **change}
with pytest.raises(compose.ComposeRefused) as e:
(await composer()).compose(op, taken={"start"})
assert words in e.value.message, e.value.message
async def test_bare_slots_take_numbers_only_and_ids_must_not_clash():
graph = S.caliber_graph(sql="SELECT SUM(amount) AS gmv FROM orders WHERE status = 'paid' AND id > {{ input.after }}")
# 字段没写类型(按文字收):表单校验放行,裸写进 SQL 的只能是数
graph["nodes"][0]["data"]["config"]["fields"] = [{"name": "after", "default": "0"}]
wf = await S.publish(graph=graph)
pool = await composer()
with pytest.raises(compose.ComposeRefused, match="只能是数"):
pool.compose({**use(wf), "inputs": {"after": "1 OR 1=1"}}, taken=set())
made = pool.compose({**use(wf), "inputs": {"after": "3"}}, taken=set())
fetch = made.nodes[0]["data"]["config"]
assert fetch["args"]["sql"].endswith("id > 3")
assert fetch["parameters"]["parameters"] == [{"name": "after", "kind": "text", "declared": "string", "slot": "bare"}]
with pytest.raises(compose.ComposeRefused, match="撞了"):
pool.compose({**use(wf), "inputs": {"after": "3"}}, taken={"c1"})
async def test_a_card_that_reads_model_output_is_listed_but_not_composable():
graph = S.chain(S.node("start", "input"), S.node("ask", "agent", tools=[S.tool()], assign_to="kpi"),
S.node("card", "metrics", caliber="周销售", metrics=[{"id": "gmv", "expression": "vars.kpi.gmv"}]),
S.node("out", "output", fields=[{"name": "r", "value": "{{ nodes.card.text }}"}]))
wf = await S.publish(graph=graph)
async with SessionLocal() as session:
kind = await CaliberKind.load(session, compose=True)
[option] = [o for o in kind.options if o.workflow_id == wf]
# 读模型写的数:这个指标一个也不可选(原因照实给,不是「没找到」)
assert not option.composable and "输入不全是查库结果里的格" in option.reason
assert [m["metric_id"] for m in option.unavailable_metrics] == ["gmv"]
assert option.unavailable_metrics[0]["reason"] in option.reason
text, _ = await kind.detail([(i, "named") for i in kind.items if i.workflow_id == wf], 4000)
assert "不能组合" in text and "输入不全是查库结果里的格" in text
with pytest.raises(compose.ComposeRefused, match="不能组合"):
compose.Composer(list(kind.options)).compose(use(wf), taken=set())
# --------------------------------------------------------------------------
# 查看:入口字段、能不能组合,不给 SQL
# --------------------------------------------------------------------------
async def test_the_answer_look_shows_fields_and_how_to_compose_but_no_sql():
wf = await S.publish()
async with SessionLocal() as session:
kind = await CaliberKind.load(session, compose=True)
plain = await CaliberKind.load(session)
objects = [(i, "named") for i in kind.items if i.workflow_id == wf]
text, [record] = await kind.detail(objects, 4000)
# 入口字段只列这次可选指标要用的那几个(store 没有指标用得到,不列)
assert "入口字段:start(string,必填);end(string,必填)" in text and "store" not in text
assert "能选用的指标:销售额(gmv,要 start、end)" in text and "用不了的指标" not in text
assert '"op": "use_caliber"' in text and f'"workflow_id": "{wf}"' in text and '"card": "card"' in text
assert "SELECT" not in text.upper() and "orders" not in text
assert record["kind"] == "caliber" and record["named"]
# 蓝图助手那一支(不组合)照旧给复用写法,没有组合写法
plain_text, _ = await plain.detail([(i, "named") for i in plain.items if i.workflow_id == wf], 4000)
assert "复用写法" in plain_text and "use_caliber" not in plain_text
async def test_the_answer_prompt_switches_only_when_calibers_can_be_composed():
from app.look.service import LookService
assert copilot_context.LOOK_WHEN_ANSWER_PROMPT != copilot_context.LOOK_WHEN_PROMPT
assert "use_caliber" in copilot_context.LOOK_WHEN_ANSWER_PROMPT and "含退款" in copilot_context.LOOK_WHEN_ANSWER_PROMPT
async with SessionLocal() as session:
composing = LookService([await CaliberKind.load(session, compose=True)])
plain = LookService([await CaliberKind.load(session)])
assert copilot_context.look_when(composing) == copilot_context.LOOK_WHEN_ANSWER_PROMPT
assert copilot_context.look_when(plain) == copilot_context.LOOK_WHEN_PROMPT
assert copilot_context.composer(plain) is None and copilot_context.composer(composing) is not None
# --------------------------------------------------------------------------
# 落地:GraphApplier
# --------------------------------------------------------------------------
async def test_the_applier_lands_the_nodes_wires_the_entry_and_reports_refusals():
wf = await S.publish()
nodes: dict = {}
edges: list = []
applier = GraphApplier(nodes, edges, composer=await composer())
op = use(wf)
applied = applier.apply(op)
assert applied.changed and applied.forward and op["result"]["status"] == "ok"
assert set(nodes) == {"c1_fetch", "c1"} and edges == [{"source": "c1_fetch", "target": "c1", "sourceHandle": None}]
# 入口后加的:加上时接到第一个取数节点;模型再发一条同样的连线不重复
applier.apply({"op": "add_node", "node": {"id": "start", "type": "input", "label": "入口", "config": {}}})
assert {"source": "start", "target": "c1_fetch", "sourceHandle": None} in edges
assert not applier.apply({"op": "add_edge", "edge": {"source": "start", "target": "c1_fetch"}}).changed
assert len([e for e in edges if e["target"] == "c1_fetch"]) == 1
# 接着写时重发同一个选用:不再落地
applier.continuing()
assert not applier.apply(use(wf)).changed
assert "c1" in applier.landed()
bad = use(wf, id="c2", inputs={"start": "2026-09-01"})
refused = applier.apply(bad)
assert not refused.changed and refused.forward and bad["result"]["status"] == "refused"
checker = GraphChecker(applier)
first = await checker.issues()
assert [i["code"] for i in first if i["code"] == compose.REFUSED] == [compose.REFUSED]
assert "请填写「end」" in first[0]["message"] and "重新输出 use_caliber" in first[0]["for_model"]
# 交回过一次就不再交
assert not [i for i in await checker.issues() if i["code"] == compose.REFUSED]
# 兜底重放到上一轮的图上:拷进来的节点和接线照样在
applier.replay_onto({"nodes": [{"id": "start", "type": "input", "data": {"config": {}}}], "edges": []})
assert {"c1_fetch", "c1"} <= set(nodes) and any(e["target"] == "c1_fetch" for e in edges)
async def test_without_a_composer_use_caliber_is_dropped_like_any_unknown_op():
nodes: dict = {}
applier = GraphApplier(nodes, [])
applied = applier.apply({"op": "use_caliber", "id": "c1", "workflow_id": "w", "version": 1})
assert not applied.changed and not applied.forward and nodes == {}
# --------------------------------------------------------------------------
# 问数据走一遍:生成 → 运行 → 比对标 composed
# --------------------------------------------------------------------------
def _answer_script(wf: str) -> list[dict]:
from tests.test_copilot_answer_ops import _edge, _node
return [
{"op": "plan", "summary": "选用周销售口径"},
_node("start", "input", {}),
use(wf),
_node("write", "report", {"instructions": "回答:{{ input.question }}"}),
_edge("c1", "write"),
_node("out", "output", {"fields": [{"name": "答案", "value": "{{ nodes.write.text }}"}]}),
_edge("write", "out"),
{"op": "done", "explanation": "按口径周销售回答", "run": True},
]
async def test_an_answer_built_with_use_caliber_is_labelled_composed(client, monkeypatch):
from tests.test_copilot_answer_ops import BODY, _Scripted, _sse, _use
wf = await S.publish()
_use(monkeypatch, _Scripted(_answer_script(wf), []))
ops = await _sse(client, {**BODY, "instruction": "九月第一周卖了多少"})
[chosen] = [o for o in ops if o["op"] == "use_caliber"]
assert chosen["result"]["status"] == "ok" and chosen["result"]["workflow_name"] == "周销售"
assert not [o for o in ops if o["op"] == "add_node" and o["node"]["id"] in ("c1", "c1_fetch")], \
"拷进来的节点不另外转给界面:过程里只有 use_caliber 那一行"
[final] = [o for o in ops if o["op"] == "final"]
assert final["next"] == "run", final["issues"]
graph = final["graph"]
assert {n["id"] for n in graph["nodes"]} == {"start", "c1_fetch", "c1", "write", "out"}
assert not [i for i in validate_graph(GraphSpec.model_validate(graph)).issues if i.level == "error"]
# 走过应用器、写进最终图之后,参数契约还认得出它注解的就是这条 args(K01 的哈希绑定不靠内存里的对象)
fetch = next(n for n in graph["nodes"] if n["id"] == "c1_fetch")["data"]["config"]
assert params.literal_keys(fetch, "tool") == ("args",)
assert fetch["parameters"]["rendered_hash"] == params.rendered_hash(fetch["args"])
S.script(monkeypatch, "九月第一周销售额 [[m:gmv]]。")
run = await run_manager.start(graph=graph, input_payload={"question": "九月第一周卖了多少"}, origin_kind="chat")
row = await S.wait(run.id)
assert row.status == "succeeded", row.error
await ledger_match.drain()
recorded = await store.latest(row.id)
content = recorded.content
assert content["algo"] == store.ALGO == "5"
[query] = content["queries"]
assert query["status"] == "composed" and query["node_id"] == "c1_fetch"
[cand] = query["candidates"]
assert cand["status"] == "proven" and cand["how"] == "composed" and cand["caliber"] == f"{wf}@1"
[label] = content["reports"]["write"]["labels"].values()
assert label["kind"] == "caliber" and label["how"] == "composed" and label["queries"] == [query["artifact"]]
assert content["whole"]["equivalent"] is True and content["whole"]["how"] == "composed"
assert recorded.content and (await store.latest(row.id)).trigger == "sealed"
async with SessionLocal() as session:
from app.db.models import Artifact
ref = await session.get(Artifact, (recorded.artifact, row.id))
assert ref.meta["calibers"] == [f"{wf}@1"] and ref.meta["counts"]["composed"] == 1
async def test_users_get_the_authorized_calibers_but_no_use_caliber(monkeypatch):
"""使用者(开登录后的 user 角色):问数据里**看得到**自己有授权的口径(K14),但没有 use_caliber 那条路。
看得到的是「按资产授权收窄过的清单 + 入口字段 + 按口径查询工具取数的写法」(look/calibers 的 query 那一档),不是把口径的
取数 SQL 拷进自己的图(那是搭建者及以上那一档,compose)。"""
import app.api.copilot as copilot
from app.api.copilot import GenerateIn
from app.api import copilot_context
seen: dict = {}
async def spy(session, payload, sources, **kw):
seen.update(kw)
raise RuntimeError("到这里就够了")
monkeypatch.setattr(copilot, "_look_service", spy)
monkeypatch.setattr("app.api.deps.restricted", lambda: True)
with pytest.raises(RuntimeError):
await copilot.answer_ops(GenerateIn(instruction="x", intent="answer")).__anext__()
assert seen["kinds"] == ("table", "capability", "caliber")
assert seen["caliber"] == copilot_context.CALIBER_QUERY, "使用者这一档只给按口径查询工具取数"
monkeypatch.setattr("app.api.deps.restricted", lambda: False)
with pytest.raises(RuntimeError):
await copilot.answer_ops(GenerateIn(instruction="x", intent="answer")).__anext__()
assert seen["kinds"] == ("table", "capability", "caliber")
assert seen["caliber"] == copilot_context.CALIBER_COMPOSE, "搭建者及以上照旧能 use_caliber"
with pytest.raises(RuntimeError):
await copilot.answer_ops(GenerateIn(instruction="x", intent="build")).__anext__()
assert seen["kinds"] == ("table", "capability")
assert seen["caliber"] == copilot_context.CALIBER_LOOK, "画布那一档根本不看口径"
# --------------------------------------------------------------------------
# card 填什么:协议写清两个 id,写错了交回的原因能一次改对(校验不放宽)
# --------------------------------------------------------------------------
def test_the_protocol_says_card_is_the_cards_node_id_and_id_is_the_name_you_give():
text = copilot_context.USE_CALIBER_PROMPT
assert '"card":"card"' in text and '"card":"口径卡节点 id"' not in text
assert "card 填那一版工作流图里口径卡的节点 id" in text and "不是你起的名字" in text
assert "id 是你给这次选用起的名字" in text and "workflow_id、version、card 原样照抄" in text
def test_a_user_round_refuses_use_caliber_and_points_at_the_query_tool():
"""使用者那一轮看得见口径但不能选用:use_caliber 照实拒收、自查交回模型改用口径查询工具,不静默丢掉。
没有口径这一档的入口(画布、发布前修复、升级)照旧:use_caliber 是认不出的操作,不进拒收。"""
from app.api.copilot_gen import NO_COMPOSE_TEXT, GraphApplier, _refused_issues
op = {"op": "use_caliber", "id": "c1", "workflow_id": "wf", "version": 1, "card": "card",
"metrics": ["gmv"], "inputs": {}}
user = GraphApplier({}, [], composer=None, no_compose=True)
assert user.apply(dict(op)).changed is False
[issue] = _refused_issues(user)
assert issue["level"] == "error" and NO_COMPOSE_TEXT in issue["message"]
assert "use_caliber" in issue["for_model"] and "caliber_query" in issue["for_model"]
assert user.nodes == {} and user.edges == [], "拒收的选用一个节点、一条连线都不落地"
plain = GraphApplier({}, [])
plain.apply(dict(op))
assert _refused_issues(plain) == []
async def test_writing_your_own_id_as_the_card_is_refused_with_what_to_write_instead():
wf = await S.publish()
pool = await composer()
with pytest.raises(compose.ComposeRefused) as e:
pool.compose(use(wf, card="c1"), taken={"start"})
message = e.value.message
assert "没有口径卡「c1」" in message and "card 要填那一版工作流图里口径卡的节点 id" in message
assert "可填的有 「card」(口径卡「card」)" in message, message
assert "「c1」是你给这次选用起的 id,不是口径卡的节点 id" in message
# 别的写错(不是自己起的 id)不多那一句
with pytest.raises(compose.ComposeRefused) as other:
pool.compose(use(wf, card="other"), taken={"start"})
assert "是你给这次选用起的 id" not in other.value.message
# 校验不放宽:照着交回的原因改成节点 id 就过
made = pool.compose(use(wf, card="card"), taken={"start"})
assert made.op_id == "c1"
# --------------------------------------------------------------------------
# 只算选中的指标(K08 S2):op.metrics 贯穿参数校验与拷贝
# --------------------------------------------------------------------------
#: 只有 B 那条查询用的字段(region):只选 A 时它不该被要求
FIELDS_AB = [{"name": "start", "required": True}, {"name": "end", "required": True},
{"name": "buyer", "required": True}]
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(*, on_missing: bool = False, a_sql: str = SQL_A, b_sql: str = SQL_B) -> dict:
"""一张卡两个指标、两条各自独立的查询;buyer 只有 B 那条用(A 不需要它)。"""
card: dict = {"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')"}]}
if on_missing:
card["on_missing"] = "null"
return S.chain(S.node("start", "input", fields=FIELDS_AB),
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", **card),
S.node("out", "output", fields=[{"name": "报告", "value": "{{ nodes.write.text }}"}]),
S.node("write", "report", instructions="写周销售"))
def shared_graph() -> dict:
"""两个指标读同一条取数(共享 SQL):选一个时那条 SQL 保留,还在算另一个。"""
return S.chain(S.node("start", "input", fields=FIELDS_AB[:2]),
S.node("fetch", "tool", tool=S.tool(), args={"sql": SQL_A}),
S.node("card", "metrics", caliber="共享取数", caliber_version="v1",
metrics=[{"id": "gmv", "name": "销售额", "expression": "cell(nodes.fetch, 0, 'gmv')"},
{"id": "n", "name": "单量", "expression": "cell(nodes.fetch, 0, 'n')"}]),
S.node("out", "output"))
def select(wf: str, metrics: list[str], inputs: dict | None = None, *, id: str = "c1") -> dict:
return {"op": "use_caliber", "id": id, "workflow_id": wf, "version": 1, "card": "card",
"metrics": metrics, "inputs": inputs if inputs is not None else
{"start": "2026-09-01", "end": "2026-09-08"}}
async def test_selecting_one_metric_no_longer_asks_for_the_other_query_s_parameters():
"""op.metrics 决定这次真正要执行哪些取数:只选 A 时不再要求 B 的参数,也只拷 A 那条查询。"""
wf = await S.publish(graph=two_metric_graph())
pool = await composer()
made = pool.compose(select(wf, ["gmv"]), taken={"start"})
assert [n["id"] for n in made.nodes] == ["c1_fetch_a", "c1"]
card = made.nodes[-1]["data"]["config"]
assert card["caliber_from"] == {"workflow_id": wf, "workflow_version": 1, "node_id": "card",
"nodes": {"fetch_a": "c1_fetch_a"}, "metrics": ["gmv"]}
assert made.inputs == {"start": "2026-09-01", "end": "2026-09-08"}
assert made.fetch_ids == ["c1_fetch_a"]
# 整卡(两个指标)时还是要求 B 的参数:不收窄、也不静默放宽
with pytest.raises(compose.ComposeRefused, match="buyer"):
pool.compose(select(wf, ["gmv", "n"], id="c2"), taken={"start"})
# 结果带上这次选择的身份与排除的查询
result = made.result()
assert result["selection"]["mode"] == "explicit" and result["selection"]["metric_ids"] == ["gmv"]
assert result["selection"]["query_ids"] == [f"{wf}:fetch_a"]
assert result["selection"]["excluded_query_ids"] == [f"{wf}:fetch_b"]
assert result["selection"]["selection_hash"] and result["selection"]["closure_hash"]
assert "shared_queries" not in result
# 拷进来的图能过画布校验(口径卡上多记的选择不影响它)
nodes = [S.node("start", "input", fields=[{"name": "x"}]), *made.nodes,
S.node("write", "report", instructions="答"), S.node("out", "output")]
issues = validate_graph(GraphSpec.model_validate(S.chain(*nodes))).issues
assert not [i for i in issues if i.level == "error"], issues
async def test_a_shared_query_is_kept_and_declared_as_still_computing_the_unselected_metric():
"""A 和 B 共用一条 SQL:选 A 也保留那条 SQL,并在结果里声明它还在算 B(不做投影)。"""
wf = await S.publish(graph=shared_graph())
made = (await composer()).compose(select(wf, ["n"]), taken={"start"})
assert [n["id"] for n in made.nodes] == ["c1_fetch", "c1"]
assert made.nodes[-1]["data"]["config"]["caliber_from"]["metrics"] == ["n"]
result = made.result()
assert result["selection"]["metric_ids"] == ["n"] and result["selection"]["excluded_query_ids"] == []
assert result["shared_queries"] == [{"query_id": f"{wf}:fetch", "node_id": "fetch",
"computes_unselected": ["gmv"]}]
async def test_a_selection_whose_dependencies_do_not_resolve_is_refused_not_widened():
"""点名的指标一个取数也不依赖(解析不出闭包):拒收,不悄悄退回整卡。"""
graph = S.chain(S.node("start", "input", fields=FIELDS_AB[:2]),
S.node("fetch", "tool", tool=S.tool(), args={"sql": SQL_A}),
S.node("card", "metrics", caliber="半个常量", caliber_version="v1",
metrics=[{"id": "gmv", "name": "销售额", "expression": "cell(nodes.fetch, 0, 'gmv')"},
{"id": "constant", "name": "常量", "expression": "1 + 1"}]),
S.node("out", "output"))
wf = await S.publish(graph=graph)
pool = await composer()
# 整卡两张指标都在:gmv 那条查询照拷(常量指标不牵连它)
ok = pool.compose(select(wf, ["gmv"]), taken={"start"})
assert [n["id"] for n in ok.nodes] == ["c1_fetch", "c1"]
with pytest.raises(compose.ComposeRefused) as caught:
pool.compose(select(wf, ["constant"], id="c2"), taken={"start"})
assert "no_query_dependency" in caught.value.message and "constant" in caught.value.message
async def test_check_inputs_takes_the_same_selection_by_itself():
"""check_inputs 也能直接按 metrics 收窄(callers 不必先裁一份):只选 A 时不要求 B 的参数。"""
from app.ledger import derive
wf = await S.publish(graph=two_metric_graph())
async with SessionLocal() as session:
kind = await CaliberKind.load(session, compose=True)
[option] = [o for o in kind.options if o.workflow_id == wf]
pinned = option.pinned
values = compose.check_inputs(pinned, {"start": "2026-09-01", "end": "2026-09-08"}, metric_ids=["gmv"])
assert values == {"start": "2026-09-01", "end": "2026-09-08"}
with pytest.raises(compose.ComposeRefused, match="buyer"):
compose.check_inputs(pinned, {"start": "2026-09-01", "end": "2026-09-08"})
# caliber_block 拿同一份选择:改名映射只列保留的那条查询,卡上记下这次算哪个指标
block = derive.caliber_block(pinned, prefix="c1_", metrics=["gmv"])
assert [n["id"] for n in block.nodes] == ["c1_fetch_a", "c1_card"]
assert block.nodes[-1]["data"]["config"]["caliber_from"]["metrics"] == ["gmv"]
# --------------------------------------------------------------------------
# 按指标判可组合性(K08 S4):B 用不了不牵连 A;一次选用按源卡顺序
# --------------------------------------------------------------------------
async def test_a_broken_query_of_one_metric_leaves_the_other_composable():
"""B 那条查询引用了入口没声明的字段:B 用不了,A 照旧可选、能组合、能实例化;不再把整张卡提前判死。"""
wf = await S.publish(graph=two_metric_graph(b_sql=SQL_B + " AND note = '{{ input.nope }}'"))
pool = await composer()
[option] = [o for o in pool.options if o.workflow_id == wf]
assert option.composable and [m["metric_id"] for m in option.selectable_metrics] == ["gmv"]
assert [m["metric_id"] for m in option.unavailable_metrics] == ["n"]
assert "undeclared_parameter" in option.unavailable_metrics[0]["reason"]
# 不点名(整卡)那一份算不出来:原因照实给,但按指标还能用
assert option.default_closure is None and "undeclared_parameter" in option.reason
[sel] = option.selectable_metrics
assert sel["query_ids"] == [f"{wf}:fetch_a"] and sel["required_entry_fields"] == ["start", "end"]
blob = json.dumps(sel, ensure_ascii=False)
assert "SUM(" not in blob and "orders" not in blob and "paid_at" not in blob and '"sql"' not in blob
# 能选的照旧实例化(只拷 A 那条查询);不能选的逐项硬校验拒收,不靠预筛放宽
made = pool.compose(select(wf, ["gmv"]), taken={"start"})
assert [n["id"] for n in made.nodes] == ["c1_fetch_a", "c1"]
with pytest.raises(compose.ComposeRefused) as caught:
pool.compose(select(wf, ["n"], id="c2"), taken={"start"})
assert "undeclared_parameter" in caught.value.message
# 查看结果:能选的写要哪些字段,用不了的写原因;整段没有 SQL
async with SessionLocal() as session:
kind = await CaliberKind.load(session, compose=True)
text, _ = await kind.detail([(i, "named") for i in kind.items if i.workflow_id == wf], 4000)
assert "能选用的指标:销售额(gmv,要 start、end)" in text
assert "用不了的指标:单量(n):" in text and "undeclared_parameter" in text
assert "SELECT" not in text.upper() and "orders" not in text
async def test_a_metric_whose_binding_is_out_of_step_does_not_close_the_whole_card():
"""B 声明了业务字段(统计时间口径)但那条取数没有受控绑定:B 用不了,A 照旧可选、能实例化(K08 S4)。"""
graph = two_metric_graph()
card = next(n for n in graph["nodes"] if n["type"] == "metrics")
card["data"]["config"]["metrics"][1]["time_role"] = "payment_time" # B 声明了,可是 fetch_b 上没有绑定
wf = await S.publish(graph=graph)
pool = await composer()
[option] = [o for o in pool.options if o.workflow_id == wf]
assert option.composable and [m["metric_id"] for m in option.selectable_metrics] == ["gmv"]
[bad] = option.unavailable_metrics
assert bad["metric_id"] == "n" and "受控绑定" in bad["reason"]
assert option.default_closure is None and "受控绑定" in option.reason
made = pool.compose(select(wf, ["gmv"]), taken={"start"})
assert [n["id"] for n in made.nodes] == ["c1_fetch_a", "c1"]
with pytest.raises(compose.ComposeRefused) as caught:
pool.compose(select(wf, ["n"], id="c2"), taken={"start"})
assert "binding_unresolved" in caught.value.message
async def test_a_card_whose_metrics_all_fail_is_not_offered_for_composition():
"""两个指标都用不了:不提供组合写法、也不实例化,原因与每个指标的原因照实给。"""
graph = two_metric_graph(a_sql=SQL_A + " AND note = '{{ input.nope }}'",
b_sql=SQL_B + " AND note = '{{ input.nope }}'")
wf = await S.publish(graph=graph)
pool = await composer()
[option] = [o for o in pool.options if o.workflow_id == wf]
assert not option.composable and option.pinned is None and option.selectable_metrics == ()
assert [m["metric_id"] for m in option.unavailable_metrics] == ["gmv", "n"]
assert "undeclared_parameter" in option.reason
with pytest.raises(compose.ComposeRefused, match="不能组合"):
pool.compose(select(wf, ["gmv"]), taken={"start"})
async with SessionLocal() as session:
kind = await CaliberKind.load(session, compose=True)
text, _ = await kind.detail([(i, "named") for i in kind.items if i.workflow_id == wf], 4000)
assert "不能组合" in text and "undeclared_parameter" in text and "SELECT" not in text.upper()
async def test_a_selection_is_normalized_to_the_source_card_order():
"""一次选用里的指标一律按源卡顺序:Composed.metrics、口径卡上记的 metrics、selection_hash 与执行数组都不随
调用方传的顺序、重复项变(K08 S4)。"""
wf = await S.publish(graph=two_metric_graph(on_missing=True))
pool = await composer()
both = {"start": "2026-09-01", "end": "2026-09-08", "buyer": "east"}
forward = pool.compose(select(wf, ["gmv", "n"], both), taken=set())
backward = pool.compose(select(wf, ["n", "gmv", "n"], both), taken=set())
assert [m["id"] for m in forward.metrics] == [m["id"] for m in backward.metrics] == ["gmv", "n"]
assert forward.result()["selection"]["metric_ids"] == ["gmv", "n"]
assert forward.nodes[-1]["data"]["config"]["caliber_from"]["metrics"] == ["gmv", "n"]
assert forward.nodes == backward.nodes and forward.edges == backward.edges
assert forward.result()["selection"]["selection_hash"] == backward.result()["selection"]["selection_hash"]
assert forward.result()["selection"]["closure_hash"] == backward.result()["selection"]["closure_hash"]
async def test_the_offered_option_follows_the_recommended_version():
"""K07:问数据里看到的是推荐发布版(Workflow.published_version),不是机械取最高已发布版;按指标的可选性也按那一版。"""
from app.db.models import Workflow
wf = await S.publish(graph=two_metric_graph()) # v1:两个指标都读得出来
await S.publish(name="周销售", version=2, workflow_id=wf,
graph=two_metric_graph(b_sql=SQL_B + " AND note = '{{ input.nope }}'")) # v2:B 坏了
async with SessionLocal() as session:
row = await session.get(Workflow, wf)
row.published_version = 1 # 重新推回 v1
await session.commit()
kind = await CaliberKind.load(session, compose=True)
assert {i.version for i in kind.items if i.workflow_id == wf} == {1}
[option] = [o for o in kind.options if o.workflow_id == wf]
assert option.version == 1 and option.composable and option.unavailable_metrics == ()
assert [m["metric_id"] for m in option.selectable_metrics] == ["gmv", "n"]
assert option.default_closure["query_ids"] == [f"{wf}:fetch_a", f"{wf}:fetch_b"]
# --------------------------------------------------------------------------
# 复核:重复的指标 id、字段「不在本次选择范围」与「入口没这个字段」
# --------------------------------------------------------------------------
async def test_a_card_with_a_duplicated_metric_id_is_never_offered_for_composition():
"""同 id 指向两条不同查询的卡:look 一个指标也不提供(理由照实)、实例化也不放行(K08 复核)。"""
graph = two_metric_graph()
card = next(n for n in graph["nodes"] if n["type"] == "metrics")
card["data"]["config"]["metrics"][1]["id"] = "gmv" # 两个 gmv:一个读 fetch_a、一个读 fetch_b
wf = await S.publish(graph=graph)
pool = await composer()
[option] = [o for o in pool.options if o.workflow_id == wf]
assert not option.composable and option.pinned is None and option.selectable_metrics == ()
assert [m["metric_id"] for m in option.unavailable_metrics] == ["gmv", "gmv"]
assert all("duplicate_metric_id" in m["reason"] for m in option.unavailable_metrics)
assert "duplicate_metric_id" in (option.reason or "")
with pytest.raises(compose.ComposeRefused, match="不能组合"):
pool.compose(select(wf, ["gmv"]), taken={"start"})
async with SessionLocal() as session:
kind = await CaliberKind.load(session, compose=True)
text, _ = await kind.detail([(i, "named") for i in kind.items if i.workflow_id == wf], 4000)
assert "不能组合" in text and "duplicate_metric_id" in text and "SELECT" not in text.upper()
async def test_a_field_of_another_metric_is_out_of_scope_not_missing():
"""这一版入口声明过、只是不在这次选中的指标的范围里:说法是「不在这次选用的范围里」,不是「入口没有字段」;
真的没声明过的字段照旧说「入口没有字段」(K08 复核)。两种都拒,行为不变。"""
wf = await S.publish(graph=two_metric_graph(on_missing=True))
pool = await composer()
both_fields = {"start": "2026-09-01", "end": "2026-09-08", "buyer": "east"}
with pytest.raises(compose.ComposeRefused) as caught:
pool.compose(select(wf, ["gmv"], both_fields), taken=set())
message = caught.value.message
assert "「buyer」这一版入口声明过,但不在这次选用的范围里" in message
assert "这次算 gmv,只要 start、end" in message and "入口没有字段" not in message
with pytest.raises(compose.ComposeRefused) as unknown:
pool.compose(select(wf, ["gmv"], {"start": "2026-09-01", "end": "2026-09-08", "买了没": "x"}, id="c2"),
taken=set())
assert "入口没有字段「买了没」" in unknown.value.message and "不在这次选用的范围里" not in unknown.value.message
# 两张指标都选上时 buyer 就在这次的范围里:照旧收(整卡老行为不变)
made = pool.compose(select(wf, ["gmv", "n"], both_fields, id="c3"), taken=set())
assert made.inputs == both_fields