Files
AgentLab/backend/tests/test_issuance_positions.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

88 lines
4.6 KiB
Python

"""旧契约(按数值回指)的止血:每个数字记下位置,同值的多个指标不再默认算给第一个。
真实反例:code 节点解析失败后 13 个数值全部兜底成 0,叙述里 56 个「命中」全部算给了
同一个指标——`next(...)` 取第一个命中的。界面上那 56 个数都标着「来自指标 A」,
其实哪个都说不清。位置也没记:同一个 token 出现多次共用一个标记,只能按字符串全局
匹配,标错地方是必然的。
"""
from __future__ import annotations
import pytest
from app.engine.issuance import decide_tier, trace_numbers
ZEROS = [{"id": f"k{i}", "value": 0.0} for i in range(5)]
def test_same_value_metrics_are_ambiguous_not_first_wins():
narrative = "订单 0.0 单,退款 0.0 单,投诉 0.0 起,复购 0.0 次,流失 0.0 户"
report = trace_numbers(narrative, ZEROS)
assert len(report.matched) == 5 and not report.unmatched
for hit in report.matched:
assert hit["metric"] is None, hit
assert hit["ambiguous"] is True
assert hit["candidates"] == ["k0", "k1", "k2", "k3", "k4"]
def test_every_number_carries_its_offsets():
narrative = "本周订单 1,204 单,客单价 35.5 元,另有 7 单;复购 1,204 单"
report = trace_numbers(narrative, [{"id": "orders", "value": 1204}, {"id": "aov", "value": 35.5}])
assert [h["token"] for h in report.matched] == ["1,204", "35.5", "1,204"]
for hit in [*report.matched, *report.unmatched]:
assert narrative[hit["start"]:hit["end"]] == hit["token"], hit
# 同一个 token 出现两次,位置各是各的
assert report.matched[0]["start"] != report.matched[2]["start"]
assert report.unmatched == [{"token": "7", "context": report.unmatched[0]["context"],
"start": narrative.index("7 单"), "end": narrative.index("7 单") + 1}]
def test_offsets_for_chinese_numerals_and_suffixes():
narrative = "客户两千五百家,营收 3.5万,增长三成"
report = trace_numbers(narrative, [{"id": "c", "value": 2500}, {"id": "r", "value": 35000},
{"id": "g", "value": 0.3}])
assert [narrative[h["start"]:h["end"]] for h in report.matched] == ["两千五百", "3.5万", "三成"]
def test_unique_hit_keeps_the_old_shape():
"""只有一个指标对得上时,形状和以前一样(多了位置),老前端照常能读。"""
report = trace_numbers("订单 120 单", [{"id": "orders", "value": 120}, {"id": "aov", "value": 35.5}])
assert report.matched == [{"token": "120", "metric": "orders", "start": 3, "end": 6}]
def test_same_metric_id_from_two_calibers_is_not_ambiguous():
"""两张口径卡都定义了 orders:出处的指标是同一个,不算出处不唯一。"""
report = trace_numbers("订单 120 单", [{"id": "orders", "value": 120}, {"id": "orders", "value": 120}])
assert report.matched == [{"token": "120", "metric": "orders", "start": 3, "end": 6}]
# --------------------------------------------------------------------------
# decide_tier 的新参数:默认都是空,旧调用一个字不改、结果不变
# --------------------------------------------------------------------------
BASE = {"missing_required": [], "missing_expected": [], "unmatched": [], "strict": False}
def test_old_calls_are_unchanged():
assert decide_tier(**BASE) == "formal"
assert decide_tier(**{**BASE, "gaps": ["x"]}) == "degraded"
assert decide_tier(**{**BASE, "unmatched": [{"token": "1"}], "strict": True}) == "withheld"
@pytest.mark.parametrize("strict, tier", [(False, "degraded"), (True, "withheld")])
def test_unresolved_references_count_like_unmatched_numbers(strict, tier):
assert decide_tier(**{**BASE, "strict": strict}, unresolved=[{"ref": "m:gmvx"}]) == tier
def test_claims_are_ignored_without_a_policy():
assert decide_tier(**BASE, unsupported=[{"unit": "u1"}], uncited_claims=3) == "formal"
assert decide_tier(**BASE, unsupported=[{"unit": "u1"}], claims_policy="off") == "formal"
def test_claims_policy_degrades_or_withholds():
assert decide_tier(**BASE, unsupported=[{"unit": "u1"}], claims_policy="judge") == "degraded"
assert decide_tier(**BASE, uncited_claims=2, claims_policy="require_citation") == "degraded"
policy = {"policy": "judge", "on_unsupported": "withhold", "on_uncited": "ignore"}
assert decide_tier(**BASE, unsupported=[{"unit": "u1"}], claims_policy=policy) == "withheld"
assert decide_tier(**BASE, uncited_claims=[{"unit": "u2"}], claims_policy=policy) == "formal"
assert decide_tier(**BASE, unsupported=[], uncited_claims=0, claims_policy="judge") == "formal"