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

660 lines
33 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.
"""证据内核:引用标记的解析、目录、渲染、切块切句切片段、裸数字,以及独立复核。
这一层是纯函数,报告节点自查、出口契约复核、证据接口都用它。所以这里守的是
「同样的输入永远得到同样的结果」和「复核不信任文档自己的说法」两件事。
"""
from __future__ import annotations
import copy
import pytest
from app.core.artifact_store import canonical_json, content_hash
from app.engine.evidence import (
DOC_SCHEMA,
LATER_REASON,
MARKER_MAX,
RenderError,
StreamRenderer,
build_catalog,
catalog_prompt,
compose_doc,
describe_violations,
find_segment,
input_eid,
iter_segments,
make_eid,
parse_markers,
render_markers,
render_number,
verify_doc,
)
# --------------------------------------------------------------------------
# 夹具:一张口径卡的产出(形状同 metrics 节点),外加运行输入
# --------------------------------------------------------------------------
ART = "a" * 64
def metric(mid, name, value, unit="", decimals=None, fmt="thousands", status="ok"):
return {"id": mid, "name": name, "unit": unit, "value": value, "decimals": decimals,
"format": fmt, "status": status, "expression": "…"}
CARD = {
"kind": "metric_set", "caliber": "周报口径", "caliber_version": "v2", "artifact": ART,
"metrics": [
metric("gmv", "销售额", 45678.5, "元", 1),
metric("wow", "环比增幅", 8.7, "%", 1, "plain"),
metric("orders", "订单数", 1234, "单"),
metric("aov", "客单价", 37, "元", 1),
metric("refund_rate", "退款率", 0.0235, "", 4, "percent_of_ratio"),
metric("big", "大数", 45678.5),
metric("gone", "缺数", None, status="missing_input"),
],
}
LEDGER = [{"kind": "metric_set", "node_id": "caliber", "exec": 1, "artifact": ART,
"caliber": "周报口径", "version": "v2", "metrics": [m["id"] for m in CARD["metrics"]]}]
NODES = {"caliber": CARD, "fetch": {"text": "上游笔记 999"}}
INPUTS = {"week": "2026-W37", "region": "华东", "rows": [1, 2]}
@pytest.fixture
def catalog():
return build_catalog(nodes=NODES, ledger=LEDGER, inputs=INPUTS)
def texts(doc):
return [s["text"] for s in iter_segments(doc)]
def codes(doc):
return [v["code"] for v in doc["violations"]]
# --------------------------------------------------------------------------
# 标记语法
# --------------------------------------------------------------------------
def test_markers_parse():
text = ("销售额 [[m:gmv]],折合 [[m:gmv|万]]。[[see:m:gmv, m:wow,Q4]] 周期 [[i:week]],"
"[[v:Q4.r0.amount]] [[t:orders]] [[c:orders.amount]] [[q:K2|原话。]] "
"[[table:Q4 cols=a,b rows=0-4]] [[metric:gmv]] [[注意]]")
found = parse_markers(text)
assert [(m["kind"], m.get("ref")) for m in found] == [
("m", "gmv"), ("m", "gmv"), ("see", None), ("i", "week"), ("v", "Q4.r0.amount"),
("t", "orders"), ("c", "orders.amount"), ("q", "K2"), ("table", "Q4"), ("metric", "gmv")]
assert found[1]["conv"] == "万"
assert found[2]["refs"] == ["m:gmv", "m:wow", "Q4"]
assert found[7]["quote"] == "原话。"
assert found[8]["options"] == {"cols": "a,b", "rows": "0-4"}
for m in found:
assert text[m["start"]:m["end"]] == m["raw"]
# --------------------------------------------------------------------------
# 目录与 eid
# --------------------------------------------------------------------------
def test_catalog_entries_and_stable_eids(catalog):
gmv = catalog["m:gmv"]
assert gmv["kind"] == "metric" and gmv["node_id"] == "caliber" and gmv["artifact"] == ART
assert gmv["locator"] == {"metric": "gmv"} and gmv["caliber"] == "周报口径" and gmv["version"] == "v2"
assert gmv["rendered"] == "45,678.5元" and gmv["label"] == "销售额 = 45,678.5元"
assert gmv["eid"] == make_eid("metric", ART, {"metric": "gmv"})
assert gmv["eid"].startswith("ev:metric:") and len(gmv["eid"]) == len("ev:metric:") + 16
assert catalog["i:week"]["value"] == "2026-W37" and catalog["i:week"]["kind"] == "input"
assert "i:rows" not in catalog # 容器型输入没法写进一句话
# 同一输入,eid 一个字不差
again = build_catalog(nodes=copy.deepcopy(NODES), ledger=copy.deepcopy(LEDGER), inputs=dict(INPUTS))
assert {k: v["eid"] for k, v in again.items()} == {k: v["eid"] for k, v in catalog.items()}
# 内容变了,eid 跟着变
other = build_catalog(nodes={"caliber": {**CARD, "artifact": "b" * 64}},
ledger=[{**LEDGER[0], "artifact": "b" * 64}], inputs=INPUTS)
assert other["m:gmv"]["eid"] != gmv["eid"]
def test_catalog_respects_metrics_from_and_allowed():
second = {**CARD, "caliber": "月报口径", "artifact": "c" * 64,
"metrics": [metric("gmv", "销售额", 1.0), metric("mau", "月活", 9)]}
nodes = {**NODES, "monthly": second}
ledger = [*LEDGER, {**LEDGER[0], "node_id": "monthly", "artifact": "c" * 64}]
both = build_catalog(nodes=nodes, ledger=ledger)
# 两张卡都有 gmv:不许按先来后到默认给一个,只登记带卡名的写法
assert "m:gmv" not in both
assert both["m:caliber.gmv"]["value"] == 45678.5 and both["m:monthly.gmv"]["value"] == 1.0
assert both["m:mau"]["node_id"] == "monthly"
only = build_catalog(nodes=nodes, ledger=ledger, metrics_from=["monthly"])
assert only["m:gmv"]["node_id"] == "monthly" and "m:orders" not in only
upstream = build_catalog(nodes=nodes, ledger=ledger, allowed={"caliber"})
assert "m:mau" not in upstream and upstream["m:gmv"]["node_id"] == "caliber"
# --------------------------------------------------------------------------
# 渲染
# --------------------------------------------------------------------------
def test_rendering(catalog):
raw = ("本周([[i:week]])销售额 [[m:gmv]],环比 [[m:wow]];订单 [[m:orders]],客单价 [[m:aov]]。"
"[[see:m:gmv,m:wow]]\n\n退款率 [[m:refund_rate]],大数折合 [[m:big|万]],销售额折合 [[m:gmv|万]]。")
doc = compose_doc(raw, catalog, node_id="write", run_id="r1")
assert doc["schema"] == DOC_SCHEMA and doc["node_id"] == "write" and doc["run_id"] == "r1"
assert doc["markdown"] == ("本周(2026-W37)销售额 45,678.5元,环比 8.7%;订单 1,234单,客单价 37.0元。"
"\n\n退款率 2.35%,大数折合 4.57万,销售额折合 4.57万元。")
assert doc["violations"] == [] and doc["stats"]["numbers_cited"] == 7
seg = next(s for s in iter_segments(doc) if s["text"] == "45,678.5元")
assert seg["kind"] == "number" and seg["state"] == "deterministic" and seg["ref"] == "m:gmv"
assert seg["cite"]["status"] == "resolved" and seg["cite"]["value"] == 45678.5
assert seg["cite"]["eid"] == catalog["m:gmv"]["eid"] and seg["cite"]["alias"] == "m:gmv"
week = next(s for s in iter_segments(doc) if s["text"] == "2026-W37")
assert week["kind"] == "value" and week["ref"] == "i:week"
def _one_metric_catalog(value, unit="", decimals=None, fmt="thousands"):
card = {**CARD, "metrics": [metric("small", "小值", value, unit, decimals, fmt)]}
return build_catalog(nodes={"caliber": card}, ledger=LEDGER)
@pytest.mark.parametrize("value, unit, decimals, fmt, conv", [
(29, "单", None, "thousands", "万"), # 0.0029万 → 显示成「0万单」
(45678.5, "元", 1, "thousands", "亿"), # 0.000456785亿 → 「0亿元」
(0.004, "", None, "thousands", "int"),
(-0.004, "", None, "thousands", ".2"),
(0.00004, "", None, "thousands", "pct"), # 0.004% 按一位小数是 0.0%
(0.00004, "", 4, "percent_of_ratio", "万"), # 比率不能换算(这条本来就解析不了)
])
def test_conversions_never_show_a_nonzero_value_as_zero(value, unit, decimals, fmt, conv):
"""系统渲染的数带着「有出处」的实线:把 29 单换算成「0万单」再标成有出处,比模型
自己编一个数还糟——读者没有任何理由怀疑它。显示成 0 的非零值一律算解析不了。"""
cat = _one_metric_catalog(value, unit, decimals, fmt)
doc = compose_doc(f"数值 [[m:small|{conv}]]。", cat)
seg = next(s for s in iter_segments(doc) if s.get("ref"))
assert seg["state"] == "none" and seg["issue"] == "unresolved_ref", seg
assert seg["cite"]["status"] == "unresolved"
assert codes(doc) == ["unresolved_ref"]
assert seg["text"] == f"⟦?m:small|{conv}⟧"
if fmt != "percent_of_ratio":
assert "显示为 0" in seg["cite"]["reason"] and "精度不够" in seg["cite"]["reason"]
def test_values_that_cannot_be_shown_are_flagged_in_the_catalog_and_prompt():
cat = _one_metric_catalog(1e-12)
assert cat["m:small"]["rendered"] == "—" and cat["m:small"]["value"] == 1e-12
assert "[[m:small]] 小值:按口径卡的格式显示不出来,不要引用" in catalog_prompt(cat)
doc = compose_doc("小值 [[m:small]]。", cat)
assert codes(doc) == ["unresolved_ref"] and "显示为 0" in doc["violations"][0]["message"]
def test_zero_guard_only_fires_for_nonzero_values():
assert render_number(0, conv="万") == "0万"
assert render_number(0.0, conv="int") == "0"
assert render_number(-0.0, conv=".2") == "0.00"
assert render_number(50, unit="单", conv="万") == "0.01万单" # 四舍五入到 0.01,不是 0
assert render_number(0.005, conv=".2") == "0.01"
with pytest.raises(RenderError, match="显示为 0"):
render_number(1e-12) # 不写小数位时最多留 10 位
with pytest.raises(RenderError, match="显示为 0"):
render_number(29, decimals=-2) # 口径卡自己的小数位把它抹成 0 也一样
@pytest.mark.parametrize("value, expected", [
(1e55, f"{10 ** 55:,}"),
(9e14 ** 4, f"{6561 * 10 ** 56:,}"),
(10 ** 70 + 1, f"{10 ** 70 + 1:,}"), # 超过 60 位有效数字也不能被悄悄舍入
(-(10 ** 65) - 7, f"{-(10 ** 65) - 7:,}"),
])
def test_huge_values_render_exactly(value, expected):
assert render_number(value) == expected
def test_huge_values_with_conversions_and_decimals():
assert render_number(10 ** 40 + 12345, conv="万") == f"{10 ** 36 + 1:,}.23万"
assert render_number(10 ** 40 + 1, conv="pct") == f"{(10 ** 40 + 1) * 100:,}.0%"
assert render_number(1e55, decimals=2) == f"{10 ** 55:,}.00"
assert render_number(12345.678, decimals=-2) == "12,300"
with pytest.raises(RenderError):
render_number(10 ** 5000) # 大到不像话:说清楚,而不是抛 InvalidOperation
with pytest.raises(RenderError):
render_number(1.5, decimals=10 ** 6)
def test_input_eids_are_derived_from_the_value():
w37 = build_catalog(nodes={}, inputs={"week": "2026-W37"})["i:week"]
w38 = build_catalog(nodes={}, inputs={"week": "2026-W38"})["i:week"]
again = build_catalog(nodes={}, inputs={"week": "2026-W37"})["i:week"]
assert w37["eid"] != w38["eid"] and w37["eid"] == again["eid"]
assert w37["eid"] == input_eid("week", "2026-W37")
# 目录条目自己就能复算:值被改过,eid 就对不上
assert w37["eid"] == make_eid("input", content_hash(canonical_json(w37["value"])), w37["locator"])
# 同一个值换一个字段名也是另一件证据
assert build_catalog(nodes={}, inputs={"period": "2026-W37"})["i:period"]["eid"] != w37["eid"]
@pytest.mark.parametrize("empty", [[], ""])
def test_empty_metrics_from_means_every_card(empty):
"""validate_graph 把 metrics_from=[] 当成「所有上游口径卡」,目录要是当成「一张都不要」,
报告就一个数都写不出来,而画布上什么都不提示。两边必须同一个意思。"""
full = build_catalog(nodes=NODES, ledger=LEDGER, metrics_from=None)
assert build_catalog(nodes=NODES, ledger=LEDGER, metrics_from=empty) == full
assert "m:gmv" in full
def test_unresolved_references(catalog):
doc = compose_doc("销售额 [[m:gmvx]],缺数 [[m:gone]],单元格 [[v:Q4.r0.amount]],"
"输入 [[i:nope]],换算 [[m:wow|pct]],[[metric:gmv]]。", catalog)
assert doc["markdown"] == ("销售额 ⟦?m:gmvx⟧,缺数 —,单元格 ⟦?v:Q4.r0.amount⟧,"
"输入 ⟦?i:nope⟧,换算 ⟦?m:wow|pct⟧,⟦?metric:gmv⟧。")
bad = [v for v in doc["violations"] if v["code"] == "unresolved_ref"]
assert [v["ref"] for v in bad] == ["m:gmvx", "m:gone", "v:Q4.r0.amount", "i:nope", "m:wow|pct",
"metric:gmv"]
reasons = {v["ref"]: v["message"] for v in bad}
assert "gmvx" in reasons["m:gmvx"]
assert "没有值" in reasons["m:gone"]
assert "查询结果 Q4 不存在" in reasons["v:Q4.r0.amount"] # 单元格二期起支持,这份目录里没有 Q4
assert "百分数" in reasons["m:wow|pct"]
for s in iter_segments(doc):
if s.get("ref"):
assert s["state"] == "none" and s["cite"]["status"] == "unresolved" and s["issue"] == "unresolved_ref"
assert doc["stats"]["unresolved"] == 6 and doc["stats"]["numbers_cited"] == 0
def test_see_refs_are_checked_but_not_rendered(catalog):
doc = compose_doc("销售额见口径卡。[[see:m:gmv,i:week,gmv,m:nope,t:refunds]]下面看退款。", catalog)
assert doc["markdown"] == "销售额见口径卡。下面看退款。"
first = doc["blocks"][0]["units"][0]
assert [c["alias"] for c in first["see"] if c["status"] == "resolved"] == ["m:gmv", "i:week", "m:gmv"]
assert first["cites"] == ["m:gmv", "i:week"]
bad = {v["ref"]: v["message"] for v in doc["violations"]}
assert set(bad) == {"m:nope", "t:refunds"} and LATER_REASON in bad["t:refunds"]
assert doc["blocks"][0]["units"][1]["cites"] == []
# --------------------------------------------------------------------------
# 裸数字
# --------------------------------------------------------------------------
def test_bare_numbers_are_violations_with_offsets(catalog):
doc = compose_doc("销售额 [[m:gmv]],增长 12%,客户两千五百家。", catalog)
bare = [v for v in doc["violations"] if v["code"] == "uncited_number"]
assert [v["text"] for v in bare] == ["12%", "两千五百"]
for v in bare:
assert doc["markdown"][v["span"][0]:v["span"][1]] == v["text"]
assert v["unit"] == "u0" and v["segment"]
seg = next(s for s in iter_segments(doc) if s["text"] == "12%")
assert seg["kind"] == "number" and seg["state"] == "none" and seg["issue"] == "uncited_number"
assert "ref" not in seg
assert doc["stats"]["uncited_numbers"] == 2 and doc["stats"]["numbers"] == 3
def test_dates_weeks_structural_and_allowed_numbers_pass(catalog):
doc = compose_doc("2026-09-27 发布,覆盖 2026-W37,2026年9月的数据。前 3 名、第 2 季度、Top 5、"
"从 3 个方面看,时间 10:30。客单价 37 元,编号 120。原因有两点:1、价格(2)渠道。",
catalog, allow_numbers=["37", "120"])
assert codes(doc) == [], doc["violations"]
# 结构性数字限 20 以内:前 30 名就得有出处
doc = compose_doc("前 30 名。", catalog)
assert [v["text"] for v in doc["violations"]] == ["30"]
def test_numbers_in_structure_do_not_count_and_cannot_hide(catalog):
"""行首序号按出具校验的规则放过(1–2 位);再大的序号前端照样显示成 <ol start=100>,
不能借「这是列表语法」躲过裸数字检查。"""
raw = ("1. 第一项 [[m:orders]]\n2. 第二项 7 单\n\n| 区域 | 金额 |\n| --- | --- |\n| 甲 | 8 |\n\n"
"100. 编号从这里开始的列表\n101. 下一项")
doc = compose_doc(raw, catalog)
assert [v["text"] for v in doc["violations"]] == ["7", "8", "100", "101"]
assert doc["blocks"][-1]["start"] == 100
assert not INTEGRITY & set(codes(doc))
# 违规指向的是那个序号所在的结构片段,偏移切得回原字
for v in doc["violations"][2:]:
assert doc["markdown"][v["span"][0]:v["span"][1]] == v["text"]
seg = find_segment(doc, v["segment"])[2]
assert seg["kind"] == "structural" and v["text"] in seg["text"]
@pytest.mark.parametrize("raw, bare", [
("45678. 本周销售额创新高", ["45678"]),
("段落\n\n98765) 订单", ["98765"]),
("12. 序号照旧放过\n13. 下一项", []),
(" 3) 缩进的序号", []),
("```45678\nx\n```", ["45678"]), # 代码块的语言标签显示在块头上
("```py3\nx\n```", []), # 和正文同一条规则:v2 / py3 这类标识符不算数字
("```\nx\n```", []),
("1234567890. 很长的序号", ["1234567890"]), # 切块认它是列表,复核也得认它是语法
# 表格第一格不在行首:竖线涂白后不能让「12.」沾行首序号免检的光
("| 序号 | 区域 |\n| --- | --- |\n| 12. | 华东 |", ["12"]),
("> 3. 引用里的序号", []),
])
def test_ordinals_and_fence_labels_follow_the_prose_rules(catalog, raw, bare):
doc = compose_doc(raw, catalog)
assert [v["text"] for v in doc["violations"] if v["code"] == "uncited_number"] == bare
assert not INTEGRITY & set(codes(doc)), doc["violations"]
again = verify_doc(copy.deepcopy(doc), catalog)
assert again["violations"] == doc["violations"]
# --------------------------------------------------------------------------
# 切块、切句、切片段
# --------------------------------------------------------------------------
REPORT = """## 本周概览
本周([[i:week]])销售额 [[m:gmv]],环比 [[m:wow]]。[[see:m:gmv,m:wow]]增长主要来自新客首单。下面看退款情况。
- 订单 [[m:orders]]
(含预售)
- 客单价 **[[m:aov]]**
| 指标 | 值 |
| --- | :-: |
| 退款率 | [[m:refund_rate]] |
| 大数 | [[m:big|万]] |
> 口径说明:[[i:region]]。
> 第二行。
```
code 块
```
---"""
def test_segments_tile_the_markdown(catalog):
doc = compose_doc(REPORT, catalog)
joined = "".join(texts(doc))
assert joined == doc["markdown"]
pos = 0
for seg in iter_segments(doc):
assert seg["span"][0] == pos and doc["markdown"][seg["span"][0]:seg["span"][1]] == seg["text"]
pos = seg["span"][1]
assert pos == len(doc["markdown"])
assert doc["violations"] == [], doc["violations"]
def test_doc_is_json_and_content_addressable(catalog):
"""报告节点要把文档 put_json 成 report_doc 工件:必须能序列化,同样的原文得到同样的哈希。"""
from app.core.artifact_store import canonical_json, content_hash
first = content_hash(canonical_json(compose_doc(REPORT, catalog, node_id="write", run_id="r1")))
fresh = build_catalog(nodes=copy.deepcopy(NODES), ledger=copy.deepcopy(LEDGER), inputs=dict(INPUTS))
assert content_hash(canonical_json(compose_doc(REPORT, fresh, node_id="write", run_id="r1"))) == first
def test_blocks_units_and_kinds(catalog):
doc = compose_doc(REPORT, catalog)
blocks = doc["blocks"]
assert [b["type"] for b in blocks] == ["heading", "paragraph", "list", "table", "quote", "code", "hr"]
assert [b["id"] for b in blocks] == [f"b{i}" for i in range(7)]
heading, para, lst, table, quote, code, hr = blocks
assert heading["level"] == 2 and heading["units"][0]["kind"] == "heading"
h_unit = heading["units"][0]
assert doc["markdown"][h_unit["span"][0]:h_unit["span"][1]] == "本周概览"
# 句子按句号切;[[see:]] 挂在它前面那一句;连接性的话单独成一句
sentences = [doc["markdown"][u["span"][0]:u["span"][1]] for u in para["units"]]
assert sentences == ["本周(2026-W37)销售额 45,678.5元,环比 8.7%。", "增长主要来自新客首单。",
"下面看退款情况。"]
assert [u["kind"] for u in para["units"]] == ["claim", "claim", "connective"]
assert para["units"][0]["cites"] == ["i:week", "m:gmv", "m:wow"]
# 列表每项一个 unit,续行接在同一项里
assert lst["ordered"] is False and len(lst["units"]) == 2
first_item = "".join(s["text"] for s in lst["units"][0]["segments"] if s["kind"] != "structural")
assert first_item == "订单 1,234单\n(含预售)"
# **[[m:aov]]** 归一成 strong,正文里不留星号
aov = next(s for s in iter_segments(doc) if s.get("ref") == "m:aov")
assert aov["strong"] is True and "**" not in doc["markdown"]
# 表格每格一个 unit,带行列;表头 row = -1
locs = [(u["loc"]["row"], u["loc"]["col"]) for u in table["units"]]
assert locs == [(-1, 0), (-1, 1), (0, 0), (0, 1), (1, 0), (1, 1)]
assert any(s.get("ref") == "m:big|万" for s in table["units"][5]["segments"])
assert [u["kind"] for u in quote["units"]] == ["claim", "connective"]
assert code["units"][0]["kind"] == "code" and hr["units"][0]["kind"] == "connective"
# 编号全局连续
unit_ids = [u["id"] for b in blocks for u in b["units"]]
assert unit_ids == [f"u{i}" for i in range(len(unit_ids))]
seg_ids = [s["id"] for s in iter_segments(doc)]
assert seg_ids == [f"s{i}" for i in range(len(seg_ids))]
block, unit, seg = find_segment(doc, aov["id"])
assert block["id"] == "b2" and unit["id"] == lst["units"][1]["id"] and seg is aov
def test_bold_around_markers_becomes_strong_segments(catalog):
"""**销售额 [[m:gmv]]**:数字切成单独的片段后,两边各剩半对星号,前端只能原样显示。
星号去掉、里面的每一段都标 strong;不含标记的粗体原样留在文字里。"""
doc = compose_doc("前面 **销售额 [[m:gmv]]** 后面,__[[m:refund_rate]]__,**重点** 不动。", catalog)
assert doc["markdown"] == "前面 销售额 45,678.5元 后面,2.35%,**重点** 不动。"
flags = [(s["text"], bool(s.get("strong"))) for s in iter_segments(doc)]
assert flags == [("前面 ", False), ("销售额 ", True), ("45,678.5元", True), (" 后面,", False),
("2.35%", True), (",**重点** 不动。", False)]
assert doc["violations"] == []
def test_sentence_ends_inside_code_links_bold_and_markers_do_not_split(catalog):
doc = compose_doc("看 `x. y` 和 [链接](http://a.b/?q=1) 还有 **重点。强调** 以及 [[q:K2|原话。]] 结束。第二句。",
catalog)
sentences = [doc["markdown"][u["span"][0]:u["span"][1]] for u in doc["blocks"][0]["units"]]
assert sentences == ["看 `x. y` 和 [链接](http://a.b/?q=1) 还有 **重点。强调** 以及 ⟦?q:K2|原话。⟧ 结束。",
"第二句。"]
# 链接地址以问号结尾:问号在链接里,不能因为它后面紧跟链接的右括号就在链接后面断句
doc = compose_doc("见[文档](http://a.b/?) 然后继续。", catalog)
assert len(doc["blocks"][0]["units"]) == 1
def test_offsets_are_code_points(catalog):
doc = compose_doc("😀🎉 销售额 [[m:gmv]],另有 7 单。", catalog)
seg = next(s for s in iter_segments(doc) if s.get("ref") == "m:gmv")
assert seg["span"] == [7, 16] and doc["markdown"][7:16] == "45,678.5元"
assert doc["violations"][0]["span"] == [20, 21] and doc["markdown"][20] == "7"
def test_pipes_inside_markers_do_not_split_table_cells(catalog):
doc = compose_doc("| a | b |\n| - | - |\n| [[m:gmv|万]] | x |", catalog)
cells = doc["blocks"][0]["units"]
assert [u["loc"] for u in cells][2:] == [{"row": 0, "col": 0}, {"row": 0, "col": 1}]
assert doc["markdown"] == "| a | b |\n| - | - |\n| 4.57万元 | x |"
# --------------------------------------------------------------------------
# verify_doc:独立复核,不信任文档自己的说法
# --------------------------------------------------------------------------
def test_verify_doc_agrees_with_compose(catalog):
doc = compose_doc(REPORT + "\n\n另有 7 单。", catalog)
again = verify_doc(copy.deepcopy(doc), catalog)
assert again["violations"] == doc["violations"] and again["stats"] == doc["stats"]
assert again["ok"] is False and [v["code"] for v in again["violations"]] == ["uncited_number"]
def test_verify_doc_catches_tampering(catalog):
doc = compose_doc("销售额 [[m:gmv]],订单 [[m:orders]]。", catalog)
assert verify_doc(doc, catalog)["ok"] is True
# 1) 数字片段的字被改了:重渲染对不上
forged = copy.deepcopy(doc)
seg = next(s for s in iter_segments(forged) if s.get("ref") == "m:gmv")
shift = len("99,999.9元") - len(seg["text"])
old_end = seg["span"][1]
forged["markdown"] = forged["markdown"].replace("45,678.5元", "99,999.9元")
seg["text"], seg["span"][1] = "99,999.9元", seg["span"][1] + shift
for s in iter_segments(forged):
if s["span"][0] >= old_end:
s["span"] = [s["span"][0] + shift, s["span"][1] + shift]
assert "render_mismatch" in [v["code"] for v in verify_doc(forged, catalog)["violations"]]
# 2) 把裸数字塞进文字片段、又不标出来:复核自己去正文里找
hidden = copy.deepcopy(doc)
last = list(iter_segments(hidden))[-1]
last["text"] += "另有 7 单"
hidden["markdown"] += "另有 7 单"
last["span"][1] = len(hidden["markdown"])
assert [v["code"] for v in verify_doc(hidden, catalog)["violations"]] == ["uncited_number"]
# 3) 片段接不上正文
broken = copy.deepcopy(doc)
broken["markdown"] = broken["markdown"] + "尾巴"
assert "segment_mismatch" in [v["code"] for v in verify_doc(broken, catalog)["violations"]]
# 3b) 中间一段的字和它记的位置对不上 / 少了一段
swapped = copy.deepcopy(doc)
next(s for s in iter_segments(swapped) if s["kind"] == "text")["text"] = "换掉的"
assert "segment_mismatch" in [v["code"] for v in verify_doc(swapped, catalog)["violations"]]
dropped = copy.deepcopy(doc)
dropped["blocks"][0]["units"][0]["segments"].pop(1)
assert "segment_mismatch" in [v["code"] for v in verify_doc(dropped, catalog)["violations"]]
# 4) 结构片段里夹带数字
smuggled = copy.deepcopy(doc)
first = next(iter_segments(smuggled))
smuggled["markdown"] = "12345" + smuggled["markdown"]
for s in iter_segments(smuggled):
s["span"] = [s["span"][0] + 5 if s is not first else 0, s["span"][1] + 5]
first["text"] = "12345" + first["text"]
first["kind"] = "structural"
assert "structural_text" in [v["code"] for v in verify_doc(smuggled, catalog)["violations"]]
# 5) 目录里的 eid 被换了
fake = copy.deepcopy(doc)
next(s for s in iter_segments(fake) if s.get("ref") == "m:gmv")["cite"]["eid"] = "ev:metric:0000000000000000"
assert "eid_mismatch" in [v["code"] for v in verify_doc(fake, catalog)["violations"]]
# 6) 状态标错:没有出处的地方标成「有出处」,界面就会画成确定的实线
lying = copy.deepcopy(compose_doc("销售额 [[m:gmv]],另有 7 单。", catalog))
bare = next(s for s in iter_segments(lying) if s.get("issue") == "uncited_number")
bare["state"] = "deterministic"
next(s for s in iter_segments(lying) if s["kind"] == "text")["state"] = "deterministic"
assert sorted(v["code"] for v in verify_doc(lying, catalog)["violations"]) \
== ["state_mismatch", "state_mismatch", "uncited_number"]
# 7) 不认识的文档
assert [v["code"] for v in verify_doc({**doc, "schema": "x"}, catalog)["violations"]][:1] == ["bad_schema"]
def test_verify_doc_uses_the_catalog_it_is_given(catalog):
"""出口复核拿自己重建的目录:口径卡的值变了,文档里的旧数字就对不上。"""
doc = compose_doc("销售额 [[m:gmv]]。", catalog)
changed = build_catalog(
nodes={"caliber": {**CARD, "metrics": [metric("gmv", "销售额", 1.5, "元", 1)]}},
ledger=LEDGER, inputs=INPUTS)
assert [v["code"] for v in verify_doc(doc, changed)["violations"]] == ["render_mismatch"]
assert [v["code"] for v in verify_doc(doc, {})["violations"]] == ["unresolved_ref"]
# --------------------------------------------------------------------------
# 流式渲染
# --------------------------------------------------------------------------
@pytest.mark.parametrize("cut", range(1, 12))
def test_stream_renderer_never_leaks_raw_markers(catalog, cut):
raw = "销售额 [[m:gmv]],折合 [[m:gmv|万]]。[[see:m:gmv]] 未知 [[m:x]] 字面 [[ 和 [ 结束"
chunks = [raw[i:i + cut] for i in range(0, len(raw), cut)]
renderer = StreamRenderer(catalog)
out = [renderer.feed(c) for c in chunks] + [renderer.flush()]
for piece in out:
assert "[[m:" not in piece and "[[see:" not in piece, out
assert "".join(out) == render_markers(raw, catalog)
assert "".join(out) == "销售额 45,678.5元,折合 4.57万元。 未知 ⟦?m:x⟧ 字面 [[ 和 [ 结束"
@pytest.mark.parametrize("body", [
"x" * 170, # 比能缓冲的还长:两边都当普通文字
"x" * (MARKER_MAX - len("[[m:]]")), # 恰好到上限:两边都当标记
"x" * (MARKER_MAX - len("[[m:]]") + 1), # 超出一个字
"gmv|万" + " " * (MARKER_MAX - len("[[m:gmv|万]]")),
])
def test_stream_output_equals_the_full_render_for_markers_of_any_length(catalog, body):
raw = f"a [[m:{body}]] b [[see:{body}]] c"
full = render_markers(raw, catalog)
for size in (1, 2, 7, 50, len(raw)):
renderer = StreamRenderer(catalog)
out = [renderer.feed(raw[i:i + size]) for i in range(0, len(raw), size)] + [renderer.flush()]
assert "".join(out) == full, (size, out)
over = len(f"[[m:{body}]]") > MARKER_MAX
assert (f"[[m:{body}]]" in full) is over # 超长的不是标记,原样留着
assert all(len(m["raw"]) <= MARKER_MAX for m in parse_markers(raw))
def _stream_corpus(n: int) -> list[str]:
import random
rng = random.Random(20260929)
pieces = ["[[m:gmv]]", "[[m:gmv|万]]", "[[see:m:wow]]", "[[", "]]", "[", "]", "\n", "文字", " ",
"[[m:" + "y" * 150, "[[see:" + "z" * 160 + "]]", "[[i:week]]", "[[m:x" + "q" * 155 + "]]"]
return ["".join(rng.choice(pieces) for _ in range(rng.randint(1, 12))) for _ in range(n)]
@pytest.mark.parametrize("raw", _stream_corpus(120))
def test_stream_renderer_matches_render_markers_on_random_input(catalog, raw):
import random
rng = random.Random(raw)
for _ in range(3):
renderer, out, i = StreamRenderer(catalog), [], 0
while i < len(raw):
step = rng.randint(1, 40)
out.append(renderer.feed(raw[i:i + step]))
i += step
out.append(renderer.flush())
assert "".join(out) == render_markers(raw, catalog)
def test_stream_renderer_releases_a_long_unclosed_bracket(catalog):
renderer = StreamRenderer(catalog)
first = renderer.feed("开头 [[" + "很长的正文" * 40)
assert first.startswith("开头 [[") # 不会一直憋着不发
assert renderer.feed("\n下一行") and renderer.flush() == ""
# --------------------------------------------------------------------------
# 给报告节点用的辅助
# --------------------------------------------------------------------------
def test_catalog_prompt_and_violation_text(catalog):
prompt = catalog_prompt(catalog)
assert "[[m:gmv]]" in prompt and "45,678.5元" in prompt and "周报口径 @ v2" in prompt
assert "[[i:week]]" in prompt and "2026-W37" in prompt
assert "[[m:gone]]" in prompt and "没有值" in prompt
doc = compose_doc("增长 12%,[[m:gmvx]]。", catalog)
text = describe_violations(doc["violations"])
assert "12%" in text and "gmvx" in text
# --------------------------------------------------------------------------
# 性质:什么样的原文进来,片段都铺满正文,组装和复核的结论一致,自己不制造完整性问题
# --------------------------------------------------------------------------
AWKWARD = [
"", " \n\n ", "[[see:m:gmv]]", "---", "```py\nx = 1", "```\n```", "| a | |\n|---|---|\n| | [[m:gmv]] |",
"- a\n - b [[m:orders]]\n 续行\n- c", "3. 三\n4. 四", "# 标题 [[m:gmv]] ", "## ", "[[m:orders]]. 第一句。",
"a\r\nb\r\n", "开头 [[ 没闭合", "**加粗 [[m:gmv]] 里面**。", "> a\n>\n> b [[m:gmv]]", "[[see:m:gmv]] 开头。",
"e.g. 3.5 和 v2. 结束", "表格 | 不是 | 表格", "| x |\n| y |", "__[[m:gmv]]__ 与 ** [[m:wow]] **",
"`code 12` 和 [link](http://x.y/12)", "多个\n\n\n\n段落", "[[m:gmv|万]]|[[m:gmv]]", "1) 一\n2) 二",
]
def _random_corpus(n: int) -> list[str]:
import random
rng = random.Random(20260928)
alphabet = [*"我们。!?\n- *#>|`[]:0123456789 .,,、()_", "[[m:gmv]]", "[[see:m:wow]]", "[[i:week]]",
"[[m:refund_rate]]", "**", "__", "```", "---", "| a |", "\n\n", "[[v:Q1.r0.x]]"]
return ["".join(rng.choice(alphabet) for _ in range(rng.randint(1, 40))) for _ in range(n)]
INTEGRITY = {"segment_mismatch", "structural_text", "state_mismatch", "render_mismatch", "eid_mismatch", "bad_schema"}
@pytest.mark.parametrize("raw", AWKWARD + _random_corpus(400))
def test_composition_invariants(catalog, raw):
doc = compose_doc(raw, catalog)
assert "".join(texts(doc)) == doc["markdown"]
again = verify_doc(copy.deepcopy(doc), catalog)
assert again["violations"] == doc["violations"] and again["stats"] == doc["stats"]
assert not INTEGRITY & set(codes(doc)), doc["violations"]