Files
AgentLab/backend/tests/test_ledger_compose.py
ThearchyHelios 271bc6e782 口径标签逐格判:引用了好几列的查询,每个数按它自己那一格标
真实模型下问「上周的销售额是多少」,助手写 SUM(amount) AS 销售额, COUNT(*) AS 订单数, MIN / MAX(paid_date) AS 区间首日 /
末日,报告四列都引用了,口径「周销售」只取 SUM(amount)。按「全部引用列」投影后还是 4 列,对不上口径的 1 列,整条比、
四个数都标相关口径,说明整段兜底。

- ledger/match._cells / _cell(algo 4):整条(含按全部引用列投影)没证明、又引用了这条查询两列以上的,被引用的每一列单独
  投影到那一列,和每份口径的每个指标读的那一列(card_reads;口径版本没有口径卡的按取数的每一列)逐个比,仍只走
  match_template / explain_sql_diff,上次的投影规则照用:投影不了(分组列、DISTINCT……)、过了时限、不可比的格照用整条
  查询的那一个候选。结果记在 queries[].cells {列名: {status, candidates}},另记 one_row(sql_semantics.one_row:不分组的
  聚合,一定只出一行)。整条这一层的结论、候选一字不改;老工件没有 cells,照旧按整条读。
- ledger/labels:单元格按它那一格的结论标(_view);指标用了同一条查询的好几格时,只有那条查询一定只出一行才把逐格的
  结论合起来用(多行的结果里每一列各自对得上,不等于每一行的几列一起对得上)。整题照拍板 L1 不变:每个数都证明来自
  同一份口径才等价;一格证明了、别的格只是相关的整题不等价,原因写「有的数和相关口径不一致,不来自口径」(以前一律说
  「这次的推理」)。出具入口照 ledger/issue 现有判法只在整题等价时给,没有放宽。
- 逐格证明了的口径各处都算上(labels.candidates_of):工件 meta 的 calibers(召回按旧版本反查)、ledger/uses 的使用记录、
  召回时找原答案对应的那一格、E4;E1 另数各种结论各几格(cells),E2 的兜底比例按标签上给人看的那一份说明数。
- GET /runs/{id}/ledger 的 queries 带上逐格的 cells(遮罩同候选);证据片段的 ledger 键、轮次摘要的计数本来就按每个数的
  标签,数据变了、形状不变。前端只补类型;check-ledger 加一段:同一条查询一格来自口径(正文挂小标记)、一格相关(差在哪
  先说多查了几列),整题不等价不给出具。
- 评测逐题比 SQL(eval/score.sql_match)取和期望配上的那一格(score.matched_cells:评分时配上的格),没给时取逐格证明了
  的那一格;结果带 column。runner、rescore 传它。

测试:tests/test_ledger_cells.py(同一条查询一列等价、一列不等价各自标对;两份口径只证明了一份标那一份;口径多个指标按列
配对;指标跨两格只在一行的查询上用逐格结论;分组列、DISTINCT 逐格也不投影;照真实例子造的虚构库真跑一遍:销售额标等价
已证明,订单数、首末日标相关,整题不等价、不给出具、不自动出具,证据片段的 ledger 键一致;评测取配上的那一格)。去掉逐格
再跑,10 条失败;去掉「一行才合起来用」、去掉评测取格,各 1 条失败。
2026-10-17 13:07:19 +08:00

325 lines
17 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""组合式问数:use_caliber 选用已发布口径,服务端按那一版确定性内联实例化(next-round 02 做法 4,ledger/compose.py)。
- 实例化是确定的:同一版、同一个操作得到一模一样的节点;取数节点照抄、id 加前缀、模板按参数渲染成字面 SQL,口径卡 id
就是操作 id、caliber_from 带改名映射;取数首尾相接再接口径卡,入口连到第一个取数节点;
- 参数按口径入口的字段校验(未知字段、缺必填、类型、引号里的值带引号、裸写的值不是数),不合规拒收、原因交回模型;
只能选这一轮查看到的那一版、能组合的卡、卡上的指标,id 不能撞;
- 问数据的查看对象多了口径:给入口字段和「能不能组合」,不给 SQL;「什么时候该看」换成问数据那一份;使用者不给;
- 走一遍问数据的生成(剧本模型)再真跑出来:比对器把这些数标成 composed「助手选用」,整题 how=composed。
数据是虚构店铺库。
"""
from __future__ import annotations
import json
import pytest
from httpx import ASGITransport, AsyncClient
from app.api import copilot_context
from app.api.copilot_gen import GraphApplier, GraphChecker
from app.db.base import SessionLocal
from app.engine.runner import run_manager
from app.engine.schema import GraphSpec, validate_graph
from app.ledger import compose
from app.ledger import match as ledger_match
from app.ledger import store
from app.look.calibers import CaliberKind
from app.main import app
from tests import ledger_samples as S
@pytest.fixture(autouse=True)
async def engine_up(tmp_path):
await run_manager.setup()
await S.fresh_source(tmp_path / "shop.db")
yield
await ledger_match.drain()
await run_manager.shutdown()
@pytest.fixture
async def client():
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
yield c
def use(wf: str, **extra) -> dict:
return {"op": "use_caliber", "id": "c1", "workflow_id": wf, "version": 1, "metrics": ["gmv"],
"inputs": {"start": "2026-09-01", "end": "2026-09-08"}, **extra}
async def composer() -> compose.Composer:
async with SessionLocal() as session:
kind = await CaliberKind.load(session, compose=True)
return compose.Composer(list(kind.options))
# --------------------------------------------------------------------------
# 实例化
# --------------------------------------------------------------------------
async def test_instantiation_is_deterministic_and_copies_the_pinned_fetch():
wf = await S.publish()
made = (await composer()).compose(use(wf), taken={"start"})
again = (await composer()).compose(use(wf), taken={"start"})
assert made.nodes == again.nodes and made.edges == again.edges
fetch, card = made.nodes
assert fetch["id"] == "c1_fetch" and fetch["type"] == "tool"
assert fetch["data"]["config"]["tool"] == S.tool()
assert fetch["data"]["config"]["args"]["sql"] == S.TEMPLATE.replace("{{ input.start }}", "2026-09-01").replace(
"{{ input.end }}", "2026-09-08")
assert card["id"] == "c1" and card["type"] == "metrics" and card["data"]["label"] == "card"
assert card["data"]["config"]["caliber_from"] == {"workflow_id": wf, "workflow_version": 1, "node_id": "card",
"nodes": {"fetch": "c1_fetch"}}
assert made.edges == [{"source": "c1_fetch", "target": "c1"}]
# 缺省值补上了(store 有缺省 all),不进结果
assert made.inputs == {"start": "2026-09-01", "end": "2026-09-08", "store": "all"}
result = made.result()
assert result["status"] == "ok" and result["card_id"] == "c1" and result["fetches"] == ["c1_fetch"]
assert result["level_text"] == "受管" and result["metrics"] == [{"id": "gmv", "name": "销售额"}]
assert "sql" not in json.dumps(result).lower()
@pytest.mark.parametrize(("change", "words"), [
({"workflow_id": "nope"}, "只能选用这一轮查看到的已发布口径"),
({"version": 2}, "能选用的是 v1"),
({"card": "other"}, "没有口径卡「other」"),
({"id": "C 1"}, "简短的英文小写"),
({"metrics": []}, "metrics 要写"),
({"metrics": ["profit"]}, "没有指标「profit」"),
({"inputs": {"start": "2026-09-01", "end": "2026-09-08", "city": "x"}}, "入口没有字段「city」"),
({"inputs": {"start": "2026-09-01"}}, "请填写「end」"),
({"inputs": {"start": "2026-09-01' OR '1'='1", "end": "2026-09-08"}}, "引号里,值里不能有引号"),
({"inputs": "start=2026-09-01"}, "inputs 要写成"),
])
async def test_bad_choices_are_refused_with_a_reason(change, words):
wf = await S.publish()
op = use(wf, **change) if change.get("workflow_id") != "nope" else {**use(wf), **change}
with pytest.raises(compose.ComposeRefused) as e:
(await composer()).compose(op, taken={"start"})
assert words in e.value.message, e.value.message
async def test_bare_slots_take_numbers_only_and_ids_must_not_clash():
graph = S.caliber_graph(sql="SELECT SUM(amount) AS gmv FROM orders WHERE status = 'paid' AND id > {{ input.after }}")
# 字段没写类型(按文字收):表单校验放行,裸写进 SQL 的只能是数
graph["nodes"][0]["data"]["config"]["fields"] = [{"name": "after", "default": "0"}]
wf = await S.publish(graph=graph)
pool = await composer()
with pytest.raises(compose.ComposeRefused, match="只能是数"):
pool.compose({**use(wf), "inputs": {"after": "1 OR 1=1"}}, taken=set())
made = pool.compose({**use(wf), "inputs": {"after": "3"}}, taken=set())
assert made.nodes[0]["data"]["config"]["args"]["sql"].endswith("id > 3")
with pytest.raises(compose.ComposeRefused, match="撞了"):
pool.compose({**use(wf), "inputs": {"after": "3"}}, taken={"c1"})
async def test_a_card_that_reads_model_output_is_listed_but_not_composable():
graph = S.chain(S.node("start", "input"), S.node("ask", "agent", tools=[S.tool()], assign_to="kpi"),
S.node("card", "metrics", caliber="周销售", metrics=[{"id": "gmv", "expression": "vars.kpi.gmv"}]),
S.node("out", "output", fields=[{"name": "r", "value": "{{ nodes.card.text }}"}]))
wf = await S.publish(graph=graph)
async with SessionLocal() as session:
kind = await CaliberKind.load(session, compose=True)
[option] = [o for o in kind.options if o.workflow_id == wf]
assert not option.composable and "不能复用" in option.reason
text, _ = await kind.detail([(i, "named") for i in kind.items if i.workflow_id == wf], 4000)
assert "不能组合" in text
with pytest.raises(compose.ComposeRefused, match="不能组合"):
compose.Composer(list(kind.options)).compose(use(wf), taken=set())
# --------------------------------------------------------------------------
# 查看:入口字段、能不能组合,不给 SQL
# --------------------------------------------------------------------------
async def test_the_answer_look_shows_fields_and_how_to_compose_but_no_sql():
wf = await S.publish()
async with SessionLocal() as session:
kind = await CaliberKind.load(session, compose=True)
plain = await CaliberKind.load(session)
objects = [(i, "named") for i in kind.items if i.workflow_id == wf]
text, [record] = await kind.detail(objects, 4000)
assert "入口字段:start(string,必填);end(string,必填);store(string,缺省 all)" in text
assert '"op": "use_caliber"' in text and f'"workflow_id": "{wf}"' in text and '"card": "card"' in text
assert "SELECT" not in text.upper() and "orders" not in text
assert record["kind"] == "caliber" and record["named"]
# 蓝图助手那一支(不组合)照旧给复用写法,没有组合写法
plain_text, _ = await plain.detail([(i, "named") for i in plain.items if i.workflow_id == wf], 4000)
assert "复用写法" in plain_text and "use_caliber" not in plain_text
async def test_the_answer_prompt_switches_only_when_calibers_can_be_composed():
from app.look.service import LookService
assert copilot_context.LOOK_WHEN_ANSWER_PROMPT != copilot_context.LOOK_WHEN_PROMPT
assert "use_caliber" in copilot_context.LOOK_WHEN_ANSWER_PROMPT and "含退款" in copilot_context.LOOK_WHEN_ANSWER_PROMPT
async with SessionLocal() as session:
composing = LookService([await CaliberKind.load(session, compose=True)])
plain = LookService([await CaliberKind.load(session)])
assert copilot_context.look_when(composing) == copilot_context.LOOK_WHEN_ANSWER_PROMPT
assert copilot_context.look_when(plain) == copilot_context.LOOK_WHEN_PROMPT
assert copilot_context.composer(plain) is None and copilot_context.composer(composing) is not None
# --------------------------------------------------------------------------
# 落地:GraphApplier
# --------------------------------------------------------------------------
async def test_the_applier_lands_the_nodes_wires_the_entry_and_reports_refusals():
wf = await S.publish()
nodes: dict = {}
edges: list = []
applier = GraphApplier(nodes, edges, composer=await composer())
op = use(wf)
applied = applier.apply(op)
assert applied.changed and applied.forward and op["result"]["status"] == "ok"
assert set(nodes) == {"c1_fetch", "c1"} and edges == [{"source": "c1_fetch", "target": "c1", "sourceHandle": None}]
# 入口后加的:加上时接到第一个取数节点;模型再发一条同样的连线不重复
applier.apply({"op": "add_node", "node": {"id": "start", "type": "input", "label": "入口", "config": {}}})
assert {"source": "start", "target": "c1_fetch", "sourceHandle": None} in edges
assert not applier.apply({"op": "add_edge", "edge": {"source": "start", "target": "c1_fetch"}}).changed
assert len([e for e in edges if e["target"] == "c1_fetch"]) == 1
# 接着写时重发同一个选用:不再落地
applier.continuing()
assert not applier.apply(use(wf)).changed
assert "c1" in applier.landed()
bad = use(wf, id="c2", inputs={"start": "2026-09-01"})
refused = applier.apply(bad)
assert not refused.changed and refused.forward and bad["result"]["status"] == "refused"
checker = GraphChecker(applier)
first = await checker.issues()
assert [i["code"] for i in first if i["code"] == compose.REFUSED] == [compose.REFUSED]
assert "请填写「end」" in first[0]["message"] and "重新输出 use_caliber" in first[0]["for_model"]
# 交回过一次就不再交
assert not [i for i in await checker.issues() if i["code"] == compose.REFUSED]
# 兜底重放到上一轮的图上:拷进来的节点和接线照样在
applier.replay_onto({"nodes": [{"id": "start", "type": "input", "data": {"config": {}}}], "edges": []})
assert {"c1_fetch", "c1"} <= set(nodes) and any(e["target"] == "c1_fetch" for e in edges)
async def test_without_a_composer_use_caliber_is_dropped_like_any_unknown_op():
nodes: dict = {}
applier = GraphApplier(nodes, [])
applied = applier.apply({"op": "use_caliber", "id": "c1", "workflow_id": "w", "version": 1})
assert not applied.changed and not applied.forward and nodes == {}
# --------------------------------------------------------------------------
# 问数据走一遍:生成 → 运行 → 比对标 composed
# --------------------------------------------------------------------------
def _answer_script(wf: str) -> list[dict]:
from tests.test_copilot_answer_ops import _edge, _node
return [
{"op": "plan", "summary": "选用周销售口径"},
_node("start", "input", {}),
use(wf),
_node("write", "report", {"instructions": "回答:{{ input.question }}"}),
_edge("c1", "write"),
_node("out", "output", {"fields": [{"name": "答案", "value": "{{ nodes.write.text }}"}]}),
_edge("write", "out"),
{"op": "done", "explanation": "按口径周销售回答", "run": True},
]
async def test_an_answer_built_with_use_caliber_is_labelled_composed(client, monkeypatch):
from tests.test_copilot_answer_ops import BODY, _Scripted, _sse, _use
wf = await S.publish()
_use(monkeypatch, _Scripted(_answer_script(wf), []))
ops = await _sse(client, {**BODY, "instruction": "九月第一周卖了多少"})
[chosen] = [o for o in ops if o["op"] == "use_caliber"]
assert chosen["result"]["status"] == "ok" and chosen["result"]["workflow_name"] == "周销售"
assert not [o for o in ops if o["op"] == "add_node" and o["node"]["id"] in ("c1", "c1_fetch")], \
"拷进来的节点不另外转给界面:过程里只有 use_caliber 那一行"
[final] = [o for o in ops if o["op"] == "final"]
assert final["next"] == "run", final["issues"]
graph = final["graph"]
assert {n["id"] for n in graph["nodes"]} == {"start", "c1_fetch", "c1", "write", "out"}
assert not [i for i in validate_graph(GraphSpec.model_validate(graph)).issues if i.level == "error"]
S.script(monkeypatch, "九月第一周销售额 [[m:gmv]]。")
run = await run_manager.start(graph=graph, input_payload={"question": "九月第一周卖了多少"}, origin_kind="chat")
row = await S.wait(run.id)
assert row.status == "succeeded", row.error
await ledger_match.drain()
recorded = await store.latest(row.id)
content = recorded.content
assert content["algo"] == store.ALGO == "4"
[query] = content["queries"]
assert query["status"] == "composed" and query["node_id"] == "c1_fetch"
[cand] = query["candidates"]
assert cand["status"] == "proven" and cand["how"] == "composed" and cand["caliber"] == f"{wf}@1"
[label] = content["reports"]["write"]["labels"].values()
assert label["kind"] == "caliber" and label["how"] == "composed" and label["queries"] == [query["artifact"]]
assert content["whole"]["equivalent"] is True and content["whole"]["how"] == "composed"
assert recorded.content and (await store.latest(row.id)).trigger == "sealed"
async with SessionLocal() as session:
from app.db.models import Artifact
ref = await session.get(Artifact, (recorded.artifact, row.id))
assert ref.meta["calibers"] == [f"{wf}@1"] and ref.meta["counts"]["composed"] == 1
async def test_users_do_not_get_calibers_in_the_answer_look(monkeypatch):
"""使用者(开登录后的 user 角色):问数据里不看口径、不能选用(拷进来的取数 SQL 在他的图里看得到)。"""
import app.api.copilot as copilot
from app.api.copilot import GenerateIn
seen: dict = {}
async def spy(session, payload, sources, **kw):
seen["kinds"] = kw.get("kinds")
raise RuntimeError("到这里就够了")
monkeypatch.setattr(copilot, "_look_service", spy)
monkeypatch.setattr("app.api.deps.restricted", lambda: True)
with pytest.raises(RuntimeError):
await copilot.answer_ops(GenerateIn(instruction="x", intent="answer")).__anext__()
assert seen["kinds"] == ("table", "capability")
monkeypatch.setattr("app.api.deps.restricted", lambda: False)
with pytest.raises(RuntimeError):
await copilot.answer_ops(GenerateIn(instruction="x", intent="answer")).__anext__()
assert seen["kinds"] == ("table", "capability", "caliber")
with pytest.raises(RuntimeError):
await copilot.answer_ops(GenerateIn(instruction="x", intent="build")).__anext__()
assert seen["kinds"] == ("table", "capability")
# --------------------------------------------------------------------------
# card 填什么:协议写清两个 id,写错了交回的原因能一次改对(校验不放宽)
# --------------------------------------------------------------------------
def test_the_protocol_says_card_is_the_cards_node_id_and_id_is_the_name_you_give():
text = copilot_context.USE_CALIBER_PROMPT
assert '"card":"card"' in text and '"card":"口径卡节点 id"' not in text
assert "card 填那一版工作流图里口径卡的节点 id" in text and "不是你起的名字" in text
assert "id 是你给这次选用起的名字" in text and "workflow_id、version、card 原样照抄" in text
async def test_writing_your_own_id_as_the_card_is_refused_with_what_to_write_instead():
wf = await S.publish()
pool = await composer()
with pytest.raises(compose.ComposeRefused) as e:
pool.compose(use(wf, card="c1"), taken={"start"})
message = e.value.message
assert "没有口径卡「c1」" in message and "card 要填那一版工作流图里口径卡的节点 id" in message
assert "可填的有 「card」(口径卡「card」)" in message, message
assert "「c1」是你给这次选用起的 id,不是口径卡的节点 id" in message
# 别的写错(不是自己起的 id)不多那一句
with pytest.raises(compose.ComposeRefused) as other:
pool.compose(use(wf, card="other"), taken={"start"})
assert "是你给这次选用起的 id" not in other.value.message
# 校验不放宽:照着交回的原因改成节点 id 就过
made = pool.compose(use(wf, card="card"), taken={"start"})
assert made.op_id == "c1"