mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 00:51:59 +00:00
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 通过。
679 lines
38 KiB
Python
679 lines
38 KiB
Python
"""K09 的进程内 QueryCore 固定执行与最小 API(app/ledger/querycore.py、POST /api/ledger/querycore)。
|
||
|
||
这份文件盯住这几条:
|
||
|
||
1. **版本只解析一次并固定。** 不点名读推荐版(Workflow.published_version 指的那一版)并固定到它;点名始终固定到点名的那一版。
|
||
2. **只按固定发布执行。** 必须读并核验 K07 的发布清单(读得出来、内容哈希对得上、就是这一版这一张图);编译器/绑定/
|
||
参数/解析器约定不兼容、清单错图/缺卡一律明确拒,**绝不按新编译器现场重解释旧发布**;老资产(没有清单)明确拒,
|
||
旧 trial 路径一个字不动。
|
||
3. **用 K08 闭包选指标、K01 参数语义。** 选 A 时 B 独有的取数节点与参数不进这次范围;A、B 共用一条 SQL 时那条 SQL 保留并由
|
||
computes_unselected 声明它还在算谁;缺参数/未声明参数在发 SQL 之前就拒(一条 SQL 都不发)。
|
||
4. **内部 LLM = 0、作者侧 SQL 编译 = 0、零运行期重绑定。** 探针图里没有模型节点;执行过的语句与这次计划渲染出来的一致、
|
||
参数输入哈希一致。
|
||
5. **类型化交回。** 大整数不转 float(精确整数原样);Decimal 过投影时换成精确文本并标成 decimal(接口把 Decimal 编码成
|
||
float 会丢精度);带时区的取值原样进语句、原样回交(不 shift、不重解释)。
|
||
6. **空数组与部分失败不冒充成功。** 没跑完(超时)回 running + run_id;没有值的指标、被遮罩的指标、没有快照的查询一律
|
||
ok=False。
|
||
7. **身份与用途由宿主定。** HTTP 入口固定 compose/探索,客户端传 purpose / role / actor 都不看;formal 只在宿主入口
|
||
(execute_formal),复用同一个 publications.judge,发布依据不齐就拒且**不换路**(不降级成探索、不建运行)。
|
||
|
||
数据全是合成的(一个临时 SQLite 库、7 条合成标量指标),不连真实业务库;这些指标不是任何真实业务口径。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import json
|
||
import sqlite3
|
||
import uuid
|
||
from decimal import Decimal
|
||
from typing import Any
|
||
|
||
import pytest
|
||
from httpx import ASGITransport, AsyncClient
|
||
from sqlalchemy import select
|
||
|
||
from app.apps import publications
|
||
from app.core.artifact_store import graph_hash
|
||
from app.db.base import SessionLocal
|
||
from app.db.models import DataSource, Run, Workflow, WorkflowVersion
|
||
from app.engine.runner import run_manager
|
||
from app.ledger import querycore
|
||
from app.main import app
|
||
from tests import ledger_samples as S
|
||
from tests.publication_samples import (add_record, add_version, confirm, make_workflow, publish, report_model)
|
||
|
||
#: 这一版口径卡的节点 id(源头口径卡)
|
||
CARD = "card"
|
||
#: 取数窗口:start(含)/ end(不含)
|
||
WINDOW = "AND paid_at >= '{{ input.start }}' AND paid_at < '{{ input.end }}'"
|
||
#: 合成库的行(status / region / amount / qty / paid_at)
|
||
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
|
||
WINDOW_UNITS = 7
|
||
EAST_SUM = 150.0
|
||
PEAK = 100.0
|
||
|
||
|
||
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 / "querycore.db"
|
||
build_db(path)
|
||
S.CURRENT["source"] = f"qc_{uuid.uuid4().hex[:8]}"
|
||
await S.set_source(path)
|
||
return S.CURRENT["source"]
|
||
|
||
|
||
def metrics() -> list[dict[str, Any]]:
|
||
"""7 条合成标量指标:三条共用 sales 那条 SQL,一条只读 region 那条(带 region 参数),两条共用 shared 那条。"""
|
||
return [
|
||
{"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": "label", "name": "合成标签", "unit": "",
|
||
"expression": "cell(nodes.sales, 0, 'label')"},
|
||
{"id": "units_exact", "name": "件数放大", "unit": "件",
|
||
"expression": "cell(nodes.sales, 0, 'units') * 1000000000000000001"},
|
||
{"id": "region_gmv", "name": "大区销售额", "unit": "元", "decimals": 2,
|
||
"expression": "cell(nodes.region, 0, 'gmv')"},
|
||
{"id": "total", "name": "订单总额", "unit": "元", "decimals": 2,
|
||
"expression": "cell(nodes.shared, 0, 'total')"},
|
||
{"id": "peak", "name": "最大单笔", "unit": "元", "decimals": 2,
|
||
"expression": "cell(nodes.shared, 0, 'peak')"},
|
||
]
|
||
|
||
|
||
#: 除了 region_gmv 之外的全部指标(选它就不用 region 那个参数)
|
||
WITHOUT_REGION = [m["id"] for m in metrics() if m["id"] != "region_gmv"]
|
||
ALL_METRICS = [m["id"] for m in metrics()]
|
||
|
||
|
||
def graph(*, sales_sql: str | None = None, on_missing: str | None = None, table_metric: bool = False) -> dict[str, Any]:
|
||
"""入口 → 三条取数 → 口径卡 → 报告 → 出口(报告节点只在原图里,探针图不跑它)。
|
||
|
||
on_missing="null" 时口径卡声明「缺输入按空值算」(engine/nodes/metrics 的 on_missing):这时跑完但某个指标没有值,
|
||
正是「跑成功不等于算成了」那一种。"""
|
||
card_extra = {"on_missing": on_missing} if on_missing else {}
|
||
made = metrics()
|
||
if table_metric:
|
||
# 声明成表的那一条:这一版只交标量,执行前就拒(不悄悄按第一行给个值)
|
||
next(m for m in made if m["id"] == "total")["contract"] = {"shape": "table"}
|
||
return S.chain(
|
||
S.node("start", "input", fields=[
|
||
{"name": "start", "type": "string", "required": True, "description": "开始日期(含)"},
|
||
{"name": "end", "type": "string", "required": True, "description": "结束日期(不含)"},
|
||
{"name": "region", "type": "string", "required": False, "description": "大区(只有大区口径用)"},
|
||
]),
|
||
S.node("sales", "tool", tool=S.tool(),
|
||
args={"sql": sales_sql or f"SELECT SUM(amount) AS gmv, SUM(qty) AS units, '合成标签' AS label "
|
||
f"FROM orders WHERE status = 'paid' {WINDOW}"}),
|
||
S.node("region", "tool", tool=S.tool(),
|
||
args={"sql": "SELECT SUM(amount) AS gmv FROM orders WHERE region = '{{ input.region }}' "
|
||
f"AND status = 'paid' {WINDOW}"}),
|
||
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=made, **card_extra),
|
||
S.node("write", "report", instructions="写周销售"),
|
||
S.node("out", "output", fields=[{"name": "报告", "value": "{{ nodes.write.text }}"}]))
|
||
|
||
|
||
def other_graph() -> dict[str, Any]:
|
||
"""换个窗口就换了取数(等价于「图上改了东西」),让 v2 有自己的版本行与清单。"""
|
||
return graph(sales_sql=f"SELECT SUM(amount) AS gmv, SUM(qty) AS units, '合成标签' AS label "
|
||
f"FROM orders WHERE status IN ('paid', 'open') {WINDOW}")
|
||
|
||
|
||
@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 run_once(asset: dict, *, metric_ids: list[str], inputs: dict[str, Any] | None = None, **kw):
|
||
"""走服务入口跑一次(HTTP 之外的那条路),等结果。asset 里带 version 就是点名那一版。"""
|
||
calls = dict(kw)
|
||
if "version" in asset:
|
||
calls.setdefault("version", asset["version"])
|
||
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 {"start": "2026-09-01", "end": "2026-10-01"}, **calls)
|
||
|
||
|
||
def values_of(outcome) -> dict[str, Any]:
|
||
return {m["id"]: m["value"] for m in outcome.as_dict()["metrics"]}
|
||
|
||
|
||
async def settled(run_id: str) -> Run:
|
||
"""等这次运行收尾(测试收尾不留挂着的任务)。"""
|
||
for _ in range(400):
|
||
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}")
|
||
|
||
|
||
def no_model(monkeypatch) -> None:
|
||
"""内部 LLM = 0:任何模型调用都让这次执行炸掉。"""
|
||
from app.providers import mock_model
|
||
|
||
def boom(self, messages):
|
||
raise AssertionError("QueryCore 不该调用模型")
|
||
|
||
monkeypatch.setattr(mock_model.MockChatModel, "_decide", boom)
|
||
|
||
|
||
# ==========================================================================
|
||
# 1、显式版本 / 推荐版:解析一次、当场固定
|
||
# ==========================================================================
|
||
|
||
|
||
async def test_explicit_and_recommended_versions_are_pinned_once(client):
|
||
asset = await make_workflow(graph=graph())
|
||
await publish(client, asset)
|
||
asset = await add_version(asset, other_graph(), version=2)
|
||
await publish(client, asset, version=2)
|
||
await publish(client, asset, version=1) # 旧版重新推上去成推荐版
|
||
|
||
follow = await run_once({"workflow_id": asset["workflow_id"]}, metric_ids=["gmv"],
|
||
inputs={"start": "2026-09-01", "end": "2026-10-01"})
|
||
assert follow.ok and follow.version == 1 and follow.explicit_version is False
|
||
assert follow.publication["version"] == 1 and follow.publication["manifest"]
|
||
# 推荐版那一版是 status='paid':v2 把 open 也算进去,值会不一样(如果解析错了会看出来)
|
||
assert follow.metrics[0]["value"] == WINDOW_SUM
|
||
|
||
named = await run_once({"workflow_id": asset["workflow_id"], "version": 2}, metric_ids=["gmv"],
|
||
inputs={"start": "2026-09-01", "end": "2026-10-01"})
|
||
assert named.ok and named.version == 2 and named.explicit_version is True
|
||
assert named.metrics[0]["value"] == WINDOW_SUM + 12.5
|
||
|
||
|
||
# ==========================================================================
|
||
# 2、K08 闭包:选 A 时 B 独有的取数和参数为 0;共享 SQL 保留并声明
|
||
# ==========================================================================
|
||
|
||
|
||
async def test_the_unselected_metrics_query_and_parameters_stay_out(client):
|
||
asset = await make_workflow(graph=graph())
|
||
await publish(client, asset)
|
||
outcome = await run_once(asset, metric_ids=WITHOUT_REGION,
|
||
inputs={"start": "2026-09-01", "end": "2026-10-01", "region": "east"})
|
||
assert outcome.ok
|
||
data = outcome.as_dict()
|
||
# region 那条查询不进这次范围
|
||
assert [q["node_id"] for q in data["queries"]] == ["sales", "shared"]
|
||
assert "region" not in json.dumps(data["queries"])
|
||
# region 这个参数这次不用:不带进执行(照实记在 ignored 里),也不在这次的参数契约里
|
||
assert data["parameters"]["values"] == {"start": "2026-09-01", "end": "2026-10-01"}
|
||
assert data["parameters"]["ignored"] == ["region"]
|
||
assert [c["name"] for c in data["parameters"]["contract"]] == ["start", "end"]
|
||
assert "region" not in json.dumps([q["parameters"] for q in data["parameters"]["queries"]])
|
||
assert set(data["selection"]["metric_ids"]) == set(WITHOUT_REGION)
|
||
assert "region_gmv" not in json.dumps(data["queries"])
|
||
for query in data["queries"]:
|
||
assert query["found"] and query["executed_hash"] and "executed_sql" not in query
|
||
|
||
|
||
async def test_a_shared_sql_stays_and_declares_who_else_it_computes(client):
|
||
asset = await make_workflow(graph=graph())
|
||
await publish(client, asset)
|
||
outcome = await run_once(asset, metric_ids=["total"])
|
||
assert outcome.ok and outcome.metrics[0]["value"] == WINDOW_SUM
|
||
[query] = outcome.as_dict()["queries"]
|
||
assert query["query_id"].endswith(":shared") and query["metric_ids"] == ["total"]
|
||
assert query["computes_unselected"] == ["peak"], "共享的那条 SQL 还在算未选中的指标,要如实声明"
|
||
# 共享 SQL 里的另一个指标不交回
|
||
assert "peak" not in [m["id"] for m in outcome.as_dict()["metrics"]]
|
||
|
||
|
||
async def test_missing_and_unknown_parameters_are_refused_before_any_sql(client):
|
||
asset = await make_workflow(graph=graph())
|
||
await publish(client, asset)
|
||
async with SessionLocal() as session:
|
||
with pytest.raises(querycore.QueryCoreRefused) as caught:
|
||
await querycore.execute(session, workflow_id=asset["workflow_id"], card_id=CARD,
|
||
metric_ids=["region_gmv"], inputs={"start": "2026-09-01", "end": "2026-10-01"})
|
||
assert caught.value.code == "missing_parameter" and caught.value.status_code == 422
|
||
assert caught.value.problems and caught.value.problems[0]["code"] == "missing_parameter"
|
||
async with SessionLocal() as session:
|
||
with pytest.raises(querycore.QueryCoreRefused) as unknown:
|
||
await querycore.execute(session, workflow_id=asset["workflow_id"], card_id=CARD,
|
||
metric_ids=["gmv"], inputs={"start": "2026-09-01", "end": "2026-10-01",
|
||
"store": "s1"})
|
||
assert unknown.value.code == querycore.UNKNOWN_INPUT and unknown.value.status_code == 422
|
||
# 两条都在发起运行之前拒:一条 SQL 都没发,也没有留下运行
|
||
async with SessionLocal() as session:
|
||
assert (await session.execute(select(Run).where(Run.workflow_id == asset["workflow_id"]))).scalars().all() == []
|
||
|
||
|
||
# ==========================================================================
|
||
# 3、零模型、零作者侧编译、零运行期重绑定
|
||
# ==========================================================================
|
||
|
||
|
||
async def test_no_model_and_no_author_side_compilation(client, monkeypatch):
|
||
no_model(monkeypatch)
|
||
|
||
def no_compile(*a, **kw):
|
||
raise AssertionError("QueryCore 不做作者侧 SQL 编译")
|
||
|
||
monkeypatch.setattr("app.data.rule_compile.compile_rule", no_compile)
|
||
asset = await make_workflow(graph=graph())
|
||
await publish(client, asset)
|
||
outcome = await run_once(asset, metric_ids=WITHOUT_REGION,
|
||
inputs={"start": "2026-09-01", "end": "2026-10-01"})
|
||
assert outcome.ok
|
||
async with SessionLocal() as session:
|
||
row = await session.get(Run, outcome.run_id)
|
||
kinds = {n["type"] for n in row.graph["nodes"]}
|
||
assert kinds <= {"input", "tool", "metrics", "output"}, f"探针图里不该有模型节点:{kinds}"
|
||
assert row.origin["service"] == "querycore" and row.origin["purpose"] == querycore.DEFAULT_PURPOSE
|
||
assert row.options.get("read_only_data") is True, "这次运行的数据源查询一律只读(READ_ONLY 边界)"
|
||
|
||
|
||
async def test_the_executed_sql_is_exactly_the_planned_one(client):
|
||
"""零运行期重绑定:执行过的语句与这次计划渲染出来的哈希一致、参数输入哈希一致;SQL 正文不外发。"""
|
||
asset = await make_workflow(graph=graph())
|
||
await publish(client, asset)
|
||
outcome = await run_once(asset, metric_ids=WITHOUT_REGION,
|
||
inputs={"start": "2026-09-01", "end": "2026-10-01"})
|
||
assert outcome.ok
|
||
data = outcome.as_dict()
|
||
assert all(q["rebound"] is None for q in data["queries"])
|
||
planned = {p["node_id"]: p for p in data["parameters"]["queries"]}
|
||
for query in data["queries"]:
|
||
plan = planned[query["node_id"]]
|
||
assert "executed_sql" not in query, "返回里不给 SQL 正文:要读正文走证据接口"
|
||
assert query["executed_hash"] == query["planned_statement_hash"] == plan["statement_hash"]
|
||
assert query["parameters"]["inputs_hash"] == plan["inputs_hash"] == query["planned_inputs_hash"]
|
||
assert plan["template_hash"] != plan["statement_hash"], "源模板哈希与执行语句哈希不是一回事"
|
||
# 这一整段(除快照自带的检查项)里没有 SQL 正文
|
||
shown = json.dumps([{k: v for k, v in q.items() if k not in ("checks", "no_data")} for q in data["queries"]],
|
||
ensure_ascii=False).lower()
|
||
assert "from orders" not in shown and "paid_at" not in shown and "{{" not in shown
|
||
|
||
|
||
# ==========================================================================
|
||
# 4、类型与时区:decimal / 大整数不转 float,带时区的取值原样进语句
|
||
# ==========================================================================
|
||
|
||
|
||
def test_typed_values_never_coerce_decimal_or_big_integers():
|
||
assert querycore.typed(Decimal("123.456")) == "decimal"
|
||
assert querycore.typed(2 ** 80) == "integer"
|
||
assert querycore.typed("123.456") == "decimal"
|
||
assert querycore.typed("000012") == "string"
|
||
assert querycore.typed(True) == "boolean"
|
||
assert querycore.typed(None) == "null"
|
||
assert querycore.typed(1.5) == "number"
|
||
|
||
|
||
def test_the_projection_keeps_decimal_and_big_integers_exact():
|
||
"""Decimal 与超长整数过投影时一个位数都不丢:Decimal 交回精确文本并标成 decimal(接口把 Decimal 编码成 float 会
|
||
丢精度),大整数原样是整数。整份投影还要能直接 json.dumps(不会有 Decimal 漏过去)。"""
|
||
base = {"id": "d", "name": "十进制", "unit": "元", "status": "ok", "rendered": "123.456", "decimals": 3}
|
||
decimal = querycore._typed_metric({**base, "value": Decimal("123.456")}, None)
|
||
assert decimal["value"] == "123.456" and decimal["value_type"] == "decimal"
|
||
assert not isinstance(decimal["value"], float)
|
||
big = querycore._typed_metric({**base, "value": 10 ** 25}, None)
|
||
assert big["value"] == 10 ** 25 and big["value_type"] == "integer" and isinstance(big["value"], int)
|
||
assert json.dumps([decimal, big], ensure_ascii=False)
|
||
assert querycore.exact(Decimal("0.10")) == "0.10" and querycore.exact(2 ** 70) == 2 ** 70
|
||
|
||
|
||
async def test_values_keep_their_exact_type_and_timezone(client):
|
||
asset = await make_workflow(graph=graph())
|
||
await publish(client, asset)
|
||
tz_end = "2026-10-01T00:00:00+08:00"
|
||
outcome = await run_once(asset, metric_ids=WITHOUT_REGION, inputs={"start": "2026-09-01", "end": tz_end})
|
||
assert outcome.ok
|
||
data = outcome.as_dict()
|
||
by_id = {m["id"]: m for m in data["metrics"]}
|
||
# 大整数:精确的整数,不是 float(不无条件转 float)
|
||
assert by_id["units_exact"]["value"] == WINDOW_UNITS * 1000000000000000001
|
||
assert by_id["units_exact"]["value_type"] == "integer"
|
||
assert isinstance(by_id["units_exact"]["value"], int)
|
||
# 整数与小数各自标成自己的类型;非数字文本照旧是文本
|
||
assert by_id["units"]["value_type"] == "integer" and isinstance(by_id["units"]["value"], int)
|
||
assert by_id["gmv"]["value_type"] == "number" and by_id["label"]["value"] == "合成标签"
|
||
assert by_id["label"]["value_type"] == "string"
|
||
# 带时区的取值原样进语句、原样回交(不 shift、不重解释):执行过的语句与计划一致(见上面那条),
|
||
# 参数那一份里记的就是原样的字符串
|
||
assert data["parameters"]["values"]["end"] == tz_end
|
||
assert [p["parameters"] for p in data["parameters"]["queries"]][0][0]["name"] == "start"
|
||
assert data["data"]["window"]["end"] == tz_end and data["data"]["as_of"]
|
||
|
||
|
||
# ==========================================================================
|
||
# 5、伪造版本 / 过期清单 / 不兼容编译器 / 老资产
|
||
# ==========================================================================
|
||
|
||
|
||
async def test_a_forged_version_is_refused(client):
|
||
asset = await make_workflow(graph=graph())
|
||
await publish(client, asset)
|
||
async with SessionLocal() as session:
|
||
with pytest.raises(querycore.QueryCoreRefused) as caught:
|
||
await querycore.execute(session, workflow_id=asset["workflow_id"], card_id=CARD, metric_ids=["gmv"],
|
||
version=99, inputs={"start": "2026-09-01", "end": "2026-10-01"})
|
||
assert caught.value.code == querycore.VERSION_MISSING and caught.value.status_code == 404
|
||
async with SessionLocal() as session:
|
||
assert (await session.execute(select(Run).where(Run.workflow_id == asset["workflow_id"]))).scalars().all() == []
|
||
|
||
|
||
async def test_a_card_outside_the_version_and_an_empty_selection_are_refused(client):
|
||
asset = await make_workflow(graph=graph())
|
||
await publish(client, asset)
|
||
async with SessionLocal() as session:
|
||
with pytest.raises(querycore.QueryCoreRefused) as missing:
|
||
await querycore.execute(session, workflow_id=asset["workflow_id"], card_id="nope", metric_ids=["gmv"],
|
||
inputs={"start": "2026-09-01", "end": "2026-10-01"})
|
||
with pytest.raises(querycore.QueryCoreRefused) as empty:
|
||
await querycore.execute(session, workflow_id=asset["workflow_id"], card_id=CARD, metric_ids=[],
|
||
inputs={"start": "2026-09-01", "end": "2026-10-01"})
|
||
assert missing.value.code == querycore.CARD_MISSING and missing.value.status_code == 409
|
||
assert empty.value.code == querycore.SELECTION_REFUSED and empty.value.status_code == 422
|
||
async with SessionLocal() as session:
|
||
assert (await session.execute(select(Run).where(Run.workflow_id == asset["workflow_id"]))).scalars().all() == []
|
||
|
||
|
||
async def test_a_table_shaped_metric_is_refused_in_this_version(client):
|
||
"""首版只交标量:声明成表的指标在发 SQL 之前就拒,不悄悄按第一行给个值。"""
|
||
asset = await make_workflow(graph=graph(table_metric=True))
|
||
await publish(client, asset)
|
||
async with SessionLocal() as session:
|
||
with pytest.raises(querycore.QueryCoreRefused) as caught:
|
||
await querycore.execute(session, workflow_id=asset["workflow_id"], card_id=CARD, metric_ids=["total"],
|
||
inputs={"start": "2026-09-01", "end": "2026-10-01"})
|
||
assert caught.value.code == querycore.OUTPUT_UNSUPPORTED and caught.value.status_code == 422
|
||
async with SessionLocal() as session:
|
||
assert (await session.execute(select(Run).where(Run.workflow_id == asset["workflow_id"]))).scalars().all() == []
|
||
|
||
|
||
async def test_a_manifest_for_another_graph_is_refused(client):
|
||
asset = await make_workflow(graph=graph())
|
||
await publish(client, asset)
|
||
async with SessionLocal() as session:
|
||
row = (await session.execute(select(WorkflowVersion).where(
|
||
WorkflowVersion.workflow_id == asset["workflow_id"], WorkflowVersion.version == 1))).scalar_one()
|
||
row.graph = other_graph() # 同一版号、换了图
|
||
await session.commit()
|
||
async with SessionLocal() as session:
|
||
with pytest.raises(querycore.QueryCoreRefused) as caught:
|
||
await querycore.execute(session, workflow_id=asset["workflow_id"], card_id=CARD, metric_ids=["gmv"],
|
||
inputs={"start": "2026-09-01", "end": "2026-10-01"})
|
||
assert caught.value.code == publications.BASIS_MISMATCH
|
||
assert caught.value.problems and "图指纹" in caught.value.problems[0]["message"]
|
||
|
||
|
||
async def test_an_incompatible_compiler_is_refused_without_reinterpreting(client, monkeypatch):
|
||
asset = await make_workflow(graph=graph())
|
||
await publish(client, asset)
|
||
real = publications.compiler_versions
|
||
monkeypatch.setattr(publications, "compiler_versions",
|
||
lambda: {**real(), "binding": {"binder": "controlled_template", "version": 99}})
|
||
async with SessionLocal() as session:
|
||
with pytest.raises(querycore.QueryCoreRefused) as caught:
|
||
await querycore.execute(session, workflow_id=asset["workflow_id"], card_id=CARD, metric_ids=["gmv"],
|
||
inputs={"start": "2026-09-01", "end": "2026-10-01"})
|
||
assert caught.value.code == querycore.INCOMPATIBLE
|
||
assert caught.value.problems and caught.value.problems[0]["key"] == "binding"
|
||
assert "不按新规则重解释旧发布" in caught.value.message
|
||
|
||
|
||
def test_the_compiler_seam_rejects_an_old_manifest_without_a_resolver():
|
||
"""加 resolver 这一项之前物化的清单(缺 resolver):认不出它按哪一版闭包解析,明确拒。"""
|
||
content = {"format": publications.FORMAT, "compiler": {k: v for k, v in publications.compiler_versions().items()
|
||
if k != "resolver"}}
|
||
problems = publications.compiler_problems(content)
|
||
assert [p["key"] for p in problems] == ["resolver"] and problems[0]["code"] == publications.COMPILER_MISMATCH
|
||
# 现在这一套自己写出来的清单一项都不差;没有 compiler 这一段的清单另给一个码
|
||
assert publications.compiler_problems({"compiler": publications.compiler_versions()}) == []
|
||
assert publications.compiler_problems({"compiler": None})[0]["code"] == publications.COMPILER_MISSING
|
||
# 多出一项现在认不出的约定一样算不兼容(不静默放行)
|
||
extra = {**publications.compiler_versions(), "future_thing": 1}
|
||
assert [p["key"] for p in publications.compiler_problems({"compiler": extra})] == ["future_thing"]
|
||
|
||
|
||
async def test_a_legacy_asset_without_a_manifest_is_refused(client):
|
||
"""老资产(这一版没有发布事件):QueryCore 明确拒;旧 trial 路径一个字不动。"""
|
||
workflow_id = await S.publish(graph=graph()) # 直接写库(老写法,不经过发布入口)
|
||
async with SessionLocal() as session:
|
||
with pytest.raises(querycore.QueryCoreRefused) as caught:
|
||
await querycore.execute(session, workflow_id=workflow_id, card_id=CARD, metric_ids=["gmv"],
|
||
inputs={"start": "2026-09-01", "end": "2026-10-01"})
|
||
assert caught.value.code == querycore.MANIFEST_REQUIRED and caught.value.status_code == 409
|
||
# 老试跑照旧能跑(试跑只认这一版发布过)
|
||
res = await client.post(f"/api/ledger/calibers/{workflow_id}/1/{CARD}/trial",
|
||
json={"inputs": {"start": "2026-09-01", "end": "2026-10-01", "region": "east"}})
|
||
assert res.status_code == 200, res.text
|
||
assert res.json()["status"] in ("succeeded", "running", "failed")
|
||
|
||
|
||
# ==========================================================================
|
||
# 6、formal:只在宿主入口、复用同一个 judge、发布依据不齐不换路
|
||
# ==========================================================================
|
||
|
||
|
||
async def test_the_formal_host_entry_reuses_the_judge_and_never_switches_path(client):
|
||
asset = await make_workflow(graph=graph())
|
||
await publish(client, asset) # 有清单、没有验证记录与审阅
|
||
async with SessionLocal() as session:
|
||
with pytest.raises(querycore.QueryCoreRefused) as caught:
|
||
await querycore.execute_formal(session, workflow_id=asset["workflow_id"], card_id=CARD, metric_ids=["gmv"],
|
||
inputs={"start": "2026-09-01", "end": "2026-10-01"}, actor="宿主")
|
||
assert caught.value.code == publications.RECORD_MISSING, "formal 用的是 publications.judge 的原码"
|
||
assert caught.value.status_code == 409
|
||
# 不换路:不降级成探索执行,也不留下任何运行
|
||
async with SessionLocal() as session:
|
||
assert (await session.execute(select(Run).where(Run.workflow_id == asset["workflow_id"]))).scalars().all() == []
|
||
|
||
record_id = await add_record(asset)
|
||
await confirm(asset, record_id)
|
||
await publish(client, asset) # 依据齐了之后重新发布
|
||
async with SessionLocal() as session:
|
||
outcome = await querycore.execute_formal(session, workflow_id=asset["workflow_id"], card_id=CARD,
|
||
metric_ids=["gmv"],
|
||
inputs={"start": "2026-09-01", "end": "2026-10-01"}, actor="宿主")
|
||
assert outcome.ok and outcome.purpose == publications.PURPOSE_FORMAL
|
||
assert outcome.publication["identity"] == publications.PROVEN
|
||
assert outcome.publication["requires_proof"] is True
|
||
async with SessionLocal() as session:
|
||
run = await session.get(Run, outcome.run_id)
|
||
assert run.origin["purpose"] == publications.PURPOSE_FORMAL
|
||
assert run.origin["identity"] == publications.PROVEN and run.started_by == "宿主"
|
||
|
||
|
||
async def test_the_interactive_entry_is_exploratory_and_cannot_be_escalated(client):
|
||
"""默认交互入口是 compose/探索:客户端传 purpose / role / actor 一律不看,也拿不到正式身份。"""
|
||
asset = await make_workflow(graph=graph())
|
||
await publish(client, asset) # 有清单、没有验证记录(formal 会拒)
|
||
res = await client.post("/api/ledger/querycore", json={
|
||
"workflow_id": asset["workflow_id"], "card_id": CARD, "metric_ids": ["gmv"],
|
||
"inputs": {"start": "2026-09-01", "end": "2026-10-01"},
|
||
"purpose": "formal", "role": "admin", "actor": "冒充的人", "version": 1})
|
||
assert res.status_code == 200, res.text
|
||
body = res.json()
|
||
assert body["ok"] and body["purpose"] == publications.PURPOSE_COMPOSE
|
||
async with SessionLocal() as session:
|
||
run = await session.get(Run, body["run_id"])
|
||
assert run.origin["purpose"] == publications.PURPOSE_COMPOSE, "用途由宿主常量定,客户端改不了"
|
||
assert run.origin["identity"] == publications.UNPROVEN and run.run_class == "exploratory"
|
||
assert run.started_by is None, "认证主体来自服务端,请求体里的 actor 不看"
|
||
|
||
|
||
async def test_a_role_refusal_does_not_switch_path(client):
|
||
"""权限失败不换路:这个入口只给搭建者及以上(路由级依赖)。使用者被拒时不降级、不换别的路、也不留下运行。"""
|
||
from tests.fakes import auth_env
|
||
|
||
asset = await make_workflow(graph=graph())
|
||
await publish(client, asset)
|
||
try:
|
||
await auth_env.turn_on_local()
|
||
await auth_env.make_user("uma", "user")
|
||
uma = await auth_env.login("uma")
|
||
res = await uma.post("/api/ledger/querycore", json={"workflow_id": asset["workflow_id"], "card_id": CARD,
|
||
"metric_ids": ["gmv"],
|
||
"inputs": {"start": "2026-09-01", "end": "2026-10-01"}})
|
||
assert res.status_code == 403 and res.json()["code"] == "role_required"
|
||
finally:
|
||
await auth_env.reset()
|
||
async with SessionLocal() as session:
|
||
assert (await session.execute(select(Run).where(Run.workflow_id == asset["workflow_id"]))).scalars().all() == []
|
||
|
||
|
||
# ==========================================================================
|
||
# 7、超时:回 running + run_id,绝不冒充成功
|
||
# ==========================================================================
|
||
|
||
|
||
async def test_a_timeout_returns_running_and_never_success(client):
|
||
asset = await make_workflow(graph=graph())
|
||
await publish(client, asset)
|
||
outcome = await run_once(asset, metric_ids=["gmv"], timeout_s=0.0,
|
||
inputs={"start": "2026-09-01", "end": "2026-10-01"})
|
||
assert outcome.status == "running" and outcome.ok is False and outcome.run_id
|
||
assert [p["code"] for p in outcome.problems] == [querycore.TIMEOUT]
|
||
assert outcome.metrics == () and outcome.queries == ()
|
||
assert outcome.quality["execution"] == "running" and outcome.quality["status"] != "pass"
|
||
# 运行照旧跑完(超时只影响这次接口怎么回,不影响运行本身)
|
||
assert (await settled(outcome.run_id)).status == "succeeded"
|
||
# HTTP 那条路同样:回 running + run_id,绝不成功
|
||
res = await client.post("/api/ledger/querycore", json={
|
||
"workflow_id": asset["workflow_id"], "card_id": CARD, "metric_ids": ["gmv"],
|
||
"inputs": {"start": "2026-09-01", "end": "2026-10-01"}, "budget": {"timeout_s": 0.0}})
|
||
assert res.status_code == 200 and res.json()["status"] == "running" and res.json()["ok"] is False
|
||
assert res.json()["problems"][0]["code"] == querycore.TIMEOUT
|
||
|
||
|
||
# ==========================================================================
|
||
# 8、空结果 / 只有一部分指标有值:都不是成功
|
||
# ==========================================================================
|
||
|
||
|
||
async def test_an_empty_window_is_not_a_success(client):
|
||
"""窗口里没有数据(SUM 全空):这次执行明确失败,ok=False,一件指标都不交回——空结果不冒充成功。"""
|
||
asset = await make_workflow(graph=graph())
|
||
await publish(client, asset)
|
||
outcome = await run_once(asset, metric_ids=WITHOUT_REGION,
|
||
inputs={"start": "2030-01-01", "end": "2030-02-01"})
|
||
assert outcome.status == "failed" and outcome.ok is False
|
||
assert [p["code"] for p in outcome.problems] == [querycore.EXECUTION]
|
||
assert outcome.metrics == () and outcome.queries == ()
|
||
assert outcome.quality["status"] == "fail" and outcome.quality["execution"] == "failed"
|
||
|
||
|
||
async def test_a_metric_without_a_value_is_not_a_success(client):
|
||
"""声明了「缺输入按空值算」的口径:跑完了,但指标没有值——跑成功不等于算成了,照实 ok=False。"""
|
||
asset = await make_workflow(graph=graph(on_missing="null"))
|
||
await publish(client, asset)
|
||
outcome = await run_once(asset, metric_ids=WITHOUT_REGION,
|
||
inputs={"start": "2030-01-01", "end": "2030-02-01"})
|
||
assert outcome.status == "succeeded" and outcome.ok is False
|
||
codes = [p["code"] for p in outcome.problems]
|
||
# 6 条里 5 条没有值(聚合全空);label 那一格是 SELECT 里的字面量,照常有一行,所以它有值
|
||
no_value = [m["id"] for m in outcome.metrics if m["value"] is None]
|
||
assert codes.count(querycore.METRIC_NO_VALUE) == len(no_value) == len(WITHOUT_REGION) - 1
|
||
assert {m["id"]: m["value"] for m in outcome.metrics}["label"] == "合成标签"
|
||
assert outcome.quality["status"] == "fail"
|
||
|
||
|
||
async def test_a_budget_smaller_than_the_plan_is_refused(client):
|
||
asset = await make_workflow(graph=graph())
|
||
await publish(client, asset)
|
||
async with SessionLocal() as session:
|
||
with pytest.raises(querycore.QueryCoreRefused) as caught:
|
||
await querycore.execute(session, workflow_id=asset["workflow_id"], card_id=CARD,
|
||
metric_ids=WITHOUT_REGION, budget={"max_queries": 1},
|
||
inputs={"start": "2026-09-01", "end": "2026-10-01"})
|
||
assert caught.value.code == querycore.BUDGET_REFUSED and caught.value.status_code == 429
|
||
async with SessionLocal() as session:
|
||
assert (await session.execute(select(Run).where(Run.workflow_id == asset["workflow_id"]))).scalars().all() == []
|
||
|
||
|
||
async def test_a_masked_metric_is_not_handed_out_as_a_value(client, tmp_path):
|
||
"""数据源设了遮罩列:被遮的指标不给值、ok=False——不把遮罩值当结果交出(同试跑与证据接口的分寸)。"""
|
||
asset = await make_workflow(graph=graph())
|
||
await publish(client, asset)
|
||
await S.set_source(tmp_path / "querycore.db", options={"mask_columns": ["gmv"]})
|
||
outcome = await run_once(asset, metric_ids=WITHOUT_REGION,
|
||
inputs={"start": "2026-09-01", "end": "2026-10-01"})
|
||
by_id = {m["id"]: m for m in outcome.as_dict()["metrics"]}
|
||
assert by_id["gmv"].get("masked") is True and by_id["gmv"]["value"] is None
|
||
assert outcome.ok is False and querycore.METRIC_MASKED in [p["code"] for p in outcome.problems]
|
||
|
||
|
||
# ==========================================================================
|
||
# 9、证据关联:每条查询都指得到工件、来源与 attempt;指标出自这次运行的封存证据
|
||
# ==========================================================================
|
||
|
||
|
||
async def test_every_query_points_at_its_artifact_and_source(client):
|
||
asset = await make_workflow(graph=graph())
|
||
await publish(client, asset)
|
||
outcome = await run_once(asset, metric_ids=WITHOUT_REGION,
|
||
inputs={"start": "2026-09-01", "end": "2026-10-01"})
|
||
assert outcome.ok
|
||
from app.core.artifact_store import load
|
||
|
||
for query in outcome.as_dict()["queries"]:
|
||
assert query["found"] is True and query["artifact"]
|
||
snap = load(query["artifact"])
|
||
assert snap["sql"] and query["executed_hash"] and "executed_sql" not in query
|
||
assert query["data_source"] == S.CURRENT["source"]
|
||
assert query["attempt"] is not None and query["attempt"] >= 1
|
||
assert query["columns"] and query["row_count"] == 1 and query["truncated"] is False
|
||
assert query["source"]["workflow_id"] == asset["workflow_id"] and query["source"]["version"] == 1
|
||
assert query["source"]["node_id"] in ("sales", "shared")
|
||
assert query["tables"] == ["orders"]
|
||
assert {m["id"] for m in outcome.metrics} == set(WITHOUT_REGION)
|
||
assert outcome.data["sources"] == [S.CURRENT["source"]] and outcome.data["tables"] == ["orders"]
|
||
assert outcome.data["read"] is True and outcome.quality["status"] in ("pass", "unknown")
|
||
|
||
|
||
async def test_all_seven_synthetic_scalars_come_back_typed(client):
|
||
"""一次全覆盖:7 条合成标量指标都回来(金额/件数/文本/放大/大区/总额/最大单笔),不是真实业务指标。"""
|
||
asset = await make_workflow(graph=graph())
|
||
await publish(client, asset)
|
||
outcome = await run_once(asset, metric_ids=ALL_METRICS,
|
||
inputs={"start": "2026-09-01", "end": "2026-10-01", "region": "east"})
|
||
assert outcome.ok
|
||
got = values_of(outcome)
|
||
assert got["gmv"] == WINDOW_SUM and got["units"] == WINDOW_UNITS
|
||
assert got["region_gmv"] == EAST_SUM and got["total"] == WINDOW_SUM and got["peak"] == PEAK
|
||
assert got["label"] == "合成标签"
|
||
assert got["units_exact"] == WINDOW_UNITS * 1000000000000000001
|
||
assert len(outcome.metrics) == 7
|
||
# 大区和总额两条不同的查询各留下自己的工件与来源
|
||
assert {q["node_id"] for q in outcome.as_dict()["queries"]} == {"sales", "region", "shared"}
|
||
assert len({q["artifact"] for q in outcome.as_dict()["queries"]}) == 3
|