mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 07:11:57 +00:00
0a3c45eb97
- ledger/match.py:合并查询的快照不和口径比(它的数来自各个输入的查询),引用了它的格以前标成「没有比对记录」,现在写明 「合并查询的格不和口径比对」;它不进工件的 queries,统计不数它。 - 测试:账本的用例以前在开头把别的用例发布的版本一律退役,好让候选口径只剩自己的——并行时会碰到同一个进程里别的测试模块的 数据。改成每个用例一个名字不同的数据源(候选口径本来就按数据源预筛),不动别人的数据。补启动补比随调度开关起后台任务的用例。
161 lines
7.9 KiB
Python
161 lines
7.9 KiB
Python
"""口径账本测试共用的虚构样本:一个店铺库、一份受管口径「周销售」、跑一次探索运行。数据全是编的。
|
||
|
||
- 店铺库(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
|