"""从业务文档起草口径(app/ledger/draftplan.py + /api/ledger/drafts…):一次产出定义、SQL、入口和图。 - 抽取先建一行 drafting、在后台跑模型;结果写成草稿:payload 里有 metrics / sql / entry_fields / trial,工作流和 v1 已建; - 没有数据源、或数据源没有表结构时 422,一个草稿都不建;SQL 对照不上目录时草稿 failed; - 有未决问题 → questions_open(图和试跑照样有),没有 → ready; - 一轮覆盖不完就带着 covered 续跑:第二轮收成第二个取数节点,两组指标挂同一张口径卡;某一轮没交新指标就停; - PATCH 换指标 / 消问题并重算状态;dismiss 置 rejected 并写审计;列表给计数、按改过的先后排; - GET /drafts/{id}/trial 交回契约里的形状。 模型一律注入假模型(monkeypatch resolve_draft_model),试跑也换成假的(不启动引擎),测试不发真实请求。 """ from __future__ import annotations import copy import json import sys import types import uuid import pytest from httpx import ASGITransport, AsyncClient from sqlalchemy import delete, select from app.api import ledger as api_ledger from app.data import recipe_ai from app.db.base import SessionLocal from app.db.models import (AuditEvent, CaliberDraft, Chunk, DataSource, Document, Run, Workflow, WorkflowVersion) from app.engine.schema import GraphSpec, validate_graph from app.main import app #: 真实的试跑实现:autouse 的 fake_trial 会把它换掉,这一个用例换回来跑真引擎 REAL_RUN_TRIAL = api_ledger._run_trial #: 文档原文:两段,退款率的事实来源 CHUNK_ONE = "退款率 = 已支付后退款的订单数 / 已支付订单数" CHUNK_TWO = "退款率按门店拆,只算已支付的订单" def plan_reply(chunk_id: str, *, questions=None, sql: str | None = None, metrics=None) -> str: """模型回复:一条指标,引用逐字抄原文。""" metric = { "id": "m1", "name": "已支付退款率", "synonyms": ["退款率"], "unit": "%", "decimals": 2, "expression": "cell(nodes.fetch, 0, 'refund_rate')", "definition": {"text": "已支付订单里发生退款的比例", "scope": "全部门店", "exclusions": [], "owner": "运营", "effective_from": "2026-01-01", "citations": [{"chunk": chunk_id, "quote": "只算已支付的订单"}]}, "open_questions": [], } return json.dumps({ "entry_fields": [{"name": "store", "type": "string", "required": False, "default": "", "description": "门店(留空是全部门店)"}], "sql": sql or "SELECT COUNT(*) * 1.0 AS refund_rate FROM orders WHERE status = 'paid' " "AND store = '{{ input.store }}'", "metrics": metrics if metrics is not None else [metric], "open_questions": list(questions or []), }, ensure_ascii=False) def groups_reply(*plans: dict) -> str: """一次交多组:每组沿用 plan_reply 的 sql / entry_fields / metrics。""" return json.dumps({ "groups": [{"sql": plan["sql"], "entry_fields": plan["entry_fields"], "metrics": plan["metrics"]} for plan in plans], "open_questions": [], "uncovered": [], }, ensure_ascii=False) class FakeModel: """起草模型:ainvoke 返回带 .content 的对象;按顺序交回复,交完重复最后一份。""" model_name = "fake-model" def __init__(self, replies: list[str]) -> None: self.replies = list(replies) self.calls: list[list] = [] async def ainvoke(self, messages): self.calls.append(list(messages)) from types import SimpleNamespace return SimpleNamespace(content=self.replies.pop(0) if len(self.replies) > 1 else self.replies[0]) def fake_model(monkeypatch, *replies: str) -> FakeModel: model = FakeModel(list(replies)) async def resolve(_session): from types import SimpleNamespace return model, model.model_name, SimpleNamespace(available=True, reason="") monkeypatch.setattr(recipe_ai, "resolve_draft_model", resolve) return model @pytest.fixture async def client(): async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c: yield c @pytest.fixture(autouse=True) async def fake_trial(monkeypatch): """试跑换成写一行 succeeded 的运行:不启动引擎、不查库,接口和 payload 的形状照真的一样。 和在跑的 `_run_trial` 一样,这里只把这次试跑先在 payload 里置成 running(替换旧指针),**终态留给调用方保存** (extract / POST 试跑都在拿到返回值之后调 _save_trial)。终态由调用方落库,试跑带没带固定期望(K05)才不会被 这里的提前保存吞掉。""" made: list[str] = [] async def start(*, draft_id, workflow_id, graph, values, started_by, entry_fields=(), version=1): run_id = f"run-draft-{uuid.uuid4().hex[:10]}" async with SessionLocal() as session: session.add(Run(id=run_id, workflow_id=workflow_id, status="succeeded", graph=graph, input=values, run_class="exploratory", origin_kind="caliber_trial", origin={"kind": "caliber_trial", "draft_id": draft_id, "workflow_id": workflow_id, "version": version})) await session.commit() made.append(run_id) binding = {**api_ledger._trial_binding(version, graph, values), "trial_id": uuid.uuid4().hex} await api_ledger._save_trial(draft_id, {**binding, "run_id": run_id, "status": "running", "error": ""}, replace=True) return {**binding, "run_id": run_id, "status": "succeeded", "error": ""} monkeypatch.setattr(api_ledger, "_run_trial", start) yield made async with SessionLocal() as session: for run_id in made: row = await session.get(Run, run_id) if row is not None: await session.delete(row) await session.commit() @pytest.fixture async def documents(): """建文档和段落(ordinal 故意倒着插,确认读出来按 ordinal 排)。测试结束删文档、段落、草稿和草稿建出的工作流。""" made: list[str] = [] async def make(doc_id: str, *, title: str = "销售口径.md", collection: str = "default") -> str: chunks = ((2, CHUNK_TWO), (1, CHUNK_ONE)) async with SessionLocal() as session: session.add(Document(id=doc_id, collection=collection, title=title, source=title, content="".join(content for _, content in chunks), chunk_count=len(chunks))) for ordinal, content in chunks: session.add(Chunk(id=f"{doc_id}-{ordinal}", document_id=doc_id, ordinal=ordinal, content=content)) await session.commit() made.append(doc_id) return doc_id yield make async with SessionLocal() as session: drafts = (await session.execute(select(CaliberDraft))).scalars().all() workflow_ids = [draft.workflow_id for draft in drafts if draft.workflow_id] # attach 会把 workflow_id 换成接上的那一份;起草时建的那份仍在 payload 里,照样要清掉 workflow_ids += [draft.payload.get("workflow_id") for draft in drafts if isinstance(draft.payload, dict) and draft.payload.get("workflow_id")] workflow_ids = list(dict.fromkeys(workflow_ids)) for draft in drafts: await session.delete(draft) if workflow_ids: await session.execute(delete(WorkflowVersion).where(WorkflowVersion.workflow_id.in_(workflow_ids))) await session.execute(delete(Workflow).where(Workflow.id.in_(workflow_ids))) for doc_id in made: await session.execute(delete(Chunk).where(Chunk.document_id == doc_id)) doc = await session.get(Document, doc_id) if doc is not None: await session.delete(doc) await session.commit() @pytest.fixture async def datasource(): """一个有表结构的数据源:SQL 只能照这里的表写。""" source_id = f"ds-{uuid.uuid4().hex[:10]}" async def make(*, tables: bool = True) -> str: cache = {"tables": {"orders": {"columns": [ {"name": "status", "type": "TEXT"}, {"name": "store", "type": "TEXT"}, {"name": "amount", "type": "NUMERIC"}, {"name": "paid_at", "type": "TEXT"}]}}, "total": 1} \ if tables else {} async with SessionLocal() as session: session.add(DataSource(id=source_id, name=f"shop_{uuid.uuid4().hex[:8]}", kind="sqlite", description="销售库", schema_cache=cache)) await session.commit() return source_id yield make async with SessionLocal() as session: row = await session.get(DataSource, source_id) if row is not None: await session.delete(row) await session.commit() async def extract(client, doc: str, source_id: str, **extra) -> dict: body = (await client.post("/api/ledger/drafts/extract", json={"documents": [doc], "datasource_id": source_id, **extra})).json() return body # ========================================================================== # 主路径 # ========================================================================== async def test_extract_produces_a_ready_draft_with_a_graph_and_a_trial(client, documents, datasource, monkeypatch): doc = await documents("dd-1") source_id = await datasource() fake_model(monkeypatch, plan_reply(f"{doc}-2")) body = await extract(client, doc, source_id) assert body["status"] == "drafting" made = (await client.get(f"/api/ledger/drafts/{body['draft_id']}")).json() assert made["status"] == "ready" assert made["title"] == "销售口径.md" assert made["workflow_id"] and made["workflow_name"] == "销售口径.md" assert made["metric_count"] == 1 and made["question_count"] == 0 assert [m["name"] for m in made["metrics"]] == ["已支付退款率"] assert made["metrics"][0]["expression"] == "cell(nodes.fetch, 0, 'refund_rate')" assert made["metrics"][0]["definition"]["citations"][0]["span"] == [8, 16] assert made["sql"].startswith("SELECT COUNT(*)") and "{{ input.store }}" in made["sql"] assert [f["name"] for f in made["entry_fields"]] == ["store"] assert made["trial"]["run_id"] and made["trial"]["status"] == "succeeded" assert [d["document_id"] for d in made["documents"]] == [doc] assert made["documents"][0]["chunk_ids"] == [f"{doc}-1", f"{doc}-2"] assert made["alignment"]["new"] == ["已支付退款率"] assert made["truncated"] is False and made["model_id"] == "fake-model" assert made["datasource_id"] == source_id # 发布前检查:五项都从图和试跑状态算,不另起一份判断 assert {check["key"] for check in made["checks"]} == {"graph", "sql", "entries", "trial", "citations"} assert all(check["ok"] for check in made["checks"] if check["key"] != "trial"), made["checks"] assert next(check for check in made["checks"] if check["key"] == "trial")["status"] == "unknown" # 工作流和 v1 已建:合法的草稿图,取数指向这个数据源 async with SessionLocal() as session: row = await session.get(CaliberDraft, body["draft_id"]) workflow = await session.get(Workflow, row.workflow_id) version = (await session.execute(select(WorkflowVersion).where( WorkflowVersion.workflow_id == row.workflow_id))).scalars().one() assert workflow.status == "draft" and workflow.tags == ["doc_draft"] assert version.version == 1 and version.graph == workflow.graph assert [n["id"] for n in workflow.graph["nodes"]] == ["start", "fetch", "card", "write", "out"] assert validate_graph(GraphSpec.model_validate(workflow.graph)).ok # 试跑只读接口:契约里的形状(queries 是取数节点交回的查询快照;这次是假试跑,没有快照) # trial_id 是真试跑本来就有的一项(_run_trial 建 binding 时写),老的必填键一个不改,validation 里另并进 K05 的记录 trial = (await client.get(f"/api/ledger/drafts/{body['draft_id']}/trial")).json() assert set(trial) == {"run_id", "status", "metrics", "queries", "error", "version", "graph_hash", "execution_hash", "inputs_hash", "trial_id", "validation"} assert trial["run_id"] == made["trial"]["run_id"] and trial["status"] == "succeeded" assert trial["queries"] == [] async def test_publish_checks_point_at_an_entry_field_the_sql_never_uses(client, documents, datasource, monkeypatch): """发布前检查照实说:声明了入口但 SQL 没用,也算不一致;其他项不跟着判失败。""" doc = await documents("dd-checks") source_id = await datasource() plan = json.loads(plan_reply(f"{doc}-2", sql="SELECT COUNT(*) * 1.0 AS refund_rate FROM orders " "WHERE status = 'paid'")) fake_model(monkeypatch, json.dumps(plan, ensure_ascii=False)) draft_id = (await extract(client, doc, source_id))["draft_id"] made = (await client.get(f"/api/ledger/drafts/{draft_id}")).json() by_key = {check["key"]: check for check in made["checks"]} assert by_key["entries"]["ok"] is False and "store" in by_key["entries"]["detail"] assert by_key["graph"]["ok"] and by_key["sql"]["ok"] and by_key["citations"]["ok"] assert by_key["trial"]["status"] == "unknown" async def test_open_questions_keep_the_draft_in_questions_open_but_still_run_it( client, documents, datasource, monkeypatch): doc = await documents("dd-2") source_id = await datasource() fake_model(monkeypatch, plan_reply(f"{doc}-2", questions=["退款算在哪一天"])) body = await extract(client, doc, source_id) made = (await client.get(f"/api/ledger/drafts/{body['draft_id']}")).json() assert made["status"] == "questions_open" assert made["stage"] == "review" assert made["open_questions"] == ["退款算在哪一天"] assert made["workflow_id"] and made["sql"] assert made["trial"]["status"] == "succeeded" async def test_a_missing_or_empty_datasource_is_refused_and_no_draft_is_created(client, documents, datasource): doc = await documents("dd-3") without = await client.post("/api/ledger/drafts/extract", json={"documents": [doc]}) assert without.status_code == 422 and "数据源" in without.text unknown = await client.post("/api/ledger/drafts/extract", json={"documents": [doc], "datasource_id": "ds-nope"}) assert unknown.status_code == 422 and "数据源不存在" in unknown.text empty_source = await datasource(tables=False) empty = await client.post("/api/ledger/drafts/extract", json={"documents": [doc], "datasource_id": empty_source}) assert empty.status_code == 422 and "表结构" in empty.text assert (await client.get("/api/ledger/drafts")).json()["drafts"] == [] async def test_a_missing_document_is_refused_and_no_draft_is_created(client, datasource): source_id = await datasource() res = await client.post("/api/ledger/drafts/extract", json={"documents": ["d-nope"], "datasource_id": source_id}) assert res.status_code == 422 and "d-nope" in res.text assert (await client.get("/api/ledger/drafts")).json()["drafts"] == [] async def test_sql_outside_the_catalog_fails_the_draft_with_a_reason(client, documents, datasource, monkeypatch): doc = await documents("dd-4") source_id = await datasource() fake_model(monkeypatch, plan_reply(f"{doc}-2", sql="SELECT * FROM secrets")) body = await extract(client, doc, source_id) made = (await client.get(f"/api/ledger/drafts/{body['draft_id']}")).json() assert made["status"] == "failed" and made["workflow_id"] is None assert "secrets" in made["error"] and "数据目录" in made["error"] assert any("secrets" in d["reason"] for d in made["dropped"]) assert not made.get("sql") assert (await client.get(f"/api/ledger/drafts/{body['draft_id']}/trial")).status_code == 404 async def test_a_metric_with_a_bad_expression_is_dropped_but_the_draft_still_goes_on( client, documents, datasource, monkeypatch): doc = await documents("dd-5") source_id = await datasource() reply = json.loads(plan_reply(f"{doc}-2")) reply["metrics"].append({**reply["metrics"][0], "id": "m2", "name": "订单数", "expression": "SUM(amount)"}) fake_model(monkeypatch, json.dumps(reply, ensure_ascii=False)) body = await extract(client, doc, source_id) made = (await client.get(f"/api/ledger/drafts/{body['draft_id']}")).json() # 一条好、一条坏:坏的那条进 dropped / open_questions,草稿照样 ready 之外还有图 assert [m["id"] for m in made["metrics"]] == ["m1"] assert any(d["name"] == "订单数" and "cell(nodes.fetch, 0" in d["reason"] for d in made["dropped"]) assert any("订单数" in q for q in made["open_questions"]) assert made["status"] == "questions_open" and made["workflow_id"] # ========================================================================== # 改、驳回、列表、试跑接口 # ========================================================================== async def test_patch_replaces_metrics_and_clears_resolved_questions(client, documents, datasource, monkeypatch): doc = await documents("dd-6") source_id = await datasource() fake_model(monkeypatch, plan_reply(f"{doc}-2", questions=["按哪个时区结算"])) draft_id = (await extract(client, doc, source_id))["draft_id"] fixed = {**copy.deepcopy(json.loads(plan_reply(f"{doc}-2"))["metrics"][0]), "name": "已支付退款率(按支付日)"} # 已接管的草稿:写要带上读到的基准版本(K02) revision = (await client.get(f"/api/ledger/drafts/{draft_id}")).json()["revision"] res = await client.patch(f"/api/ledger/drafts/{draft_id}", json={"title": "退款率口径", "metrics": [fixed], "resolved": ["按哪个时区结算"], "expected_revision": revision}) assert res.status_code == 200, res.text made = res.json() assert made["title"] == "退款率口径" and made["status"] == "ready" assert [m["name"] for m in made["metrics"]] == ["已支付退款率(按支付日)"] assert made["open_questions"] == [] and made["question_count"] == 0 # PATCH 和画布一样新增版本,读取新版本而不是覆盖 v1 async with SessionLocal() as session: draft_row = await session.get(CaliberDraft, draft_id) version_row = (await session.execute(select(WorkflowVersion).where( WorkflowVersion.workflow_id == draft_row.workflow_id, WorkflowVersion.version == made["version"]))).scalars().one() card = next(n for n in version_row.graph["nodes"] if n["type"] == "metrics") assert [m["name"] for m in card["data"]["config"]["metrics"]] == ["已支付退款率(按支付日)"] # 结构不对的指标 422,草稿不动 bad = await client.patch(f"/api/ledger/drafts/{draft_id}", json={"metrics": [{"synonyms": ["x"]}], "expected_revision": made["revision"]}) assert bad.status_code == 422 still = (await client.get(f"/api/ledger/drafts/{draft_id}")).json() assert [m["name"] for m in still["metrics"]] == ["已支付退款率(按支付日)"] # 还有问题(只换指标、带着新问题)→ questions_open res = await client.patch(f"/api/ledger/drafts/{draft_id}", json={"metrics": [{**fixed, "open_questions": ["按哪个门店口径"]}], "expected_revision": made["revision"]}) assert res.json()["status"] == "questions_open" and res.json()["question_count"] == 1 async def test_draft_view_reads_the_graph_so_canvas_edits_show_up(client, documents, datasource, monkeypatch): """画布改了指标名和取数 SQL 之后,草稿页从图里读最新的那几项,不再显示起草时的 payload 快照。""" doc = await documents("dd-live") source_id = await datasource() fake_model(monkeypatch, plan_reply(f"{doc}-2")) draft_id = (await extract(client, doc, source_id))["draft_id"] # 必须走画布的真实保存接口:它新增 v2,不能直接覆盖 v1 来模拟。已接管的资产要带基准版本(K02) async with SessionLocal() as session: draft = await session.get(CaliberDraft, draft_id) workflow = await session.get(Workflow, draft.workflow_id) row = (await session.execute(select(WorkflowVersion).where( WorkflowVersion.workflow_id == draft.workflow_id, WorkflowVersion.version == 1))).scalars().one() graph = copy.deepcopy(row.graph) for node in graph["nodes"]: if node["type"] == "metrics": node["data"]["config"]["metrics"][0]["name"] = "已支付退款率(画布改过)" if node["type"] == "tool" and (node["data"]["config"].get("args") or {}).get("sql"): node["data"]["config"]["args"]["sql"] = "SELECT 1 AS refund_rate" workflow_id, revision = draft.workflow_id, workflow.version saved = await client.patch(f"/api/workflows/{workflow_id}", json={"graph": graph, "expected_revision": revision}) assert saved.status_code == 200 and saved.json()["version"] == 2, saved.text made = (await client.get(f"/api/ledger/drafts/{draft_id}")).json() assert made["metrics"][0]["name"] == "已支付退款率(画布改过)" assert made["sql"] == "SELECT 1 AS refund_rate" assert [group["sql"] for group in made["groups"]] == ["SELECT 1 AS refund_rate"] assert made["version"] == 2 and made["trial"]["status"] == "stale" async def test_resolving_a_metric_question_writes_it_back_to_the_graph(client, documents, datasource, monkeypatch): """答清一条指标自己的未决问题之后,图里那条指标的 open_questions 也要去掉:草稿页从图读,不能又显示出来。""" doc = await documents("dd-resolve") source_id = await datasource() plan = json.loads(plan_reply(f"{doc}-2")) plan["metrics"][0]["open_questions"] = ["按哪个门店口径"] fake_model(monkeypatch, json.dumps(plan, ensure_ascii=False)) draft_id = (await extract(client, doc, source_id))["draft_id"] made = (await client.get(f"/api/ledger/drafts/{draft_id}")).json() assert made["status"] == "questions_open" assert made["metrics"][0]["open_questions"] == ["按哪个门店口径"] res = await client.patch(f"/api/ledger/drafts/{draft_id}", json={"resolved": ["按哪个门店口径"], "expected_revision": made["revision"]}) assert res.status_code == 200, res.text fixed = res.json() assert fixed["status"] == "ready" and fixed["question_count"] == 0 assert fixed["metrics"][0]["open_questions"] == [] async with SessionLocal() as session: draft = await session.get(CaliberDraft, draft_id) row = (await session.execute(select(WorkflowVersion).where( WorkflowVersion.workflow_id == draft.workflow_id, WorkflowVersion.version == fixed["version"]))).scalars().one() card = next(n for n in row.graph["nodes"] if n["type"] == "metrics") assert not card["data"]["config"]["metrics"][0].get("open_questions") async def test_trial_validates_against_graph_entry_fields_after_canvas_edit( client, documents, datasource, monkeypatch): """画布上给入口加了必填字段之后,试跑按图里的字段校验;payload 的旧入口字段不能把新字段漏掉。""" doc = await documents("dd-entry-live") source_id = await datasource() fake_model(monkeypatch, plan_reply(f"{doc}-2")) draft_id = (await extract(client, doc, source_id))["draft_id"] async with SessionLocal() as session: draft = await session.get(CaliberDraft, draft_id) workflow = await session.get(Workflow, draft.workflow_id) row = (await session.execute(select(WorkflowVersion).where( WorkflowVersion.workflow_id == draft.workflow_id, WorkflowVersion.version == 1))).scalars().one() graph = copy.deepcopy(row.graph) for node in graph["nodes"]: if node["type"] == "input": node["data"]["config"]["fields"].append( {"name": "region", "type": "string", "required": True, "default": "", "description": "区域"}) workflow_id, revision = draft.workflow_id, workflow.version saved = await client.patch(f"/api/workflows/{workflow_id}", json={"graph": graph, "expected_revision": revision}) assert saved.status_code == 200 and saved.json()["version"] == 2, saved.text missing = await client.post(f"/api/ledger/drafts/{draft_id}/trial", json={"inputs": {}}) assert missing.status_code == 422 and "region" in missing.text ok = await client.post(f"/api/ledger/drafts/{draft_id}/trial", json={"inputs": {"region": "华东"}}) assert ok.status_code == 200, ok.text and ok.text[:200] assert ok.json()["status"] == "succeeded" async def test_dismiss_marks_rejected_and_writes_an_audit(client, documents, datasource, monkeypatch): doc = await documents("dd-7") source_id = await datasource() fake_model(monkeypatch, plan_reply(f"{doc}-2")) draft_id = (await extract(client, doc, source_id))["draft_id"] res = await client.post(f"/api/ledger/drafts/{draft_id}/dismiss", json={"note": "已经有口径了"}) assert res.status_code == 200 and res.json()["status"] == "rejected" and res.json()["stage"] == "discarded" async with SessionLocal() as session: event = (await session.execute(select(AuditEvent).where( AuditEvent.action == "ledger.docdraft.dismiss", AuditEvent.subject_id == draft_id))).scalars().one() assert event.subject_kind == "ledger_draft" and event.data["note"] == "已经有口径了" assert (await client.get(f"/api/ledger/drafts/{draft_id}")).json()["status"] == "rejected" async def test_list_counts_orders_by_last_change_and_filters_by_status(client, documents, datasource, monkeypatch): first = await documents("dd-8", title="一.md") second = await documents("dd-9", title="二.md") source_id = await datasource() fake_model(monkeypatch, plan_reply(f"{first}-2", questions=["按哪个时区结算"]), plan_reply(f"{second}-2")) one = (await extract(client, first, source_id))["draft_id"] two = (await extract(client, second, source_id))["draft_id"] listed = (await client.get("/api/ledger/drafts")).json()["drafts"] assert [d["id"] for d in listed] == [two, one] latest, earlier = listed assert (latest["title"], latest["status"], latest["stage"], latest["metric_count"], latest["question_count"]) == \ ("二.md", "ready", "review", 1, 0) assert (earlier["status"], earlier["stage"], earlier["metric_count"], earlier["question_count"]) == \ ("questions_open", "review", 1, 1) assert earlier["workflow_id"] and earlier["created_at"] and earlier["error"] is None only = (await client.get("/api/ledger/drafts", params={"status": "questions_open"})).json()["drafts"] assert [d["id"] for d in only] == [one] # 界面按阶段筛:ready 和 questions_open 都是「待审」 review = (await client.get("/api/ledger/drafts", params={"stage": "review"})).json()["drafts"] assert {d["id"] for d in review} == {one, two} assert (await client.get("/api/ledger/drafts", params={"stage": "discarded"})).json()["drafts"] == [] assert (await client.get("/api/ledger/drafts", params={"status": "nope"})).status_code == 422 assert (await client.get("/api/ledger/drafts", params={"stage": "nope"})).status_code == 422 assert (await client.get("/api/ledger/drafts/d-nope")).status_code == 404 async def test_trial_endpoint_returns_metric_values_from_the_run(client, documents, datasource, monkeypatch): doc = await documents("dd-10") source_id = await datasource() fake_model(monkeypatch, plan_reply(f"{doc}-2")) draft_id = (await extract(client, doc, source_id))["draft_id"] async def metrics(_run_id): return [{"id": "m1", "name": "已支付退款率", "rendered": "3.2%", "value": 0.032}] monkeypatch.setattr(api_ledger, "_trial_metrics", metrics) res = await client.get(f"/api/ledger/drafts/{draft_id}/trial") assert res.status_code == 200, res.text body = res.json() assert body == {"run_id": body["run_id"], "status": "succeeded", "version": 1, "trial_id": body["trial_id"], "graph_hash": body["graph_hash"], "execution_hash": body["execution_hash"], "validation": body["validation"], "inputs_hash": body["inputs_hash"], "metrics": [{"id": "m1", "name": "已支付退款率", "rendered": "3.2%", "value": 0.032}], "queries": [], "error": ""} # 没有试跑的老草稿 async with SessionLocal() as session: session.add(CaliberDraft(id=f"draft-old-{uuid.uuid4().hex[:8]}", title="老草稿", status="path_pending", payload={"metrics": []})) await session.commit() old = (await session.execute(select(CaliberDraft.id).where( CaliberDraft.status == "path_pending"))).scalars().first() assert (await client.get(f"/api/ledger/drafts/{old}/trial")).status_code == 404 async def test_a_model_that_is_unavailable_leaves_the_draft_with_an_error(client, documents, datasource, monkeypatch): doc = await documents("dd-11") source_id = await datasource() async def unavailable(_session): from types import SimpleNamespace return None, "", SimpleNamespace(available=False, reason="还没有接入可用的起草模型") monkeypatch.setattr(recipe_ai, "resolve_draft_model", unavailable) body = await extract(client, doc, source_id) made = (await client.get(f"/api/ledger/drafts/{body['draft_id']}")).json() assert made["status"] == "failed" and made["error"] == "还没有接入可用的起草模型" assert made["metric_count"] == 0 and not made.get("metrics") async def test_attach_hands_the_draft_to_docattach_and_records_the_workflow(client, documents, datasource, monkeypatch): """attach 只按约定调 app.ledger.docattach.attach(辅助入口):这里换成一个假的模块,看接线和写回。""" doc = await documents("dd-12") source_id = await datasource() fake_model(monkeypatch, plan_reply(f"{doc}-2")) draft_id = (await extract(client, doc, source_id))["draft_id"] seen: dict = {} async def attach(session, *, payload, name, run_id, draft_id): seen.update(payload=payload, name=name, run_id=run_id, draft_id=draft_id) return {"workflow_id": "wf-docdraft", "version": 1, "metrics": ["m1"], "warnings": []} module = types.ModuleType("app.ledger.docattach") module.attach = attach monkeypatch.setitem(sys.modules, "app.ledger.docattach", module) async with SessionLocal() as session: session.add(Workflow(id="wf-docdraft", name="退款率口径")) await session.commit() try: res = await client.post(f"/api/ledger/drafts/{draft_id}/attach", json={"run_id": "run-1"}) assert res.status_code == 200, res.text assert res.json() == {"workflow_id": "wf-docdraft", "version": 1, "metrics": ["m1"], "warnings": []} assert (seen["run_id"], seen["name"], seen["draft_id"]) == ("run-1", "销售口径.md", draft_id) assert seen["payload"]["metrics"][0]["name"] == "已支付退款率" made = (await client.get(f"/api/ledger/drafts/{draft_id}")).json() assert made["status"] == "attached" and made["workflow_id"] == "wf-docdraft" assert made["workflow_name"] == "退款率口径" finally: async with SessionLocal() as session: workflow = await session.get(Workflow, "wf-docdraft") if workflow is not None: await session.delete(workflow) await session.commit() async def test_a_real_trial_run_feeds_the_trial_endpoint(client, documents, tmp_path, monkeypatch): """真引擎跑一次草稿图:数从取数节点进口径卡,GET /trial 交回指标值(不 mock 试跑)。""" from app.engine.runner import run_manager from tests import ledger_samples as S await run_manager.setup() run_id = None try: name = await S.fresh_source(tmp_path / "shop.db") async with SessionLocal() as session: row = (await session.execute(select(DataSource).where(DataSource.name == name))).scalar_one() row.schema_cache = {"tables": {"orders": {"columns": [ {"name": "status", "type": "TEXT"}, {"name": "region", "type": "TEXT"}, {"name": "amount", "type": "REAL"}, {"name": "paid_at", "type": "TEXT"}]}}, "total": 1} await session.commit() source_id = row.id doc = await documents("dd-real") reply = json.loads(plan_reply(f"{doc}-2")) reply["entry_fields"] = [{"name": "region", "type": "string", "required": False, "default": "east", "description": "区域"}] reply["sql"] = ("SELECT SUM(amount) AS gmv FROM orders WHERE status = 'paid' " "AND region = '{{ input.region }}'") reply["metrics"][0].update({"expression": "cell(nodes.fetch, 0, 'gmv')", "name": "已支付销售额", "unit": "元", "decimals": 2}) fake_model(monkeypatch, json.dumps(reply, ensure_ascii=False)) # 报告节点也要一个模型:用品自带的 mock 模型,正文引用卡上的指标,核对规则照过 S.script(monkeypatch, "已支付销售额 [[m:m1]]。") monkeypatch.setattr(api_ledger, "_run_trial", REAL_RUN_TRIAL) draft_id = (await extract(client, doc, source_id))["draft_id"] made = (await client.get(f"/api/ledger/drafts/{draft_id}")).json() assert made["status"] == "ready" and made["trial"]["status"] == "succeeded", made.get("error") run_id = made["trial"]["run_id"] trial = (await client.get(f"/api/ledger/drafts/{draft_id}/trial")).json() assert trial["status"] == "succeeded" and trial["error"] == "" assert [m["id"] for m in trial["metrics"]] == ["m1"] assert trial["metrics"][0]["name"] == "已支付销售额" assert float(trial["metrics"][0]["value"]) == 156.5 # 试跑结果集:取数节点交回的查询快照,给列和行、不给 SQL assert [query["node_id"] for query in trial["queries"]] == ["fetch"] query = trial["queries"][0] assert set(query) == {"node_id", "tool", "source", "columns", "rows", "total_rows", "truncated", "masked", "source_truncated", "preview_truncated"} assert query["columns"] == ["gmv"] and query["rows"] == [[156.5]] assert query["total_rows"] == 1 and query["truncated"] is False and query["masked"] == [] assert "sql" not in query async with SessionLocal() as session: run = await session.get(Run, run_id) assert run.origin_kind == "caliber_trial" and run.run_class == "exploratory" assert not [n for n in (run.graph or {}).get("nodes") or [] if n.get("type") == "report"], \ "试跑只跑取数与口径卡,不该触发报告撰写" assert run.origin["kind"] == "caliber_trial" and run.origin["draft_id"] == draft_id assert run.origin["workflow_id"] == made["workflow_id"] and run.origin["version"] == 1 finally: await run_manager.shutdown() async with SessionLocal() as session: if run_id: row = await session.get(Run, run_id) if row is not None: await session.delete(row) source = (await session.execute(select(DataSource).where( DataSource.name == S.CURRENT["source"]))).scalars().first() if source is not None: await session.delete(source) await session.commit() async def test_one_reply_can_bring_several_independent_groups(client, documents, datasource, monkeypatch): """模型一次交两组:只调一次模型,两组各自成块,不再一组一轮。""" doc = await documents("dd-groups") source_id = await datasource() first = json.loads(plan_reply(f"{doc}-2")) second = json.loads(plan_reply(f"{doc}-2")) second["sql"] = ("SELECT COUNT(*) AS orders FROM orders WHERE status = 'paid' " "AND store = '{{ input.store }}'") second["metrics"] = [{**second["metrics"][0], "id": "m1", "name": "已支付订单数", "expression": "cell(nodes.fetch, 0, 'orders')"}] model = fake_model(monkeypatch, groups_reply(first, second)) body = await extract(client, doc, source_id) made = (await client.get(f"/api/ledger/drafts/{body['draft_id']}")).json() assert made["status"] == "ready" and made["blocks"] == 2 and made["metric_count"] == 2 assert [group["sql"] for group in made["groups"]] == [first["sql"], second["sql"]] assert [m["id"] for m in made["metrics"]] == ["m1", "m2"] assert made["progress"] == {"round": 1, "covered": 2, "uncovered": 0} assert len(model.calls) == 1 async def test_one_bad_group_is_repaired_without_failing_the_others(client, documents, datasource, monkeypatch): """一组 SQL 不合格只重写这一组;修好后照常落图,好的那组不受影响。""" doc = await documents("dd-groups-bad") source_id = await datasource() good = json.loads(plan_reply(f"{doc}-2")) bad = json.loads(plan_reply(f"{doc}-2", sql="SELECT * FROM secrets")) bad["metrics"] = [{**bad["metrics"][0], "id": "m1", "name": "已支付订单数", "expression": "cell(nodes.fetch, 0, 'orders')"}] fixed = {**bad, "sql": ("SELECT COUNT(*) AS orders FROM orders WHERE status = 'paid' " "AND store = '{{ input.store }}'")} model = fake_model(monkeypatch, groups_reply(good, bad), groups_reply(fixed)) body = await extract(client, doc, source_id) made = (await client.get(f"/api/ledger/drafts/{body['draft_id']}")).json() assert made["status"] == "ready" and made["blocks"] == 2 and made["metric_count"] == 2 assert made["workflow_id"] and len(model.calls) == 2 assert [group["sql"] for group in made["groups"]] == [good["sql"], fixed["sql"]] assert not any("secrets" in item["reason"] for item in made["dropped"]) async def test_a_bad_group_that_still_cannot_be_repaired_is_dropped(client, documents, datasource, monkeypatch): """重写也没修好:只丢这一组,问题照实写下来,不把整份草稿判死。""" doc = await documents("dd-groups-unfixable") source_id = await datasource() good = json.loads(plan_reply(f"{doc}-2")) bad = json.loads(plan_reply(f"{doc}-2", sql="SELECT * FROM secrets")) bad["metrics"] = [{**bad["metrics"][0], "id": "m1", "name": "已支付订单数", "expression": "cell(nodes.fetch, 0, 'orders')"}] model = fake_model(monkeypatch, groups_reply(good, bad)) body = await extract(client, doc, source_id) made = (await client.get(f"/api/ledger/drafts/{body['draft_id']}")).json() assert made["status"] == "ready" and made["blocks"] == 1 and made["metric_count"] == 1 assert len(model.calls) == 2 assert any("secrets" in item["reason"] for item in made["dropped"]) async def test_a_big_document_is_drafted_in_several_rounds_onto_one_multi_fetch_graph( client, documents, datasource, monkeypatch): """第一轮交一组、uncovered 还剩一条:续跑第二轮,收成一张两个取数节点、卡上两条指标的图。""" doc = await documents("dd-rounds") source_id = await datasource() first = json.loads(plan_reply(f"{doc}-2")) first["uncovered"] = ["已支付订单数"] second = json.loads(plan_reply(f"{doc}-2")) second["sql"] = ("SELECT COUNT(*) AS orders FROM orders WHERE status = 'paid' " "AND store = '{{ input.store }}'") second["metrics"] = [{**second["metrics"][0], "id": "m1", "name": "已支付订单数", "expression": "cell(nodes.fetch, 0, 'orders')"}] second["uncovered"] = [] model = fake_model(monkeypatch, json.dumps(first, ensure_ascii=False), json.dumps(second, ensure_ascii=False)) body = await extract(client, doc, source_id) made = (await client.get(f"/api/ledger/drafts/{body['draft_id']}")).json() assert made["status"] == "ready" assert [m["name"] for m in made["metrics"]] == ["已支付退款率", "已支付订单数"] assert made["blocks"] == 2 # groups 逐组给出 SQL、入口字段和指标 id:页面按组画「这条 SQL 出哪几个指标」,不必从拼接的 sql 里猜边界 assert [set(group) for group in made["groups"]] == [{"sql", "entry_fields", "metric_ids"}] * 2 assert [group["sql"] for group in made["groups"]] == [first["sql"], second["sql"]] assert [group["metric_ids"] for group in made["groups"]] == [["m1"], ["m2"]] # 各组都从 m1 起草,拼图前重排:payload 的指标 id 和 groups 指同一组指标,页面按 id 配试跑值才不会张冠李戴 assert [m["id"] for m in made["metrics"]] == ["m1", "m2"] assert [f["name"] for f in made["groups"][0]["entry_fields"]] == ["store"] assert made["progress"] == {"round": 2, "covered": 2, "uncovered": 0} assert made["truncated"] is False and made["doc_truncated"] is False and made["catalog_truncated"] is False # 第二轮提示词写明上一轮起草过什么:模型据此不再重复起草 assert len(model.calls) == 2 and "已支付退款率" in str(model.calls[1][-1].content) async with SessionLocal() as session: row = await session.get(CaliberDraft, body["draft_id"]) workflow = await session.get(Workflow, row.workflow_id) graph = workflow.graph assert [n["id"] for n in graph["nodes"]] == ["start", "fetch_1", "fetch_2", "card", "write", "out"] card = next(n for n in graph["nodes"] if n["id"] == "card")["data"]["config"]["metrics"] # 两组都交 m1:跨组重名按 m1…mN 重排,表达式改写到各自的取数节点 assert [m["id"] for m in card] == ["m1", "m2"] assert [m["expression"] for m in card] == ["cell(nodes.fetch_1, 0, 'refund_rate')", "cell(nodes.fetch_2, 0, 'orders')"] assert graph["nodes"][-1]["data"]["config"]["contract"]["required"] == ["m1", "m2"] assert validate_graph(GraphSpec.model_validate(graph)).ok assert made["trial"]["status"] == "succeeded" and made["trial"]["run_id"] async def test_a_round_that_brings_nothing_new_stops_the_loop_without_a_half_built_group( client, documents, datasource, monkeypatch): """第二轮什么也没交:停在那一轮,不拿空 SQL 拼出第三组取数,也不留一条半成品指标。""" doc = await documents("dd-rounds-stop") source_id = await datasource() first = json.loads(plan_reply(f"{doc}-2")) first["uncovered"] = ["已支付订单数"] fake_model(monkeypatch, json.dumps(first, ensure_ascii=False), json.dumps({ "entry_fields": [], "sql": "", "metrics": [], "open_questions": [], "uncovered": ["已支付订单数"], }, ensure_ascii=False)) body = await extract(client, doc, source_id) made = (await client.get(f"/api/ledger/drafts/{body['draft_id']}")).json() assert made["status"] == "ready" and made["blocks"] == 1 and made["metric_count"] == 1 assert made["progress"] == {"round": 2, "covered": 1, "uncovered": 1} assert made["uncovered"] == ["已支付订单数"] async with SessionLocal() as session: row = await session.get(CaliberDraft, body["draft_id"]) workflow = await session.get(Workflow, row.workflow_id) assert [n["id"] for n in workflow.graph["nodes"]] == ["start", "fetch", "card", "write", "out"] card = next(n for n in workflow.graph["nodes"] if n["id"] == "card") assert [m["id"] for m in card["data"]["config"]["metrics"]] == ["m1"] async def test_conflicting_entry_field_declarations_are_merged_not_failed( client, documents, datasource, monkeypatch): """两组把 region 声明得不一样:必填取并集、类型取更宽,草稿照常出图,只把不一致摆给人看。""" doc = await documents("dd-rounds-clash") source_id = await datasource() first = json.loads(plan_reply(f"{doc}-2")) first["uncovered"] = ["已支付订单数"] second = json.loads(plan_reply(f"{doc}-2")) second["entry_fields"] = [{**second["entry_fields"][0], "required": True}] second["metrics"] = [{**second["metrics"][0], "id": "m2", "name": "已支付订单数", "expression": "cell(nodes.fetch, 0, 'orders')"}] second["uncovered"] = [] fake_model(monkeypatch, json.dumps(first, ensure_ascii=False), json.dumps(second, ensure_ascii=False)) body = await extract(client, doc, source_id) made = (await client.get(f"/api/ledger/drafts/{body['draft_id']}")).json() assert made["status"] in ("ready", "questions_open") and made["workflow_id"] is not None assert made["metric_count"] == 2 and made["blocks"] == 2 fields = {f["name"]: f for f in made["entry_fields"]} assert fields["store"]["required"] is True assert any("store" in note for note in made["entry_notes"]) async def test_a_real_trial_run_reads_values_from_both_fetch_nodes(client, documents, tmp_path, monkeypatch): """两个取数节点的草稿图真跑一次探针:两条 SQL 各出一个值,卡上两条指标都读得出来(不 mock 试跑)。""" from app.engine.runner import run_manager from tests import ledger_samples as S await run_manager.setup() run_id = None try: name = await S.fresh_source(tmp_path / "shop-two.db") async with SessionLocal() as session: row = (await session.execute(select(DataSource).where(DataSource.name == name))).scalar_one() row.schema_cache = {"tables": {"orders": {"columns": [ {"name": "status", "type": "TEXT"}, {"name": "region", "type": "TEXT"}, {"name": "amount", "type": "REAL"}, {"name": "paid_at", "type": "TEXT"}]}}, "total": 1} await session.commit() source_id = row.id doc = await documents("dd-real-two") fields = [{"name": "region", "type": "string", "required": False, "default": "east", "description": "区域"}] gmv = json.loads(plan_reply(f"{doc}-2")) gmv.update({"entry_fields": fields, "uncovered": ["已支付订单数"]}) gmv["sql"] = ("SELECT SUM(amount) AS gmv FROM orders WHERE status = 'paid' " "AND region = '{{ input.region }}'") gmv["metrics"][0].update({"expression": "cell(nodes.fetch, 0, 'gmv')", "name": "已支付销售额", "unit": "元", "decimals": 2}) count = json.loads(plan_reply(f"{doc}-2")) count.update({"entry_fields": fields, "uncovered": []}) count["sql"] = ("SELECT COUNT(*) AS orders FROM orders WHERE status = 'paid' " "AND region = '{{ input.region }}'") count["metrics"] = [{**count["metrics"][0], "id": "m1", "name": "已支付订单数", "unit": "单", "decimals": 0, "expression": "cell(nodes.fetch, 0, 'orders')"}] fake_model(monkeypatch, json.dumps(gmv, ensure_ascii=False), json.dumps(count, ensure_ascii=False)) S.script(monkeypatch, "已支付销售额 [[m:m1]],已支付订单数 [[m:m2]]。") monkeypatch.setattr(api_ledger, "_run_trial", REAL_RUN_TRIAL) draft_id = (await extract(client, doc, source_id))["draft_id"] made = (await client.get(f"/api/ledger/drafts/{draft_id}")).json() assert made["status"] == "ready" and made["trial"]["status"] == "succeeded", made.get("error") run_id = made["trial"]["run_id"] trial = (await client.get(f"/api/ledger/drafts/{draft_id}/trial")).json() assert trial["status"] == "succeeded" and trial["error"] == "" assert {m["id"]: float(m["value"]) for m in trial["metrics"]} == {"m1": 156.5, "m2": 4.0} assert [query["node_id"] for query in trial["queries"]] == ["fetch_1", "fetch_2"] assert [query["columns"] for query in trial["queries"]] == [["gmv"], ["orders"]] async with SessionLocal() as session: run = await session.get(Run, run_id) tools = [n["id"] for n in (run.graph or {}).get("nodes") or [] if n.get("type") == "tool"] assert tools == ["fetch_1", "fetch_2"], "试跑要把两组取数都带上" finally: await run_manager.shutdown() async with SessionLocal() as session: if run_id: row = await session.get(Run, run_id) if row is not None: await session.delete(row) source = (await session.execute(select(DataSource).where( DataSource.name == S.CURRENT["source"]))).scalars().first() if source is not None: await session.delete(source) await session.commit() async def test_the_model_asking_questions_is_not_a_failure(client, documents, datasource, monkeypatch): """模型说「目录里没有这些列、写不了 SQL」时,这是要补信息的草稿,不是失败:问题原样留下,不编指标。""" doc = await documents("dd-questions") source_id = await datasource() fake_model(monkeypatch, json.dumps({ "entry_fields": [], "sql": "", "metrics": [], "open_questions": ["数据目录里没有 attribute01…54 的取值列,写不出时间类指标"], }, ensure_ascii=False)) body = await extract(client, doc, source_id) made = (await client.get(f"/api/ledger/drafts/{body['draft_id']}")).json() assert made["status"] == "questions_open" and made["error"] == "" assert made["open_questions"] == ["数据目录里没有 attribute01…54 的取值列,写不出时间类指标"] assert made["metrics"] == [] and made["workflow_id"] is None assert made["trial"]["run_id"] == "" def test_the_schema_text_expands_the_tables_the_document_names_first(): """目录十万字符、预算只有两万多:先展开文档点到的表,其余只靠列名清单认。""" tables = ["orders", "refunds", "stores", "kpi_info", "attr_info"] picked = api_ledger._focus_tables(tables, "指标取自 kpi_info,属性在 attr_info 里") assert picked[:2] == ["kpi_info", "attr_info"] assert set(picked) >= {"kpi_info", "attr_info"} def test_questions_asked_again_next_round_are_deduped(): """同一件事每轮换个说法再问一遍:只留先出现的那一句,别在页面上堆几十条重复问题。""" first = ["文档说明比率类指标可被「配置 → 计算口径」覆盖,要不要读配置?"] again = ["文档说明比率类指标可被「配置 → 计算口径」覆盖,本模板按内置口径起草,是否要读?", "报废模架数需要跨表,关联键未确认"] merged = api_ledger._merge_questions(first, again) assert merged == [first[0], "报废模架数需要跨表,关联键未确认"] async def test_a_trial_without_required_inputs_waits_for_them_instead_of_failing(): """必填入口没有值时不拿系统编的日期去跑:停在 needs_input,等人在页面上填。""" fields = [{"name": "start", "type": "string", "required": True}] out = await REAL_RUN_TRIAL(draft_id="d", workflow_id="w", graph={"nodes": []}, values={}, started_by=None, entry_fields=fields) assert out["status"] == "needs_input" and "start" in out["error"] async def test_running_the_trial_with_inputs_typed_on_the_page(client, documents, datasource, monkeypatch): """自动试跑缺必填输入,人填了再跑:参数按入口字段校验,缺值 422,填了才发起。""" doc = await documents("dd-inputs") source_id = await datasource() reply = json.loads(plan_reply(f"{doc}-2")) reply["entry_fields"] = [{"name": "start", "type": "string", "required": True, "default": "", "description": "开始日期"}] reply["sql"] = ("SELECT COUNT(*) * 1.0 AS refund_rate FROM orders WHERE status = 'paid' " "AND paid_at >= '{{ input.start }}'") fake_model(monkeypatch, json.dumps(reply, ensure_ascii=False)) async def waiting(*, draft_id, workflow_id, graph, values, started_by, entry_fields=(), version=1): return {**api_ledger._trial_binding(version, graph, values), "run_id": "", "status": "needs_input", "error": "缺少必填输入:start"} monkeypatch.setattr(api_ledger, "_run_trial", waiting) body = await extract(client, doc, source_id) made = (await client.get(f"/api/ledger/drafts/{body['draft_id']}")).json() assert made["trial"]["status"] == "needs_input" trial = (await client.get(f"/api/ledger/drafts/{body['draft_id']}/trial")).json() assert trial["status"] == "needs_input" and trial["metrics"] == [] and trial["queries"] == [] missing = await client.post(f"/api/ledger/drafts/{body['draft_id']}/trial", json={"inputs": {}}) assert missing.status_code == 422 seen: dict[str, object] = {} async def started(*, draft_id, workflow_id, graph, values, started_by, entry_fields=(), version=1): seen.clear() seen.update(values) return {**api_ledger._trial_binding(version, graph, values), "run_id": "", "status": "running", "error": ""} monkeypatch.setattr(api_ledger, "_run_trial", started) res = await client.post(f"/api/ledger/drafts/{body['draft_id']}/trial", json={"inputs": {"start": "2026-01-01"}}) assert res.status_code == 200, res.text assert seen["start"] == "2026-01-01" async def test_extract_hands_the_fixed_filters_to_every_round(client, documents, datasource, monkeypatch): """固定条件(工厂、产线、属性组)要交给模型写死在 SQL 里,不做成入口字段。""" from app.ledger import draftplan doc = await documents("dd-fixed") source_id = await datasource() fake_model(monkeypatch, plan_reply(f"{doc}-2")) seen: dict[str, object] = {} real_plan = draftplan.plan_groups async def spy(session, **kwargs): seen.update(kwargs) return await real_plan(session, **kwargs) monkeypatch.setattr(draftplan, "plan_groups", spy) body = await client.post("/api/ledger/drafts/extract", json={ "documents": [doc], "datasource_id": source_id, "fixed_filters": "line_code = 'FOAM01'"}) made = (await client.get(f"/api/ledger/drafts/{body.json()['draft_id']}")).json() assert made["workflow_id"] assert seen["fixed"] == "line_code = 'FOAM01'"