mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 02:31:58 +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 通过。
634 lines
39 KiB
Python
634 lines
39 KiB
Python
"""SQL 模板参数完整性(开工任务书 v3 · K01,§4.4):ledger/params 一套语义,各入口同一结果。
|
||
|
||
- 认模板里的参数(引号里的字 / 裸写 / 注释里)、未声明的字段、缺值的字段(含声明成可选、又没有缺省值的);写在表名列名
|
||
里、认不出写法的照实拒(单元一层,函数返回机器码);
|
||
- 组合式问数拷进来的取数节点:值在拷的时候渲染进 SQL(只渲染这一次),节点上写一份与**这条渲染好的 args** 绑定的
|
||
参数契约:跑一次真实的问数运行,断言**实际发给数据库的那条 SQL** 就带着值里的 {{ }} 文本(字面量,不再被解释);
|
||
- 契约与它注解的语句绑定哈希:复制走、改过 SQL 之后契约作废,那个节点回退成普通模板(正常渲染、正常判参数,带 {{ }}
|
||
的语句绝不原样发出去);
|
||
- 派生图(口径探针)同样:入口参数值里的 {{ }} 是字面量,各入口同值同语义;
|
||
- 缺参数(声明成可选、没有缺省值;开放入口里显式传了空串)、未声明参数:**零 SQL**——数一下 run_query 被调了几次,就是 0;
|
||
显式空串算没给,作者声明的 `default=""` 才算声明式省略(两条都有真实运行反例);
|
||
- 查询快照记源模板哈希、规范化输入哈希、参数契约与写在哪儿,承载方式照实写 inline_text(不假装驱动绑定);
|
||
- 对账图里「这次的取数」放的是执行过的语句:运行时的模板渲染不再碰它。
|
||
|
||
数据是虚构店铺库(tests/ledger_samples.py)。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import hashlib
|
||
|
||
import pytest
|
||
from sqlalchemy import select
|
||
|
||
from app.db.base import SessionLocal
|
||
from app.db.models import Artifact
|
||
from app.engine.runner import run_manager
|
||
from app.ledger import compose, derive, params
|
||
from app.ledger import match as ledger_match
|
||
from app.look.calibers import CaliberKind
|
||
from tests import ledger_samples as S
|
||
|
||
TEMPLATE = S.TEMPLATE
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
async def engine_up(tmp_path):
|
||
await run_manager.setup()
|
||
await S.fresh_source(tmp_path / "shop.db")
|
||
yield
|
||
await ledger_match.drain()
|
||
await run_manager.shutdown()
|
||
|
||
|
||
@pytest.fixture
|
||
def executed(monkeypatch) -> list[str]:
|
||
"""这一次测试里真正发给数据库的语句(内联渲染之后的那一条),按先后。"""
|
||
from app.tools import datasource
|
||
|
||
seen: list[str] = []
|
||
real = datasource.run_query
|
||
|
||
async def spy(source, sql, **kw):
|
||
seen.append(sql)
|
||
return await real(source, sql, **kw)
|
||
|
||
monkeypatch.setattr(datasource, "run_query", spy)
|
||
return seen
|
||
|
||
|
||
async def query_snapshots(run_id: str) -> list[dict]:
|
||
"""这次运行落下的查询快照内容(按工件 id)。"""
|
||
from app.core.artifact_store import load
|
||
from app.data.catalog import QUERY_SNAPSHOT_KIND
|
||
|
||
async with SessionLocal() as session:
|
||
rows = (await session.execute(select(Artifact.id).where(
|
||
Artifact.run_id == run_id, Artifact.kind == QUERY_SNAPSHOT_KIND))).scalars().all()
|
||
return [payload for payload in (load(rid) for rid in rows) if isinstance(payload, dict)]
|
||
|
||
|
||
# ==========================================================================
|
||
# 认得模板里的参数(单元一层:机器码 + 写在哪儿)
|
||
# ==========================================================================
|
||
|
||
|
||
def test_parameters_are_read_where_they_are_written():
|
||
slots = params.slots_of("SELECT * FROM t WHERE d >= '{{ input.start }}' AND n > {{ input.n }} "
|
||
"AND r = '{{ input.region | trim }}' -- {{ input.note }}")
|
||
assert [(s.field, s.context, s.filters) for s in slots] == [
|
||
("start", "quoted", ()), ("n", "bare", ()), ("region", "quoted", ("trim",)), ("note", "comment", ())]
|
||
|
||
|
||
@pytest.mark.parametrize(("sql", "words"), [
|
||
('SELECT * FROM "{{ input.table }}"', "表名或列名"),
|
||
("SELECT {{ vars.pick }} FROM t", "运行时才有的变量"),
|
||
("SELECT {{ file_text(input.f) }} FROM t", "附件"),
|
||
("SELECT * FROM t WHERE a = '{{ input.x | upper }}'", "upper"),
|
||
("SELECT {{ input }} FROM t", "认不出"),
|
||
])
|
||
def test_unreadable_parameter_writings_are_refused(sql, words):
|
||
problems = params.check_template(sql, fields={"x": {"name": "x"}}, values={"x": "v"})
|
||
assert [p.code for p in problems] == [params.UNSUPPORTED] and words in problems[0].message
|
||
|
||
|
||
def test_undeclared_and_missing_are_two_different_findings():
|
||
fields = {"start": {"name": "start", "required": True}}
|
||
problems = params.check_template("SELECT '{{ input.start }}', '{{ input.nope }}'", fields=fields, values={},
|
||
where="口径「周销售」的入口")
|
||
assert [(p.code, p.name) for p in problems] == [(params.MISSING, "start"), (params.UNDECLARED, "nope")]
|
||
assert "(undeclared_parameter)" in problems[1].message and "nope" in problems[1].message
|
||
assert "不能当空串用(missing_parameter)" in problems[0].message
|
||
# 模板和声明对得上(还没到判值的那一步):读一版口径时只判这一层
|
||
assert params.check_template("SELECT '{{ input.start }}'", fields=fields) == []
|
||
|
||
|
||
def test_an_optional_parameter_without_a_default_is_missing_not_empty():
|
||
fields = {"store": {"name": "store"}}
|
||
[problem] = params.check_template("SELECT * FROM t WHERE s = '{{ input.store }}'", fields=fields, values={})
|
||
assert problem.code == params.MISSING and problem.name == "store"
|
||
# 声明了缺省值(含 `""`:作者写的就是空值不过滤)就不算缺
|
||
assert params.check_template("SELECT * FROM t WHERE s = '{{ input.store }}'",
|
||
fields={"store": {"name": "store", "default": ""}}, values={}) == []
|
||
assert params.check_template("SELECT * FROM t WHERE s = '{{ input.store }}'",
|
||
fields={"store": {"name": "store", "default": "all"}}, values={}) == []
|
||
|
||
|
||
@pytest.mark.parametrize(("sql", "value", "code", "words"), [
|
||
("SELECT * FROM t WHERE s = '{{ input.x }}'", "O'Brien", params.LITERAL, "值里不能有引号"),
|
||
("SELECT * FROM t WHERE s = '{{ input.x }}'", "a\\b", params.LITERAL, "反斜杠"),
|
||
("SELECT * FROM t WHERE n > {{ input.x }}", "1 OR 1=1", params.LITERAL, "只能是数"),
|
||
("SELECT * FROM t -- {{ input.x }}", "*/ DROP TABLE t", params.LITERAL, "注释"),
|
||
])
|
||
def test_values_the_inline_adapter_cannot_carry_are_refused(sql, value, code, words):
|
||
fields = {"x": {"name": "x"}}
|
||
[problem] = params.check_template(sql, fields=fields, values={"x": value})
|
||
assert problem.code == code and words in problem.message
|
||
|
||
|
||
def test_one_field_with_two_slots_reports_one_problem():
|
||
"""同一个字段写了两处槽位(引号里一处、裸写一处):承载不了只报一条,不重复报。"""
|
||
[problem] = params.check_template("SELECT '{{ input.x }}' AS a, {{ input.x }} AS b",
|
||
fields={"x": {"name": "x"}}, values={"x": "O'Brien"})
|
||
assert (problem.code, problem.name) == (params.LITERAL, "x")
|
||
|
||
|
||
@pytest.mark.parametrize(("sql", "fields", "dynamic"), [
|
||
("SELECT '{{ input.store }}'", ("store",), ()),
|
||
("SELECT '{{ input['store'] }}'", ("store",), ()),
|
||
('SELECT {{ input["store"] }}', ("store",), ()),
|
||
("SELECT {{ input[0] }}", ("0",), ()),
|
||
("SELECT {{ input.rows[0].name }}", ("rows",), ()),
|
||
("SELECT '{{ input['store'] | trim }}'", ("store",), ()),
|
||
("SELECT {{ file_text(input['附件']) }}", ("附件",), ()),
|
||
("SELECT {{ input }}", (), ("input",)),
|
||
("SELECT '{{ input['a.b'] }}'", (), ("input['a.b']",)),
|
||
("SELECT {{ input[a[b]] }}", (), ("input[a[b]]",)),
|
||
("SELECT {{ input x }}", (), ("input x",)),
|
||
("SELECT {{ inputs.x }}, {{ input_value }}", (), ()),
|
||
("SELECT {{ vars.region }}, {{ nodes.q.text }}", (), ()),
|
||
])
|
||
def test_input_references_follow_the_same_path_walk_as_resolve_path(sql, fields, dynamic):
|
||
"""运行时认的入口引用和 engine/expressions.resolve_path 一个走法:点号与合法下标都算字段引用(取第一层),
|
||
认不出的写法(会渲染成空串)单独交出来——vars./nodes. 不归这一趟管。"""
|
||
assert params.input_refs(sql) == fields
|
||
assert params.dynamic_input_writings(sql) == dynamic
|
||
assert params.mentions_input(sql) is bool(fields or dynamic)
|
||
|
||
|
||
def test_declared_types_are_normalized_without_looking_at_the_value():
|
||
assert params.field_type({"type": "integer"}) == params.field_type({"type": "int"}) == "integer"
|
||
assert params.field_type({}) == params.field_type({"type": "nonsense"}) == "string"
|
||
assert params.value_kind(3) == "number" and params.value_kind("3") == "text" and params.value_kind(None) == "null"
|
||
|
||
|
||
# ==========================================================================
|
||
# 组合式问数:值只渲染一次(E03)
|
||
# ==========================================================================
|
||
|
||
|
||
async def composed_answer_graph(workflow_id: str, inputs: dict) -> dict:
|
||
"""把口径拷进问数据的图(同 tests/test_ledger_compose.py 的做法,这里直接走 compose):args 是字面语句 + 参数契约。"""
|
||
from app.ledger import compose
|
||
|
||
async with SessionLocal() as session:
|
||
kind = await CaliberKind.load(session, compose=True)
|
||
made = compose.Composer(list(kind.options)).compose(
|
||
{"op": "use_caliber", "id": "c1", "workflow_id": workflow_id, "version": 1, "metrics": ["gmv"],
|
||
"inputs": inputs}, taken=set())
|
||
return S.chain(S.node("start", "input", fields=[{"name": "question"}]), *made.nodes,
|
||
S.node("write", "report", instructions="写周销售"),
|
||
S.node("out", "output", fields=[{"name": "报告", "value": "{{ nodes.write.text }}"}]))
|
||
|
||
|
||
async def test_a_value_that_looks_like_a_template_stays_literal_in_the_executed_sql(monkeypatch, executed):
|
||
"""E03:参数值里的 {{ }} 是数据,不被二次解释。
|
||
|
||
这里挑的是**在答数图里取得出值**的那一种写法({{ vars.question }} 指向入口字段):万一运行时又渲染一遍,值会被换成
|
||
问题的原文,SQL 就被数据改掉了——正是这条反例要拦住的那条路。"""
|
||
wf = await S.publish()
|
||
graph = await composed_answer_graph(wf, {"start": "{{ vars.question }}", "end": "2026-09-08"})
|
||
S.script(monkeypatch, "本周销售额 [[m:gmv]]。")
|
||
run = await run_manager.start(graph=graph, input_payload={"question": "九月第一周卖了多少"}, origin_kind="chat")
|
||
row = await S.wait(run.id)
|
||
assert row.status == "succeeded", row.error
|
||
[sql] = executed
|
||
assert sql == TEMPLATE.replace("{{ input.start }}", "{{ vars.question }}").replace("{{ input.end }}", "2026-09-08")
|
||
assert "'{{ vars.question }}'" in sql and "九月第一周卖了多少" not in sql
|
||
|
||
|
||
async def test_a_value_naming_a_variable_that_does_not_exist_runs_as_a_literal(monkeypatch, executed):
|
||
"""反例的另一半:值里写的是图里根本没有的变量({{ vars.region }})——照样按字面量跑,不判成「没人产出的引用」。
|
||
|
||
画布上那条变量校验(engine/variables)会把节点配置里带 {{ }} 的字当成活引用;组合式问数拷进来的取数节点是服务端
|
||
渲染好的**数据**,它不该被那条规则拦下——否则「值里恰好有几个花括号」就等于这张图跑不了。"""
|
||
wf = await S.publish()
|
||
graph = await composed_answer_graph(wf, {"start": "{{ vars.region }}", "end": "2026-09-08"})
|
||
S.script(monkeypatch, "本周销售额 [[m:gmv]]。")
|
||
run = await run_manager.start(graph=graph, input_payload={"question": "九月第一周卖了多少"}, origin_kind="chat")
|
||
row = await S.wait(run.id)
|
||
assert row.status == "succeeded", row.error
|
||
[sql] = executed
|
||
assert "'{{ vars.region }}'" in sql and "'2026-09-08'" in sql
|
||
|
||
|
||
async def test_a_copied_node_edited_back_into_a_template_renders_normally(monkeypatch, executed):
|
||
"""复制一个拷进来的取数节点、把 SQL 改回普通模板:契约作废,回退正常渲染——只渲染一次,不发带 {{ }} 的语句。"""
|
||
wf = await S.publish()
|
||
graph = await composed_answer_graph(wf, {"start": "2026-09-01", "end": "2026-09-08"})
|
||
fetch = next(n for n in graph["nodes"] if n["id"] == "c1_fetch")
|
||
assert params.literal_keys(fetch["data"]["config"], "tool") == ("args",)
|
||
fetch["data"]["config"]["args"]["sql"] += " AND '{{ input.question }}' <> ''"
|
||
assert params.literal_keys(fetch["data"]["config"], "tool") == ()
|
||
S.script(monkeypatch, "本周销售额 [[m:gmv]]。")
|
||
run = await run_manager.start(graph=graph, input_payload={"question": "九月第一周卖了多少"}, origin_kind="chat")
|
||
row = await S.wait(run.id)
|
||
assert row.status == "succeeded", row.error
|
||
[sql] = executed
|
||
assert "'九月第一周卖了多少'" in sql and "{{" not in sql
|
||
|
||
|
||
async def test_a_copied_node_with_an_incomplete_edit_sends_no_sql(monkeypatch, executed):
|
||
"""复制后改过的 SQL 引用了入口声明过、这次没给的参数:运行时按同一套参数完整性拒,零 SQL。
|
||
|
||
契约在这里也帮不上忙(哈希对不上就当没写):走的是普通模板那条路——渲染前判缺参数,绝不把带 {{ }} 的语句发出去。"""
|
||
wf = await S.publish()
|
||
graph = await composed_answer_graph(wf, {"start": "2026-09-01", "end": "2026-09-08"})
|
||
start = next(n for n in graph["nodes"] if n["id"] == "start")
|
||
start["data"]["config"]["fields"].append({"name": "store"}) # 可选、没有缺省值
|
||
fetch = next(n for n in graph["nodes"] if n["id"] == "c1_fetch")
|
||
fetch["data"]["config"]["args"]["sql"] += " AND region = '{{ input.store }}'"
|
||
assert params.literal_keys(fetch["data"]["config"], "tool") == ()
|
||
S.script(monkeypatch, "本周销售额 [[m:gmv]]。")
|
||
run = await run_manager.start(graph=graph, input_payload={"question": "九月第一周卖了多少"}, origin_kind="chat")
|
||
row = await S.wait(run.id)
|
||
assert row.status == "failed" and "store" in row.error and "missing_parameter" in row.error, row.error
|
||
assert executed == []
|
||
|
||
|
||
async def test_the_probe_path_renders_a_template_like_value_once_too(executed):
|
||
"""派生图(口径探针、试跑走的就是它)同值同语义:入口值里的 {{ }} 也是字面量。"""
|
||
wf = await S.publish()
|
||
async with SessionLocal() as session:
|
||
pinned = await derive.load_pinned(session, wf, 1, "card")
|
||
run = await run_manager.start(graph=derive.caliber_probe(pinned).graph,
|
||
input_payload={"start": "{{ vars.region }}", "end": "2026-09-08"})
|
||
row = await S.wait(run.id)
|
||
assert row.status == "succeeded", row.error
|
||
[sql] = executed
|
||
assert "'{{ vars.region }}'" in sql and "'2026-09-08'" in sql
|
||
|
||
|
||
async def test_the_query_snapshot_records_the_template_hash_types_and_no_fake_binding(monkeypatch, executed):
|
||
"""快照记源模板哈希、规范化输入哈希、参数契约与写在哪儿;承载方式照实写内联(不假装驱动绑定)。"""
|
||
wf = await S.publish()
|
||
graph = await composed_answer_graph(wf, {"start": "2026-09-01", "end": "2026-09-08"})
|
||
S.script(monkeypatch, "本周销售额 [[m:gmv]]。")
|
||
run = await run_manager.start(graph=graph, input_payload={"question": "九月第一周卖了多少"}, origin_kind="chat")
|
||
row = await S.wait(run.id)
|
||
assert row.status == "succeeded", row.error
|
||
[payload] = await query_snapshots(run.id)
|
||
material = payload["parameters"]
|
||
assert material["adapter"] == params.ADAPTER == "inline_text"
|
||
assert material["binding"] == "values_in_statement"
|
||
# 源模板正文不在节点上(engine/variables 会扫节点配置里的 {{ }}):记哈希 + 那一版取数节点的固定引用,
|
||
# 模板本体按引用从不可变版本快照还原,哈希核对
|
||
assert material["template_hash"] == hashlib.sha256(TEMPLATE.encode("utf-8")).hexdigest()
|
||
assert len(material["inputs_hash"]) == 64 and "template" not in material
|
||
assert [(p["name"], p["kind"], p["declared"], p["slot"]) for p in material["parameters"]] == [
|
||
("start", "text", "string", "quoted"), ("end", "text", "string", "quoted")]
|
||
# 契约跟着材料一起记;绑定值不另存一份:它就是内联进实际语句里的那些字,实际语句本身在快照里
|
||
assert [d["name"] for d in material["declared"]] == ["start", "end", "store"]
|
||
assert set(material) == {"adapter", "binding", "template_hash", "inputs_hash", "declared", "parameters",
|
||
"source"}
|
||
assert payload["sql"] == TEMPLATE.replace("{{ input.start }}", "2026-09-01").replace("{{ input.end }}",
|
||
"2026-09-08")
|
||
assert material["source"]["node_id"] == "fetch" and material["source"]["workflow_version"] == 1
|
||
|
||
|
||
# ==========================================================================
|
||
# 缺参数、未声明参数:零 SQL(E04)
|
||
# ==========================================================================
|
||
|
||
|
||
def optional_field_graph(sql: str, fields: list[dict]) -> dict:
|
||
graph = S.caliber_graph(sql=sql)
|
||
graph["nodes"][0]["data"]["config"]["fields"] = fields
|
||
return graph
|
||
|
||
|
||
async def test_a_missing_optional_parameter_sends_no_sql(executed):
|
||
"""可选、没有缺省值的参数这次没给:不发 SQL,运行说清是哪个参数(以前渲染成空串照发)。"""
|
||
sql = ("SELECT SUM(amount) AS gmv FROM orders WHERE status = 'paid' AND region = '{{ input.store }}' "
|
||
"AND paid_at >= '{{ input.start }}' AND paid_at < '{{ input.end }}'")
|
||
graph = optional_field_graph(sql, [{"name": "start", "required": True}, {"name": "end", "required": True},
|
||
{"name": "store"}])
|
||
wf = await S.publish(graph=graph)
|
||
async with SessionLocal() as session:
|
||
pinned = await derive.load_pinned(session, wf, 1, "card")
|
||
run = await run_manager.start(graph=derive.caliber_probe(pinned).graph,
|
||
input_payload={"start": "2026-09-01", "end": "2026-09-08"})
|
||
row = await S.wait(run.id)
|
||
assert row.status == "failed" and "store" in row.error and "missing_parameter" in row.error, row.error
|
||
assert executed == [] and await query_snapshots(run.id) == []
|
||
|
||
|
||
async def test_a_plain_canvas_query_with_a_missing_optional_parameter_sends_no_sql(executed):
|
||
"""普通画布上的一条取数:参数声明成可选、没有缺省值、这次没给——不发 SQL(以前渲染成空串照发)。"""
|
||
graph = S.chain(S.node("start", "input", fields=[{"name": "start", "required": True},
|
||
{"name": "store"}]),
|
||
S.node("q", "tool", tool=S.tool(),
|
||
args={"sql": "SELECT COUNT(*) AS n FROM orders WHERE region = '{{ input.store }}' "
|
||
"AND paid_at >= '{{ input.start }}'"}),
|
||
S.node("out", "output", fields=[{"name": "r", "value": "{{ nodes.q.text }}"}]))
|
||
run = await run_manager.start(graph=graph, input_payload={"start": "2026-09-01"})
|
||
row = await S.wait(run.id)
|
||
assert row.status == "failed" and "store" in row.error and "missing_parameter" in row.error, row.error
|
||
assert executed == [] and await query_snapshots(run.id) == []
|
||
|
||
|
||
async def test_an_undeclared_parameter_cannot_be_composed_or_derived():
|
||
"""模板引用了入口没声明的字段:读这一版(派生、组合)时就拒,机器码 undeclared_parameter。"""
|
||
graph = optional_field_graph("SELECT SUM(amount) AS gmv FROM orders WHERE region = '{{ input.nope }}'",
|
||
[{"name": "start", "required": True}, {"name": "end", "required": True}])
|
||
wf = await S.publish(graph=graph)
|
||
async with SessionLocal() as session:
|
||
with pytest.raises(derive.NotDerivable) as caught:
|
||
await derive.load_pinned(session, wf, 1, "card")
|
||
assert caught.value.code == derive.UNDECLARED_PARAMETER and "nope" in caught.value.message
|
||
assert "undeclared_parameter" in caught.value.message
|
||
kind = await CaliberKind.load(session, compose=True)
|
||
[option] = [o for o in kind.options if o.workflow_id == wf]
|
||
assert not option.composable and "undeclared_parameter" in option.reason
|
||
|
||
|
||
async def test_a_query_referencing_a_parameter_nobody_declares_sends_no_sql(executed):
|
||
"""取数 SQL 引用了这次运行没有的参数:不发 SQL,说清是哪个参数。
|
||
|
||
入口一个字段都没声明时(开放入口)engine/variables 不会拦这种引用——运行到取数这一步由参数完整性拦,以前渲染成
|
||
空串照发。入口声明了字段的图更早就会被画布校验拦下(那是另一道,见下一条)。"""
|
||
graph = S.chain(S.node("start", "input"),
|
||
S.node("q", "tool", tool=S.tool(),
|
||
args={"sql": "SELECT COUNT(*) AS n FROM orders WHERE region = '{{ input.store }}'"}),
|
||
S.node("out", "output", fields=[{"name": "r", "value": "{{ nodes.q.text }}"}]))
|
||
run = await run_manager.start(graph=graph, input_payload={"question": "有多少单"})
|
||
row = await S.wait(run.id)
|
||
assert row.status == "failed" and "store" in row.error and "undeclared_parameter" in row.error, row.error
|
||
assert executed == [] and await query_snapshots(run.id) == []
|
||
|
||
|
||
async def test_a_value_that_the_run_provides_is_enough_even_without_a_declared_field(executed):
|
||
"""入口没声明字段、值由发起时传进来(开放入口):照常发出 SQL——判断看的是「这次有没有值」,不是只认声明。
|
||
|
||
这是画布上很常见的写法(入口节点只写「开始」,参数由发起时传),拦掉它会把跑得好的图判成参数不完整。"""
|
||
graph = S.chain(S.node("start", "input"),
|
||
S.node("q", "tool", tool=S.tool(),
|
||
args={"sql": "SELECT COUNT(*) AS n FROM orders WHERE paid_at >= '{{ input.start }}'"}),
|
||
S.node("out", "output", fields=[{"name": "r", "value": "{{ nodes.q.text }}"}]))
|
||
run = await run_manager.start(graph=graph, input_payload={"start": "2026-09-01"})
|
||
row = await S.wait(run.id)
|
||
assert row.status == "succeeded", row.error
|
||
assert executed == ["SELECT COUNT(*) AS n FROM orders WHERE paid_at >= '2026-09-01'"]
|
||
|
||
|
||
async def test_an_open_entry_with_an_explicit_empty_string_sends_no_sql(executed):
|
||
"""开放入口(入口节点一个字段都没声明、键由发起时传)传来空串:算没给,不发 SQL。
|
||
|
||
空串不是值,是省略——和入口收值(params.provided)同一套,也和「声明了 default="" 才算声明式省略」同一套。以前只认
|
||
None,这种图会渲染成 `AND region = ''` 发出去;作者要表达「空值不过滤」,得把字段声明出来并写 `default=""`(下一条用例)。"""
|
||
graph = S.chain(S.node("start", "input"),
|
||
S.node("q", "tool", tool=S.tool(),
|
||
args={"sql": "SELECT COUNT(*) AS n FROM orders WHERE region = '{{ input.store }}'"}),
|
||
S.node("out", "output", fields=[{"name": "r", "value": "{{ nodes.q.text }}"}]))
|
||
run = await run_manager.start(graph=graph, input_payload={"store": ""})
|
||
row = await S.wait(run.id)
|
||
assert row.status == "failed" and "store" in row.error and "missing_parameter" in row.error, row.error
|
||
assert executed == [] and await query_snapshots(run.id) == []
|
||
|
||
|
||
async def test_a_declared_empty_default_still_runs_in_an_open_entry(executed):
|
||
"""作者把字段声明出来、写 `default=""`:那是声明的省略规则,照跑,空串不是「没给」。"""
|
||
graph = S.chain(S.node("start", "input", fields=[{"name": "store", "default": ""}]),
|
||
S.node("q", "tool", tool=S.tool(),
|
||
args={"sql": "SELECT COUNT(*) AS n FROM orders WHERE ('{{ input.store }}' = '' "
|
||
"OR region = '{{ input.store }}')"}),
|
||
S.node("out", "output", fields=[{"name": "r", "value": "{{ nodes.q.text }}"}]))
|
||
run = await run_manager.start(graph=graph, input_payload={})
|
||
row = await S.wait(run.id)
|
||
assert row.status == "succeeded", row.error
|
||
[sql] = executed
|
||
assert "{{" not in sql and "('' = ''" in sql
|
||
|
||
|
||
async def test_an_executed_statement_carried_into_a_graph_is_not_templated_again(executed):
|
||
"""对账里「这次的取数」:放进去的是执行过的语句,运行时不再当模板解释一遍。
|
||
|
||
语句文本里挑了在答数图里取得出值的写法({{ vars.question }}):真的被再渲染一次的话,这条 SQL 就变成别的语句了。"""
|
||
text = "SELECT SUM(amount) AS gmv FROM orders WHERE region = '{{ vars.question }}' AND id > 1"
|
||
block = derive.ours_block(text, S.tool())
|
||
assert params.mode_of(block.nodes[0]["data"]["config"]) == params.LITERAL_ARGS
|
||
graph = S.chain(S.node("start", "input", fields=[{"name": "question"}]),
|
||
block.nodes[0],
|
||
S.node("out", "output", fields=[{"name": "r", "value": "{{ nodes.ours_q1.text }}"}]))
|
||
run = await run_manager.start(graph=graph, input_payload={"question": "九月第一周"})
|
||
row = await S.wait(run.id)
|
||
assert row.status == "succeeded", row.error
|
||
assert executed == [text]
|
||
|
||
|
||
async def test_a_bracket_reference_without_a_value_sends_no_sql(executed):
|
||
"""`{{ input['store'] }}`(下标写法、SQL 里还嵌着引号)没给值:一样不发 SQL。
|
||
|
||
运行时的兜底以前只认 `{{ input.字段 }}`,这种写法认不出来 → 渲染成 `''`,发出一条 `AND region = ''`。"""
|
||
graph = S.chain(S.node("start", "input", fields=[{"name": "store"}]),
|
||
S.node("q", "tool", tool=S.tool(),
|
||
args={"sql": "SELECT COUNT(*) AS n FROM orders WHERE region = '{{ input['store'] }}'"}),
|
||
S.node("out", "output", fields=[{"name": "r", "value": "{{ nodes.q.text }}"}]))
|
||
run = await run_manager.start(graph=graph, input_payload={"store": ""})
|
||
row = await S.wait(run.id)
|
||
assert row.status == "failed" and "store" in row.error and "missing_parameter" in row.error, row.error
|
||
assert executed == [] and await query_snapshots(run.id) == []
|
||
|
||
|
||
async def test_a_bracket_reference_with_a_value_renders_and_runs(executed):
|
||
"""`{{ input['store'] }}` 正常给值:照常渲染进 SQL 发出去(不是拒绝一切下标写法)。"""
|
||
graph = S.chain(S.node("start", "input", fields=[{"name": "store"}]),
|
||
S.node("q", "tool", tool=S.tool(),
|
||
args={"sql": "SELECT COUNT(*) AS n FROM orders WHERE region = '{{ input['store'] }}'"}),
|
||
S.node("out", "output", fields=[{"name": "r", "value": "{{ nodes.q.text }}"}]))
|
||
run = await run_manager.start(graph=graph, input_payload={"store": "east"})
|
||
row = await S.wait(run.id)
|
||
assert row.status == "succeeded", row.error
|
||
assert executed == ["SELECT COUNT(*) AS n FROM orders WHERE region = 'east'"]
|
||
|
||
|
||
async def test_a_bracket_reference_uses_the_declared_default(executed):
|
||
"""`{{ input['store'] }}` 配声明缺省值:没给值时按入口那套补上,渲染进去的是缺省值。"""
|
||
graph = S.chain(S.node("start", "input", fields=[{"name": "store", "default": "east"}]),
|
||
S.node("q", "tool", tool=S.tool(),
|
||
args={"sql": 'SELECT COUNT(*) AS n FROM orders WHERE region = \'{{ input["store"] }}\''}),
|
||
S.node("out", "output", fields=[{"name": "r", "value": "{{ nodes.q.text }}"}]))
|
||
run = await run_manager.start(graph=graph, input_payload={})
|
||
row = await S.wait(run.id)
|
||
assert row.status == "succeeded", row.error
|
||
assert executed == ["SELECT COUNT(*) AS n FROM orders WHERE region = 'east'"]
|
||
|
||
|
||
@pytest.mark.parametrize("sql", ["SELECT COUNT(*) AS n FROM orders WHERE region = '{{ input }}'",
|
||
"SELECT COUNT(*) AS n FROM orders WHERE region = '{{ input['a.b'] }}'",
|
||
"SELECT COUNT(*) AS n FROM orders WHERE region = '{{ input[a[b]] }}'"])
|
||
async def test_an_unreadable_input_writing_sends_no_sql(executed, sql):
|
||
"""认不出取哪个字段的入口写法(光秃秃的 `{{ input }}`、下标里带点/套中括号):明确拒,零 SQL。
|
||
|
||
这些写法 resolve_path 取不到值 → 渲染成空串;当「没引用参数」放过去就等于发出一条条件被抹掉的查询。"""
|
||
graph = S.chain(S.node("start", "input", fields=[{"name": "question"}]),
|
||
S.node("q", "tool", tool=S.tool(), args={"sql": sql}),
|
||
S.node("out", "output", fields=[{"name": "r", "value": "{{ nodes.q.text }}"}]))
|
||
run = await run_manager.start(graph=graph, input_payload={"question": "有多少单"})
|
||
row = await S.wait(run.id)
|
||
assert row.status == "failed" and "unsupported_parameter" in row.error, row.error
|
||
assert "看不出取的是哪个字段" in row.error
|
||
assert executed == [] and await query_snapshots(run.id) == []
|
||
|
||
|
||
async def test_vars_and_nodes_are_left_alone_in_a_canvas_query(executed):
|
||
"""普通模板照旧:取数 SQL 引用 vars./nodes. 不受这一趟影响(照常渲染后发出去)。"""
|
||
graph = S.chain(S.node("start", "input"),
|
||
S.node("pick", "transform", mode="expression", expression="'east'", assign_to="region"),
|
||
S.node("q", "tool", tool=S.tool(),
|
||
args={"sql": "SELECT COUNT(*) AS n FROM orders WHERE region = '{{ vars.region }}'"}),
|
||
S.node("out", "output", fields=[{"name": "r", "value": "{{ nodes.q.text }}"}]))
|
||
run = await run_manager.start(graph=graph, input_payload={})
|
||
row = await S.wait(run.id)
|
||
assert row.status == "succeeded", row.error
|
||
assert executed == ["SELECT COUNT(*) AS n FROM orders WHERE region = 'east'"]
|
||
|
||
|
||
# ==========================================================================
|
||
# 可复用的接口(试跑等入口接线用)
|
||
# ==========================================================================
|
||
|
||
|
||
def test_resolve_prefers_explicit_values_then_declared_defaults():
|
||
"""共享层定这一次的值:显式给的(含 0——0 是值)优先,否则用声明的缺省值(含 0 与空串);没缺省值的照旧缺。"""
|
||
fields = [{"name": "region", "default": "east"}, {"name": "min", "default": 0},
|
||
{"name": "note", "default": ""}, {"name": "start"}]
|
||
assert params.resolve(fields, {"start": "2026-09-01"}) == {
|
||
"region": "east", "min": 0, "note": "", "start": "2026-09-01"}
|
||
assert params.resolve(fields, {"region": "west", "min": 5, "note": "x", "start": "s"}) == {
|
||
"region": "west", "min": 5, "note": "x", "start": "s"}
|
||
# 显式空串算没给:回到声明的缺省值;没声明缺省值的(start)不只是空串问题——它压根没值,不在这份里
|
||
assert params.resolve(fields, {"region": "", "min": "", "note": "", "start": ""}) == {
|
||
"region": "east", "min": 0, "note": ""}
|
||
|
||
|
||
def test_plan_renders_the_declared_default_instead_of_an_empty_string():
|
||
"""声明了缺省值、调用方没给:渲染进去的必须是那个缺省值(以前 plan 拿原始 values 渲染,声明了却渲染成空)。"""
|
||
template = ("SELECT * FROM orders WHERE region = '{{ input.region }}' AND amount > {{ input.min }} "
|
||
"AND note = '{{ input.note }}'")
|
||
fields = [{"name": "region", "default": "east"}, {"name": "min", "default": 0}, {"name": "note", "default": ""}]
|
||
out = params.plan(template, fields=fields, values={})
|
||
assert "'east'" in out.statement and "amount > 0" in out.statement and "note = ''" in out.statement
|
||
assert "{{" not in out.statement
|
||
# 检查、渲染、材料里的输入哈希用的是同一份 resolved values:显式给同样的值 → 同一份材料
|
||
same = params.plan(template, fields=fields, values={"region": "east", "min": 0, "note": ""})
|
||
assert same.material["inputs_hash"] == out.material["inputs_hash"]
|
||
assert [p["kind"] for p in out.material["parameters"]] == ["text", "number", "text"]
|
||
# 覆盖声明:显式给的值优先
|
||
override = params.plan(template, fields=fields, values={"region": "west", "min": 10, "note": "x"})
|
||
assert "'west'" in override.statement and "amount > 10" in override.statement
|
||
assert override.material["inputs_hash"] != out.material["inputs_hash"]
|
||
|
||
|
||
@pytest.mark.parametrize("values", [{}, {"region": ""}, {"region": None}])
|
||
def test_plan_without_a_declared_default_still_reports_missing(values):
|
||
"""没有声明的缺省值:没给、给了空串、给了 None 都是缺参数(空串不是值)。"""
|
||
with pytest.raises(params.ParameterRefused) as caught:
|
||
params.plan("SELECT * FROM orders WHERE region = '{{ input.region }}'",
|
||
fields=[{"name": "region"}], values=values)
|
||
assert caught.value.code == params.MISSING and caught.value.problems[0].name == "region"
|
||
|
||
|
||
def test_plan_renders_once_and_carries_the_snapshot_material():
|
||
out = params.plan(TEMPLATE, fields=[{"name": "start", "required": True, "type": "date"},
|
||
{"name": "end", "required": True, "type": "date"}],
|
||
values={"start": "{{ vars.x }}", "end": "2026-09-08"})
|
||
assert "'{{ vars.x }}'" in out.statement
|
||
assert out.material["parameters"][0]["declared"] == "date"
|
||
with pytest.raises(params.ParameterRefused) as caught:
|
||
params.plan(TEMPLATE, fields=[{"name": "start", "required": True}, {"name": "end", "required": True}],
|
||
values={"start": "2026-09-01"})
|
||
assert caught.value.code == params.MISSING and "end" in caught.value.message
|
||
|
||
|
||
# ==========================================================================
|
||
# 只算选中的指标(K08 S2):只选 A 时 B 那条 SQL 不进图,也不要求它的参数
|
||
# ==========================================================================
|
||
|
||
#: buyer 只有 B 那条查询用:只选 A 时这次不必给
|
||
FIELDS_AB = [{"name": "start", "required": True}, {"name": "end", "required": True},
|
||
{"name": "buyer", "required": True}]
|
||
SQL_A = ("SELECT SUM(amount) AS gmv FROM orders WHERE status = 'paid' "
|
||
"AND paid_at >= '{{ input.start }}' AND paid_at < '{{ input.end }}'")
|
||
SQL_B = SQL_A + " AND region = '{{ input.buyer }}'"
|
||
|
||
|
||
def two_metric_graph(*, on_missing: bool = True) -> dict:
|
||
"""一张卡两个指标、两条各自独立的查询(B 多要一个 buyer)。"""
|
||
card: dict = {"caliber": "双指标", "caliber_version": "v1",
|
||
"metrics": [{"id": "gmv", "name": "销售额", "expression": "cell(nodes.fetch_a, 0, 'gmv')"},
|
||
{"id": "n", "name": "单量", "expression": "cell(nodes.fetch_b, 0, 'n')"}]}
|
||
if on_missing:
|
||
card["on_missing"] = "null"
|
||
return S.chain(S.node("start", "input", fields=FIELDS_AB),
|
||
S.node("fetch_a", "tool", tool=S.tool(), args={"sql": SQL_A}),
|
||
S.node("fetch_b", "tool", tool=S.tool(), args={"sql": SQL_B}),
|
||
S.node("card", "metrics", **card),
|
||
S.node("out", "output", fields=[{"name": "r", "value": "{{ nodes.card.text }}"}]))
|
||
|
||
|
||
def shared_graph() -> dict:
|
||
"""两个指标读同一条取数(共享 SQL)。"""
|
||
return S.chain(S.node("start", "input", fields=FIELDS_AB[:2]),
|
||
S.node("fetch", "tool", tool=S.tool(), args={"sql": SQL_A}),
|
||
S.node("card", "metrics", caliber="共享取数", caliber_version="v1",
|
||
metrics=[{"id": "gmv", "name": "销售额", "expression": "cell(nodes.fetch, 0, 'gmv')"},
|
||
{"id": "n", "name": "单量", "expression": "cell(nodes.fetch, 0, 'n')"}]),
|
||
S.node("out", "output"))
|
||
|
||
|
||
async def composed_graph(workflow_id: str, *, metrics: list[str] | None = None) -> tuple[dict, "compose.Composed"]:
|
||
"""照 test_ledger_compose 的做法:把口径拷进问数据的图,交回 (图, 这次选用)。"""
|
||
async with SessionLocal() as session:
|
||
kind = await CaliberKind.load(session, compose=True)
|
||
made = compose.Composer(list(kind.options)).compose(
|
||
{"op": "use_caliber", "id": "c1", "workflow_id": workflow_id, "version": 1, "card": "card",
|
||
"metrics": metrics or ["gmv"], "inputs": {"start": "2026-09-01", "end": "2026-09-08"}}, taken=set())
|
||
return S.chain(S.node("start", "input", fields=[{"name": "question"}]), *made.nodes,
|
||
S.node("write", "report", instructions="写周销售"),
|
||
S.node("out", "output", fields=[{"name": "报告", "value": "{{ nodes.write.text }}"}])), made
|
||
|
||
|
||
def rendered_a() -> str:
|
||
return SQL_A.replace("{{ input.start }}", "2026-09-01").replace("{{ input.end }}", "2026-09-08")
|
||
|
||
|
||
async def test_only_the_selected_metric_s_sql_is_executed(monkeypatch, executed):
|
||
"""组合式问数只选 gmv:B 那条查询不进图,实际发给数据库的只有 A 这一条(B 的参数这次不用给)。"""
|
||
wf = await S.publish(graph=two_metric_graph())
|
||
graph, made = await composed_graph(wf)
|
||
assert [n["id"] for n in graph["nodes"] if n["type"] == "tool"] == ["c1_fetch_a"]
|
||
assert made.result()["selection"]["metric_ids"] == ["gmv"]
|
||
S.script(monkeypatch, "本周销售额 [[m:gmv]]。")
|
||
run = await run_manager.start(graph=graph, input_payload={"question": "九月第一周卖了多少"}, origin_kind="chat")
|
||
row = await S.wait(run.id)
|
||
assert row.status == "succeeded", row.error
|
||
assert executed == [rendered_a()] # 一条:B 的零条
|
||
|
||
|
||
async def test_a_shared_query_still_runs_and_declares_the_metric_it_used_to_compute(monkeypatch, executed):
|
||
"""A 和 B 共用一条 SQL:选 A 那条照样执行(一次),结果里如实声明它还在算 B。"""
|
||
wf = await S.publish(graph=shared_graph())
|
||
graph, made = await composed_graph(wf, metrics=["gmv"])
|
||
[query] = made.closure.queries
|
||
assert query.computes_unselected == ("n",) and made.closure.query_ids == (f"{wf}:fetch",)
|
||
S.script(monkeypatch, "本周销售额 [[m:gmv]]。")
|
||
run = await run_manager.start(graph=graph, input_payload={"question": "九月第一周卖了多少"}, origin_kind="chat")
|
||
row = await S.wait(run.id)
|
||
assert row.status == "succeeded", row.error
|
||
assert executed == [rendered_a()]
|
||
|
||
|
||
async def test_the_probe_of_a_selection_carries_only_the_selected_query(executed):
|
||
"""派生探针按同一份选择裁:入口表单收窄到这次用的(不必给 buyer),拷出来的只有 A 那条查询。"""
|
||
wf = await S.publish(graph=two_metric_graph())
|
||
async with SessionLocal() as session:
|
||
pinned = await derive.load_pinned(session, wf, 1, "card", metric_ids=["gmv"])
|
||
probe = derive.caliber_probe(pinned)
|
||
assert [n["id"] for n in probe.graph["nodes"]] == [derive.ENTRY_ID, "fetch_a", "card", derive.EXIT_ID]
|
||
assert probe.graph["nodes"][0]["data"]["config"]["fields"] == FIELDS_AB[:2]
|
||
run = await run_manager.start(graph=probe.graph, input_payload={"start": "2026-09-01", "end": "2026-09-08"})
|
||
row = await S.wait(run.id)
|
||
assert row.status == "succeeded", row.error
|
||
assert executed == [rendered_a()]
|