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

1488 lines
84 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.
"""K10:父 Run 的口径查询工具(`caliber_query`)与父子 Run 的证据接入。
这份文件盯住这几条:
1. **工具是真的、参数是固定的。** 登记表里有 `caliber_query`(不是 `db_query__` 前缀那类动态工具),参数只有口径身份
(workflow / card)、要算的指标和这次入口的类型化取值;用途、主体、父 Run 身份、固定的数据版本都由父 Run 的上下文定,
参数里没有也传不了。
2. **证据接回父 Run。** 用父 Run 的上下文执行(`ToolContext`),当场 await 收尾;派生的探针运行是**明确的子运行**
(origin 里记 parent_run_id),父 Run 的节点把每条 query artifact 记进自己的台账(query_id / execution_id / attempt、
来源、列与行数、截断都在)——一次交回**多条**查询(queries 数组)不会只记第一条。
3. **伪造、跨运行、别的种类的工件一律拒绝。** 每条 query artifact 必须是这次授权的父运行自己落的,或它明确派生的子运行
落下的,且 kind=query_snapshot(capabilities.registry.snapshot_owned)。
4. **同一指标工作台与父 Run 是同一种说法。** 同一份口径、同一组指标与入口,HTTP 工作台(compose)与父 Run 拿到的发布
清单、结果类型、参数解释一致。
5. **父 Run 的边界跟着走。** 遮罩、截断、上传/记录源版本固定、只读、预算、复算要的材料;正式父 Run 走 formal judge,
交互(compose)拿不到正式身份;超时、失败都不是成功。
数据全是合成的(一个临时 SQLite 库、几条合成标量指标),不连真实业务库;这些指标不是任何真实业务口径。
"""
from __future__ import annotations
import asyncio
import copy
import json
import sqlite3
import uuid
from typing import Any
import pytest
from httpx import ASGITransport, AsyncClient
from langchain_core.messages import AIMessage, ToolMessage
from sqlalchemy import select
from app.apps import publications
from app.db.base import SessionLocal
from app.db.models import Artifact, DataSource, Run, RunEvent
from app.engine.runner import run_manager
from app.ledger import querycore
from app.main import app
from app.records import store
from app.records.tables import columns_of, path_of
from tests import ledger_samples as S
from tests.publication_samples import add_record, add_version, confirm, make_workflow, publish
#: 这一版口径卡的节点 id(源头口径卡)
CARD = "card"
#: 取数窗口:start(含)/ end(不含)
WINDOW = "AND paid_at >= '{{ input.start }}' AND paid_at < '{{ input.end }}'"
ROWS = [
(1, "east", "paid", 100.0, 2, "2026-09-02"),
(2, "east", "paid", 50.0, 1, "2026-09-03"),
(3, "west", "refunded", 30.0, 3, "2026-09-04"),
(4, "west", "paid", 20.0, 4, "2026-09-07"),
(5, "east", "open", 12.5, 5, "2026-09-07"),
(6, "west", "paid", 70.0, 6, "2026-10-02"),
(7, "east", "paid", 5.0, 7, "2026-08-31"),
]
#: 窗口内的期望值(2026-09-01 ≤ paid_at < 2026-10-01,只算 paid)
WINDOW_SUM = 170.0
INPUTS = {"start": "2026-09-01", "end": "2026-10-01"}
def build_db(path) -> None:
db = sqlite3.connect(path)
db.executescript("CREATE TABLE orders (id INTEGER PRIMARY KEY, region TEXT, status TEXT, amount REAL, "
"qty INTEGER, paid_at TEXT);")
db.executemany("INSERT INTO orders VALUES (?, ?, ?, ?, ?, ?)", ROWS)
db.commit()
db.close()
async def fresh_source(tmp_path) -> str:
"""每个用例一个名字不同的数据源(候选口径按数据源预筛,别的用例的口径不会混进来)。"""
path = tmp_path / "caliber_query.db"
build_db(path)
S.CURRENT["source"] = f"cq_{uuid.uuid4().hex[:8]}"
await S.set_source(path)
return S.CURRENT["source"]
def graph(*, sales_limit: int | None = None, grouped: bool = False) -> dict[str, Any]:
"""入口 → 两条取数 → 口径卡 → 报告 → 出口(报告节点只在原图里,探针图不跑它)。
grouped=True 时 sales 那条按大区分组、可以给 limit:用来测截断(结果被截短照实记进台账)。"""
sales_sql = (f"SELECT region, SUM(amount) AS gmv, SUM(qty) AS units FROM orders WHERE status = 'paid' {WINDOW} "
"GROUP BY region" if grouped else
f"SELECT SUM(amount) AS gmv, SUM(qty) AS units FROM orders WHERE status = 'paid' {WINDOW}")
sales_args: dict[str, Any] = {"sql": sales_sql}
if sales_limit is not None:
sales_args["limit"] = sales_limit
return S.chain(
S.node("start", "input", fields=[
{"name": "start", "type": "string", "required": True, "description": "开始日期(含)"},
{"name": "end", "type": "string", "required": True, "description": "结束日期(不含)"},
]),
S.node("sales", "tool", tool=S.tool(), args=sales_args),
S.node("shared", "tool", tool=S.tool(),
args={"sql": f"SELECT SUM(amount) AS total, MAX(amount) AS peak FROM orders "
f"WHERE status = 'paid' {WINDOW}"}),
S.node(CARD, "metrics", caliber="合成周销售", caliber_version="v1", metrics=[
{"id": "gmv", "name": "销售额", "unit": "元", "decimals": 2, "format": "thousands",
"expression": "cell(nodes.sales, 0, 'gmv')"},
{"id": "units", "name": "件数", "unit": "件", "expression": "cell(nodes.sales, 0, 'units')"},
{"id": "peak", "name": "最大单笔", "unit": "元", "decimals": 2,
"expression": "cell(nodes.shared, 0, 'peak')"},
]),
S.node("write", "report", instructions="写周销售"),
S.node("out", "output", fields=[{"name": "报告", "value": "{{ nodes.write.text }}"}]),
)
def twin_graph() -> dict[str, Any]:
"""两个取数节点写**同一条 SQL**、读同一张表、同一批数据(内容寻址下会落到同一个工件 id):两条不同的逻辑查询。
这是「同 artifact 对应不同逻辑 query」的反例:按工件 id 去重的证据索引会把它们并成一条。"""
sql = f"SELECT SUM(amount) AS gmv FROM orders WHERE status = 'paid' {WINDOW}"
metrics = [{"id": "left", "name": "甲", "unit": "元", "expression": "cell(nodes.one, 0, 'gmv')"},
{"id": "right", "name": "乙", "unit": "元", "expression": "cell(nodes.two, 0, 'gmv')"}]
return S.chain(
S.node("start", "input", fields=[{"name": "start", "type": "string", "required": True},
{"name": "end", "type": "string", "required": True}]),
S.node("one", "tool", tool=S.tool(), args={"sql": sql}),
S.node("two", "tool", tool=S.tool(), args={"sql": sql}),
S.node(CARD, "metrics", caliber="合成双查", caliber_version="v1", metrics=metrics),
S.node("write", "report", instructions="写周销售"),
S.node("out", "output", fields=[{"name": "报告", "value": "{{ nodes.write.text }}"}]),
)
def parent_graph(workflow_id: str, *, metric_ids: list[str] | None = None, card: str = CARD,
inputs: dict[str, Any] | None = None, gate: bool = False) -> dict[str, Any]:
"""父 Run 的图:入口 →(可选的人工确认)→ 调用工具(caliber_query)→ 出口。"""
parts = [S.node("start", "input")]
if gate:
parts.append(S.node("gate", "human", mode="approve", title="看一眼"))
parts.append(S.node("look", "tool", tool=querycore.CALIBER_QUERY, args={
"workflow": workflow_id, "card": card,
"metric_ids": metric_ids or ["gmv", "peak"],
"inputs": inputs if inputs is not None else dict(INPUTS)}))
parts.append(S.node("out", "output", fields=[{"name": "答", "value": "{{ nodes.look.ok }}"}]))
return S.chain(*parts)
@pytest.fixture(autouse=True)
async def engine_up(tmp_path):
await run_manager.setup()
await fresh_source(tmp_path)
yield
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 published(client: AsyncClient, *, sales_limit: int | None = None, grouped: bool = False) -> dict:
"""建一份已发布的口径(走 K07 的真实发布入口),返回资产。"""
asset = await make_workflow(graph=graph(sales_limit=sales_limit, grouped=grouped))
await publish(client, asset)
return asset
async def run_parent(asset: dict, *, inputs: dict[str, Any] | None = None, metric_ids: list[str] | None = None,
data_versions: dict[str, Any] | None = None, options: dict[str, Any] | None = None,
run_class: str = "exploratory") -> Run:
"""起一次父 Run(走调用工具节点那条路),等它收尾。"""
run = await run_manager.start(graph=parent_graph(asset["workflow_id"], inputs=inputs, metric_ids=metric_ids),
input_payload={}, run_class=run_class, data_versions=data_versions,
options=options)
return await settled(run.id)
async def run_once(asset: dict, *, metric_ids: list[str], inputs: dict[str, Any] | None = None, **kw: Any):
"""走服务入口跑一次(HTTP 之外的那条路),等 K09 的执行结果。"""
async with SessionLocal() as session:
return await querycore.execute(session, workflow_id=asset["workflow_id"], card_id=CARD,
metric_ids=metric_ids,
inputs=inputs if inputs is not None else dict(INPUTS), **kw)
async def settled(run_id: str) -> Run:
for _ in range(600):
async with SessionLocal() as session:
row = await session.get(Run, run_id)
if row.status in querycore.TERMINAL_RUN_STATUSES:
return row
await asyncio.sleep(0.05)
raise AssertionError(f"运行 {run_id} 没有结束:{row.status}")
async def events_of(run_id: str, etype: str) -> list[dict[str, Any]]:
async with SessionLocal() as session:
rows = (await session.execute(select(RunEvent).where(RunEvent.run_id == run_id,
RunEvent.type == etype)
.order_by(RunEvent.seq))).scalars().all()
return [dict(r.data or {}) for r in rows]
async def tool_end(run_id: str) -> dict[str, Any]:
[data] = await events_of(run_id, "tool.end")
return data
async def tool_result(run_id: str, node_id: str = "look") -> dict[str, Any]:
"""调用工具节点的**完整**产出:tool.end 的 preview 截到 2000 个字符,读不全 JSON(K10 的结果里带几条查询)。"""
from app.engine.evidence_reader import read_sealed
sealed = await read_sealed(run_id)
payload = sealed.payload_of([node_id])
assert isinstance(payload, dict), "调用工具节点没有产出"
return payload
async def finished_evidence(run_id: str, node_id: str = "look") -> list[dict[str, Any]]:
async with SessionLocal() as session:
rows = (await session.execute(select(RunEvent).where(RunEvent.run_id == run_id,
RunEvent.type == "node.finished")
.order_by(RunEvent.seq))).scalars().all()
for row in rows:
data = dict(row.data or {})
if row.node_id == node_id and data.get("evidence"):
return list(data["evidence"])
return []
# ==========================================================================
# 1、工具是真的:登记表里有它、参数固定、不是 db_query__ 前缀
# ==========================================================================
async def test_the_tool_is_registered_with_fixed_parameters():
from app.capabilities.registry import is_platform_query
from app.tools.registry import all_specs
spec = all_specs()[querycore.CALIBER_QUERY]
assert spec.category and spec.description and not spec.dangerous and not spec.writes_state
assert spec.effect == "read", "口径查询是只读的:不写外部系统、不该要确认"
assert set(spec.args_schema.model_fields) == {"workflow", "card", "metric_ids", "inputs"}
# 用途、主体、版本、预算都不在参数里:由父 Run 的上下文定,模型改不了
assert not set(spec.args_schema.model_fields) & {"purpose", "role", "actor", "version", "budget", "trace"}
assert is_platform_query(querycore.CALIBER_QUERY), "口径查询的结果要能当查询快照作证"
assert not querycore.CALIBER_QUERY.startswith("db_query__"), "它是真实工具,不是数据源那类动态工具"
async def test_it_needs_a_parent_run():
from app.tools.registry import ToolContext
with pytest.raises(querycore.QueryCoreRefused) as caught:
await querycore.caliber_query(ToolContext(), workflow="w", card=CARD, metric_ids=["gmv"], inputs={})
assert caught.value.code == querycore.NO_PARENT
# ==========================================================================
# 2、证据接回父 Run:子运行、多条查询、执行身份
# ==========================================================================
async def test_the_parent_run_records_every_query_of_one_call(client):
asset = await published(client)
parent = await run_parent(asset, metric_ids=["gmv", "peak"])
assert parent.status == "succeeded", parent.error
end = await tool_end(parent.id)
assert end["tool"] == querycore.CALIBER_QUERY
# 一次调用交回两条查询(sales、shared):tool.end 上两条的工件 id 都在
assert len(end["query_artifacts"]) == 2 and end["query_artifact"] == end["query_artifacts"][0]
entries = [e for e in await finished_evidence(parent.id) if e.get("kind") == "query"]
assert [e["tool"] for e in entries] == [querycore.CALIBER_QUERY] * 2
assert {e["artifact"] for e in entries} == set(end["query_artifacts"])
child_id = (await tool_result(parent.id))["run_id"]
for entry in entries:
assert entry["columns"] and entry["rows"] and entry["truncated"] is False
# 源名写在和数据源查询同一个位置(证据面板、遮罩按它认源)
assert entry["source"] == S.CURRENT["source"]
# 出处是这一版这一张图里的那条取数,不是父 Run 的节点
assert entry["origin"]["workflow_id"] == asset["workflow_id"] and entry["origin"]["version"] == 1
assert entry["origin"]["node_id"] in ("sales", "shared")
# 执行身份三件套齐(K10):执行号记的是真的执行那次 SQL 的运行(派生的子运行),不是父 Run
assert entry["query_id"] and entry["execution_id"] and entry["attempt"] >= 1
assert child_id in entry["execution_id"], "执行身份带上真的执行那次,别把两次调用的执行混成一个"
# 复算要的材料跟着条目走:这次参数输入的哈希与源模板哈希(值本身、SQL 正文都不进台账)
assert entry["parameters"]["inputs_hash"] and entry["parameters"]["template_hash"]
assert {e["query_id"] for e in entries} == {f"{asset['workflow_id']}:sales", f"{asset['workflow_id']}:shared"}
# 父 Run 的封存证据里认得出这两条查询(报告目录、证据面板从这里出发)
from app.engine.evidence_reader import read_sealed
sealed = await read_sealed(parent.id)
assert {e["artifact"] for e in entries} <= set(sealed.queries)
for artifact in end["query_artifacts"]:
assert sealed.queries[artifact]["node_id"] == "look"
async def test_the_probe_run_is_an_explicit_child_of_the_parent(client):
asset = await published(client)
parent = await run_parent(asset)
end = await tool_end(parent.id)
child_id = (await tool_result(parent.id))["run_id"]
assert child_id, "结果里要给出子运行的 id"
async with SessionLocal() as session:
child = await session.get(Run, child_id)
assert child is not None and child.id != parent.id and child.run_class == "exploratory"
origin = child.origin or {}
assert origin["service"] == "querycore" and origin["purpose"] == querycore.DEFAULT_PURPOSE
assert origin["parent_run_id"] == parent.id, "子运行的来源里必须记着父运行,父子关系不能丢"
assert origin["manifest"] and origin["selection_hash"] and origin["closure_hash"], "复算要的材料都在"
assert child.options.get("read_only_data") is True, "派生的子运行照旧一律只读"
async def test_two_identical_calls_get_different_execution_ids(client):
"""同样的参数、同样的 SQL 调两次:两个子运行,执行身份各不相同(工件按内容寻址,可以是同一件)。"""
from app.tools.registry import ToolContext
asset = await published(client)
ctx = ToolContext(run_id="parent-fixed", node_id="look")
first = await querycore.caliber_query(ctx, workflow=asset["workflow_id"], card=CARD,
metric_ids=["gmv"], inputs=dict(INPUTS))
second = await querycore.caliber_query(ctx, workflow=asset["workflow_id"], card=CARD,
metric_ids=["gmv"], inputs=dict(INPUTS))
assert first["ok"] and second["ok"]
assert first["run_id"] != second["run_id"], "每次调用派生一次自己的子运行"
left, right = first["queries"][0], second["queries"][0]
assert left["query_id"] == right["query_id"], "逻辑查询是同一条"
assert left["execution_id"] != right["execution_id"], "同一条 SQL 执行两次,执行身份必须不同"
async def test_two_identical_executions_in_one_run_are_not_merged(client):
"""同一个父 Run 里两个节点跑同一条 SQL:台账里两条各记各的执行,不合并成一条。"""
asset = await published(client)
args = {"workflow": asset["workflow_id"], "card": CARD, "metric_ids": ["gmv"], "inputs": dict(INPUTS)}
graph = S.chain(
S.node("start", "input"),
S.node("one", "tool", tool=querycore.CALIBER_QUERY, args=dict(args)),
S.node("two", "tool", tool=querycore.CALIBER_QUERY, args=dict(args)),
S.node("out", "output", fields=[{"name": "答", "value": "{{ nodes.two.ok }}"}]),
)
run = await run_manager.start(graph=graph, input_payload={})
row = await settled(run.id)
assert row.status == "succeeded", row.error
entries = ([e for e in await finished_evidence(row.id, "one") if e.get("kind") == "query"]
+ [e for e in await finished_evidence(row.id, "two") if e.get("kind") == "query"])
assert len(entries) == 2, "两次执行各记一条"
assert len({e["execution_id"] for e in entries}) == 2, "同一份 SQL 跑两次,各记各的执行身份"
assert len({e["query_id"] for e in entries}) == 1, "逻辑查询是同一条"
# ==========================================================================
# 3、伪造 / 跨运行 / 别的种类:一律不当证据
# ==========================================================================
async def test_a_forged_or_cross_run_artifact_is_refused(client):
"""名字对了,工件还得是这次父运行(或它明确派生的子运行)落下的 query_snapshot。"""
from app.capabilities.registry import snapshot_owned
from app.core.artifact_store import put_json
asset = await published(client)
parent = await run_parent(asset)
[artifact] = (await tool_end(parent.id))["query_artifacts"][:1]
# 真的:父 Run 明确派生的子运行落下的查询快照
assert await snapshot_owned(artifact, parent.id, querycore.CALIBER_QUERY)
# 别的运行落下的同一件:不认
assert not await snapshot_owned(artifact, "another-run", querycore.CALIBER_QUERY)
# 伪造的工件 id:不认
assert not await snapshot_owned("f" * 64, parent.id, querycore.CALIBER_QUERY)
# 种类不对(同一件内容、父运行自己落下的 tool_snapshot):不认
async with SessionLocal() as session:
snap = (await session.execute(select(Artifact).where(Artifact.id == artifact))).first()
wrong_kind = await put_json({"x": 1}, kind="tool_snapshot", run_id=parent.id)
assert wrong_kind != artifact
assert not await snapshot_owned(wrong_kind, parent.id, querycore.CALIBER_QUERY), "不是 query_snapshot 的不当作证"
assert snap is not None, "子运行那件工件在工件表里记着"
async def test_a_forged_tool_result_does_not_become_evidence(client, monkeypatch):
"""调用工具节点交回一份拼出来的 caliber 结果(工件是别人的):不进台账、tool.end 上也不带。"""
from app.engine.nodes import tools as tool_node
asset = await published(client)
async def forged_call_tool(*_a, **_k):
return {"tool": querycore.CALIBER_QUERY, "kind": querycore.CALIBER_QUERY, "ok": True, "run_id": "made-up",
"queries": [{"artifact": "a" * 64, "source": {"workflow_id": asset["workflow_id"], "version": 1,
"node_id": "sales"},
"columns": ["gmv"], "row_count": 1, "truncated": False,
"query_id": "made-up:sales", "execution_id": "made-up:sales#e1", "attempt": 1}]}
monkeypatch.setattr(tool_node, "call_tool", forged_call_tool)
parent = await run_parent(asset)
assert parent.status == "succeeded", parent.error
end = await tool_end(parent.id)
assert "query_artifact" not in end and "query_artifacts" not in end
assert [e for e in await finished_evidence(parent.id) if e.get("kind") == "query"] == []
# ==========================================================================
# 4、同一指标工作台与父 Run 是同一种说法
# ==========================================================================
async def test_the_workbench_and_the_parent_run_agree(client):
asset = await published(client)
res = await client.post("/api/ledger/querycore", json={
"workflow_id": asset["workflow_id"], "card_id": CARD, "metric_ids": ["gmv", "peak"], "inputs": INPUTS})
assert res.status_code == 200, res.text
bench = res.json()
assert bench["ok"] and bench["purpose"] == publications.PURPOSE_COMPOSE
parent = await run_parent(asset, metric_ids=["gmv", "peak"])
end = await tool_end(parent.id)
tool = await tool_result(parent.id)
# 同一份发布清单、同一种结果类型、同一套参数解释
assert tool["publication"] == bench["publication"]
assert tool["version"] == bench["version"] and tool["explicit_version"] == bench["explicit_version"]
assert [m["id"] for m in tool["metrics"]] == [m["id"] for m in bench["metrics"]]
assert [m["value"] for m in tool["metrics"]] == [m["value"] for m in bench["metrics"]]
assert tool["metrics"] == bench["metrics"]
assert tool["parameters"]["values"] == bench["parameters"]["values"] == INPUTS
assert tool["parameters"]["contract"] == bench["parameters"]["contract"]
assert tool["parameters"]["adapter"] == bench["parameters"]["adapter"]
assert tool["selection"]["closure_hash"] == bench["selection"]["closure_hash"]
assert tool["data"]["tables"] == bench["data"]["tables"] == ["orders"]
# 每条查询:逻辑身份、列、参数输入哈希、行数与截断一致(工件是两次运行各自的,不要求相同)
assert [(q["query_id"], q["columns"], q["truncated"]) for q in tool["queries"]] \
== [(q["query_id"], q["columns"], q["truncated"]) for q in bench["queries"]]
assert [q["parameters"]["inputs_hash"] for q in tool["queries"]] \
== [q["parameters"]["inputs_hash"] for q in bench["queries"]]
# ==========================================================================
# 5、父 Run 的边界:遮罩、截断、版本固定、只读、预算、formal
# ==========================================================================
async def test_masked_values_are_not_handed_out(client, tmp_path):
asset = await published(client)
await S.set_source(tmp_path / "caliber_query.db", options={"mask_columns": ["gmv"]})
parent = await run_parent(asset, metric_ids=["gmv"])
tool = await tool_result(parent.id)
[metric] = [m for m in tool["metrics"] if m["id"] == "gmv"]
assert metric.get("masked") is True and metric["value"] is None
assert tool["ok"] is False and querycore.METRIC_MASKED in [p["code"] for p in tool["problems"]]
assert "masked" not in json.dumps([{"id": m["id"], "v": m["value"]} for m in tool["metrics"]])
async def test_truncated_results_are_reported_as_truncated(client):
asset = await published(client, grouped=True, sales_limit=1)
parent = await run_parent(asset, metric_ids=["gmv"])
[entry] = [e for e in await finished_evidence(parent.id) if e.get("kind") == "query"]
assert entry["truncated"] is True, "结果被截短照实记进台账,不悄悄当完整结果"
assert entry["rows"] == 1, "台账里记的是快照里真的有多少行"
async def test_the_parent_run_pins_the_source_versions_into_the_child(client):
"""父 Run 固定的上传/记录源版本照原样带进子运行:父子两段查的是同一版数据。"""
asset = await published(client)
pinned = {"src-1": {"snapshot": "snap-1", "name": "表格"}}
parent = await run_parent(asset, data_versions=pinned)
assert parent.data_versions == pinned
child_id = (await tool_result(parent.id))["run_id"]
async with SessionLocal() as session:
child = await session.get(Run, child_id)
assert child.data_versions == pinned, "子运行必须照父 Run 那一份固定,不能改用当前版本"
assert child.options.get("read_only_data") is True
async def test_a_timeout_is_not_a_success(client):
"""父 Run 给的预算到点没跑完:交回 running + 子运行 id,ok=False,台账里一条查询都没有。"""
asset = await published(client)
parent = await run_parent(asset, options={querycore.BUDGET_OPTION: {"timeout_s": 0.0}})
assert parent.status == "succeeded", parent.error
end = await tool_end(parent.id)
tool = await tool_result(parent.id)
assert tool["ok"] is False and tool["status"] == "running" and tool["run_id"]
assert [p["code"] for p in tool["problems"]] == [querycore.TIMEOUT]
assert "query_artifact" not in end
assert [e for e in await finished_evidence(parent.id) if e.get("kind") == "query"] == []
async def test_a_refusal_is_a_tool_failure(client):
"""口径不在(卡的名字写错):工具当场拒,节点失败并说清原因——不交回一份空结果冒充成功。"""
asset = await published(client)
parent = await run_parent(asset, metric_ids=["gmv"], inputs=dict(INPUTS))
assert parent.status == "succeeded"
run = await run_manager.start(graph=parent_graph(asset["workflow_id"], card="nope", inputs=dict(INPUTS)),
input_payload={})
row = await settled(run.id)
assert row.status == "failed" and querycore.CARD_MISSING in (row.error or "")
async def test_a_formal_parent_uses_the_formal_judge(client):
"""正式父 Run:走 execute_formal 那一套(发布依据不齐就拒);交互(compose)拿不到正式身份。"""
from app.tools.registry import ToolContext
asset = await published(client) # 有清单、没有验证记录与审阅
compose = ToolContext(run_id="p1", node_id="look")
outcome = await querycore.caliber_query(compose, workflow=asset["workflow_id"], card=CARD,
metric_ids=["gmv"], inputs=dict(INPUTS))
assert outcome["ok"] and outcome["purpose"] == publications.PURPOSE_COMPOSE
formal = ToolContext(run_id="p2", node_id="look", run_class="formal")
with pytest.raises(ValueError) as caught:
await querycore.caliber_query(formal, workflow=asset["workflow_id"], card=CARD,
metric_ids=["gmv"], inputs=dict(INPUTS))
assert publications.RECORD_MISSING in str(caught.value), "正式父 Run 用的是 publications.judge 的原码"
record_id = await add_record(asset)
await confirm(asset, record_id)
await publish(client, asset)
proven = await querycore.caliber_query(ToolContext(run_id="p3", node_id="look", run_class="formal"),
workflow=asset["workflow_id"], card=CARD, metric_ids=["gmv"],
inputs=dict(INPUTS))
assert proven["ok"] and proven["purpose"] == publications.PURPOSE_FORMAL
assert proven["publication"]["identity"] == publications.PROVEN
async with SessionLocal() as session:
child = await session.get(Run, proven["run_id"])
assert child.origin["purpose"] == publications.PURPOSE_FORMAL and child.origin["parent_run_id"] == "p3"
async def test_the_actor_comes_from_the_parent_run_not_the_parameters(client):
"""认证主体来自父 Run,工具参数里没有这一项,也传不了。"""
asset = await published(client)
run = await run_manager.start(graph=parent_graph(asset["workflow_id"]), input_payload={}, started_by="老王")
row = await settled(run.id)
assert row.status == "succeeded", row.error
child_id = (await tool_result(row.id))["run_id"]
async with SessionLocal() as session:
child = await session.get(Run, child_id)
assert child.started_by == "老王"
# ==========================================================================
# 6、协作成员路径:成员绑口径查询时,多查询逐条编号、逐条认工件
# ==========================================================================
def team_graph(workflow_id: str) -> dict[str, Any]:
"""协作团队:成员「查数」绑 caliber_query 与分析工具(绑了分析工具才编 Qn),另一个只是说话。"""
agents = [
{"name": "查数", "description": "查口径", "system": "你会查口径", "max_steps": 1,
"tools": [querycore.CALIBER_QUERY, "analysis_comparable"]},
{"name": "复核", "description": "复核别人算的数", "system": "你复核", "max_steps": 1, "tools": []},
]
return S.chain(
S.node("start", "input"),
S.node("team", "supervisor", goal="算一下销售额与最大单笔", agents=agents, max_rounds=2, max_parallel=1,
approval="never"),
S.node("out", "output", fields=[{"name": "答", "value": "{{ nodes.team.text }}"}]),
)
async def test_a_cooperative_member_numbers_every_query_of_one_call(client):
"""成员绑的口径查询交回两条查询:逐条编号、逐条进成员自己的分析容器,编号与执行身份记在调用记录里。
协作节点不记证据台账时编号只给成员自己下钻用,所以编号要和这次调用的调用记录一起留下来;否则事后看不出
某个 Qn 是哪一次执行的哪条查询。给了 ledger 时(K10 收尾)同时逐条记一条运行台账的 query 条目。"""
from app.capabilities.registry import snapshot_owned
from app.engine.nodes import multi
from app.tools.analysis import AnalysisScope
asset = await published(client)
parent = await run_parent(asset, metric_ids=["gmv", "peak"])
payload = await tool_result(parent.id)
content = json.dumps(payload, ensure_ascii=False)
scope = AnalysisScope(node_id="team")
record: dict[str, Any] = {}
ledger: list[dict[str, Any]] = []
out = await multi._numbered(scope, querycore.CALIBER_QUERY, content, None, parent.id, "team",
call_id="call-1", member="查数", exec_no=1, record=record, ledger=ledger)
assert out.startswith("【证据 Q1、Q2】"), "一次交回多条查询:两条都要编号(以前只认第一条)"
assert list(scope.queries) == ["Q1", "Q2"]
traced = record["queries"]
assert [q["alias"] for q in traced] == ["Q1", "Q2"]
assert record["alias"] == "Q1" and record["artifact"] == payload["queries"][0]["artifact"]
assert [q["query_id"] for q in traced] == [q["query_id"] for q in payload["queries"]]
assert [q["artifact"] for q in traced] == [q["artifact"] for q in payload["queries"]]
# 执行身份照抄口径查询自己带着的那一份(K09 算好的):真的执行那次 SQL 的子运行、那条查询的第几次
child = payload["run_id"]
assert all(q["execution_id"] == p["execution_id"] and child in q["execution_id"]
for q, p in zip(traced, payload["queries"]))
assert all(q["attempt"] == p["attempt"] >= 1 for q, p in zip(traced, payload["queries"]))
# 两条工件都是这次父运行(明确派生的子运行)落下的查询快照
assert all([await snapshot_owned(q["artifact"], parent.id, querycore.CALIBER_QUERY) for q in traced])
# 运行台账的条目:与 tools/llm 记的那一种同形,另带成员归属(node_id 前缀 + member)
assert [e["node_id"] for e in ledger] == ["team:查数", "team:查数"]
assert [e["member"] for e in ledger] == ["查数", "查数"]
assert [e["artifact"] for e in ledger] == [q["artifact"] for q in payload["queries"]]
assert all(e["kind"] == "query" and e["tool"] == querycore.CALIBER_QUERY and e["exec"] == 1
and e["call_id"] == "call-1" for e in ledger)
assert all(e["source"] and e["columns"] and e["rows"] >= 1 and e["truncated"] is False for e in ledger)
assert [e["query_id"] for e in ledger] == [q["query_id"] for q in payload["queries"]]
assert [e["execution_id"] for e in ledger] == [q["execution_id"] for q in payload["queries"]]
assert all(e["origin"]["workflow_id"] == asset["workflow_id"] for e in ledger)
assert all(e["parameters"]["inputs_hash"] for e in ledger), "复算要的材料跟着条目走"
async def test_a_cooperative_member_refuses_forged_and_other_runs_artifacts(client):
"""名字对了,工件也得对:伪造的、别的运行落的、不是查询快照的都不进成员的分析容器、也不进运行台账。"""
from app.engine.nodes import multi
from app.tools.analysis import AnalysisScope
asset = await published(client)
parent = await run_parent(asset, metric_ids=["gmv", "peak"])
payload = await tool_result(parent.id)
# 一份真的、一份工件 id 换掉的
forged = json.loads(json.dumps(payload))
forged["queries"][0]["artifact"] = "a" * 64
content = json.dumps(forged, ensure_ascii=False)
scope = AnalysisScope(node_id="team")
record: dict[str, Any] = {}
ledger: list[dict[str, Any]] = []
out = await multi._numbered(scope, querycore.CALIBER_QUERY, content, None, parent.id, "team",
call_id="call-1", member="查数", exec_no=1, record=record, ledger=ledger)
assert out == f"【证据 Q1】\n{content}", "只给认下来的那条编号"
assert list(scope.queries) == ["Q1"], "伪造的那条不进容器,真的那条照记"
assert [q["artifact"] for q in record["queries"]] == [payload["queries"][1]["artifact"]]
assert [e["artifact"] for e in ledger] == [payload["queries"][1]["artifact"]], "台账里也只记真的那条"
# 同一份真工件,换一个运行 id:不是这次运行(也不是它派生的子运行)落的,整条都不认
real = json.dumps(payload, ensure_ascii=False)
other = AnalysisScope(node_id="team")
other_record: dict[str, Any] = {}
other_ledger: list[dict[str, Any]] = []
out = await multi._numbered(other, querycore.CALIBER_QUERY, real, None, "another-run", "team",
call_id="call-1", member="查数", exec_no=1, record=other_record,
ledger=other_ledger)
assert out == real and not other.queries and not other_record and not other_ledger
async def test_a_cooperative_member_keeps_the_single_query_behavior():
"""单条数据源查询照旧:一个 Qn、内容前面一行编号,执行身份按这次调用的 id 算(同 tools/registry)。"""
from app.engine.nodes import multi
from app.tools.analysis import AnalysisScope
scope = AnalysisScope(node_id="team")
record: dict[str, Any] = {}
ledger: list[dict[str, Any]] = []
payload = {"artifact": "b" * 64, "columns": ["gmv"], "rows": [[1.0]], "truncated": False, "source": "shop",
"sql": "SELECT 1"}
content = json.dumps(payload)
out = await multi._numbered(scope, "db_query__shop", content, None, "run-1", "team",
call_id="call-1", member="查数", exec_no=2, record=record, ledger=ledger)
assert out == f"【证据 Q1】\n{content}" and list(scope.queries) == ["Q1"]
assert [q["artifact"] for q in record["queries"]] == ["b" * 64]
assert record["queries"][0]["execution_id"] == "run-1:team#call-1"
assert record["queries"][0]["query_id"] == "team"
[entry] = ledger
assert entry["node_id"] == "team:查数" and entry["member"] == "查数" and entry["exec"] == 2
assert entry["query_id"] == "team" and entry["attempt"] == 2
assert entry["source"] == "shop" and entry["columns"] == ["gmv"] and entry["rows"] == 1
async def test_a_plain_tool_result_leaves_no_ledger_behind():
"""不是平台执行的查询(普通工具、编造的同样的 JSON):容器、调用记录、台账都不动。"""
from app.engine.nodes import multi
from app.tools.analysis import AnalysisScope
scope = AnalysisScope(node_id="team")
record: dict[str, Any] = {}
ledger: list[dict[str, Any]] = []
content = json.dumps({"artifact": "c" * 64, "columns": ["x"], "rows": [[1]]})
out = await multi._numbered(scope, "calculator", content, None, "run-1", "team",
call_id="call-1", member="查数", exec_no=1, record=record, ledger=ledger)
assert out == content and not scope.queries and not record and not ledger
async def test_the_team_member_path_records_the_queries_it_numbered(client, monkeypatch):
"""走真的一次协作运行:成员绑口径查询,编号与执行身份随团队产出留下来,成员看到的是「【证据 Q1、Q2】」。"""
from app.providers import mock_model
asset = await published(client)
args = {"workflow": asset["workflow_id"], "card": CARD, "metric_ids": ["gmv", "peak"], "inputs": dict(INPUTS)}
decisions = [
{"assignments": [{"agent": "查数", "instruction": "算 gmv 与 peak"}], "done": False, "reason": "先查"},
{"assignments": [], "done": True, "reason": "够了"},
]
turns = {"n": 0}
seen: list[str] = []
def _decide(self, messages):
seen.extend(str(m.content) for m in messages if isinstance(m, ToolMessage))
if self.response_format:
i = turns["n"]
turns["n"] += 1
return AIMessage(content=json.dumps(decisions[i] if i < len(decisions) else decisions[-1],
ensure_ascii=False))
if self.tools and not any(isinstance(m, ToolMessage) for m in messages):
return AIMessage(content="查一下口径。",
tool_calls=[{"name": querycore.CALIBER_QUERY, "args": args, "id": "c1"}])
return AIMessage(content="查完了:gmv 与 peak 都取到了。")
monkeypatch.setattr(mock_model.MockChatModel, "_decide", _decide)
run = await run_manager.start(graph=team_graph(asset["workflow_id"]), input_payload={})
row = await settled(run.id)
assert row.status == "succeeded", row.error
assert any(text.startswith("【证据 Q1、Q2】") for text in seen), "成员拿到的结果前面要写全这一次的编号"
from app.engine.evidence_reader import read_sealed
team = (await read_sealed(row.id)).payload_of(["team"])
assert isinstance(team, dict)
[record] = team["transcript"][0]["tool_calls"]
traced = record["queries"]
assert [q["alias"] for q in traced] == ["Q1", "Q2"]
assert {q["query_id"] for q in traced} == {f"{asset['workflow_id']}:sales", f"{asset['workflow_id']}:shared"}
# 执行身份指的是真的执行那次 SQL 的子运行;父子关系在子运行的来源里
child_id = str(traced[0]["execution_id"]).split(":", 1)[0]
async with SessionLocal() as session:
child = await session.get(Run, child_id)
assert child is not None and (child.origin or {}).get("parent_run_id") == row.id
assert {str(q["execution_id"]).split(":", 1)[0] for q in traced} == {child_id}, "两条查询在同一次执行里"
assert [q["attempt"] for q in traced] == [1, 1]
#: 成员这一轮查的是另一个窗口:和上面调用工具节点那条的 SQL 不一样,两条的查询快照各是各的(好断言归属)
MEMBER_INPUTS = {"start": "2026-08-01", "end": "2026-12-01"}
def team_report_graph(workflow_id: str) -> dict[str, Any]:
"""入口 → 调用工具(口径查询)→ 协作团队(成员也绑口径查询)→ 报告 → 出口。
两处口径查询各交回两条 SQL,报告目录只收调用工具节点那两条:成员的查询是**附加**在封存证据上的。"""
agents = [
{"name": "查数", "description": "查口径", "system": "你会查口径", "max_steps": 1,
"tools": [querycore.CALIBER_QUERY, "analysis_comparable"]},
{"name": "复核", "description": "复核别人算的数", "system": "你复核", "max_steps": 1, "tools": []},
]
return S.chain(
S.node("start", "input"),
S.node("look", "tool", tool=querycore.CALIBER_QUERY, args={
"workflow": workflow_id, "card": CARD, "metric_ids": ["gmv", "peak"], "inputs": dict(INPUTS)}),
S.node("team", "supervisor", goal="再按全年口径核一遍", agents=agents, max_rounds=2, max_parallel=1,
approval="never"),
S.node("write", "report", instructions="写一段说明"),
S.node("out", "output", fields=[{"name": "报告", "value": "{{ nodes.write.text }}"}]),
)
async def test_member_queries_join_the_sealed_evidence_without_renumbering_the_report(client, monkeypatch):
"""成员查到的数进父 Run 的封存证据(带成员前缀、来源、执行身份的 query 条目),报告目录的全局 Q 编号一个字不变。
报告目录按祖先收证据(report.report_catalog 的 allowed),成员条目的节点 id 是「协作节点 id:成员名」,既不像子工作流
转发的「协作节点 id/成员名」那样被 engine/compiler 的 _own_evidence 滤掉(那会让条目根本进不了封存),也不落在报告的
收证据范围里——成员查到的数是附加证据:封存读取(sealed.queries / query_records / entries)、导出包的来源与遮罩判断、
运行对比读得到,报告里的 Qn 还是原来那几条、面板按报告引用的那一份也不变。"""
from app.core import artifact_store
from app.engine.evidence_reader import read_sealed
from app.providers import mock_model
asset = await published(client)
args = {"workflow": asset["workflow_id"], "card": CARD, "metric_ids": ["gmv", "peak"],
"inputs": dict(MEMBER_INPUTS)}
decisions = [
{"assignments": [{"agent": "查数", "instruction": "按全年口径核一遍"}], "done": False, "reason": "先查"},
{"assignments": [], "done": True, "reason": "够了"},
]
turns = {"n": 0}
def _decide(self, messages):
if self.response_format:
i = turns["n"]
turns["n"] += 1
return AIMessage(content=json.dumps(decisions[i] if i < len(decisions) else decisions[-1],
ensure_ascii=False))
if self.tools and not any(isinstance(m, ToolMessage) for m in messages):
return AIMessage(content="查一下口径。",
tool_calls=[{"name": querycore.CALIBER_QUERY, "args": args, "id": "c1"}])
return AIMessage(content="按全年口径核过一遍,与周口径口径一致。")
monkeypatch.setattr(mock_model.MockChatModel, "_decide", _decide)
run = await run_manager.start(graph=team_report_graph(asset["workflow_id"]), input_payload={})
row = await settled(run.id)
assert row.status == "succeeded", row.error
sealed = await read_sealed(row.id)
# 1) 调用工具节点那两条(tools.py 记的)与成员那两条(这次新加的)各是各的
look = [e for e in await finished_evidence(row.id, "look") if e.get("kind") == "query"]
member = [e for e in await finished_evidence(row.id, "team") if e.get("kind") == "query"]
assert [e["member"] for e in member] == ["查数", "查数"], "一次调用两条查询,各记一条(不重复)"
assert {e["node_id"] for e in member} == {"team:查数"}, "条目带成员归属"
assert all(e["exec"] == 1 and e["call_id"] == "c1" and e["tool"] == querycore.CALIBER_QUERY for e in member)
assert all(e["source"] == S.CURRENT["source"] and e["columns"] and e["rows"] >= 1
and e["truncated"] is False for e in member)
assert all(e["query_id"] and e["execution_id"] and e["attempt"] == 1 for e in member)
assert all(e["origin"]["workflow_id"] == asset["workflow_id"] and e["parameters"]["inputs_hash"]
for e in member), "复算要的出处与材料跟着条目走"
assert not ({e["artifact"] for e in member} & {e["artifact"] for e in look}), "两处查的是不同窗口,工件各是各的"
# 2) 封存证据(证据面板、导出包从这里认):成员那两条在 sealed.queries / query_records / entries 里
member_artifacts = {e["artifact"] for e in member}
assert member_artifacts <= set(sealed.queries)
assert all(sealed.queries[a]["tool"] == querycore.CALIBER_QUERY for a in member_artifacts)
records = sealed.query_records_of("team")
assert {r["execution_id"] for r in records} == {e["execution_id"] for e in member}
assert [e.get("member") for e in sealed.entries if e.get("artifact") in member_artifacts] == ["查数", "查数"]
# 3) 报告目录的全局 Q 编号一个字不变:只有调用工具节点那两条,成员的查数不在目录里
write = sealed.payload_of(["write"])
doc = artifact_store.load(str(write["doc_artifact"]))
aliases = {a: e.get("artifact") for a, e in (doc.get("catalog") or {}).items() if e.get("kind") == "query"}
assert set(aliases) == {"Q1", "Q2"}, "成员的查询不重编报告里的编号"
assert set(aliases.values()) == {e["artifact"] for e in look}
assert not (member_artifacts & set(aliases.values()))
# 4) 导出包(可验证报告)也把成员查到的快照算进这次运行的查询:以前成员那条路连工具快照都不落,只能看事件里的预览,
# 长一点的返回被截断就认不出来(打包时按「认不出」宁可多遮),来源也就认不出
from app.bundle.build import _query_artifacts, _query_sources
events = list(sealed.events)
assert member_artifacts <= set(_query_artifacts(events))
assert all(_query_sources(events).get(a) == S.CURRENT["source"] for a in member_artifacts)
page = await client.get(f"/api/runs/{row.id}/bundle.html")
assert page.status_code == 200, page.text
async def test_a_member_without_queries_leaves_the_team_output_unchanged(client, monkeypatch):
"""普通成员(没查库)的产出与以前一字不差:团队产出不带 evidence 键,transcript 里也不多东西。"""
from app.providers import mock_model
asset = await published(client)
agents = [{"name": "撰稿", "description": "写一段话", "system": "你撰稿", "max_steps": 1, "tools": []}]
graph = S.chain(
S.node("start", "input"),
S.node("team", "supervisor", goal="写一句", agents=agents, max_rounds=1, max_parallel=1, approval="never"),
S.node("out", "output", fields=[{"name": "答", "value": "{{ nodes.team.text }}"}]),
)
turns = {"n": 0}
def _decide(self, messages):
if self.response_format:
turns["n"] += 1
decided = ({"assignments": [{"agent": "撰稿", "instruction": "写一句"}], "done": False, "reason": "派活"}
if turns["n"] == 1 else
{"assignments": [], "done": True, "reason": "够了"})
return AIMessage(content=json.dumps(decided, ensure_ascii=False))
return AIMessage(content="写好了。")
monkeypatch.setattr(mock_model.MockChatModel, "_decide", _decide)
run = await run_manager.start(graph=graph, input_payload={})
row = await settled(run.id)
assert row.status == "succeeded", row.error
assert await finished_evidence(row.id, "team") == [], "成员没查库,一条台账条目都不记"
from app.engine.evidence_reader import read_sealed
team = (await read_sealed(row.id)).payload_of(["team"])
[entry] = team["transcript"]
assert set(entry) == {"round", "agent", "instruction", "text", "tool_calls", "duration_ms"}, entry
assert "queries" not in team # 团队产出里只有 transcript 那一条,没有别的证据键
assert asset["workflow_id"]
async def test_a_member_without_an_analysis_tool_still_gets_its_queries_into_the_evidence(client, monkeypatch):
"""成员只绑口径查询(没绑分析工具):交给成员的内容一个字不改(没有容器可放编号),查到的数照样进封存证据。"""
from app.engine.evidence_reader import read_sealed
from app.providers import mock_model
asset = await published(client)
args = {"workflow": asset["workflow_id"], "card": CARD, "metric_ids": ["gmv", "peak"],
"inputs": dict(MEMBER_INPUTS)}
agents = [{"name": "查数", "description": "查口径", "system": "你会查口径", "max_steps": 1,
"tools": [querycore.CALIBER_QUERY]},
{"name": "复核", "description": "复核别人算的数", "system": "你复核", "max_steps": 1, "tools": []}]
graph = S.chain(
S.node("start", "input"),
S.node("team", "supervisor", goal="算一下销售额", agents=agents, max_rounds=2, max_parallel=1,
approval="never"),
S.node("out", "output", fields=[{"name": "答", "value": "{{ nodes.team.text }}"}]),
)
decisions = [
{"assignments": [{"agent": "查数", "instruction": "算 gmv 与 peak"}], "done": False, "reason": "先查"},
{"assignments": [], "done": True, "reason": "够了"},
]
turns = {"n": 0}
seen: list[str] = []
def _decide(self, messages):
seen.extend(str(m.content) for m in messages if isinstance(m, ToolMessage))
if self.response_format:
i = turns["n"]
turns["n"] += 1
return AIMessage(content=json.dumps(decisions[i] if i < len(decisions) else decisions[-1],
ensure_ascii=False))
if self.tools and not any(isinstance(m, ToolMessage) for m in messages):
return AIMessage(content="查一下口径。",
tool_calls=[{"name": querycore.CALIBER_QUERY, "args": args, "id": "c1"}])
return AIMessage(content="查完了。")
monkeypatch.setattr(mock_model.MockChatModel, "_decide", _decide)
run = await run_manager.start(graph=graph, input_payload={})
row = await settled(run.id)
assert row.status == "succeeded", row.error
assert seen and all(not t.startswith("【证据") for t in seen), "没绑分析工具:交回成员的内容一个字不改"
entries = [e for e in await finished_evidence(row.id, "team") if e.get("kind") == "query"]
assert len(entries) == 2 and {e["node_id"] for e in entries} == {"team:查数"}
assert all(e["member"] == "查数" and e["query_id"] and e["execution_id"] and e["attempt"] == 1
for e in entries)
sealed = await read_sealed(row.id)
assert {e["artifact"] for e in entries} <= set(sealed.queries)
[record] = (sealed.payload_of(["team"]) or {}).get("transcript", [{}])[0].get("tool_calls") or [{}]
assert [q["artifact"] for q in record["queries"]] == [e["artifact"] for e in entries]
assert all("alias" not in q for q in record["queries"]), "没有容器就没有本节点内的编号"
# ==========================================================================
# 6、复核出来的三条:授权范围继承、逐次执行身份、发起时预解析口径的源
# ==========================================================================
#: 记录表的列(和 tests/test_records.py 同一个形状的假数据)
RECORD_COLUMNS = [{"name": "门店", "label": "门店", "type": "string", "required": True},
{"name": "数量", "label": "报损数量", "type": "integer", "required": True}]
#: 记录表的行级范围认的一列:查全部、只看自己的、一行都不给
RECORD_COUNT = "SELECT count(*) AS n FROM records"
def records_graph(source_name: str) -> dict[str, Any]:
"""父 Run 的图:查这张记录表(固定的数据版本里带着它的高水位与行级范围)。"""
return S.chain(
S.node("start", "input"),
S.node("q", "tool", tool=f"db_query__{source_name}", args={"sql": RECORD_COUNT}),
S.node("out", "output", fields=[{"name": "n", "value": "{{ nodes.q }}"}]),
)
async def records_source(name: str = "门店报损", *, grants: dict[str, str] | None = None) -> tuple[str, str]:
"""建一张记录表:所属应用 + 逐人的授权。返回 (源 id, 源名)。grants 缺省是给 uma 的一档 use。"""
from app.db.models import DataSource
from app.db.models_apps import App, AppGrant
from app.records.tables import ensure_table
async with SessionLocal() as session:
app = App(name=f"{name}-{uuid.uuid4().hex[:6]}", created_by="建立人")
session.add(app)
await session.flush()
for who, role in ({"uma": "use"} if grants is None else grants).items():
session.add(AppGrant(app_id=app.id, principal_kind="user", principal=who, role=role))
table = await ensure_table(session, app, RECORD_COLUMNS)
await session.commit()
source = await session.get(DataSource, table.source_id)
# 一行 uma 提交的记录:行级范围是 all / mine 时看得见这一行,none 看不见
store.append(path_of(table), columns_of(table), {"门店": "一店", "数量": 1}, version=table.version,
run_id=None, actor="uma", action_key=uuid.uuid4().hex)
return source.id, source.name
async def grant_caliber(workflow_id: str, version: int, who: str, role: str) -> str:
"""给这个人在**这一版口径**上的资产授权:一份应用(当前发布版精确固定着这一版)+ 一条 AppGrant。返回应用 id。
普通使用者按口径取数靠的就是它(apps/access.usable_calibers / caliber_role,K14):没有这一份,QueryCore 在起 Run
之前就拒(querycore_not_authorized),工具那一层也拿不到数——记录表的行级授权(records_source 的 grants)不管口径。"""
from app.db.models_apps import App, AppGrant, AppVersion
async with SessionLocal() as session:
app = App(name=f"口径授权-{uuid.uuid4().hex[:6]}", status="active", published_version=1)
session.add(app)
await session.flush()
session.add(AppVersion(app_id=app.id, version=1, spec={}, spec_hash="grant",
pins=[{"alias": "cal", "workflow_id": workflow_id, "version": int(version)}]))
session.add(AppGrant(app_id=app.id, principal_kind="user", principal=who, role=role))
await session.commit()
return app.id
async def wait_for(run_id: str, statuses: tuple[str, ...], timeout: float = 30.0) -> Run:
"""等运行到这几个状态之一(settled 把 interrupted 也当终态,审批那一段要另等)。"""
deadline = asyncio.get_running_loop().time() + timeout
while asyncio.get_running_loop().time() < deadline:
async with SessionLocal() as session:
row = await session.get(Run, run_id)
if row is not None and row.status in statuses:
return row
await asyncio.sleep(0.05)
raise AssertionError(f"运行 {run_id} 没有进入 {statuses}")
async def test_inherit_scope_keeps_the_parents_authorized_rows():
"""继承父 Run 的 data_versions:记录表的行级范围原样带着(all/mine/none 都不动),缺 rows 的按最窄的算。"""
from app.records.views import inherit_scope
versions = {
"a": {"name": "甲", "origin": "records", "table": "t", "version": 1, "seq": 3, "rows": "all"},
"b": {"name": "乙", "origin": "records", "table": "t", "version": 1, "seq": 4, "rows": "mine",
"viewer": "uma"},
"c": {"name": "丙", "origin": "records", "table": "t", "version": 1, "seq": 5, "rows": "none"},
"d": {"name": "丁", "origin": "records", "table": "t", "version": 1, "seq": 6}, # 老运行:没有范围
"e": {"snapshot": "s1", "name": "上传表格"}, # 不是记录表
}
inherit_scope(versions)
assert versions["a"]["rows"] == "all"
assert versions["b"]["rows"] == "mine" and versions["b"]["viewer"] == "uma"
assert versions["c"]["rows"] == "none"
assert versions["d"]["rows"] == "none", "没有范围的老项按最窄的算,绝不当成 all"
assert versions["e"] == {"snapshot": "s1", "name": "上传表格"}, "上传源不碰"
async def test_a_derived_child_inherits_the_parents_authorized_scope(client):
"""正式父 Run 的行级范围是 all;派生的子运行是 exploratory,也不能按发起人重算成 mine。"""
from app.engine.runner import _data_versions # noqa: F401
from tests.fakes import auth_env
await auth_env.turn_on_local()
try:
await auth_env.make_user("uma", "user")
source_id, name = await records_source()
graph = records_graph(name)
# 父 Run:正式运行 → 看全部
parent = await run_manager.start(graph=graph, input_payload={}, run_class="formal", started_by="uma")
parent = await settled(parent.id)
assert parent.status == "succeeded", parent.error
assert parent.data_versions[source_id]["rows"] == "all"
# 反例:同一个起点自己发起(不继承父 Run)→ 按发起人的 use 档收成 mine。没有这一条,上面那句断言说明不了什么
own = await settled((await run_manager.start(graph=graph, input_payload={}, run_class="exploratory",
started_by="uma")).id)
assert own.data_versions[source_id]["rows"] == "mine"
assert own.data_versions[source_id]["viewer"] == "uma"
# 反例(同一条规则直接跑一遍):拿父 Run 那一份按子运行的类别与发起人重算,all 会收成 mine
from app.records.views import scope_entries
recomputed = copy.deepcopy(parent.data_versions)
async with SessionLocal() as session:
await scope_entries(session, recomputed, run_class="exploratory", started_by="uma")
assert recomputed[source_id]["rows"] == "mine"
# 派生:照父 Run 那一份原样,不收窄、也不添 viewer
child = await settled((await run_manager.start(graph=graph, input_payload={}, run_class="exploratory",
started_by="uma", data_versions=parent.data_versions)).id)
assert child.status == "succeeded", child.error
assert child.data_versions[source_id]["rows"] == "all", "父已授权 all,子运行不能收成 mine"
assert "viewer" not in child.data_versions[source_id]
assert child.data_versions[source_id]["seq"] == parent.data_versions[source_id]["seq"]
finally:
await auth_env.reset()
async def test_a_compose_parent_keeps_its_scope_after_the_user_is_disabled(client):
"""发起之后人停用了:同一次运行(派生出来的那一段)仍按发起时定下的那一档看,不改判成 none。"""
from app.db.models import User
from tests.fakes import auth_env
await auth_env.turn_on_local()
try:
await auth_env.make_user("uma", "user")
source_id, name = await records_source()
graph = records_graph(name)
parent = await settled((await run_manager.start(graph=graph, input_payload={}, started_by="uma")).id)
assert parent.data_versions[source_id]["rows"] == "mine"
async with SessionLocal() as session:
user = (await session.execute(select(User).where(User.username == "uma"))).scalar_one()
user.disabled = True
await session.commit()
# 现在自己发起一次:人不在(停用)→ 一行都不给
fresh = await settled((await run_manager.start(graph=graph, input_payload={}, started_by="uma")).id)
assert fresh.data_versions[source_id]["rows"] == "none"
# 派生:照父 Run 那一份(mine + 看的人),不因为人后来停用而改判
child = await settled((await run_manager.start(graph=graph, input_payload={}, run_class="exploratory",
started_by="uma", data_versions=parent.data_versions)).id)
assert child.data_versions[source_id]["rows"] == "mine"
assert child.data_versions[source_id]["viewer"] == "uma"
finally:
await auth_env.reset()
# ---- 2) 同一份工件对应两条不同的逻辑查询 ----------------------------------
def same_duration(monkeypatch) -> None:
"""把每条查询的耗时记成 0:同一条 SQL、同一批数据、同一份参数的两条查询就得到**同一个**工件(内容寻址)。
耗时是快照内容的一部分(每条查询各记各的、几毫秒上下的抖动):这一条测的是「同一份工件对应两条不同的逻辑查询」
时证据索引的取法,所以先把那个抖动按平,让两份内容真的逐字相同。"""
import dataclasses
from app.tools import datasource
real = datasource.run_query
async def zeroed(source: Any, sql: str, **kw: Any) -> Any:
return dataclasses.replace(await real(source, sql, **kw), elapsed_ms=0)
monkeypatch.setattr(datasource, "run_query", zeroed)
async def test_two_logical_queries_sharing_one_artifact_are_both_reported(client, monkeypatch):
"""两个取数节点写同一条 SQL、读同一批数据:内容寻址下是**同一个**工件,但它们是两条不同的逻辑查询。
按工件 id 去重的证据索引会把它们并成一条,早先那条就找不到自己那一次——所以逐次记下的执行身份才是取法。"""
same_duration(monkeypatch)
asset = await make_workflow(graph=twin_graph())
await publish(client, asset)
outcome = await run_once(asset, metric_ids=["left", "right"], inputs=dict(INPUTS))
assert outcome.ok, [p["code"] for p in outcome.problems]
data = outcome.as_dict()
assert [q["query_id"] for q in data["queries"]] == [f"{asset['workflow_id']}:one",
f"{asset['workflow_id']}:two"]
assert all(q["found"] for q in data["queries"])
assert len({q["artifact"] for q in data["queries"]}) == 1, "同一条 SQL 同一批数据 → 同一个工件"
assert len({q["execution_id"] for q in data["queries"]}) == 2, "两条逻辑查询各有各的执行身份"
assert all(q["row_count"] == 1 and q["columns"] for q in data["queries"])
assert {m["id"]: m["value"] for m in outcome.metrics} == {"left": WINDOW_SUM, "right": WINDOW_SUM}
async def test_a_later_execution_of_the_same_sql_takes_its_own_record(client):
"""同一个节点执行两次(同一条 SQL、同一份数据、同一个工件):引用取**最后一次**那次执行的身份。"""
from app.engine.evidence_reader import read_sealed
asset = await published(client)
outcome = await run_once(asset, metric_ids=["gmv"], inputs=dict(INPUTS))
assert outcome.ok
sealed = await read_sealed(outcome.run_id)
records = sealed.query_records_of("sales")
assert len(records) == 1 and records[-1]["artifact"] == outcome.queries[0]["artifact"]
# 逐次记录与按工件去重的那一份指向同一次执行(认法不同、结论一致)
assert sealed.queries[records[-1]["artifact"]]["exec"] == records[-1]["exec"]
assert querycore._query_record_of(sealed, "sales")["execution_id"] == records[-1]["execution_id"]
# 没有这个节点的逐次记录时(老运行、只回了 tool.end)退回按工件去重的那一份
assert querycore._query_record_of(sealed, "shared") is None
# ---- 3) 父 Run 发起时预解析口径那一版读的源 -------------------------------
def book(sheets: dict[str, list[list]]) -> bytes:
import io
from openpyxl import Workbook
wb = Workbook()
wb.remove(wb.active)
for title, rows in sheets.items():
ws = wb.create_sheet(title)
for row in rows:
ws.append(row)
buf = io.BytesIO()
wb.save(buf)
return buf.getvalue()
#: 上传表格第一版:数量合计 3;第二版(重传):合计 100,另多一张表
UP_V1 = {"明细": [["区域", "数量"], ["分区甲", 1], ["分区乙", 2]]}
UP_V2 = {"明细": [["区域", "数量"], ["分区甲", 60], ["分区乙", 40]], "新增": [["时段", "客流"], ["上午", 7]]}
async def upload_source(sheets: dict[str, list[list]], *, name: str | None = None) -> tuple[str, str, str]:
"""发布一版上传表格,返回 (源 id, 快照 id, 源名)。给了 name 就是**往同一个源上重传**(新的一版)。"""
from app.data import table_versions
name = name or f"up_{uuid.uuid4().hex[:8]}"
async with SessionLocal() as session:
row = (await session.execute(select(DataSource).where(DataSource.name == name))).scalar_one_or_none()
if row is None:
row = DataSource(name=name, kind="sqlite", readonly=True, origin="upload", enabled=True)
session.add(row)
result = await table_versions.publish_upload(session, row, book(sheets), "客流.xlsx",
header_row=1, mixed="reject", raw_mode=False)
return row.id, result.snapshot_id, name
def upload_caliber_graph(source_name: str) -> dict[str, Any]:
"""口径的取数节点查这张上传表格(国产表名照原样写在 SQL 里)。"""
return S.chain(
S.node("start", "input"),
S.node("fetch", "tool", tool=f"db_query__{source_name}",
args={"sql": 'SELECT SUM("数量") AS total FROM "明细"'}),
S.node(CARD, "metrics", caliber="上传合计", caliber_version="v1",
metrics=[{"id": "total", "name": "合计", "unit": "件",
"expression": "cell(nodes.fetch, 0, 'total')"}]),
S.node("write", "report", instructions="写周销售"),
S.node("out", "output", fields=[{"name": "报告", "value": "{{ nodes.write.text }}"}]),
)
async def test_the_parent_pins_the_sources_its_caliber_reads(client):
"""绑了 caliber_query 的节点读的**不在本图里**:口径那一版图里的源也要在父 Run 发起时固定下来。"""
from app.engine.schema import GraphSpec
from app.engine.runner import _data_versions
other_id, _, _ = await upload_source(UP_V1) # 别的源:不许被顺手固定
source_id, snap1, name = await upload_source(UP_V1)
asset = await make_workflow(graph=upload_caliber_graph(name))
await publish(client, asset)
graph = parent_graph(asset["workflow_id"], metric_ids=["total"], inputs={})
async with SessionLocal() as session:
pins = await _data_versions(session, GraphSpec.model_validate(graph))
assert pins[source_id]["snapshot"] == snap1, "口径那一版的取数源要在父 Run 发起时就固定"
assert pins[source_id]["name"] == name
assert other_id not in pins, "没用到的源照旧不固定"
# 认不到工作流(名字写错了):这一次调用会被 QueryCore 拒,一条数据都不读——不必把全部源都固定上
broken = parent_graph("nope-not-a-workflow", metric_ids=["total"], inputs={})
async with SessionLocal() as session:
assert await _data_versions(session, GraphSpec.model_validate(broken)) == {}
async def test_an_agent_or_templated_caliber_reference_pins_every_source(client):
"""口径是 Agent 挑的、或者写的是模板:发起时说不准读哪个源,把启用的上传源全部固定上。"""
from app.engine.schema import GraphSpec
from app.engine.runner import _data_versions
source_id, snap1, _ = await upload_source(UP_V1)
agent_graph = S.chain(
S.node("start", "input"),
S.node("bot", "agent", prompt="算一下", tools=[querycore.CALIBER_QUERY], approval="never"),
S.node("out", "output", fields=[{"name": "答", "value": "{{ nodes.bot.text }}"}]),
)
async with SessionLocal() as session:
pins = await _data_versions(session, GraphSpec.model_validate(agent_graph))
assert pins.get(source_id, {}).get("snapshot") == snap1
templated = S.chain(
S.node("start", "input"),
S.node("look", "tool", tool=querycore.CALIBER_QUERY, args={
"workflow": "{{ vars.w }}", "card": CARD, "metric_ids": ["total"], "inputs": {}}),
S.node("out", "output", fields=[{"name": "答", "value": "{{ nodes.look.ok }}"}]),
)
async with SessionLocal() as session:
pins = await _data_versions(session, GraphSpec.model_validate(templated))
assert pins.get(source_id, {}).get("snapshot") == snap1
async def test_a_reupload_after_launch_still_reads_the_pinned_version(client):
"""父 Run 发起之后重传:这次运行里口径读的还是发起时那一版(run.started 里记的就是它)。"""
source_id, s1, name = await upload_source(UP_V1)
asset = await make_workflow(graph=upload_caliber_graph(name))
await publish(client, asset)
graph = parent_graph(asset["workflow_id"], metric_ids=["total"], inputs={}, gate=True)
run = await run_manager.start(graph=graph, input_payload={})
assert (await wait_for(run.id, ("interrupted",))).status == "interrupted"
_, s2, _ = await upload_source(UP_V2, name=name) # 停在确认上时重传:当前版本换成合计 100 的那一版
assert s2 != s1
await run_manager.resume(run.id, {"approved": True})
done = await settled(run.id)
assert done.status == "succeeded", done.error
assert done.data_versions[source_id]["snapshot"] == s1
started = await events_of(run.id, "run.started")
assert all(e["data_versions"][source_id]["snapshot"] == s1 for e in started), "run.started 里可见实际固定的版本"
tool = await tool_result(done.id)
assert tool["ok"], tool.get("problems")
assert tool["metrics"][0]["value"] == 3, "重传之后这次运行读的还是发起时那一版"
assert tool["data"]["data_versions"] == {name: s1}
async def test_two_calls_in_one_parent_run_read_the_same_version(client):
"""父 Run 的两次调用(中间重传过)读的还是同一版:不是各自在子运行里补固定当前版本。"""
source_id, s1, name = await upload_source(UP_V1)
asset = await make_workflow(graph=upload_caliber_graph(name))
await publish(client, asset)
args = {"workflow": asset["workflow_id"], "card": CARD, "metric_ids": ["total"], "inputs": {}}
graph = S.chain(
S.node("start", "input"),
S.node("one", "tool", tool=querycore.CALIBER_QUERY, args=dict(args)),
S.node("gate", "human", mode="approve", title="看一眼"),
S.node("two", "tool", tool=querycore.CALIBER_QUERY, args=dict(args)),
S.node("out", "output", fields=[{"name": "答", "value": "{{ nodes.two.ok }}"}]),
)
run = await run_manager.start(graph=graph, input_payload={})
assert (await wait_for(run.id, ("interrupted",))).status == "interrupted"
_, s2, _ = await upload_source(UP_V2, name=name) # 第一次调用之后重传(同一个源的新一版)
assert s2 != s1
await run_manager.resume(run.id, {"approved": True})
done = await settled(run.id)
assert done.status == "succeeded", done.error
first, second = await tool_result(done.id, "one"), await tool_result(done.id, "two")
assert first["ok"] and second["ok"], (first.get("problems"), second.get("problems"))
assert first["metrics"][0]["value"] == 3 and second["metrics"][0]["value"] == 3, \
"同一次运行里的两次调用要读同一版数据"
assert done.data_versions[source_id]["snapshot"] == s1
for result in (first, second):
async with SessionLocal() as session:
child = await session.get(Run, result["run_id"])
assert child.data_versions[source_id]["snapshot"] == s1, "子运行照父 Run 固定的那一版,不各自补固定"
# ==========================================================================
# 7、晚解析的源固定在**父 Run** 上(发布得晚 / 推荐版变了 / 动态引用)
# ==========================================================================
def records_caliber_graph(source_name: str) -> dict[str, Any]:
"""口径的取数节点查这张记录表(读哪些行由这次运行定下的行级范围决定)。"""
return S.chain(
S.node("start", "input"),
S.node("fetch", "tool", tool=f"db_query__{source_name}", args={"sql": RECORD_COUNT}),
S.node(CARD, "metrics", caliber="记录合计", caliber_version="v1",
metrics=[{"id": "n", "name": "条数", "unit": "条",
"expression": "cell(nodes.fetch, 0, 'n')"}]),
S.node("write", "report", instructions="写周销售"),
S.node("out", "output", fields=[{"name": "报告", "value": "{{ nodes.write.text }}"}]),
)
def gated_parent_graph(workflow_id: str, *, metric_ids: list[str], card: str = CARD,
calls: tuple[str, ...] = ("look",)) -> dict[str, Any]:
"""父 Run 的图:入口 → 人工确认 →(一到两处)调用工具(caliber_query)→ 出口。
审批挡在调用之前:测试要在「发起」与「第一次调用」之间做事(发布口径、重传、换推荐版)。"""
parts = [S.node("start", "input"), S.node("gate", "human", mode="approve", title="看一眼")]
for name in calls:
parts.append(S.node(name, "tool", tool=querycore.CALIBER_QUERY, args={
"workflow": workflow_id, "card": card, "metric_ids": metric_ids, "inputs": {}}))
parts.append(S.node("out", "output", fields=[{"name": "答", "value": "运行结束"}]))
return S.chain(*parts)
async def pin_events(run_id: str) -> list[dict[str, Any]]:
"""这次运行里补固定数据版本的那些事件(tools/datasource.LATE_PIN_CODE)。"""
from app.tools.datasource import LATE_PIN_CODE
return [e for e in await events_of(run_id, "log") if e.get("code") == LATE_PIN_CODE]
@pytest.mark.parametrize("run_class, grant, rows, viewer", [
("formal", "use", "all", None), # 正式父 Run:看全部
("exploratory", "use", "mine", "uma"), # 交互:use 档只看自己的
("exploratory", "observe", "all", None), # 交互:observe 档看全部
("exploratory", None, "none", None), # 交互:没有口径授权,这一次调用在起 Run 之前就被拒
])
async def test_a_caliber_published_after_launch_is_pinned_on_the_parent(client, run_class, grant, rows, viewer):
"""新发布:口径在父 Run 发起时还没有推荐版(发起时算不到),第一次调用时按**父 Run 的**类别与授权补固定。
固定记在父 Run 上、追加一条补固定事件(run.started 已经落库),子运行照父 Run 那一份继承——不能落到子运行里
按 exploratory 重算(那会把正式父 Run 的 all 收成 mine/none)。
exploratory 这一档(普通使用者按口径取数)另有一道 K14 的资产授权:发布之后另给 uma 一份与这里那一档相同的授权
(grant_caliber);**没有**口径授权的那一档调用在起 Run 之前就被拒,一行都不读、也不补固定。"""
from app.engine.runner import _data_versions
from app.engine.schema import GraphSpec
from tests.fakes import auth_env
admin = await auth_env.turn_on_local()
try:
await auth_env.make_user("uma", "user")
source_id, name = await records_source(grants={} if grant is None else {"uma": grant})
asset = await make_workflow(graph=records_caliber_graph(name)) # 建了,但**还没发布**
graph = gated_parent_graph(asset["workflow_id"], metric_ids=["n"])
async with SessionLocal() as session:
assert await _data_versions(session, GraphSpec.model_validate(graph)) == {}, "还没发布:发起时算不到"
run = await run_manager.start(graph=graph, input_payload={}, run_class=run_class, started_by="uma")
assert (await wait_for(run.id, ("interrupted",))).status == "interrupted"
if run_class == "formal": # 正式父 Run 走 formal judge:依据要齐
await confirm(asset, await add_record(asset, metric_ids=("n",)))
await publish(admin, asset) # 停在确认上时发布:推荐版出现了
if grant is not None:
await grant_caliber(asset["workflow_id"], 1, "uma", grant)
await run_manager.resume(run.id, {"approved": True})
done = await settled(run.id)
if grant is None:
assert done.status == "failed" and querycore.NOT_AUTHORIZED in str(done.error or ""), done.error
assert source_id not in (done.data_versions or {}), "被拒的这一次没有固定任何数据版本"
assert await pin_events(done.id) == [], "被拒的这一次连一条补固定事件都不该有"
return
assert done.status == "succeeded", done.error
entry = done.data_versions[source_id]
assert entry["rows"] == rows, "行级范围取父 Run 已授权的那一档,不按子运行的类别重算"
assert entry.get("viewer") == viewer
assert entry["late"] is True and entry["caliber"] == f"{asset['workflow_id']}@1:{CARD}"
events = await pin_events(done.id)
assert len(events) == 1 and events[0]["source"] == name and events[0]["rows"] == rows
assert events[0]["caliber"] == f"{asset['workflow_id']}@1:{CARD}" and events[0]["late"] is True
# 子运行照父 Run 那一份继承:不是它自己按 exploratory 另算一份
tool = await tool_result(done.id)
assert tool["ok"], tool.get("problems")
async with SessionLocal() as session:
child = await session.get(Run, tool["run_id"])
assert child.data_versions[source_id]["rows"] == rows
assert child.data_versions[source_id]["caliber"] == f"{asset['workflow_id']}@1:{CARD}"
assert tool["metrics"][0]["value"] == (1 if rows != "none" else 0)
finally:
await admin.aclose()
await auth_env.reset()
async def test_later_calls_reuse_the_first_pin(client):
"""同父 Run 的两次调用:第一次把源固定在父 Run 上,第二次照旧复用那一版,不重新固定、不再补一条事件。"""
from app.engine.runner import _data_versions
from app.engine.schema import GraphSpec
from tests.fakes import auth_env
admin = await auth_env.turn_on_local()
try:
await auth_env.make_user("uma", "user")
source_id, _ = await records_source()
asset = await make_workflow(graph=records_caliber_graph(await _records_name(source_id)))
graph = gated_parent_graph(asset["workflow_id"], metric_ids=["n"], calls=("one", "two"))
async with SessionLocal() as session:
assert await _data_versions(session, GraphSpec.model_validate(graph)) == {}
run = await run_manager.start(graph=graph, input_payload={}, run_class="formal", started_by="uma")
assert (await wait_for(run.id, ("interrupted",))).status == "interrupted"
await confirm(asset, await add_record(asset, metric_ids=("n",))) # 正式父 Run:依据要齐
await publish(admin, asset)
# 正式运行也要这一版口径的资产授权(K14):发布之后按同一档补上
await grant_caliber(asset["workflow_id"], 1, "uma", "use")
await run_manager.resume(run.id, {"approved": True})
done = await settled(run.id)
assert done.status == "succeeded", done.error
entry = done.data_versions[source_id]
assert entry["rows"] == "all" and entry["late"] is True
assert len(await pin_events(done.id)) == 1, "两次调用只补固定一次:后面那次复用第一版"
first, second = await tool_result(done.id, "one"), await tool_result(done.id, "two")
assert first["ok"] and second["ok"], (first.get("problems"), second.get("problems"))
assert first["data"]["data_versions"] == second["data"]["data_versions"]
for tool in (first, second):
async with SessionLocal() as session:
child = await session.get(Run, tool["run_id"])
assert child.data_versions[source_id]["seq"] == entry["seq"]
assert child.data_versions[source_id]["rows"] == "all"
finally:
await admin.aclose()
await auth_env.reset()
async def _records_name(source_id: str) -> str:
async with SessionLocal() as session:
return str((await session.get(DataSource, source_id)).name)
async def test_a_new_recommended_version_with_a_moved_source_is_refused(client):
"""审批期间推荐版换了一版、源也重传了:这一次要执行的口径引用的是另一版——明确说冲突,不静默跨版。"""
source_id, s1, name = await upload_source(UP_V1)
asset = await make_workflow(graph=upload_caliber_graph(name))
await publish(client, asset) # v1 是推荐版
graph = gated_parent_graph(asset["workflow_id"], metric_ids=["total"])
run = await run_manager.start(graph=graph, input_payload={})
row = await wait_for(run.id, ("interrupted",))
assert row.status == "interrupted"
assert row.data_versions[source_id]["snapshot"] == s1 # 发起时固定在这一版
assert row.data_versions[source_id]["caliber"] == f"{asset['workflow_id']}@1:{CARD}"
_, s2, _ = await upload_source(UP_V2, name=name) # 重传:当前版本换成合计 100 的那一版
changed = upload_caliber_graph(name)
next(metrics for metrics in changed["nodes"] if metrics["id"] == CARD)["data"]["config"]["metrics"][0]["label"] = "合计2"
asset = await add_version(asset, changed, version=2)
await publish(client, asset, version=2) # 推荐版换成 v2(引用的是重传后的那一版)
await run_manager.resume(run.id, {"approved": True})
done = await settled(run.id)
assert done.status == "failed", done.error
error = done.error or ""
assert querycore.DATA_VERSION_CONFLICT in error, error
assert name in error and s1[:12] in error and s2[:12] in error, error
async with SessionLocal() as session:
children = (await session.execute(select(Run).where(Run.origin.is_not(None)))).scalars().all()
assert [c for c in children if (c.origin or {}).get("parent_run_id") == done.id] == [], "冲突就不发起子运行"
async def test_two_calls_after_a_late_pin_read_the_same_version(client):
"""新发布的口径:第一次调用把源固定在父 Run 上,两次调用之间重传,第二次读的还是那一版(同一次运行不跨版)。"""
source_id, s1, name = await upload_source(UP_V1)
asset = await make_workflow(graph=upload_caliber_graph(name)) # 还没发布
args = {"workflow": asset["workflow_id"], "card": CARD, "metric_ids": ["total"], "inputs": {}}
graph = S.chain(
S.node("start", "input"),
S.node("gate1", "human", mode="approve", title="看一眼"),
S.node("one", "tool", tool=querycore.CALIBER_QUERY, args=dict(args)),
S.node("gate2", "human", mode="approve", title="再确认"),
S.node("two", "tool", tool=querycore.CALIBER_QUERY, args=dict(args)),
S.node("out", "output", fields=[{"name": "答", "value": "结束"}]),
)
run = await run_manager.start(graph=graph, input_payload={})
assert (await wait_for(run.id, ("interrupted",))).status == "interrupted"
await publish(client, asset) # 新发布:发起时算不到
await run_manager.resume(run.id, {"approved": True})
assert (await wait_for(run.id, ("interrupted",))).status == "interrupted"
_, s2, _ = await upload_source(UP_V2, name=name) # 两次调用之间重传
assert s2 != s1
await run_manager.resume(run.id, {"approved": True})
done = await settled(run.id)
assert done.status == "succeeded", done.error
assert done.data_versions[source_id]["snapshot"] == s1
assert len(await pin_events(done.id)) == 1, "第一次调用补固定一次,第二次复用"
first, second = await tool_result(done.id, "one"), await tool_result(done.id, "two")
assert first["ok"] and second["ok"], (first.get("problems"), second.get("problems"))
assert [first["metrics"][0]["value"], second["metrics"][0]["value"]] == [3, 3], \
"同一次运行里的两次调用读同一版数据"