Files
AgentLab/backend/tests/test_ledger_cells.py

334 lines
20 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.
"""口径标签逐格判(app/ledger/match._cells、labels._view,algo 4)。
真实模型下问「上周的销售额是多少」,助手写 SUM(amount) AS 销售额, COUNT(*) AS 订单数, MIN / MAX(paid_date) AS 区间首日 / 末日,
报告四列都引用了;口径「周销售」只取 SUM(amount)。以前按「全部引用列」投影后还是 4 列,对不上口径的 1 列,整条比、四个数都
标相关口径,说明整段兜底。现在每个被引用的格单独投影到那一列,和口径每个指标读的那一列逐个比(仍只走 match_template /
explain_sql_diff,上次的投影规则照用):
- 销售额标「等价已证明」,订单数、首末日照实标相关口径;整题不等价(订单数不来自口径),不自动出具、不给出具入口;
- 同一个数在两份口径里只证明了一份时标那一份;口径有多个指标时按列配对;一个指标用了同一条一行查询的两格,两格各自
证明了就算来自口径,多行的查询不拿逐格的结论合起来用;
- 不错给信任:多一个分组列、DISTINCT 的查询逐格也不投影;COALESCE、date('now') 现算日期的写法仍然不等价,说明说得出来;
- 评测逐题比 SQL 取和期望配上的那一格。
数据是虚构的。
"""
from __future__ import annotations
import sqlite3
import uuid
from types import SimpleNamespace
import pytest
from httpx import ASGITransport, AsyncClient
from app.engine.runner import run_manager
from app.ledger import labels as ledger_labels
from app.ledger import match as ledger_match
from app.ledger import store
from tests import ledger_samples as S
#: 口径「周销售」的取数(照真实例子:按支付日期、两头都含)
WEEKLY = ("SELECT SUM(amount) AS gmv FROM orders WHERE status = 'paid' "
"AND paid_date >= '{{ input.start }}' AND paid_date <= '{{ input.end }}'")
WINDOW = "status = 'paid' AND paid_date >= '2026-10-05' AND paid_date <= '2026-10-11'"
#: 助手写的(照真实例子)
ANSWER = ("SELECT SUM(amount) AS 销售额, COUNT(*) AS 订单数, MIN(paid_date) AS 区间首日, MAX(paid_date) AS 区间末日 "
f"FROM orders WHERE {WINDOW}")
COLUMNS = ["销售额", "订单数", "区间首日", "区间末日"]
ROWS = [(1, "paid", 120.0, "2026-10-04"), (2, "paid", 80.0, "2026-10-05"), (3, "paid", 45.5, "2026-10-08"),
(4, "refunded", 30.0, "2026-10-09"), (5, "paid", 64.5, "2026-10-11"), (6, "open", 10.0, "2026-10-10"),
(7, "paid", 99.0, "2026-10-12")]
async def week_source(tmp_path) -> None:
path = tmp_path / "week.db"
db = sqlite3.connect(path)
db.executescript("CREATE TABLE orders (id INTEGER PRIMARY KEY, status TEXT, amount REAL, paid_date TEXT);")
db.executemany("INSERT INTO orders VALUES (?, ?, ?, ?)", ROWS)
db.commit()
db.close()
S.CURRENT["source"] = f"week_{uuid.uuid4().hex[:8]}"
await S.set_source(path)
def caliber(sql: str = WEEKLY, metrics: list[dict] | None = None, workflow_id: str = "wf") -> ledger_match.Caliber:
graph = S.caliber_graph(sql)
if metrics is not None:
graph["nodes"][2]["data"]["config"]["metrics"] = metrics
found = ledger_match.caliber_of(workflow_id, SimpleNamespace(graph=graph, version=1, level="governed",
graph_hash="h"))
assert found is not None
return found
def compare(sql: str, columns: list[str], cited: set[str], calibers: list[ledger_match.Caliber] | None = None) -> dict:
query = ledger_match.Query("q1", "q", S.CURRENT["source"], sql, columns, frozenset({"orders"}), "sqlite")
[row] = ledger_match.compare_queries([query], calibers or [caliber()], referenced={"q1": cited})
return row
def label(row: dict, column: str) -> dict:
return ledger_labels.cell_label("q1", column, {"q1": row})
def texts(cand: dict) -> list[str]:
return [i["text"] for i in (cand.get("diff") or {}).get("items") or []]
# --------------------------------------------------------------------------
# 逐格判:同一条查询里一列等价、一列不等价各自标对
# --------------------------------------------------------------------------
def test_each_cited_column_is_judged_on_its_own():
row = compare(ANSWER, COLUMNS, set(COLUMNS))
assert row["status"] == "related", "按全部引用列投影还是 4 列,整条照旧不等价"
assert row["one_row"] is True and set(row["cells"]) == set(COLUMNS)
sales = label(row, "销售额")
assert sales["kind"] == "caliber" and sales["how"] == "independent" and sales["column"] == "gmv"
assert sales["binding"] == {"start": "2026-10-05", "end": "2026-10-11"}
assert [label(row, c)["kind"] for c in COLUMNS[1:]] == ["related", "related", "related"]
[orders] = row["cells"]["订单数"]["candidates"]
assert texts(orders) == ["这次多查了 3 列,不影响这个数", "这次数的是行数,口径对 amount 求和"]
assert orders["diff"]["fallback"] is False
# 整题:订单数不来自口径,不等价
whole = ledger_labels.whole_of({c: label(row, c) for c in COLUMNS}, {})
assert whole["equivalent"] is False and whole["counts"]["caliber"] == 1
assert whole["reason"] == "有的数和相关口径不一致,不来自口径"
# 只引用销售额一格(不逐格):整条按引用的列投影就证明了,照旧
single = compare(ANSWER, COLUMNS, {"销售额"})
assert single["status"] == "proven" and "cells" not in single
def test_a_number_proven_against_only_one_of_two_calibers_is_labelled_with_that_one():
paid = caliber(workflow_id="wf-paid")
everything = caliber(WEEKLY.replace("status = 'paid' AND ", ""), workflow_id="wf-all")
row = compare(ANSWER, COLUMNS, set(COLUMNS), [paid, everything])
sales = label(row, "销售额")
assert sales["kind"] == "caliber" and sales["caliber"] == "wf-paid@1"
cands = {c["caliber"]: c for c in row["cells"]["销售额"]["candidates"]}
assert cands["wf-paid@1"]["status"] == "proven" and cands["wf-all@1"]["status"] == "related"
assert any("status = 'paid'" in t for t in texts(cands["wf-all@1"]))
# 反过来:含退款的写法只对得上另一份
refunds = ANSWER.replace("status = 'paid' AND ", "")
other = label(compare(refunds, COLUMNS, set(COLUMNS), [paid, everything]), "销售额")
assert other["kind"] == "caliber" and other["caliber"] == "wf-all@1"
def test_a_caliber_with_several_metrics_is_paired_column_by_column():
two = caliber(WEEKLY.replace("SUM(amount) AS gmv", "SUM(amount) AS gmv, COUNT(*) AS n"),
[{"id": "gmv", "name": "销售额", "expression": "cell(nodes.fetch, 0, 'gmv')"},
{"id": "n", "name": "订单数", "expression": "cell(nodes.fetch, 0, 'n')"}])
sql = f"SELECT COUNT(*) AS 单数, SUM(amount) AS 销售额, MAX(paid_date) AS 末日 FROM orders WHERE {WINDOW}"
row = compare(sql, ["单数", "销售额", "末日"], {"单数", "销售额", "末日"}, [two])
assert row["status"] == "related"
assert (label(row, "单数")["kind"], label(row, "单数")["column"]) == ("caliber", "n")
assert (label(row, "销售额")["kind"], label(row, "销售额")["column"]) == ("caliber", "gmv")
assert label(row, "末日")["kind"] == "related"
def test_a_metric_over_two_cells_uses_the_cell_proofs_only_on_a_one_row_query():
two = caliber(WEEKLY.replace("SUM(amount) AS gmv", "SUM(amount) AS gmv, COUNT(*) AS n"),
[{"id": "aov", "name": "客单价", "expression": "cell(nodes.fetch, 0, 'gmv') / cell(nodes.fetch, 0, 'n')"}])
sql = f"SELECT SUM(amount) AS 销售额, COUNT(*) AS 订单数, MAX(paid_date) AS 末日 FROM orders WHERE {WINDOW}"
row = compare(sql, ["销售额", "订单数", "末日"], {"销售额", "订单数", "末日"}, [two])
inputs = [{"path": f"cell(nodes.q, 0, '{c}')", "artifact": "q1", "via": "tool_cell", "locator": {"row": 0, "column": c}}
for c in ("销售额", "订单数")]
card = {"metrics": [{"id": "aov", "expression": "cell(nodes.q, 0, '销售额') / cell(nodes.q, 0, '订单数')",
"inputs": inputs}]}
by_key = {two.key: {"expressions": list(two.expressions)}}
got = ledger_labels.metric_label(card, "aov", {"q1": row}, by_key)
assert got["kind"] == "caliber" and got["metric"] == "aov", got
# 同一条查询要是多行的,每一列各自对得上不等于每一行的两列一起对得上:不拿逐格的结论合起来用
multi = {**row, "one_row": False}
assert ledger_labels.metric_label(card, "aov", {"q1": multi}, by_key)["kind"] == "related"
# --------------------------------------------------------------------------
# 不错给信任:逐格投影照样守上次的投影规则;COALESCE、现算日期仍然不等价,说明说得出来
# --------------------------------------------------------------------------
@pytest.mark.parametrize("sql", [
f"SELECT paid_date, SUM(amount) AS 销售额, COUNT(*) AS 订单数 FROM orders WHERE {WINDOW} GROUP BY paid_date",
f"SELECT DISTINCT amount AS 销售额, paid_date AS 订单数, id FROM orders WHERE {WINDOW}",
], ids=["extra-group-by", "distinct"])
def test_cells_are_not_projected_where_the_projection_rules_say_no(sql):
columns = ["paid_date", "销售额", "订单数"] if "GROUP BY" in sql else ["销售额", "订单数", "id"]
row = compare(sql, columns, {"销售额", "订单数"})
assert row["status"] == "related"
for name in ("销售额", "订单数"):
cell = row["cells"][name]
assert cell["status"] == "related" and all("projection" not in c for c in cell["candidates"]), name
assert cell["candidates"] == row["candidates"], "投影不了的格照用整条查询的结论"
assert label(row, name)["kind"] == "related"
def test_coalesce_around_the_sum_is_still_not_equivalent_and_says_why():
sql = ANSWER.replace("SUM(amount) AS 销售额", "COALESCE(SUM(amount), 0) AS 销售额")
row = compare(sql, COLUMNS, set(COLUMNS))
cell = row["cells"]["销售额"]
assert cell["status"] == "related" and label(row, "销售额")["kind"] == "related"
[cand] = cell["candidates"]
assert texts(cand) == ["这次多查了 3 列,不影响这个数",
"这次的销售额套了 COALESCE(…, 0):没有符合条件的行、或者 amount 都为空时这次是 0、口径是空"]
assert cand["diff"]["fallback"] is False
def test_coalesce_explanation_holds_on_the_virtual_shop():
"""两条 SQL 的不同是真的:没有符合条件的行时一个是 0、一个是空。COUNT 套 COALESCE 两边其实一样,不说成不同。"""
from app.data.sql_semantics import explain_sql_diff
a = "SELECT COALESCE(SUM(amount), 0) AS s FROM orders WHERE status = 'nothing'"
b = "SELECT SUM(amount) AS s FROM orders WHERE status = 'nothing'"
db = sqlite3.connect(":memory:")
db.executescript("CREATE TABLE orders (id INTEGER, status TEXT, amount REAL)")
assert db.execute(a).fetchall() != db.execute(b).fetchall()
diff = explain_sql_diff(a, b, "sqlite")
assert not diff.equivalent and [i.aspect for i in diff.items] == ["aggregate"] and "COALESCE" in diff.items[0].text
counted = explain_sql_diff("SELECT COALESCE(COUNT(*), 0) AS n FROM orders", "SELECT COUNT(*) AS n FROM orders",
"sqlite")
assert not [i for i in counted.items if "COALESCE" in i.text]
def test_round_around_the_value_is_still_not_equivalent_and_says_why():
"""一边套了 ROUND(…, 2)、另一边没取整:取整改了值,照旧不等价;说明说清是哪一边取整、保留几位,不退回兜底。
评测的期望 SQL、口径的取数里常见这种写法(样例题集原来就是),说不清时整段兜底,人看不出只差在取整上。"""
from app.data.sql_semantics import explain_sql_diff
a = "SELECT SUM(amount) AS s FROM orders WHERE status = 'paid'"
b = "SELECT ROUND(SUM(amount), 2) AS s FROM orders WHERE status = 'paid'"
db = sqlite3.connect(":memory:")
db.executescript("CREATE TABLE orders (id INTEGER, status TEXT, amount REAL);"
"INSERT INTO orders VALUES (1, 'paid', 10.005), (2, 'paid', 0.001)")
assert db.execute(a).fetchall() != db.execute(b).fetchall(), "取整确实改了值"
diff = explain_sql_diff(a, b, "sqlite")
assert not diff.equivalent and not diff.fallback
assert [(i.aspect, i.text) for i in diff.items] == [("aggregate", "B 的 s 套了 ROUND(…, 2):保留 2 位小数,A 没有取整")]
whole = explain_sql_diff("SELECT ROUND(SUM(amount)) AS s FROM orders", "SELECT SUM(amount) AS s FROM orders", "sqlite")
assert not whole.equivalent and [i.text for i in whole.items] == ["A 的 s 套了 ROUND(…):取整到个位,B 没有取整"]
other = explain_sql_diff("SELECT ROUND(AVG(amount), 2) AS s FROM orders", "SELECT SUM(amount) AS s FROM orders", "sqlite")
assert not [i for i in other.items if "ROUND" in i.text], "取整的不是同一个表达式时不说成只差在取整"
def test_dates_computed_from_today_are_not_equivalent_and_say_so():
from app.data.sql_semantics import explain_sql_diff
now = ANSWER.replace("paid_date >= '2026-10-05' AND paid_date <= '2026-10-11'",
"paid_date >= date('now', '-7 days') AND paid_date <= date('now')")
row = compare(now, COLUMNS, set(COLUMNS))
cell = row["cells"]["销售额"]
assert cell["status"] == "related"
said = texts(cell["candidates"][0])
assert said[0] == "这次多查了 3 列,不影响这个数"
assert any("按运行当天现算" in t and "「start」" in t for t in said), said
# 拿写定日期的期望 SQL 直接比(评测的 SQL 题):说清一边现算、一边写定
fixed = "SELECT SUM(amount) AS gmv FROM orders WHERE " + WINDOW
single = now.replace(", COUNT(*) AS 订单数, MIN(paid_date) AS 区间首日, MAX(paid_date) AS 区间末日", "")
diff = explain_sql_diff(single, fixed, "sqlite", labels=("这次", "口径"))
assert not diff.equivalent and not diff.fallback
[item] = diff.items
assert item.aspect == "time_range" and "按运行当天现算" in item.text and "口径写定的是" in item.text
# --------------------------------------------------------------------------
# 真跑一遍真实例子:标签、整题、出具入口、证据片段的 ledger 键
# --------------------------------------------------------------------------
@pytest.fixture
async def engine_up(tmp_path):
await run_manager.setup()
await week_source(tmp_path)
yield
await ledger_match.drain()
await run_manager.shutdown()
async def by_column(run_id: str, labels: dict) -> dict[str, dict]:
"""标签按它引用的列排开(报告文档里每个片段引用的是哪一列)。"""
from app.api.evidence import _reports, _sealed
from app.engine.evidence import iter_units
[report] = _reports(await _sealed(run_id))
out = {}
for _, unit in iter_units(report.doc):
for seg in unit.get("segments") or []:
if seg.get("id") in labels:
out[seg["cite"]["locator"]["column"]] = {**labels[seg["id"]], "_segment": seg["id"]}
return out
async def test_last_weeks_sales_labels_only_the_sales_number_as_from_the_caliber(engine_up, monkeypatch):
graph = S.caliber_graph(WEEKLY)
wf = await S.publish(graph=graph)
row = await S.explore(monkeypatch, ANSWER, origin="chat", text=(
"上周销售额 [[v:Q1.r0.销售额]] 元,共 [[v:Q1.r0.订单数]] 单([[v:Q1.r0.区间首日]] 至 [[v:Q1.r0.区间末日]])。"))
content = (await store.latest(row.id)).content
assert content["algo"] == store.ALGO == "5"
[query] = content["queries"]
assert query["status"] == "related" and set(query["cells"]) == set(COLUMNS)
[labels] = [r["labels"] for r in content["reports"].values()]
cols = await by_column(row.id, labels)
assert cols["销售额"]["kind"] == "caliber" and cols["销售额"]["caliber"] == f"{wf}@1"
assert cols["销售额"]["binding"] == {"start": "2026-10-05", "end": "2026-10-11"}
assert {c: cols[c]["kind"] for c in COLUMNS[1:]} == {c: "related" for c in COLUMNS[1:]}
whole = content["whole"]
assert whole["equivalent"] is False and whole["counts"] == {"caliber": 1, "external": 0, "derived": 0,
"assessed": 0, "related": 3, "reasoning": 0,
"unknown": 0}
# 工件 meta:逐格证明了的口径也记上(召回按旧版本反查只读它)
from sqlalchemy import select
from app.db.base import SessionLocal
from app.db.models import Artifact
async with SessionLocal() as session:
meta = (await session.execute(select(Artifact.meta).where(
Artifact.id == (await store.latest(row.id)).artifact, Artifact.run_id == row.id))).scalar_one()
assert meta["calibers"] == [f"{wf}@1"]
from app.main import app
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
body = (await client.get(f"/api/runs/{row.id}/ledger")).json()
assert not [a for a in body["actions"] if a["kind"] == "issue"], "整题不等价:不给按口径出具的入口(拍板 L1)"
assert body["issued"] == [], "不自动出具"
[q] = body["queries"]
assert q["cells"]["销售额"]["status"] == "proven" and q["cells"]["订单数"]["status"] == "related"
shown = (await by_column(row.id, body["reports"]["write"]["labels"]))
assert shown["销售额"]["kind"] == "caliber" and shown["销售额"]["workflow_name"] == "周销售"
seg = (await client.get(f"/api/runs/{row.id}/evidence/segments/{cols['销售额']['_segment']}")).json()
assert seg["ledger"]["label"]["kind"] == "caliber"
seg = (await client.get(f"/api/runs/{row.id}/evidence/segments/{cols['订单数']['_segment']}")).json()
assert seg["ledger"]["label"]["kind"] == "related"
where = seg["ledger"]["label"]["related"][0]["diff"]["items"]
assert [i["text"] for i in where] == ["这次多查了 3 列,不影响这个数", "这次数的是行数,口径对 amount 求和"]
# --------------------------------------------------------------------------
# 评测逐题比 SQL:取和期望配上的那一格
# --------------------------------------------------------------------------
async def test_the_per_case_sql_match_takes_the_cell_that_matched_the_expectation(engine_up, monkeypatch):
from sqlalchemy import select
from app.db.base import SessionLocal
from app.db.models import DataSource
from app.eval import score
run = await S.explore(monkeypatch, ANSWER, text="上周销售额 [[v:Q1.r0.销售额]] 元,共 [[v:Q1.r0.订单数]] 单。")
answer = await score.read_answer(run.id)
async with SessionLocal() as session:
sid = (await session.execute(select(DataSource.id).where(DataSource.name == S.CURRENT["source"]))).scalar()
fixed = "SELECT SUM(amount) AS gmv FROM orders WHERE " + WINDOW
case = {"gold": {"kind": "sql", "datasource_id": sid, "sql": "x"}, "expectations": []}
gold = {"status": "ok", "before": {"rendered": {"sql": fixed}, "sets": []}}
# 没给配上的格:逐格证明了的那一格
match = await score.sql_match(case, gold, answer)
assert match["status"] == "proven" and match["column"] == "销售额", match
# 期望配上的是订单数那一格:取那一格的结论,差在哪按那一格说
query = answer.cited[0]
match = await score.sql_match(case, gold, answer, cells=[{"artifact": query, "column": "订单数"}])
assert match["status"] == "related" and match["column"] == "订单数" and match["aspects"] == ["aggregate"], match