mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 05:41:57 +00:00
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)。
126 lines
5.9 KiB
Python
126 lines
5.9 KiB
Python
"""报告撰写节点的类型登记,以及搭图时就能认出来的两种错接。
|
||
|
||
报告节点从哪几张口径卡取指标(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
|