Files
AgentLab/backend/tests/test_report_schema.py
T
ThearchyHelios e4f2693918 可点击证据第一期:报告里的每个数字都点得开出处
「引用即渲染」:写报告的模型只写引用标记([[m:指标id]]、[[i:输入字段]]、[[see:…]]),数值由系统按口径卡
填进去,每个数字都挂着一条能复算的证据链。本期是数字层闭环;实体、结论句的概率性证据放在后续几期。

内核(engine/evidence.py,口径卡、出具契约、表达式等)
- 口径卡记录每个指标的来源:原式、代入式(每个输入写成「值 ← 路径」)、复算结果、口径与版本
- 出具契约的数值匹配改为按位置标注;同一个值有几个出处时记为「出处不唯一」并列出候选,不再随便挑一个
- 证据 id 由内容派生,事后对不上就是被改过;零值、结构性数字(列表序号、代码块标签)单独处理
- 口径卡新增 on_missing:缺输入时默认仍是整个节点失败,可以改成记为空值、交给契约判档

报告撰写节点(report)与引用模式的出具契约
- 流式生成时 token 里就是真实数字,不会漏出原始标记;裸数字、不存在的引用当场判违规,交回去重写
  (max_repairs,默认 1 次);仍不过时按 on_violation 失败(正式运行默认)或把违规标在报告里(探索运行默认)
- report.checked 事件记下核对结果,落在封存范围内;fail 模式下仍不过时带 failed,运行记录里画成失败
- 出具契约写了 report_from 就按引用核对:取回文档时复验哈希,独立重算一遍,全部带引用才判完整出具
- 成果字段逐字等于报告正文时标注 _evidence;复核只能加说明,不能改写带证据的答案(前后端各拦一道)

证据接口
- GET /api/runs/{id}/evidence 返回证据图;GET …/evidence/segments/{id} 返回一个片段的证据链
- 只认封存范围内的事件能追到的工件,不读 artifacts 表;封存被改过时不把任何一项标成已封存
- 没有报告的旧运行降级为 none 或 legacy_contract

界面
- EvidenceDoc 按块、句、片段渲染报告,片段是和正文一样的按钮;整份报告只占一个 Tab 位,
  ←/→ 在片段间移动、↑/↓ 按句、n/N 跳到下一处「无证据」、Enter 打开面板、Esc 关闭并把焦点还回去
- EvidencePanel:宽屏侧边、窄屏底部抽屉、360px 画布右栏里栏内展开;显示指标步骤、代入式、复算结果、
  封存状态。正文不是封存的那份报告时明确说出来,不拿别的报告的封存画绿
- 出具横幅多一行「N/N 数字有出处 · 无证据 M」和「定位下一处」;没有 _evidence 的旧回答渲染一字不差
- 画布认得报告撰写节点;Copilot 手册补上口径卡、报告节点、出具契约的写法;新增模板「⑨ 可追溯周报」

验证(沙箱):tsc 干净,生产构建通过,pytest 1601 passed,check-all 16 项检查全绿(新增 check-evidence)。
2026-09-28 11:47:23 +08:00

126 lines
5.9 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.
"""报告撰写节点的类型登记,以及搭图时就能认出来的两种错接。
报告节点从哪几张口径卡取指标(metrics_from)、出口契约核对哪个报告节点
(report_from),都是按节点 id 写死的引用。指到一个不是口径卡的节点、或者指到
下游,运行时只会得到一个空目录、一份无从核对的报告——而这在画图时就看得出来。
"""
from __future__ import annotations
import pytest
from app.core.events import EventType
from app.engine.schema import TYPE_LABEL, GraphSpec, NodeType, type_label, validate_graph
from app.engine.variables import analyze
def node(nid, ntype, **config):
return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0},
"data": {"label": nid, "config": config}}
def spec(nodes, edges):
return GraphSpec.model_validate({
"nodes": nodes, "edges": [{"source": a, "target": b} for a, b in edges]})
def issues(graph: GraphSpec, field: str) -> list[tuple[str, str]]:
return [(i.level, i.message) for i in validate_graph(graph).issues if i.field == field]
def test_report_type_is_registered():
assert NodeType("report") is NodeType.REPORT
assert TYPE_LABEL["report"] == "报告撰写" == type_label(NodeType.REPORT)
assert EventType("report.checked") is EventType.REPORT_CHECKED
def report_graph(metrics_from=None, *, report_from="write", extra=(), edges=None, **contract):
cfg = {"instructions": "写周报", "model": "mock"}
if metrics_from is not None:
cfg["metrics_from"] = metrics_from
nodes = [
node("start", "input"),
node("fetch", "transform", mode="expression", expression="{'gmv': 1}", assign_to="kpi"),
node("caliber", "metrics", metrics=[{"id": "gmv", "expression": "vars.kpi.gmv"}]),
node("write", "report", **cfg),
node("out", "output", fields=[{"name": "周报", "value": "{{ nodes.write.text }}"}],
contract={"metrics_from": ["caliber"], "report_from": report_from, **contract}),
*extra,
]
return spec(nodes, edges or [("start", "fetch"), ("fetch", "caliber"), ("caliber", "write"),
("write", "out")])
def test_a_well_formed_report_graph_has_no_new_errors():
graph = report_graph(["caliber"])
assert issues(graph, "metrics_from") == []
assert issues(graph, "contract.report_from") == []
assert issues(report_graph("caliber"), "metrics_from") == [] # 单个 id 写成字符串也认
@pytest.mark.parametrize("metrics_from, words", [
(["nope"], ["nope", "不存在"]),
(["fetch"], ["fetch", "不是「口径卡」"]),
])
def test_metrics_from_must_name_a_caliber(metrics_from, words):
found = issues(report_graph(metrics_from), "metrics_from")
assert len(found) == 1 and found[0][0] == "error", found
assert all(w in found[0][1] for w in words), found
def test_metrics_from_must_be_upstream():
"""口径卡接在报告后面:报告跑的时候它还没算,目录里一个指标都没有。"""
graph = report_graph(["late"], extra=[node("late", "metrics", metrics=[{"id": "x", "expression": "1"}])],
edges=[("start", "fetch"), ("fetch", "caliber"), ("caliber", "write"),
("write", "late"), ("late", "out")])
found = issues(graph, "metrics_from")
assert len(found) == 1 and found[0][0] == "error" and "上游" in found[0][1], found
@pytest.mark.parametrize("report_from, words", [
("nope", ["nope", "不存在"]),
("caliber", ["caliber", "不是「报告撰写」"]),
])
def test_report_from_must_name_a_report_node(report_from, words):
found = issues(report_graph(["caliber"], report_from=report_from), "contract.report_from")
assert len(found) == 1 and found[0][0] == "error", found
assert all(w in found[0][1] for w in words), found
def test_report_from_must_be_upstream_of_the_output():
graph = report_graph(["caliber"], report_from="side", extra=[node("side", "report", instructions="x")],
edges=[("start", "fetch"), ("fetch", "caliber"), ("caliber", "write"),
("write", "out"), ("caliber", "side")])
found = issues(graph, "contract.report_from")
assert len(found) == 1 and found[0][0] == "error" and "上游" in found[0][1], found
def test_old_contracts_are_untouched():
"""没写 report_from 的旧契约:不多一条报错,缺 metrics_from 照旧报。"""
graph = spec([node("start", "input"),
node("out", "output", contract={"narrative": "x"})], [("start", "out")])
assert issues(graph, "contract.report_from") == []
assert [lv for lv, _ in issues(graph, "contract.metrics_from")] == ["error"]
# --------------------------------------------------------------------------
# 变量分析扫口径卡的表达式
# --------------------------------------------------------------------------
def test_caliber_expressions_count_as_references():
"""口径卡对上游的引用以前在变量表里看不见:vars.kpi 被报成「产出了但没有任何地方引用」,
画布上也画不出口径卡依赖谁。"""
graph = spec([
node("start", "input"),
node("fetch", "transform", mode="expression", expression="{'gmv': 1, 'prev': 1}", assign_to="kpi"),
node("caliber", "metrics", metrics=[
{"id": "gmv", "expression": "vars.kpi.gmv"},
{"id": "wow", "expression": "(vars.kpi.gmv - vars.kpi.prev) / vars.kpi.prev"}]),
node("out", "output", fields=[{"name": "r", "value": "{{ nodes.caliber.text }}"}]),
], [("start", "fetch"), ("fetch", "caliber"), ("caliber", "out")])
report = analyze(graph)
kpi = next(v for v in report.variables if v.path == "vars.kpi")
assert {(r.node_id, r.field) for r in kpi.refs} == {("caliber", "metrics[0].expression"),
("caliber", "metrics[1].expression")}
assert not [i for i in report.issues if i.path == "vars.kpi"], report.issues