Files
ThearchyHelios 0a3c45eb97 口径账本:合并查询的格照实写为什么没比对;账本测试按用例各用一个数据源隔开
- ledger/match.py:合并查询的快照不和口径比(它的数来自各个输入的查询),引用了它的格以前标成「没有比对记录」,现在写明
  「合并查询的格不和口径比对」;它不进工件的 queries,统计不数它。
- 测试:账本的用例以前在开头把别的用例发布的版本一律退役,好让候选口径只剩自己的——并行时会碰到同一个进程里别的测试模块的
  数据。改成每个用例一个名字不同的数据源(候选口径本来就按数据源预筛),不动别人的数据。补启动补比随调度开关起后台任务的用例。
2026-10-16 14:23:41 +08:00

161 lines
7.9 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.
"""口径账本测试共用的虚构样本:一个店铺库、一份受管口径「周销售」、跑一次探索运行。数据全是编的。
- 店铺库(SQLite):orders(id, region, status, amount, paid_at, phone),九单,状态有 paid / refunded / open。每个用例一个
名字不同的数据源(fresh_source):候选口径按数据源预筛,别的用例发布的口径不会混进来,也不必去动别的用例的数据。
- 口径「周销售」v1(受管):入口两个字段 start / end,取数节点 fetch 的 SQL 是
SELECT SUM(amount) AS gmv FROM orders WHERE status = 'paid' AND paid_at >= '{{ input.start }}' AND paid_at < '{{ input.end }}',
口径卡 card 上一个指标 gmv = cell(nodes.fetch, 0, 'gmv')。
- 探索运行:入口 → 调用工具 q(执行的 SQL 由测试给)→ 可选的口径卡 → 报告撰写(模型的答复由测试给,引用 [[v:Q1.r0.gmv]] /
[[m:gmv]])→ 出口。
"""
from __future__ import annotations
import asyncio
import sqlite3
from typing import Any
from langchain_core.messages import AIMessage
from sqlalchemy import select
from app.core.artifact_store import graph_hash
from app.data.engine import engines
from app.db.base import SessionLocal
from app.db.models import DataSource, Run, Workflow, WorkflowVersion
TEMPLATE = ("SELECT SUM(amount) AS gmv FROM orders WHERE status = 'paid' "
"AND paid_at >= '{{ input.start }}' AND paid_at < '{{ input.end }}'")
#: 和口径等价的写法(别名不同、条件换序)
EQUIVALENT = ("select sum(o.amount) as 销售额 from orders o where o.paid_at >= '2026-09-01' and o.status = 'paid' "
"and o.paid_at < '2026-09-08'")
#: 少了「只算已支付」这个条件(含退款的周销售)
WITH_REFUNDS = "SELECT SUM(amount) AS gmv FROM orders WHERE paid_at >= '2026-09-01' AND paid_at < '2026-09-08'"
#: 查的是别的表(没有相关口径)
ELSEWHERE = "SELECT COUNT(*) AS n FROM stores"
ROWS = [(1, "east", "paid", 100.0, "2026-09-01", "13800000001"), (2, "east", "paid", 50.5, "2026-09-03", "13800000002"),
(3, "west", "refunded", 30.0, "2026-09-04", "13800000003"), (4, "west", "paid", 20.0, "2026-09-07", "13800000004"),
(5, "east", "open", 12.5, "2026-09-07", "13800000005"), (6, "west", "paid", 70.0, "2026-09-09", "13800000006"),
(7, "east", "paid", 5.0, "2026-08-31", "13800000007"), (8, "west", "refunded", 8.0, "2026-09-02", "13800000008"),
(9, "east", "paid", 1.0, "2026-09-08", "13800000009")]
def node(nid: str, ntype: str, **config: Any) -> dict[str, Any]:
return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, "data": {"label": nid, "config": config}}
def chain(*nodes: dict[str, Any]) -> dict[str, Any]:
return {"nodes": list(nodes), "edges": [{"source": a["id"], "target": b["id"]} for a, b in zip(nodes, nodes[1:])]}
def build_shop(path) -> None:
db = sqlite3.connect(path)
db.executescript("CREATE TABLE orders (id INTEGER PRIMARY KEY, region TEXT, status TEXT, amount REAL, "
"paid_at TEXT, phone TEXT); CREATE TABLE stores (id INTEGER PRIMARY KEY, name TEXT);")
db.executemany("INSERT INTO orders VALUES (?, ?, ?, ?, ?, ?)", ROWS)
db.executemany("INSERT INTO stores VALUES (?, ?)", [(1, "s1"), (2, "s2")])
db.commit()
db.close()
#: 这个用例的数据源名(fresh_source 换):图里的查询工具是 db_query__<它>
CURRENT = {"source": "shop"}
def tool() -> str:
return f"db_query__{CURRENT['source']}"
async def fresh_source(path) -> str:
"""建库、起一个这个用例专用的数据源(名字不重复),返回数据源名。"""
import uuid
build_shop(path)
CURRENT["source"] = f"shop_{uuid.uuid4().hex[:8]}"
await set_source(path)
return CURRENT["source"]
async def set_source(path, *, options: dict[str, Any] | None = None) -> str:
name = CURRENT["source"]
async with SessionLocal() as session:
row = (await session.execute(select(DataSource).where(DataSource.name == name))).scalar_one_or_none()
if row is None:
row = DataSource(name=name, kind="sqlite", database=str(path), readonly=True, enabled=True)
session.add(row)
row.database, row.kind, row.enabled, row.readonly = str(path), "sqlite", True, True
row.options = dict(options or {})
await session.commit()
source_id = row.id
await engines.invalidate(source_id)
return source_id
def caliber_graph(sql: str = TEMPLATE, *, expression: str = "cell(nodes.fetch, 0, 'gmv')") -> dict[str, Any]:
return chain(
node("start", "input", fields=[{"name": "start", "required": True}, {"name": "end", "required": True},
{"name": "store", "default": "all"}]),
node("fetch", "tool", tool=tool(), args={"sql": sql}),
node("card", "metrics", caliber="周销售", caliber_version="v1", metrics=[
{"id": "gmv", "name": "销售额", "unit": "元", "decimals": 2, "format": "thousands", "expression": expression}]),
node("write", "report", instructions="写周销售"),
node("out", "output", fields=[{"name": "报告", "value": "{{ nodes.write.text }}"}]),
)
async def publish(name: str = "周销售", *, graph: dict[str, Any] | None = None, level: str = "governed",
version: int = 1, workflow_id: str | None = None) -> str:
"""建一个工作流并把第 version 版按 level 发布(直接写库,和发布接口写下的几列一样)。返回工作流 id。"""
graph = graph or caliber_graph()
async with SessionLocal() as session:
wf = await session.get(Workflow, workflow_id) if workflow_id else None
if wf is None:
wf = Workflow(name=name, graph=graph, status="published")
session.add(wf)
await session.flush()
session.add(WorkflowVersion(workflow_id=wf.id, version=version, graph=graph, graph_hash=graph_hash(graph),
level=level))
wf.published_version, wf.version, wf.graph = version, version, graph
await session.commit()
return wf.id
def explore_graph(sql: str, *, card_expression: str | None = None) -> dict[str, Any]:
parts = [node("start", "input"), node("q", "tool", tool=tool(), args={"sql": sql})]
if card_expression:
parts.append(node("mine", "metrics", caliber="我的口径", caliber_version="v1", metrics=[
{"id": "gmv", "name": "销售额", "unit": "元", "decimals": 2, "format": "thousands",
"expression": card_expression}]))
parts += [node("write", "report", instructions="写答案"),
node("out", "output", fields=[{"name": "答案", "value": "{{ nodes.write.text }}"}])]
return chain(*parts)
def script(monkeypatch, text: str) -> None:
from app.providers import mock_model
monkeypatch.setattr(mock_model.MockChatModel, "_decide", lambda self, messages: AIMessage(content=text))
async def wait(run_id: str) -> Run:
for _ in range(400):
await asyncio.sleep(0.05)
async with SessionLocal() as session:
row = await session.get(Run, run_id)
if row.status in ("succeeded", "failed") and row.manifest_seq is not None:
return row
raise AssertionError(f"run {run_id} 没跑完:{row.status}")
async def explore(monkeypatch, sql: str, *, text: str = "本周销售额 [[v:Q1.r0.gmv]]。", origin: str | None = "canvas",
card_expression: str | None = None, run_class: str = "exploratory") -> Run:
"""跑一次探索运行、等它封存完、等后台的口径比对做完。"""
from app.engine.runner import run_manager
from app.ledger import match as ledger_match
script(monkeypatch, text)
run = await run_manager.start(graph=explore_graph(sql, card_expression=card_expression), input_payload={},
run_class=run_class, origin_kind=origin)
row = await wait(run.id)
assert row.status == "succeeded", row.error
await ledger_match.drain()
return row