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

674 lines
41 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.
"""K03 字段变更闭环:按稳定 metric_id + 字段路径改一处,整批原子、图是原图的深拷贝、回执不吹验证。
- 打补丁打在**原图的深拷贝**上:没提到的字段(definition 的 citations/owner、contract、扩展字段)、别的指标、
别人加的图级扩展字段和其他节点的配置都原样保留(E02);
- 白名单:真有的业务字段和 definition 的展示字段能改,id / citations / 负责人 / 生效日期 / 指标类型 / 契约 /
任意路径 / 按数组位置定位一律拒,且各自有一句具体的话(不是笼统的「不认识」);
- 省略、set 空串(数组是 [])、set null、remove 四种写法语义不同;数组字段整份设置;
- 一批要么全成、要么全不成:一条不合规就一条都不落地,也不新增版本;整批最后没改动内容(先设后删)同样不落版本;
- 回执带变更分类、影响指标、定义指纹前后值、失效说明和绑定状态——没有绑定已对齐、也没有验证通过这种话;
- 同一语义变化经画布(整图保存)和经 operations 得到同一张执行图,旧试跑都判过期(E08);
- K02 的规矩一条不少:要带 expected_revision、旧写 409 stale_revision、同一 request_id 幂等(指纹包含 operations)、
同键不同内容 409 idempotency_conflict、并发同键只落一版;整份换指标那条老路照旧(回执不带 change 段)。
模型一律注入假模型(test_ledger_docdraft_api.fake_model),试跑换成写一行 succeeded 的运行(fake_trial),不发真实请求。
"""
from __future__ import annotations
import asyncio
import copy
import json
import uuid
import pytest
from sqlalchemy import select
from app.api import ledger as api_ledger
from app.core.artifact_store import execution_hash, graph_hash
from app.db.base import SessionLocal
from app.db.models import CaliberDraft, CaliberWriteRequest, Workflow, WorkflowVersion
from app.ledger import changes
from app.ledger.repository import CaliberGone, GraphBackedCaliberRepository, MetricGone
from tests.test_caliber_write_guard import ( # noqa: F401 - 这几个 fixture / 助手和 K02 的用例共用
_graph_of, _versions, cfg, client, datasource, documents, fake_trial, ready_draft,
)
def patch_url(draft_id: str) -> str:
return f"/api/ledger/drafts/{draft_id}"
def ops_set(metric_id: str, path: str, value) -> dict:
return {"op": "set", "metric_id": metric_id, "path": path, "value": value}
def ops_remove(metric_id: str, path: str) -> dict:
return {"op": "remove", "metric_id": metric_id, "path": path}
async def change(client, body: dict, operations: list[dict], *, request_id: str | None = None,
expected_revision: int | None = None, **extra):
"""按字段改一次(默认带上读草稿时给的基准版本)。"""
payload = {"operations": operations,
"expected_revision": body["revision"] if expected_revision is None else expected_revision, **extra}
if request_id is not None:
payload["request_id"] = request_id
return await client.patch(patch_url(body["id"]), json=payload)
def metric_of(graph: dict, metric_id: str = "m1", card: str = "card") -> dict:
metrics = cfg(graph, card)["metrics"]
return next(metric for metric in metrics if metric["id"] == metric_id)
async def bare_draft(client, metrics: list[dict]) -> dict:
"""一份手建的已接管草稿(docattach 那种:tags 有 doc_draft、origin 里没有 kind),图上只有一张口径卡。
起草出来的指标总是带整份 definition,画布上却什么形状都可能:边角形状(没有 definition、definition 是
null、被人写成字符串)走它来试。
"""
workflow_id = f"wf-{uuid.uuid4().hex[:12]}"
draft_id = uuid.uuid4().hex[:32]
graph = {"nodes": [{"id": "card", "type": "metrics", "position": {"x": 0, "y": 0},
"data": {"label": "口径卡", "config": {"metrics": copy.deepcopy(metrics)}}}], "edges": []}
async with SessionLocal() as session:
session.add(Workflow(id=workflow_id, name=f"{OWN_PREFIX}手建 {uuid.uuid4().hex[:6]}", status="draft",
graph=graph, version=1, tags=["doc_draft"],
origin={"run_id": "run-1", "sources": [{"run_id": "run-1"}]}))
session.add(WorkflowVersion(workflow_id=workflow_id, version=1, graph=graph, graph_hash=graph_hash(graph)))
await session.flush() # 草稿的外键指着工作流:先落工作流那一行
session.add(CaliberDraft(id=draft_id, title="手建口径", status="ready", workflow_id=workflow_id,
payload={"metrics": copy.deepcopy(metrics), "version": 1,
"workflow_id": workflow_id}))
await session.commit()
return {"id": draft_id, "workflow_id": workflow_id, "revision": 1, "card_id": "card"}
#: 这个文件自己建的工作流(连同版本、幂等记录、草稿)跑完删掉,不给后面的用例留行
OWN_PREFIX = "K03 "
@pytest.fixture(autouse=True)
async def cleanup_own_rows():
yield
async with SessionLocal() as session:
workflow_ids = list((await session.execute(
select(Workflow.id).where(Workflow.name.like(f"{OWN_PREFIX}%")))).scalars())
if workflow_ids:
for model in (CaliberWriteRequest, WorkflowVersion, CaliberDraft):
for row in (await session.execute(
select(model).where(model.workflow_id.in_(workflow_ids)))).scalars():
await session.delete(row)
for workflow in (await session.execute(
select(Workflow).where(Workflow.id.in_(workflow_ids)))).scalars():
await session.delete(workflow)
await session.commit()
# ==========================================================================
# 原图上的字段补丁:改一处,别的都留着
# ==========================================================================
async def test_a_field_change_lands_on_the_graph_and_leaves_everything_else_alone(
client, documents, datasource, monkeypatch, fake_trial):
"""改时间字段不动 citations / contract / 口径说明 / 扩展字段,也不动其他节点和其他指标(K03 的反例)。"""
body = await ready_draft(client, documents, datasource, monkeypatch)
wid = body["workflow_id"]
# 图上先有别人加的扩展字段和自定义指标键(前端不认识的那些),还有第二条指标和第二条边
graph = await _graph_of(wid)
graph["extensions"] = {"unknown": True}
card_metrics = cfg(graph, "card")["metrics"]
card_metrics[0]["x_custom"] = {"keep": [1, 2]}
card_metrics.append({"id": "m2", "name": "订单数", "expression": "cell(nodes.fetch, 0, 'orders')",
"contract": {"shape": "scalar"}})
saved = await client.patch(f"/api/workflows/{wid}", json={"graph": graph, "expected_revision": body["revision"]})
assert saved.status_code == 200, saved.text
before = await _graph_of(wid)
before_m1 = copy.deepcopy(metric_of(before))
res = await change(client, {**body, "revision": saved.json()["version"]},
[ops_set("m1", "/time_field", "paid_at"),
ops_set("m1", "/definition/exclusions", ["全额退款"]),
ops_set("m1", "/grain", "order")])
assert res.status_code == 200, res.text
made = res.json()
assert made["version"] == saved.json()["version"] + 1
assert await _versions(wid) == [1, 2, 3]
after = await _graph_of(wid)
changed = metric_of(after)
assert changed["time_field"] == "paid_at" and changed["grain"] == "order"
assert changed["definition"]["exclusions"] == ["全额退款"]
# 没碰的:口径说明、负责人、引用原文、取值契约、表达式、小数位、自定义键原样
assert changed["definition"]["text"] == before_m1["definition"]["text"]
assert changed["definition"]["owner"] == before_m1["definition"]["owner"]
assert changed["definition"]["citations"] == before_m1["definition"]["citations"]
assert changed["contract"] == before_m1["contract"]
assert changed["expression"] == before_m1["expression"]
assert changed["decimals"] == before_m1["decimals"]
assert changed["x_custom"] == {"keep": [1, 2]}
# 别的指标、别的节点、图级扩展字段都没动
assert metric_of(after, "m2") == metric_of(before, "m2")
for node_id in ("start", "fetch", "write", "out"):
assert cfg(after, node_id) == cfg(before, node_id)
assert after["extensions"] == {"unknown": True} and after["edges"] == before["edges"]
# 草稿页从图读:新定义立刻可见,旧试跑按执行指纹判过期
view = (await client.get(patch_url(body["id"]))).json()
assert view["metrics"][0]["time_field"] == "paid_at"
assert view["trial"]["status"] == "stale"
# 回执:变更分类、影响指标、定义指纹前后值、失效说明、绑定状态(没声明业务字段的旧资产:unbound,不装对齐)
block = made["change"]
assert block["applied"] is True and block["classification"] == "definition"
assert block["metrics"] == ["m1"] and block["card"] == "card" and block["revision"] == made["version"]
assert [op["path"] for op in block["operations"]] == ["/time_field", "/definition/exclusions", "/grain"]
assert block["definition_hash"]["changed"] is True
assert block["definition_hash"]["before"] != block["definition_hash"]["after"]
assert block["validation"]["invalidated"] is True and block["validation"]["scope"] == "card"
assert block["validation"]["metrics"] == ["m1"] and "重新试运行" in block["validation"]["next"]
assert block["binding"]["status"] == "unbound" and block["binding"]["checked"] is True
assert block["binding"]["synced"] is False and block["binding"]["fix"] == "none"
assert "aligned" not in json.dumps(block, ensure_ascii=False)
async def test_a_batch_can_touch_several_metrics(client, documents, datasource, monkeypatch, fake_trial):
"""一批操作可以落在不同指标上:影响摘要按卡上的先后列出每一条动过的指标。"""
body = await ready_draft(client, documents, datasource, monkeypatch)
second = {**copy.deepcopy(body["metrics"][0]), "id": "m2", "name": "订单数",
"expression": "cell(nodes.fetch, 0, 'orders')"}
seeded = await client.patch(patch_url(body["id"]),
json={"metrics": [copy.deepcopy(body["metrics"][0]), second],
"expected_revision": body["revision"]})
assert seeded.status_code == 200, seeded.text
res = await change(client, seeded.json(), [ops_set("m2", "/unit", "笔"), ops_set("m1", "/unit", "元")])
assert res.status_code == 200, res.text
assert res.json()["change"]["metrics"] == ["m1", "m2"] # 按卡上的先后,不按提交顺序
after = await _graph_of(body["workflow_id"])
assert metric_of(after, "m1")["unit"] == "元" and metric_of(after, "m2")["unit"] == "笔"
assert metric_of(after, "m2")["expression"] == second["expression"]
async def test_a_display_only_change_is_classified_as_display(client, documents, datasource, monkeypatch, fake_trial):
"""同义词这类展示字段改完,定义指纹不动、分类是 display;页面据此能说「数字怎么算没变」。"""
body = await ready_draft(client, documents, datasource, monkeypatch)
res = await change(client, body, [ops_set("m1", "/synonyms", ["退款率", "退单率"])])
assert res.status_code == 200, res.text
block = res.json()["change"]
assert block["classification"] == "display" and block["definition_hash"]["changed"] is False
assert block["validation"]["invalidated"] is True # 图还是变了:旧试跑照样过期
assert metric_of(await _graph_of(body["workflow_id"]))["synonyms"] == ["退款率", "退单率"]
# 定义类字段(时间字段)改完就是 definition
again = await change(client, res.json(), [ops_set("m1", "/time_field", "paid_at")])
assert again.json()["change"]["classification"] == "definition"
async def test_untouched_metrics_and_cards_stay_untouched(client, documents, datasource, monkeypatch, fake_trial):
"""同一张卡上的别的指标、以及图上另一张可写的卡:没点名就一个字段不动。"""
body = await ready_draft(client, documents, datasource, monkeypatch)
wid = body["workflow_id"]
graph = await _graph_of(wid)
metrics = cfg(graph, "card")["metrics"]
cfg(graph, "card")["metrics"] = [*metrics, {**copy.deepcopy(metrics[0]), "id": "m2", "name": "另一个指标"}]
graph["nodes"].append({"id": "card_2", "type": "metrics", "position": {"x": 0, "y": 200},
"data": {"label": "另一张卡", "config": {"metrics": [
{**copy.deepcopy(metrics[0]), "id": "m9", "name": "第二张卡上的指标"}]}}})
saved = await client.patch(f"/api/workflows/{wid}", json={"graph": graph, "expected_revision": body["revision"]})
assert saved.status_code == 200, saved.text
before = await _graph_of(wid)
fresh = (await client.get(patch_url(body["id"]))).json()
# 多张可写的卡、又没指明目标:拒(不做「取第一张」)
refused = await change(client, fresh, [ops_set("m1", "/unit", "元")])
assert refused.status_code == 409 and refused.json()["code"] == "card_required", refused.text
# 指明第二张卡:改的是它,card 上一条不动
written = await change(client, fresh, [ops_set("m9", "/unit", "元")], target={"card_id": "card_2"})
assert written.status_code == 200, written.text
after = await _graph_of(wid)
assert metric_of(after, "m9", card="card_2")["unit"] == "元"
assert cfg(after, "card")["metrics"] == cfg(before, "card")["metrics"] # 第一张卡整份不动
# ==========================================================================
# 四种写法:省略 / 空串 / null / remove
# ==========================================================================
async def test_omit_empty_string_null_and_remove_are_four_different_things(
client, documents, datasource, monkeypatch, fake_trial):
body = await ready_draft(client, documents, datasource, monkeypatch)
before = metric_of(await _graph_of(body["workflow_id"]))
assert before["unit"] == "%" and "time_field" not in before
# 省略:只提 unit,time_field / grain 一个字都没提,保持原样(「没有这个键」还是「没有这个键」)
first = await change(client, body, [ops_remove("m1", "/unit")])
assert first.status_code == 200, first.text
metric = metric_of(await _graph_of(body["workflow_id"]))
assert "unit" not in metric and "time_field" not in metric and "grain" not in metric
# set 空串:键在、值是空的——和 remove 的「没声明」不是一回事,存储上分得出来
second = await change(client, first.json(), [ops_set("m1", "/unit", "")])
assert second.status_code == 200, second.text
metric = metric_of(await _graph_of(body["workflow_id"]))
assert metric["unit"] == "" and "unit" in metric
# set null:拒(「清空」有 remove、「声明为空」有 '',不替客户端猜)
null = await change(client, second.json(), [ops_set("m1", "/unit", None)])
assert null.status_code == 422, null.text
assert null.json()["code"] == "operation_value" and "null" in null.json()["detail"]
# set 不带 value:拒
missing = await change(client, second.json(), [{"op": "set", "metric_id": "m1", "path": "/unit"}])
assert missing.status_code == 422 and missing.json()["code"] == "operation_value"
# remove 带 value:拒
extra = await change(client, second.json(), [{"op": "remove", "metric_id": "m1", "path": "/unit", "value": ""}])
assert extra.status_code == 422 and extra.json()["code"] == "operation_value"
# 三种错法都没落地:还是第二次那一份(键在、值为空串)
assert metric_of(await _graph_of(body["workflow_id"]))["unit"] == ""
assert await _versions(body["workflow_id"]) == [1, 2, 3]
async def test_array_fields_are_set_as_a_whole_and_never_by_position(
client, documents, datasource, monkeypatch, fake_trial):
"""数组字段整份设置;想按位置改(/synonyms/0)直接拒——位置不是身份,重排会让它指到别人。"""
body = await ready_draft(client, documents, datasource, monkeypatch)
res = await change(client, body, [ops_set("m1", "/synonyms", ["退款率"]),
ops_set("m1", "/dimensions", ["门店", "渠道"])])
assert res.status_code == 200, res.text
metric = metric_of(await _graph_of(body["workflow_id"]))
assert metric["synonyms"] == ["退款率"] and metric["dimensions"] == ["门店", "渠道"]
by_position = await change(client, res.json(), [ops_set("m1", "/synonyms/0", "新的同义词")])
assert by_position.status_code == 422, by_position.text
assert by_position.json()["code"] == "operation_path"
assert "位置" in by_position.json()["detail"]
assert metric_of(await _graph_of(body["workflow_id"]))["synonyms"] == ["退款率"]
# 值里的空串不算「声明成空」:数组元素得有内容
empty_item = await change(client, res.json(), [ops_set("m1", "/synonyms", ["", "x"])])
assert empty_item.status_code == 422 and empty_item.json()["code"] == "operation_value"
# ==========================================================================
# 非法操作:拒得具体,一个字段都不落
# ==========================================================================
@pytest.mark.parametrize("operation,code", [
({"op": "set", "metric_id": "m1", "path": "/id", "value": "m9"}, "operation_path"),
({"op": "set", "metric_id": "m1", "path": "/definition/citations", "value": []}, "operation_path"),
({"op": "set", "metric_id": "m1", "path": "/definition/owner", "value": "别人"}, "operation_path"),
({"op": "set", "metric_id": "m1", "path": "/definition/effective_from", "value": "2026-01-01"}, "operation_path"),
({"op": "set", "metric_id": "m1", "path": "/contract", "value": {"shape": "scalar"}}, "operation_path"),
({"op": "set", "metric_id": "m1", "path": "/kind", "value": "rule"}, "operation_path"),
({"op": "set", "metric_id": "m1", "path": "/open_questions", "value": ["x"]}, "operation_path"),
({"op": "set", "metric_id": "m1", "path": "unit", "value": "元"}, "operation_path"),
({"op": "set", "metric_id": "m1", "path": "/definition", "value": {"text": "整份换"}}, "operation_path"),
({"op": "patch", "metric_id": "m1", "path": "/unit", "value": "元"}, "operation_invalid"),
({"op": "set", "metric_id": "m9", "path": "/unit", "value": "元"}, "operation_metric"),
({"op": "set", "metric_id": "m1", "path": "/decimals", "value": "2"}, "operation_value"),
({"op": "set", "metric_id": "m1", "path": "/name", "value": " "}, "operation_value"),
({"op": "set", "metric_id": "m1", "path": "/name", "value": 3}, "operation_value"),
])
async def test_illegal_operations_are_refused_with_a_specific_reason(
client, documents, datasource, monkeypatch, fake_trial, operation, code):
body = await ready_draft(client, documents, datasource, monkeypatch)
before = await _graph_of(body["workflow_id"])
res = await change(client, body, [operation])
assert res.status_code == 422, res.text
assert res.json()["code"] == code, res.text
assert res.json()["field"] == "operations" and res.json()["operation"] == 0
assert res.json()["detail"] # 一句具体的话,不是空壳
assert await _versions(body["workflow_id"]) == [1]
assert await _graph_of(body["workflow_id"]) == before
async def test_the_whole_batch_is_atomic(client, documents, datasource, monkeypatch, fake_trial):
"""一条不合规,整批都不落地:前面那条合规的也不能半落进去(4.2 的原子性)。"""
body = await ready_draft(client, documents, datasource, monkeypatch)
before = await _graph_of(body["workflow_id"])
res = await change(client, body, [ops_set("m1", "/unit", "元"), ops_set("m1", "/definition/nope", "x")])
assert res.status_code == 422, res.text
assert res.json()["operation"] == 1
assert await _versions(body["workflow_id"]) == [1]
assert await _graph_of(body["workflow_id"]) == before
# 换一条好的一起提交:两条都落
ok = await change(client, body, [ops_set("m1", "/unit", "元"), ops_set("m1", "/grain", "order")])
assert ok.status_code == 200, ok.text
metric = metric_of(await _graph_of(body["workflow_id"]))
assert metric["unit"] == "元" and metric["grain"] == "order"
assert await _versions(body["workflow_id"]) == [1, 2]
async def test_a_batch_that_ends_up_changing_nothing_creates_no_version(
client, documents, datasource, monkeypatch, fake_trial):
"""先设后删(或者设成原来的值):内容没变,不新增版本,回执照实说没改动。"""
body = await ready_draft(client, documents, datasource, monkeypatch)
same = await change(client, body, [ops_set("m1", "/unit", metric_of(await _graph_of(body["workflow_id"]))["unit"])])
assert same.status_code == 200, same.text
assert same.json()["change"]["applied"] is False and same.json()["change"]["classification"] == "none"
assert same.json()["change"]["validation"]["invalidated"] is False
assert await _versions(body["workflow_id"]) == [1]
churn = await change(client, same.json(), [ops_set("m1", "/grain", "order"), ops_remove("m1", "/grain")])
assert churn.status_code == 200, churn.text
assert churn.json()["change"]["applied"] is False
assert [op["changed"] for op in churn.json()["change"]["operations"]] == [True, True]
assert await _versions(body["workflow_id"]) == [1]
async def test_a_missing_definition_object_is_created_by_set_and_ignored_by_remove(client):
"""指标上还没有 definition 对象:set /definition/… 把它建出来;remove 是「本来就没有」——无变化,不是路径非法。"""
body = await bare_draft(client, [
{"id": "m1", "name": "退款率", "unit": "%", "expression": "cell(nodes.fetch, 0, 'refund_rate')"},
{"id": "m2", "name": "订单数", "definition": None, "expression": "cell(nodes.fetch, 0, 'orders')"},
])
before = metric_of(await _graph_of(body["workflow_id"]), "m1")
res = await change(client, body, [ops_set("m1", "/definition/text", "只算已支付的订单"),
ops_set("m2", "/definition/exclusions", ["全额退款"])])
assert res.status_code == 200, res.text
after = await _graph_of(body["workflow_id"])
# 字段落在新建出来的对象里;这条指标上别的键一个不动
assert metric_of(after, "m1")["definition"] == {"text": "只算已支付的订单"}
assert metric_of(after, "m2")["definition"] == {"exclusions": ["全额退款"]}
assert metric_of(after, "m1")["unit"] == before["unit"]
assert metric_of(after, "m1")["expression"] == before["expression"]
# remove 一个本来就不存在的子字段:不新建对象、不新增版本,也不报路径非法
none = await change(client, res.json(), [ops_remove("m2", "/definition/scope")])
assert none.status_code == 200, none.text
assert none.json()["change"]["applied"] is False and none.json()["change"]["classification"] == "none"
assert metric_of(await _graph_of(body["workflow_id"]), "m2")["definition"] == {"exclusions": ["全额退款"]}
assert await _versions(body["workflow_id"]) == [1, 2]
async def test_a_definition_that_is_not_an_object_is_still_refused(client):
"""definition 在、但不是对象(被人写成了字符串或数组):照旧拒——数据坏了就说清楚,不把原来的值丢掉。"""
body = await bare_draft(client, [
{"id": "m1", "name": "退款率", "definition": "说明写在字符串里"},
{"id": "m2", "name": "订单数", "definition": ["不是对象"]},
])
before = await _graph_of(body["workflow_id"])
for metric_id in ("m1", "m2"):
for operation in (ops_set(metric_id, "/definition/text", "改成别的"), ops_remove(metric_id, "/definition/text")):
res = await change(client, body, [operation])
assert res.status_code == 422, res.text
assert res.json()["code"] == "operation_path" and "不是对象" in res.json()["detail"]
assert await _versions(body["workflow_id"]) == [1]
assert await _graph_of(body["workflow_id"]) == before
assert metric_of(await _graph_of(body["workflow_id"]), "m1")["definition"] == "说明写在字符串里"
# ==========================================================================
# 和整份换指标互斥;老路照旧
# ==========================================================================
async def test_field_operations_and_a_full_metrics_replacement_are_mutually_exclusive(
client, documents, datasource, monkeypatch, fake_trial):
body = await ready_draft(client, documents, datasource, monkeypatch)
metrics = copy.deepcopy(body["metrics"])
res = await client.patch(patch_url(body["id"]),
json={"metrics": metrics, "operations": [ops_set("m1", "/unit", "元")],
"expected_revision": body["revision"]})
assert res.status_code == 422, res.text
assert res.json()["code"] == "change_conflict"
everything = await client.patch(patch_url(body["id"]),
json={"resolved": [], "operations": [ops_set("m1", "/unit", "元")],
"expected_revision": body["revision"]})
assert everything.status_code == 422 and everything.json()["code"] == "change_conflict"
assert await _versions(body["workflow_id"]) == [1]
async def test_a_target_naming_another_workflow_is_refused_even_without_a_card(
client, documents, datasource, monkeypatch, fake_trial):
"""target.workflow_id 只要给了就必须是这份草稿自己的:没给 card_id 也一样拒(两条写路径同一套定位)。"""
body = await ready_draft(client, documents, datasource, monkeypatch)
elsewhere = await change(client, body, [ops_set("m1", "/unit", "元")], target={"workflow_id": "wf-elsewhere"})
assert elsewhere.status_code == 409, elsewhere.text
assert elsewhere.json()["code"] == "target_mismatch" and "target" == elsewhere.json()["field"]
metrics = copy.deepcopy(body["metrics"])
metrics[0]["unit"] = "元"
whole = await client.patch(patch_url(body["id"]),
json={"metrics": metrics, "expected_revision": body["revision"],
"target": {"workflow_id": "wf-elsewhere"}})
assert whole.status_code == 409, whole.text
assert whole.json()["code"] == "target_mismatch"
assert await _versions(body["workflow_id"]) == [1]
# 指对工作流、不指卡:图上只有一张可写的卡,照常改
ok = await change(client, body, [ops_set("m1", "/unit", "元")], target={"workflow_id": body["workflow_id"]})
assert ok.status_code == 200, ok.text
assert metric_of(await _graph_of(body["workflow_id"]))["unit"] == "元"
async def test_the_old_metrics_path_is_unchanged(client, documents, datasource, monkeypatch, fake_trial):
"""整份换指标照旧(回执不带 change 段);没接管到工作流上的老草稿照旧只改 payload,字段操作落不到图上。"""
body = await ready_draft(client, documents, datasource, monkeypatch)
metrics = copy.deepcopy(body["metrics"])
metrics[0]["name"] = "整份换过的"
res = await client.patch(patch_url(body["id"]),
json={"metrics": metrics, "expected_revision": body["revision"]})
assert res.status_code == 200, res.text
assert res.json()["metrics"][0]["name"] == "整份换过的" and res.json()["version"] == 2
assert "change" not in res.json()
legacy_id = uuid.uuid4().hex[:32]
async with SessionLocal() as session:
session.add(CaliberDraft(id=legacy_id, title="老草稿", status="ready",
payload={"metrics": [{"id": "m1", "name": "旧指标"}], "open_questions": []}))
await session.commit()
try:
refused = await client.patch(patch_url(legacy_id),
json={"operations": [ops_set("m1", "/unit", "元")]})
assert refused.status_code == 409, refused.text
assert refused.json()["code"] == "change_unavailable"
ok = await client.patch(patch_url(legacy_id), json={"metrics": [{"id": "m1", "name": "改过的"}]})
assert ok.status_code == 200 and ok.json()["metrics"][0]["name"] == "改过的"
finally:
async with SessionLocal() as session:
row = await session.get(CaliberDraft, legacy_id)
if row is not None:
await session.delete(row)
await session.commit()
# ==========================================================================
# 同一语义变化:画布和 operations 一致,旧试跑都过期
# ==========================================================================
async def test_the_same_change_via_the_canvas_and_via_operations_agree(
client, documents, datasource, monkeypatch, fake_trial):
"""E08:同一个语义改动,从画布(整图保存)和从工作台(operations)提交得到同一张执行图,旧试跑都判过期。"""
body = await ready_draft(client, documents, datasource, monkeypatch)
wid = body["workflow_id"]
assert (await client.get(patch_url(body["id"]))).json()["trial"]["status"] == "succeeded"
v1 = await _graph_of(wid)
# 画布那条路:拿 v1 的图,人工把同一个字段改成同一个值,整图保存
canvas_graph = copy.deepcopy(v1)
metric_of(canvas_graph)["time_field"] = "paid_at"
saved = await client.patch(f"/api/workflows/{wid}",
json={"graph": canvas_graph, "expected_revision": body["revision"]})
assert saved.status_code == 200 and saved.json()["version"] == 2, saved.text
canvas_after = await _graph_of(wid)
assert (await client.get(patch_url(body["id"]))).json()["trial"]["status"] == "stale"
# operations 那条路:回到 v1 的图上改同一个字段(回滚出一版,再按字段改)
rolled = await client.post(f"/api/workflows/{wid}/versions/1/restore", json={"expected_revision": 2})
assert rolled.status_code == 200 and rolled.json()["version"] == 3, rolled.text
assert await _graph_of(wid) == v1
changed = await change(client, {**body, "revision": 3}, [ops_set("m1", "/time_field", "paid_at")])
assert changed.status_code == 200, changed.text
ops_after = await _graph_of(wid)
assert changed.json()["change"]["applied"] is True
assert (await client.get(patch_url(body["id"]))).json()["trial"]["status"] == "stale"
# 两条路得到的是同一张执行图:图哈希可以不同(版本快照元数据),执行指纹一样
assert execution_hash(ops_after) == execution_hash(canvas_after)
assert metric_of(ops_after) == metric_of(canvas_after)
# ==========================================================================
# K02 的写入保护:字段变更一条不少
# ==========================================================================
async def test_field_operations_need_a_base_revision_and_refuse_a_stale_page(
client, documents, datasource, monkeypatch, fake_trial):
"""E01 的字段操作版:没带基准版本 409 revision_required;旧页面 409 stale_revision,先写进去的不被盖掉。"""
body = await ready_draft(client, documents, datasource, monkeypatch)
missing = await client.patch(patch_url(body["id"]), json={"operations": [ops_set("m1", "/unit", "元")]})
assert missing.status_code == 409, missing.text
assert missing.json()["code"] == "revision_required" and missing.json()["current_version"] == 1
assert await _versions(body["workflow_id"]) == [1]
first = await change(client, body, [ops_set("m1", "/unit", "元")])
assert first.status_code == 200 and first.json()["version"] == 2, first.text
stale = await change(client, body, [ops_set("m1", "/unit", "万元")]) # 还拿着 v1
assert stale.status_code == 409, stale.text
assert stale.json()["code"] == "stale_revision" and stale.json()["current_version"] == 2
assert await _versions(body["workflow_id"]) == [1, 2]
assert metric_of(await _graph_of(body["workflow_id"]))["unit"] == "元"
# 指到没有的卡:拒(K02 的卡级定位一起管着这条路)
missing_card = await change(client, body, [ops_set("m1", "/unit", "元")], target={"card_id": "card-nope"})
assert missing_card.status_code == 409 and missing_card.json()["code"] == "card_not_found"
async def test_the_same_request_id_replays_and_different_operations_conflict(
client, documents, datasource, monkeypatch, fake_trial):
"""指纹包含 operations:同键同内容重放不新增版本;同键换了字段或换了值都算另一次请求(409)。"""
body = await ready_draft(client, documents, datasource, monkeypatch)
request = [ops_set("m1", "/unit", "元")]
first = await change(client, body, request, request_id="k03-0001")
assert first.status_code == 200 and first.json()["version"] == 2, first.text
assert first.json().get("replayed") is None
# 同键同内容:基准版本这时已经过期,但幂等在版本检查之前判,照旧成功,不再新增版本
again = await change(client, body, request, request_id="k03-0001")
assert again.status_code == 200, again.text
assert again.json()["replayed"] is True and again.json()["version"] == 2
assert await _versions(body["workflow_id"]) == [1, 2]
# 同键、换了路径:内容不同 → 冲突
other_path = await change(client, body, [ops_set("m1", "/grain", "元")], request_id="k03-0001")
assert other_path.status_code == 409, other_path.text
assert other_path.json()["code"] == "idempotency_conflict"
# 同键、同一个字段换了值:内容不同 → 冲突(值算了进去)
other_value = await change(client, body, [ops_set("m1", "/unit", "万元")], request_id="k03-0001")
assert other_value.status_code == 409 and other_value.json()["code"] == "idempotency_conflict"
# 同键、同一批换顺序:顺序是内容的一部分(同一条指标上的操作有先后)→ 冲突
reordered = await change(client, body, [ops_set("m1", "/grain", "order"), ops_set("m1", "/unit", "元")],
request_id="k03-0001")
assert reordered.status_code == 409 and reordered.json()["code"] == "idempotency_conflict"
# 省略 value 和写 null 是两件事,指纹也分得开
assert api_ledger_fingerprint(body, request) != api_ledger_fingerprint(
body, [ops_set("m1", "/unit", None)])
assert await _versions(body["workflow_id"]) == [1, 2]
def api_ledger_fingerprint(body: dict, operations: list[dict]) -> str:
"""同一个 request_id 的两次请求,指纹按内容算(把 pydantic 的排除未设置行为也一起算进去)。"""
payload = api_ledger.DraftPatchIn(operations=operations, expected_revision=body["revision"])
return api_ledger._patch_fingerprint(payload, body["card_id"])
async def test_old_request_ids_still_replay_after_the_operations_field_landed(
client, documents, datasource, monkeypatch, fake_trial):
"""不带 operations 的老请求算出来的还是原来那一份指纹:升级前记下的 request_id 不会被判成「同键不同内容」。"""
from app.apps.versions import write_fingerprint
body = await ready_draft(client, documents, datasource, monkeypatch)
metrics = copy.deepcopy(body["metrics"])
metrics[0]["name"] = "老请求"
payload = api_ledger.DraftPatchIn(metrics=metrics, expected_revision=body["revision"])
before_k03 = write_fingerprint({"title": None, "metrics": metrics, "resolved": None, "card": body["card_id"]})
assert api_ledger._patch_fingerprint(payload, body["card_id"]) == before_k03
async def test_two_operations_with_the_same_key_at_once_write_once(
client, documents, datasource, monkeypatch, fake_trial):
"""同一个 request_id 的两路字段变更同时发:只落一版,两边都是 200。"""
body = await ready_draft(client, documents, datasource, monkeypatch)
payload = {"operations": [ops_set("m1", "/unit", "元")], "expected_revision": body["revision"],
"request_id": "k03-race"}
url = patch_url(body["id"])
first, second = await asyncio.gather(client.patch(url, json=payload), client.patch(url, json=payload))
assert sorted(res.status_code for res in (first, second)) == [200, 200], (first.text, second.text)
assert await _versions(body["workflow_id"]) == [1, 2]
assert [res.json().get("replayed") for res in (first, second)].count(True) <= 1
assert metric_of(await _graph_of(body["workflow_id"]))["unit"] == "元"
async def test_two_field_changes_at_once_do_not_lose_an_update(
client, documents, datasource, monkeypatch, fake_trial):
"""两路各自改一处、都基于同一版同时发:CAS 只让一路落成,另一路 409 stale_revision(不静默盖掉)。"""
body = await ready_draft(client, documents, datasource, monkeypatch)
wid = body["workflow_id"]
url = patch_url(body["id"])
first, second = await asyncio.gather(
client.patch(url, json={"operations": [ops_set("m1", "/unit", "元")],
"expected_revision": body["revision"], "request_id": "k03-a"}),
client.patch(url, json={"operations": [ops_set("m1", "/unit", "万元")],
"expected_revision": body["revision"], "request_id": "k03-b"}))
assert sorted(res.status_code for res in (first, second)) == [200, 409], (first.text, second.text)
refused = first if first.status_code == 409 else second
assert refused.json()["code"] in ("stale_revision", "idempotency_conflict")
assert refused.json()["current_version"] == 2
assert await _versions(wid) == [1, 2]
assert metric_of(await _graph_of(wid))["unit"] in ("元", "万元")
# ==========================================================================
# 仓储:薄边界,图上改
# ==========================================================================
def test_the_repository_hides_the_graph_and_refuses_what_is_not_a_source_card():
graph = {"nodes": [
{"id": "card", "type": "metrics", "data": {"label": "口径卡", "config": {"metrics": [
{"id": "m1", "name": "退款率", "unit": "%"}, {"id": "m2", "name": "订单数"}]}}},
{"id": "chained", "type": "metrics", "data": {"label": "转抄", "config": {
"caliber_from": {"workflow_id": "w", "workflow_version": 1}, "metrics": []}}},
{"id": "fetch", "type": "tool", "data": {"label": "取数", "config": {"tool": "db_query__shop"}}},
], "edges": []}
repo = GraphBackedCaliberRepository(graph, card_id="card")
assert repo.card.node_id == "card" and repo.card.title == "口径卡"
assert repo.ids() == ["m1", "m2"]
# 读出来的是深拷贝:改它不影响图
metric = repo.metric("m1")
metric["unit"] = "改过的"
assert repo.metric("m1")["unit"] == "%"
with pytest.raises(MetricGone):
repo.metric("m9")
# 换指标交回的是整张图的深拷贝,别的节点带着走,指标顺序不变
new_graph = repo.replace_metrics({"m1": {**repo.metric("m1"), "unit": "元"}})
assert [node["id"] for node in new_graph["nodes"]] == ["card", "chained", "fetch"]
assert new_graph["nodes"][2] == graph["nodes"][2] and new_graph is not graph
assert [m["id"] for m in new_graph["nodes"][0]["data"]["config"]["metrics"]] == ["m1", "m2"]
assert new_graph["nodes"][0]["data"]["config"]["metrics"][0]["unit"] == "元"
assert graph["nodes"][0]["data"]["config"]["metrics"][0]["unit"] == "%"
with pytest.raises(MetricGone):
repo.replace_metrics({"m9": {"id": "m9"}})
with pytest.raises(CaliberGone):
GraphBackedCaliberRepository(graph, card_id="chained")
with pytest.raises(CaliberGone):
GraphBackedCaliberRepository(graph, card_id="fetch")
with pytest.raises(CaliberGone):
GraphBackedCaliberRepository(graph, card_id="nope")
def test_the_operation_whitelist_covers_the_fields_that_really_exist():
"""白名单落在指标真有的字段上:顶层路径按 data/caliber_identity.SEMANTIC_FIELDS 对齐(同义词是展示字段)。"""
from app.data.caliber_identity import SEMANTIC_FIELDS
top = {path[1:] for path in changes.FIELDS if path.count("/") == 1}
assert top <= set(SEMANTIC_FIELDS) | {"synonyms"}
# 起草阶段真正会写进指标的字段(app/ledger/draftplan.METRIC_FIELDS)都要能按字段改
assert {"id", "name", "synonyms", "unit", "decimals", "expression", "grain", "time_field",
"numerator", "denominator", "dimensions", "filters"} - {"id"} <= top
assert {path for path in changes.FIELDS if path.startswith("/definition/")} == {
"/definition/text", "/definition/scope", "/definition/exclusions"}
# 身份、证据归属和治理归属都在「不能改」那一栏里
assert {"/id", "/definition/citations", "/definition/owner", "/definition/effective_from"} <= set(changes.FORBIDDEN)