Files
AgentLab/backend/tests/test_caliber_docdraft_attach.py
T
ThearchyHelios 7f66985dba 口径草稿:从业务文档抽定义,再从一次真实运行接上取数路径
新增 caliber_drafts 表与 /api/ledger/drafts 一组接口:抽取在后台跑,文档缺失整单拒绝,
有未决问题时停在 questions_open,问题清完才到 path_pending。

接上运行沿用从运行固化的建图逻辑,定义按同名或同义词并入指标卡,expression 与 id 不动;
文档引用记进 origin.documents,复核能落回具体段落。接入只产出可试跑的草稿,不发布、不发起运行。
2026-11-03 22:50:14 +08:00

150 lines
7.8 KiB
Python
Raw 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/docattach.py)。
真的跑一次问数据(虚构店铺库),等后台比对做完,再把文档草稿接上这次运行:
- 取数路径来自运行,定义并进口径卡的同名指标,expression / id 一字不改;
- origin 记着用到的文档段落(document_id / chunk_id / ordinal / quote,引用去重)和 draft_id;
- 没成功、没比对、报告没引用查询的运行都接不上(409);
- 草稿里对不上的指标名进 warnings,不拦这一次接入;
- 只落草稿:不发布、不发起运行。
"""
from __future__ import annotations
import uuid
import pytest
from sqlalchemy import select
from app.core.artifact_store import graph_hash
from app.db.base import SessionLocal
from app.db.models import Chunk, Conversation, ConversationTurn, Document, Run, Workflow, WorkflowVersion
from app.engine.runner import run_manager
from app.ledger import docattach
from app.ledger import match as ledger_match
from tests import ledger_samples as S
#: 每个用例一句话的问题:问句进全站唯一的聚类,和别的用例用同一句会在 certify 的队列里互相看见
QUESTION = f"文档接上运行后卖了多少({uuid.uuid4().hex[:8]})"
START, END = "2026-09-01", "2026-09-08"
SQL = (f"SELECT SUM(amount) AS gmv FROM orders WHERE status = 'paid' AND paid_at >= '{START}' "
f"AND paid_at < '{END}'")
@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()
async def ask(monkeypatch) -> Run:
"""问数据的一轮:建对话和轮次、跑一次探索运行、等比对做完。"""
async with SessionLocal() as session:
conv = Conversation(title="问销售", kind="chat")
session.add(conv)
await session.commit()
turn = ConversationTurn(conversation_id=conv.id, question=QUESTION)
session.add(turn)
await session.commit()
turn_id = turn.id
S.script(monkeypatch, "这一周销售额 [[v:Q1.r0.gmv]]。")
run = await run_manager.start(graph=S.explore_graph(SQL), input_payload={"question": QUESTION},
origin_kind="chat", origin={"conversation_id": conv.id, "turn_id": turn_id})
row = await S.wait(run.id)
assert row.status == "succeeded", row.error
async with SessionLocal() as session:
(await session.get(ConversationTurn, turn_id)).run_id = row.id
await session.commit()
await ledger_match.drain()
return row
async def document() -> tuple[str, str]:
"""一份业务文档和其中的一段原文(口径定义的事实来源)。"""
async with SessionLocal() as session:
doc = Document(title="销售口径说明", content="销售额按已支付订单统计,退款订单不计入。")
session.add(doc)
await session.flush()
chunk = Chunk(document_id=doc.id, ordinal=3, content="销售额按已支付订单统计,退款订单不计入。")
session.add(chunk)
await session.commit()
return doc.id, chunk.id
def payload(document_id: str, chunk_id: str, *, name: str = "gmv") -> dict:
citation = {"chunk": chunk_id, "document_id": document_id, "quote": "销售额按已支付订单统计"}
return {"question": QUESTION, "metrics": [{
"name": name, "synonyms": ["销售额"], "unit": "元",
"definition": {"text": "按已支付订单统计,退款不计入", "citations": [citation, dict(citation)]}}]}
async def test_attach_merges_the_definition_into_the_run_derived_draft(monkeypatch):
run = await ask(monkeypatch)
doc_id, chunk_id = await document()
async with SessionLocal() as session:
made = await docattach.attach(session, payload=payload(doc_id, chunk_id), name="周销售口径",
run_id=run.id, draft_id="draft-1")
assert made["version"] == 1 and made["warnings"] == []
[metric] = made["metrics"]
assert metric["id"] == "m1" and metric["expression"] == "cell(nodes.fetch, 0, 'gmv')"
assert metric["definition"]["text"] == "按已支付订单统计,退款不计入"
async with SessionLocal() as session:
wf = await session.get(Workflow, made["workflow_id"])
version = (await session.execute(
select(WorkflowVersion).where(WorkflowVersion.workflow_id == wf.id))).scalar_one()
assert wf.status == "draft" and wf.tags == ["doc_draft"] and wf.published_version is None
assert version.version == 1 and version.graph_hash == graph_hash(wf.graph)
assert version.note == f"从文档草稿接上运行 {run.id}"
card = next(n for n in wf.graph["nodes"] if n["type"] == "metrics")["data"]["config"]
[card_metric] = card["metrics"]
assert card_metric["id"] == "m1" and card_metric["expression"] == "cell(nodes.fetch, 0, 'gmv')", \
"草稿只并定义,不改取数路径"
assert card_metric["definition"]["text"] == "按已支付订单统计,退款不计入"
assert card["caliber"] == "周销售口径"
# 取数路径照抄运行:字面量都是常量、没有入口参数,SQL 原样
fetch = next(n for n in wf.graph["nodes"] if n["type"] == "tool")
assert fetch["data"]["config"]["args"]["sql"] == SQL and fetch["data"]["config"]["tool"] == S.tool()
# origin:来源运行、草稿 id、引用的文档段落(两条一样的引用去重,ordinal 从库里补)
assert wf.origin["run_id"] == run.id and wf.origin["freeze"] is True
assert wf.origin["sources"] == [{"run_id": run.id}] and wf.origin["draft_id"] == "draft-1"
assert wf.origin["documents"] == [{"document_id": doc_id, "chunk_id": chunk_id, "ordinal": 3,
"quote": "销售额按已支付订单统计"}]
assert "1 个指标" in wf.description and run.id in wf.description
async def test_attach_refuses_runs_that_are_missing_failed_or_not_matched(monkeypatch):
async with SessionLocal() as session:
session.add(Run(id="failed-run", status="failed", graph={}, input={}, output={}))
session.add(Run(id="unmatched-run", status="succeeded", graph={}, input={}, output={}))
await session.commit()
for run_id, message in (("nope", docattach.NOT_FOUND),
("failed-run", docattach.NOT_SUCCEEDED),
("unmatched-run", docattach.NOT_MATCHED)):
with pytest.raises(docattach.AttachRefused) as caught:
await docattach.attach(session, payload={}, name="周销售口径", run_id=run_id)
assert caught.value.status == 409 and caught.value.message == message
# 成功、也比对过,但报告没引用任何查询:没有可接上的取数
row = await S.explore(monkeypatch, S.ELSEWHERE, text="一共几家店,没有引用。")
async with SessionLocal() as session:
with pytest.raises(docattach.AttachRefused) as caught:
await docattach.attach(session, payload={}, name="周销售口径", run_id=row.id)
assert caught.value.status == 409 and caught.value.message == docattach.NO_QUERIES
async def test_a_metric_name_that_does_not_meet_the_card_goes_to_warnings(monkeypatch):
run = await ask(monkeypatch)
doc_id, chunk_id = await document()
async with SessionLocal() as session:
made = await docattach.attach(session, payload=payload(doc_id, chunk_id, name="毛利率"),
name="毛利口径", run_id=run.id)
assert len(made["warnings"]) == 1 and "毛利率" in made["warnings"][0]
[metric] = made["metrics"]
assert metric["id"] == "m1" and "definition" not in metric, "对不上的定义不往指标上写"
async with SessionLocal() as session:
wf = await session.get(Workflow, made["workflow_id"])
assert wf is not None and wf.status == "draft"