mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 09:51:59 +00:00
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 条失败。
325 lines
17 KiB
Python
325 lines
17 KiB
Python
"""组合式问数: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"
|