Files
AgentLab/backend/tests/test_report_node.py
T
ThearchyHelios 03c18c4d78 界面文案整改:口语改书面、术语统一、不再露出实现细节;修掉几处说错的提示;新增文案检查防回潮
一、文案(前端约 1600 处,后端传到界面的约 880 处)
- 术语统一:工作流层面用「运行」(继续运行、重新运行、试运行),节点和工具层面用「执行」,不再用「跑」;
  总时长 / 执行时长 / 等待审批;服务端;助手;Agent;token;工作流;固定版本;模型接入;系统提示;批准 / 驳回;受管级别
- 口语改书面:取不到、没取回来、读不懂、连不上、多半、照样、兜底、揉成这类说法改成「无法 / 未能 / 失败 / 请」;去掉拟人写法
- 不再露出实现细节:界面上不写 checkpoint、同一拍、哈希(凭证处除外),不写改版历史(「以前…」「老数据」);
  报错、校验、门禁、自动修复里裸写的配置键名和枚举值换成界面上的中文标签,由新的 backend/app/engine/labels.py 统一提供;
  发布弹窗里结论句的选项不再是 off / require_citation
- 同一件事只留一种说法:生成阶段文案、出具档位说明、画布锁定提示各合成一份
- 去掉界面上的工业场景示例(MES、ERP、「张工」「王工」)和真实产品名,换成通用示例

二、说错的提示(都有测试证明)
- 数据库测连接失败时的专用提示以前从不出现(按报错文字全等比较,永远比不上):改为按错误类型判断,不再拼出重复的前缀
- 「设置 → 供应商」改指「设置 → 模型接入」;「在设置里建数据源」改指「数据」页
- 缺少 API Key、模型接入已停用的报错不再夹英文,写明去哪里填
- 两处原因说错(「这种引用在后续版本支持」「这一版还不支持」)改成真实原因
- 节点说明里不准确的说法改正:代码节点在 microVM 或系统沙箱中执行;报告撰写、口径卡的说明不再说过头
- 导出的证据 CSV 里两列同名,第三列改为「片段编号」

三、前后端对齐
- 前端按后端原文识别日志、报错的地方,新旧两种写法都认得(历史运行里存的是旧文字),逐条补了新旧原文的核对
- 后端交回模型的原指令放进单独的 for_model 字段,前端在解析响应时去掉,不会显示到界面上

四、检查
- 检查脚本里按原文匹配的断言改成新文案;改完会变成永远成立的反向断言逐条修正,并做了变异验证
- 新增 check-copy(静态扫描前端界面字符串和后端上界面的文案):再出现「跑」「后端」「多半」「令牌」、
  界面上的 Copilot、裸写的配置键名、半角标点等就报错;确需保留的写进 scripts/copy-allow.json 并注明理由
2026-09-30 08:05:15 +08:00

451 lines
22 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.
"""报告撰写节点:模型只写引用标记,数字由系统从口径卡取出来渲染。
以前叙述是 llm 节点写的自由文本,出具时再按数值回头猜每个数来自哪个指标——
5 个指标都是 0.0 时,报告里所有的 0 全算给第一个;模型顺手写的「45678」没人拦,
直到出口才被判成「未回指」。报告节点把这两件事都挪到写的那一刻:
- 每个数字带着它自己的出处(按 id 引用,同值不同源也各归各的)
- 裸数字、引用不存在当场判违规,让写作者重写一次;还不行,按 on_violation 失败或标注
- 流里看到的就是最终的数字,没有 [[m:gmv]] 这种原始标记
- 自查结果(report.checked)落在封存范围内,证据接口从它出发找文档
"""
from __future__ import annotations
import asyncio
import pytest
from langchain_core.messages import AIMessage, HumanMessage
from sqlalchemy import select
from app.core import artifact_store
from app.core.events import EventType
from app.db.base import SessionLocal
from app.db.models import Run, RunEvent
from app.engine import runner as runner_mod
from app.engine.evidence import DOC_SCHEMA, iter_segments, make_eid
from app.engine.runner import run_manager, verify_manifest
DSML = ('<||DSML|| calls>\n<||DSML|| invoke name="db_query__shop">\n'
'<||DSML|| parameter name="sql">SELECT 1</||DSML|| parameter>\n'
'</||DSML|| invoke>\n</||DSML|| calls>')
@pytest.fixture(autouse=True)
async def engine_up():
await run_manager.setup()
yield
await run_manager.shutdown()
def node(nid, ntype, **config):
return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0},
"data": {"label": nid, "config": config}}
KPI = "{'gmv': 45678.5, 'gmv_prev': 42010.0, 'orders': 1234}"
STANDARD = [
{"id": "gmv", "name": "销售额", "unit": "元", "decimals": 1, "expression": "vars.kpi.gmv"},
{"id": "wow", "name": "环比增幅", "unit": "%", "decimals": 1, "format": "plain",
"expression": "round((vars.kpi.gmv - vars.kpi.gmv_prev) / vars.kpi.gmv_prev * 100, 1)"},
{"id": "orders", "name": "订单数", "unit": "单", "expression": "vars.kpi.orders"},
]
CLEAN = "## 本周概览\n\n本周([[i:week]])销售额 [[m:gmv]],环比 [[m:wow]];订单 [[m:orders]]。[[see:m:gmv,m:wow]]"
def weekly(report=None, *, kpi=KPI, metrics=STANDARD, contract=None, between=(), extra_edges=()):
"""input → 取数 → 口径卡 → 报告 → 出口。between 里的节点插在报告和出口之间。"""
out = {"fields": [{"name": "周报", "value": "{{ nodes.write.text }}"}]}
if contract is not None:
out["contract"] = contract
nodes = [
node("start", "input", fields=[{"name": "week", "default": "2026-W37"}]),
node("fetch", "transform", mode="expression", expression=kpi, assign_to="kpi"),
node("caliber", "metrics", caliber="周报口径", caliber_version="v2", metrics=metrics),
node("write", "report", instructions="为 {{ input.week }} 写周报", **(report or {})),
*between,
node("out", "output", **out),
]
chain = ["start", "fetch", "caliber", "write", *[n["id"] for n in between], "out"]
return {"nodes": nodes,
"edges": [{"source": a, "target": b} for a, b in zip(chain, chain[1:])]
+ [{"source": a, "target": b} for a, b in extra_edges]}
def script(monkeypatch, *replies):
"""模型依次回这几段,用完了一直回最后一段。返回每次调用收到的消息。"""
from app.providers import mock_model
seen: list[list] = []
def _decide(self, messages):
seen.append(list(messages))
return AIMessage(content=replies[min(len(seen), len(replies)) - 1])
monkeypatch.setattr(mock_model.MockChatModel, "_decide", _decide)
return seen
def spy_tokens(monkeypatch) -> list:
"""llm.token 不落库,只广播:在广播口上截下来。"""
captured: list = []
original = runner_mod.bus.publish
async def spy(event):
if str(event.type) == EventType.LLM_TOKEN:
captured.append(event)
await original(event)
monkeypatch.setattr(runner_mod.bus, "publish", spy)
return captured
async def finish(graph, *, run_class="exploratory", statuses=("succeeded", "failed")) -> Run:
run = await run_manager.start(graph=graph, input_payload={}, run_class=run_class)
for _ in range(400):
await asyncio.sleep(0.05)
async with SessionLocal() as session:
row = await session.get(Run, run.id)
# 状态先提交、封存随后才写:终态要等 manifest_seq 落下来再看
if row.status in statuses and (row.status == "interrupted" or row.manifest_seq is not None):
return row
raise AssertionError(f"run {run.id} 没跑完:{row.status}")
async def events(run_id: str, etype: str | None = None) -> list[RunEvent]:
async with SessionLocal() as session:
q = select(RunEvent).where(RunEvent.run_id == run_id)
if etype:
q = q.where(RunEvent.type == etype)
return list((await session.execute(q.order_by(RunEvent.seq))).scalars())
async def report_of(run_id: str) -> tuple[dict, dict]:
"""(report.checked 的载荷, 取回的文档)。"""
checked = await events(run_id, "report.checked")
assert len(checked) == 1, [e.data for e in checked]
return checked[0].data, artifact_store.load(checked[0].data["doc_artifact"])
def numbers(doc: dict) -> list[tuple[str, str | None, str]]:
return [(s["text"], (s.get("cite") or {}).get("locator", {}).get("metric"), s["state"])
for s in iter_segments(doc) if s["kind"] == "number"]
# --------------------------------------------------------------------------
# 各归各的
# --------------------------------------------------------------------------
async def test_five_zero_metrics_each_keep_their_own_source(monkeypatch):
"""旧契约按数值回指:5 个 0.0 全算给第一个指标。按 id 引用就没有这个问题。"""
zeros = [{"id": k, "name": f"指标{k.upper()}", "unit": "元", "decimals": 1,
"expression": f"vars.kpi.{k}"} for k in "abcde"]
script(monkeypatch, "各项指标:A [[m:a]],B [[m:b]],C [[m:c]],D [[m:d]],E [[m:e]]。"
"[[see:m:a,m:b,m:c,m:d,m:e]]")
row = await finish(weekly(kpi="{'a': 0.0, 'b': 0.0, 'c': 0.0, 'd': 0.0, 'e': 0.0}", metrics=zeros))
assert row.status == "succeeded", row.error
checked, doc = await report_of(row.id)
assert numbers(doc) == [("0.0元", k, "deterministic") for k in "abcde"]
card = (await events(row.id, "node.finished"))
card_artifact = next(e.data["evidence"][0]["artifact"] for e in card if e.node_id == "caliber")
eids = [s["cite"]["eid"] for s in iter_segments(doc) if s["kind"] == "number"]
assert eids == [make_eid("metric", card_artifact, {"metric": k}) for k in "abcde"]
assert len(set(eids)) == 5
assert checked["ok"] is True and checked["violations"] == [] and checked["repairs"] == 0
assert checked["stats"]["numbers_cited"] == 5 and checked["stats"]["uncited_numbers"] == 0
async def test_output_shape_and_stored_document(monkeypatch):
script(monkeypatch, CLEAN)
graph = weekly({"assign_to": "report"})
row = await finish(graph)
assert row.status == "succeeded", row.error
checked, doc = await report_of(row.id)
assert doc["schema"] == DOC_SCHEMA and doc["run_id"] == row.id and doc["node_id"] == "write"
text = doc["markdown"]
assert text == "## 本周概览\n\n本周(2026-W37)销售额 45,678.5元,环比 8.7%;订单 1,234单。"
assert row.output["周报"] == text
assert numbers(doc) == [("45,678.5元", "gmv", "deterministic"), ("8.7%", "wow", "deterministic"),
("1,234单", "orders", "deterministic")]
finished = next(e for e in await events(row.id, "node.finished") if e.node_id == "write")
payload = artifact_store.load(finished.data["artifact"])
assert payload["text"] == text and payload["doc_artifact"] == checked["doc_artifact"]
assert payload["stats"] == checked["stats"] == doc["stats"]
assert payload["violations"] == [] and payload["repairs"] == 0
assert payload["model"] and isinstance(payload["duration_ms"], int)
# assign_to 拿到的是渲染后的正文:下游模板引用它,看到的就是报告本身
state = await run_manager.checkpointer.aget_tuple({"configurable": {"thread_id": row.id}})
assert state.checkpoint["channel_values"]["vars"]["report"] == text
async def test_prompt_carries_the_catalog_and_writing_rules(monkeypatch):
seen = script(monkeypatch, CLEAN)
row = await finish(weekly({"system": "你是周报撰写人"}))
assert row.status == "succeeded", row.error
prompt = "\n".join(str(m.content) for m in seen[0])
assert "你是周报撰写人" in prompt and "为 2026-W37 写周报" in prompt
assert "[[m:gmv]] 销售额 = 45,678.5元" in prompt and "[[i:week]] = 2026-W37" in prompt
assert "任何数字都只能用引用标记写" in prompt
async def test_metrics_from_limits_the_catalog_to_the_named_card(monkeypatch):
"""两张上游口径卡里有同名指标:只写 east 时,m:gmv 就是 east 的那一个,不必写卡名。"""
seen = script(monkeypatch, "东区销售额 [[m:gmv]]。")
graph = weekly({"metrics_from": ["east"]}, between=())
graph["nodes"][2:3] = [
node("east", "metrics", caliber="东区", metrics=[{"id": "gmv", "unit": "元", "expression": "vars.kpi.gmv"}]),
node("west", "metrics", caliber="西区", metrics=[{"id": "gmv", "unit": "元", "expression": "vars.kpi.orders"}]),
]
graph["edges"] = [{"source": a, "target": b} for a, b in [
("start", "fetch"), ("fetch", "east"), ("fetch", "west"), ("east", "write"), ("west", "write"),
("write", "out")]]
row = await finish(graph)
assert row.status == "succeeded", row.error
prompt = "\n".join(str(m.content) for m in seen[0])
assert "东区" in prompt and "西区" not in prompt
_, doc = await report_of(row.id)
assert numbers(doc) == [("45,678.5元", "gmv", "deterministic")]
assert doc["catalog"]["m:gmv"]["node_id"] == "east"
# --------------------------------------------------------------------------
# 流:看到的就是最终的数字
# --------------------------------------------------------------------------
async def test_llm_tokens_carry_real_numbers_not_markup(monkeypatch):
"""mock 按 6 个字一块吐,标记必然被切在两块中间——渲染器要攒齐了再放。"""
script(monkeypatch, CLEAN + "\n\n引用错了的也不漏标记:[[m:gmvx]]。")
tokens = spy_tokens(monkeypatch)
row = await finish(weekly({"max_repairs": 0})) # 只看一遍流:修复会再流一遍
assert row.status == "succeeded", row.error
streamed = "".join(t.data["delta"] for t in tokens if t.node_id == "write")
assert "[[" not in streamed and "]]" not in streamed and "m:gmv" not in streamed.replace("?m:gmvx", "")
assert "45,678.5元" in streamed and "8.7%" in streamed and "2026-W37" in streamed
assert "⟦?m:gmvx⟧" in streamed # 解析不了的引用在流里就是占位,和文档里一样
_, doc = await report_of(row.id)
assert streamed.strip() == doc["markdown"]
# --------------------------------------------------------------------------
# 违规:修复一次;还不行按 on_violation
# --------------------------------------------------------------------------
async def test_a_stream_that_breaks_mid_marker_leaks_nothing(monkeypatch):
"""流断在一个标记的半截(「[[m:g」),_invoke_streaming 退回非流式重来。渲染器攒着的半截
得扔掉:不然收尾 flush 时它会原样漏进 llm.token,和重来的那份拼在一起。"""
from langchain_core.messages import AIMessageChunk
from langchain_core.outputs import ChatGenerationChunk
from app.providers import mock_model
script(monkeypatch, CLEAN)
async def broken(self, messages, stop=None, run_manager=None, **kwargs):
for piece in ("本周([[i:week]])销售额 ", "[[m:g"):
yield ChatGenerationChunk(message=AIMessageChunk(content=piece))
raise RuntimeError("网关断开")
monkeypatch.setattr(mock_model.MockChatModel, "_astream", broken)
tokens = spy_tokens(monkeypatch)
row = await finish(weekly({"on_violation": "fail"}))
assert row.status == "succeeded", row.error
assert [e.data["code"] for e in await events(row.id, "log") if e.data.get("code") == "stream_fallback"] \
== ["stream_fallback"]
streamed = "".join(t.data["delta"] for t in tokens if t.node_id == "write")
# 断之前放出去的是渲染好的字;半截标记一个字符都不能出去
assert streamed == "本周(2026-W37)销售额 ", streamed
checked, doc = await report_of(row.id)
assert checked["ok"] is True and checked["repairs"] == 0
assert doc["markdown"] == "## 本周概览\n\n本周(2026-W37)销售额 45,678.5元,环比 8.7%;订单 1,234单。"
assert row.output["周报"] == doc["markdown"]
async def test_a_bare_number_is_repaired_exactly_once(monkeypatch):
seen = script(monkeypatch, "本周销售额 45678 元,订单 [[m:orders]]。", CLEAN)
row = await finish(weekly())
assert row.status == "succeeded", row.error
assert len(seen) == 2, "违规之后应当恰好重写一次"
repair = seen[1][-1]
assert isinstance(repair, HumanMessage) and "45678" in str(repair.content)
assert isinstance(seen[1][-2], AIMessage) and "45678" in str(seen[1][-2].content)
warns = [e.data for e in await events(row.id, "log") if e.data.get("code") == "report_repair"]
assert len(warns) == 1 and warns[0]["level"] == "warn" and "45678" in warns[0]["message"]
checked, doc = await report_of(row.id)
assert checked["repairs"] == 1 and checked["ok"] is True and doc["violations"] == []
assert "45678" not in doc["markdown"]
async def test_persistent_violation_fails_in_fail_mode(monkeypatch):
seen = script(monkeypatch, "本周销售额 45678 元,引用 [[m:nope]]。")
row = await finish(weekly({"on_violation": "fail"}))
assert row.status == "failed"
assert row.error_node_id == "write"
assert "45678" in row.error and "nope" in row.error, row.error
assert len(seen) == 2 # 默认修复一次
# 失败的那份也要留底:自查结果照样进封存,事后看得到它哪里不对
checked, doc = await report_of(row.id)
assert checked["ok"] is False and checked["repairs"] == 1 and checked["failed"] is True
assert {v["code"] for v in doc["violations"]} == {"uncited_number", "unresolved_ref"}
async def test_max_repairs_zero_means_no_rewrite(monkeypatch):
seen = script(monkeypatch, "本周销售额 45678 元。")
row = await finish(weekly({"on_violation": "fail", "max_repairs": 0}))
assert row.status == "failed" and len(seen) == 1
async def test_persistent_violation_is_flagged_in_flag_mode(monkeypatch):
script(monkeypatch, "本周销售额 45678 元,订单 [[m:orders]]。")
row = await finish(weekly({"on_violation": "flag"}))
assert row.status == "succeeded", row.error
checked, doc = await report_of(row.id)
assert checked["ok"] is False and checked["repairs"] == 1 and checked["failed"] is False
assert row.output["周报"] == doc["markdown"]
bare = [s for s in iter_segments(doc) if s.get("issue") == "uncited_number"]
assert [(s["text"], s["state"]) for s in bare] == [("45678", "none")]
finished = next(e for e in await events(row.id, "node.finished") if e.node_id == "write")
assert [v["code"] for v in artifact_store.load(finished.data["artifact"])["violations"]] \
== ["uncited_number"]
async def test_on_violation_defaults_follow_the_run_class(monkeypatch):
"""探索运行默认标注、照常产出;正式运行默认失败。"""
script(monkeypatch, "本周销售额 45678 元。")
explore = await finish(weekly())
assert explore.status == "succeeded", explore.error
formal = await finish(weekly(), run_class="formal")
assert formal.status == "failed" and "45678" in formal.error
async def test_the_default_follows_the_run_record_in_subgraphs_and_after_resume(monkeypatch):
"""默认值按运行记录上的 run_class 取:子图的线程 id 是「运行 id:节点 id」,审批之后续跑
是另起的执行上下文——两种情况下正式运行都不能悄悄退成 flag。"""
from app.db.models import Workflow
script(monkeypatch, "本周销售额 45678 元。")
async with SessionLocal() as session:
wf = Workflow(name="周报子流程", graph=weekly())
session.add(wf)
await session.commit()
wf_id = wf.id
parent = {"nodes": [node("start", "input", fields=[]), node("sub", "subgraph", workflow_id=wf_id),
node("out", "output", fields=[{"name": "r", "value": "{{ nodes.sub.output.周报 }}"}])],
"edges": [{"source": "start", "target": "sub"}, {"source": "sub", "target": "out"}]}
explore = await finish(parent)
assert explore.status == "succeeded", explore.error
assert "45678" in explore.output["r"]
formal = await finish(parent, run_class="formal")
assert formal.status == "failed" and "45678" in formal.error, formal.error
gated = weekly()
gated["nodes"].insert(3, node("gate", "human", mode="approve", title="确认口径"))
chain = ["start", "fetch", "caliber", "gate", "write", "out"]
gated["edges"] = [{"source": a, "target": b} for a, b in zip(chain, chain[1:])]
paused = await finish(gated, run_class="formal", statuses=("interrupted", "failed"))
assert paused.status == "interrupted", paused.error
await run_manager.resume(paused.id, {"approved": True})
for _ in range(400):
await asyncio.sleep(0.05)
async with SessionLocal() as session:
resumed = await session.get(Run, paused.id)
if resumed.status in ("succeeded", "failed") and resumed.manifest_seq is not None:
break
assert resumed.status == "failed" and "45678" in resumed.error, resumed.error
[checked] = [e.data for e in await events(paused.id, "report.checked")]
assert checked["on_violation"] == "fail"
async def test_numbers_off_does_not_rewrite_but_still_records(monkeypatch):
seen = script(monkeypatch, "本周销售额 45678 元。")
row = await finish(weekly({"numbers": "off", "on_violation": "fail"}))
assert row.status == "succeeded", row.error
assert len(seen) == 1
checked, doc = await report_of(row.id)
# 不拦不等于不记:文档和出口复核照样看得到这个数没有出处
assert [v["code"] for v in doc["violations"]] == ["uncited_number"]
assert checked["ok"] is False
async def test_numbers_the_contract_allows_are_not_repaired(monkeypatch):
"""出口契约放行的字面量(allow_numbers),报告自查也放行,不必为它白白重写一次。"""
seen = script(monkeypatch, "共分 37 个门店统计,销售额 [[m:gmv]]。")
row = await finish(weekly(contract={"metrics_from": ["caliber"], "report_from": "write",
"allow_numbers": ["37"]}))
assert row.status == "succeeded", row.error
assert len(seen) == 1
checked, _ = await report_of(row.id)
assert checked["ok"] is True
@pytest.mark.parametrize("value", ["sometimes", 3])
async def test_bad_on_violation_is_rejected(monkeypatch, value):
script(monkeypatch, CLEAN)
row = await finish(weekly({"on_violation": value}))
assert row.status == "failed" and "「重写后仍有违规时」" in row.error
async def test_tool_markup_is_nudged_once(monkeypatch):
seen = script(monkeypatch, DSML, CLEAN)
row = await finish(weekly())
assert row.status == "succeeded", row.error
assert len(seen) == 2
assert "tool_markup_leak" in [e.data.get("code") for e in await events(row.id, "log")]
_, doc = await report_of(row.id)
assert "DSML" not in doc["markdown"]
# --------------------------------------------------------------------------
# 封存
# --------------------------------------------------------------------------
async def test_report_checked_is_inside_the_seal(monkeypatch):
script(monkeypatch, CLEAN)
row = await finish(weekly())
assert row.status == "succeeded", row.error
checked = (await events(row.id, "report.checked"))[0]
assert checked.node_id == "write"
assert row.manifest_seq is not None and checked.seq <= row.manifest_seq
assert (await verify_manifest(row.id))["ok"] is True
# 文档取回时复验哈希;事件里的 id 就是它的内容哈希
assert artifact_store.load(checked.data["doc_artifact"])["node_id"] == "write"
# --------------------------------------------------------------------------
# 目录参数:出口复核要用同一个函数重建,这里单独钉住它的语义
# --------------------------------------------------------------------------
def test_report_catalog_honours_evidence_from_and_ancestry():
"""evidence_from 只管查询、检索这类证据从哪几个节点来;不在上游的节点什么都不收。"""
from app.engine.nodes.report import report_catalog
from app.engine.schema import GraphSpec
spec = GraphSpec.model_validate({
"nodes": [node("a", "tool", tool="x"), node("b", "tool", tool="x"), node("late", "tool", tool="x"),
node("card", "metrics", metrics=[{"id": "gmv", "expression": "1"}]),
node("write", "report", evidence_from=["a"])],
"edges": [{"source": s, "target": t} for s, t in [("a", "card"), ("b", "card"), ("card", "write"),
("write", "late")]]})
state = {
"nodes": {"card": {"kind": "metric_set", "caliber": "c", "caliber_version": "v1", "artifact": "f" * 64,
"metrics": [{"id": "gmv", "name": "销售额", "value": 1, "unit": ""}]}},
"evidence": [{"kind": "query", "node_id": "a", "artifact": "a" * 64, "rows": 2},
{"kind": "query", "node_id": "b", "artifact": "b" * 64, "rows": 3},
{"kind": "query", "node_id": "late", "artifact": "c" * 64, "rows": 4}],
"input": {"week": "2026-W37", "items": [1, 2]},
}
write = spec.node_map()["write"]
catalog = report_catalog(state, spec, write)
assert set(catalog) == {"m:gmv", "Q1", "i:week"}
assert catalog["Q1"]["node_id"] == "a"
write.config["evidence_from"] = "ancestors"
assert {k: v.get("node_id") for k, v in report_catalog(state, spec, write).items() if k.startswith("Q")} \
== {"Q1": "a", "Q2": "b"}