Files
AgentLab/backend/tests/test_evidence_see_brackets.py
T
ThearchyHelios 60be16a0bb M1 复核修正(第 3 轮):依据两边什么都没有时也判粘连,两个数不再并成一个;只装着依据的括号认【】[]〔〕[];右边紧贴着下文的半角逗号后面补空格
渲染(evidence._tidy):
- 上一轮只沿着去掉的字往两边找粘连:依据前后没有空白、括号、标点时没有切口,「华东 [[v:Q1.r0.销售额]][[see:Q1]]
  [[v:Q1.r0.订单数]] 笔」渲染、组装都是「4,919.5212 笔」,「[[v:…]][[see:Q1]]2 月」是「4,919.52 月」,「**华东 [[v:…]]**
  [[see:Q1]]2 月」组装出「华东 4,919.52 月」,verify_doc 都不报。现在每个依据(连着写的一串、只装着依据的括号都算)
  两边都判(_glue 看所有不显示的字,不只看切口),会读成一串的补一个空格:这一片有去掉的字的照旧换掉头一个,没有的
  插在头一个依据前面(粗体收尾的星号外面)
- 只装着依据的括号认【】、[](全角半角混用也算)、〔〕、[],和()一样连括号去掉,外面悬着的停顿标点照原规则收拾;
  依据在这些括号开头、末尾紧挨着分隔标点的,同()一样收拾。核查 agent 拿到的查询结果以「【证据 Qn】」开头,模型照着
  写成「【[[see:Q1]]】」,以前渲染成「【】」
- 右边是写作者紧贴着下文写的半角逗号的(「1,200([[see:Q1]]),900」去掉括号读成「1,200,900」),空格补在逗号后面
  (同「212,[[see:Q1]],5」→「212, 5」)。上一轮把这一类当成可以不管的「),5」写法;和基线 58a6759 逐份比,结构化组合里
  有 2481 份是上一轮去掉括号之后才并成一个数的(「增长 12([[see:Q1]]),212 笔」→「增长 12,212 笔」),现在都隔开了
- 找挨上的字时也越过删除线的两个波浪线:前端照样画出删去的数,挨着后面的数读
- 流式渲染把这几种括号、半角方括号、波浪线也攒到后面的字来了再放;半角「]」攒着时,标记收尾的「]]」和标记里末尾的
  空白不算,标记整个放出去,从它后面攒起

不含依据的文档渲染结果、内容哈希逐字不变:随机 1.6 万份不含依据的文档和基线逐份比过,BEFORE 两份哈希照旧。

测试:依据两边什么都没有(数、标记、粗体、斜体、删除线、连着写的一串)、几种括号(只装着依据、全角半角混用、套着、
和别的括号连着写、开头末尾带标点、不含依据的不动)、半角逗号的用例;结构化组合加上两种括号,中间只有依据的也算;流式
渲染、随机语料加上几种括号和波浪线;审批质询、核查 agent、反方审稿、双盲复算各补一个端到端用例。15 处改动逐一去掉实现,
相关测试都变红(第 2 轮的 7 处也重跑过,照样变红)。另在测试之外:复核用的抽查脚本 6000 份 × 6 个种子只剩写作者自己
写成「212,,2」的一份;结构化组合 29.4 万份、随机含依据的 1.6 万份和基线比没有新并成的数,流式逐字、随机分块都等于整篇
渲染。
2026-10-12 12:24:14 +08:00

601 lines
38 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
"""依据标记 [[see:…]] 写进括号、夹在标点中间时的渲染(期 5 真实模型验收 M1)。
[[see:]] 渲染成空,按设计挂在句末。核查 agent(双盲复算、反方审稿、审批质询共用)以前被提示「提到某次查询时写
[[see:Q1]]」,模型就把它写进括号、句中:「结果一致([[see:Q3]],原报告 SQL 未按 status 过滤)」「合计
[[v:Q1.r1.金额合计]]([[see:Q1]])」,渲染后剩下「(,原报告…)」「()」,正式运行里随封存永久留着。
- 括号(圆括号、方括号的全角半角,一对里混用也算;方头括号【】、六角括号〔〕)里只有依据的,连括号一起去掉;去掉之后
整对括号当一个依据,外面照下面几条再收拾一遍(括号前悬着的停顿标点、括号前后的空白)。连着写的几个
(「([[see:Q1]])([[see:m:gmv]])」)并成一组收拾
- 依据渲染成空、周围的字去掉之后,两边真正挨上的两个字会粘成一串的(「4,919.5([[see:Q1]])2 月」,依据两边什么都
没有的「4,919.5[[see:Q1]]212 笔」也是),补一个空格:粘起来读成另一个数,哪项核对都查不出来。每个依据两边都判,
找挨上的字时越过一切不显示的:依据、只装着依据的括号、粗体斜体的星号和下划线、删除线的波浪线;依据标记的「]」
不当数字。一片里最多补一个,挨上的是写作者留着的空白的不补;半角逗号夹着依据、去掉后一个之后同理
(「212,[[see:Q1]],5」→「212, 5」),右边是紧贴着下文的半角逗号的(「1,200([[see:Q1]]),900」)补在逗号后面
- 依据在括号开头、后面跟着「,」「、」「;」「:」的,标点一起去掉;在括号末尾、前面紧挨着标点的同理
- 依据夹在两个分隔标点中间的,去掉后一个;后面就到句末(句末标点、全文结尾)、前面或几个依据之间有「,」「、」
「;」的,去掉这些标点,到句末只隔着空白的去掉空白;依据后面、句末前面的标点是写作者自己的,不动
- 换行不算句末:列表项末尾的「;」、软换行处的「,」是写作者自己的,依据挂在它后面正合「挂在句末」的规矩,照旧留着
- 括号里、句末前几个依据之间的分隔标点一并去掉;两边都是字时,依据之间的标点可能是分句的标点,照旧留着
- 依据标记本身留在原文里:句子照旧挂着这条依据(see、cites),只是不显示
- 不含依据的括号、没有依据的文档一个字不动:内容哈希和改动之前一样
- 流式渲染分块输出拼起来仍然等于整篇渲染
- 已知限制:依据写在「依据」「见」「取自」「来源」这类引出语后面时,引出语悬空(「依据。」「(来源)」)。渲染时去不掉,
靠核查 agent 的提示词避免,模型不照做时核查 agent 记一条日志(test_verify_rationale_rules)
数字、表名、地名都是虚构的。
"""
from __future__ import annotations
import random
import re
import pytest
from app.core.artifact_store import canonical_json, content_hash
from app.engine.evidence import (
StreamRenderer,
build_catalog,
compose_doc,
iter_units,
render_markers,
verify_doc,
)
ART = "a" * 64
SNAP_ID = "b" * 64
#: 一次查询的快照:华南 4,919.5 元 212 笔、华东 1,891.8 元 113 笔
SNAP = {"columns": ["区域", "销售额", "订单数"], "rows": [["华南", 4919.5, 212], ["华东", 1891.8, 113]],
"row_count": 2, "truncated": False, "elapsed_ms": 1, "sql": "SELECT 1", "source": "shop"}
CARD = {"kind": "metric_set", "caliber": "周报口径", "caliber_version": "v1", "artifact": ART,
"metrics": [{"id": "gmv", "name": "销售额", "unit": "元", "value": 45678.5, "decimals": 1,
"format": "thousands", "status": "ok", "expression": "…"},
{"id": "yoy", "name": "同比增幅", "unit": "%", "value": 12.5, "decimals": 1,
"format": "plain", "status": "ok", "expression": "…"}]}
LEDGER = [{"kind": "metric_set", "node_id": "cal", "exec": 1, "artifact": ART, "caliber": "周报口径",
"version": "v1", "metrics": ["gmv", "yoy"]},
{"kind": "query", "node_id": "fetch", "exec": 1, "artifact": SNAP_ID, "call_id": "c1",
"tool": "db_query__shop", "source": "shop", "columns": SNAP["columns"], "rows": 2, "truncated": False}]
LOADER = {SNAP_ID: SNAP}.__getitem__
@pytest.fixture
def catalog():
return build_catalog(nodes={"cal": CARD}, ledger=LEDGER, inputs={"week": "2026-W37"})
def render(raw: str, catalog) -> str:
return render_markers(raw, catalog, loader=LOADER)
def compose(raw: str, catalog) -> dict:
return compose_doc(raw, catalog, node_id="write", run_id="r1", loader=LOADER)
# --------------------------------------------------------------------------
# 括号里只有依据:连括号一起去掉
# --------------------------------------------------------------------------
@pytest.mark.parametrize(("raw", "shown"), [
("先按状态核对口径([[see:Q1]]):已付 [[v:Q1.r0.订单数]] 笔。", "先按状态核对口径:已付 212 笔。"),
("合计 [[v:Q1.r0.销售额]]([[see:Q1]])。", "合计 4,919.5。"),
("换口径不改变排序([[see:Q1]]),结论成立。", "换口径不改变排序,结论成立。"),
("结果一致([[see:Q1]])。", "结果一致。"), # 一对括号全角半角混用
("结果一致([[see:Q1]] [[see:m:gmv]])。", "结果一致。"),
("结果一致([[see:Q1]]、[[see:m:gmv]])。", "结果一致。"),
("结果一致( [[see:Q1]], )。", "结果一致。"),
("结果一致 ([[see:Q1]])。", "结果一致。"), # 括号前的空白一起去掉
("前一句 ([[see:Q1]])后一句", "前一句 后一句"), # 后面紧跟着字:留一个空格隔开
("末尾([[see:Q1]])", "末尾"),
])
def test_a_bracket_holding_only_see_is_dropped(catalog, raw, shown):
assert render(raw, catalog) == shown
assert compose(raw, catalog)["markdown"] == shown
def test_indentation_before_a_see_only_bracket_is_kept(catalog):
assert render("- 第一条\n ([[see:Q1]])续行", catalog) == "- 第一条\n 续行"
# --------------------------------------------------------------------------
# 依据在括号开头 / 末尾,紧挨着分隔标点
# --------------------------------------------------------------------------
@pytest.mark.parametrize(("raw", "shown"), [
# 在括号开头:后面的标点(和空白)一起去掉
("结果一致([[see:Q1]],原报告未按状态过滤)。", "结果一致(原报告未按状态过滤)。"),
("结果一致([[see:Q1]]、原报告未过滤)。", "结果一致(原报告未过滤)。"),
("结果一致([[see:Q1]];原报告未过滤)。", "结果一致(原报告未过滤)。"),
("结果一致([[see:Q1]]:原报告未过滤)。", "结果一致(原报告未过滤)。"),
("结果一致([[see:Q1]], 原报告未过滤)。", "结果一致(原报告未过滤)。"),
("结果一致([[see:Q1]] 原报告未过滤)。", "结果一致(原报告未过滤)。"),
("结果一致( [[see:Q1]],[[see:m:gmv]],原报告未过滤)。", "结果一致(原报告未过滤)。"),
# 在括号末尾:前面的标点(和空白)一起去掉
("复算原查询(同 SQL 同过滤条件,[[see:Q1]]):华南居首。", "复算原查询(同 SQL 同过滤条件):华南居首。"),
("复算原查询(同 SQL, [[see:Q1]])。", "复算原查询(同 SQL)。"),
("复算原查询(同 SQL 同过滤条件 [[see:Q1]])。", "复算原查询(同 SQL 同过滤条件)。"),
("订单([[v:Q1.r0.订单数]] 笔,[[see:Q1]])", "订单(212 笔)"),
("口径(按支付日;[[see:Q1]]、[[see:m:gmv]])相同。", "口径(按支付日)相同。"),
])
def test_punctuation_next_to_see_inside_a_bracket_goes_with_it(catalog, raw, shown):
assert render(raw, catalog) == shown
assert compose(raw, catalog)["markdown"] == shown
@pytest.mark.parametrize(("raw", "shown"), [
# 夹在两个分隔标点中间:去掉后一个(括号里外都一样)
("口径相同(按支付日,[[see:Q1]],不含退款)。", "口径相同(按支付日,不含退款)。"),
("结果一致,[[see:Q1]],原报告未过滤。", "结果一致,原报告未过滤。"),
("a, [[see:Q1]], b", "a, b"),
("a,[[see:Q1]],b", "a, b"), # 半角逗号后面照排版习惯隔一个空格
# 前面是分隔标点、后面就到句末:去掉前面那个
("华南居首,[[see:Q1]]。", "华南居首。"),
("华南居首,[[see:Q1]]", "华南居首"),
("华南居首,[[see:Q1]] 。", "华南居首。"),
# 几个依据之间的分隔标点
("华南居首 [[see:Q1]],[[see:m:gmv]]。", "华南居首。"),
# 后面就到句末、中间只隔着空白:空白一起去掉,不剩「华南居首 。」
("华南居首 [[see:Q1]] 。", "华南居首。"),
("华南居首 [[see:Q1]] [[see:m:gmv]] 。", "华南居首。"),
])
def test_punctuation_left_dangling_by_see_is_dropped(catalog, raw, shown):
assert render(raw, catalog) == shown
assert compose(raw, catalog)["markdown"] == shown
@pytest.mark.parametrize(("raw", "shown"), [
# 括号前悬着的停顿标点:去掉括号之后和没写括号的「华南居首,[[see:Q1]]。」一样收拾
("华南居首,([[see:Q1]])。", "华南居首。"),
("华南居首、([[see:Q1]])", "华南居首"),
("华南居首 ([[see:Q1]]) 。", "华南居首。"),
("华南居首,([[see:Q1]]),华东第二。", "华南居首,华东第二。"), # 夹在两个逗号中间:去掉后一个
("华南居首(([[see:Q1]]))。", "华南居首。"), # 套着的两层只装着依据,一起去掉
# 换行不算句末:括号里外一样留着写作者的标点
("华南居首、([[see:Q1]])\n下一行", "华南居首、\n下一行"),
])
def test_a_dropped_bracket_is_tidied_like_a_bare_see(catalog, raw, shown):
assert render(raw, catalog) == shown
assert compose(raw, catalog)["markdown"] == shown
@pytest.mark.parametrize(("raw", "shown"), [
# 括号是两边唯一的分隔:去掉之后数字会粘成另一个数,换成一个空格
("华东 [[v:Q1.r0.销售额]]([[see:Q1]])2 月起持续下滑。", "华东 4,919.5 2 月起持续下滑。"),
("合计 [[v:Q1.r0.销售额]]([[see:Q1]])2026 年同比持平。", "合计 4,919.5 2026 年同比持平。"),
("订单 [[v:Q1.r0.订单数]]([[see:Q1]])2 个区域。", "订单 212 2 个区域。"),
("增幅 [[m:yoy]]([[see:m:yoy]])3 个区域。", "增幅 12.5% 3 个区域。"),
("合计 [[v:Q1.r0.销售额]]([[see:Q1]])[[v:Q1.r0.订单数]] 笔。", "合计 4,919.5 212 笔。"), # 后面的标记渲染出数字
("合计 [[v:Q1.r0.销售额]]([[see:Q1]])[[see:m:gmv]]2 月。", "合计 4,919.5 2 月。"), # 越过不显示的依据看
("编号 Q1([[see:Q1]])x", "编号 Q1 x"),
("212,([[see:Q1]])5 个区域", "212, 5 个区域"),
# 半角逗号夹着依据:去掉后一个之后,前一个不能直接贴着数字
("212,[[see:Q1]],5 个区域", "212, 5 个区域"),
# 不会粘的照旧去掉:中文和数字之间、括号前已有空白、后面是中文、隔着全角逗号
("居首([[see:Q1]])2 月起", "居首2 月起"),
("合计 [[v:Q1.r0.销售额]] ([[see:Q1]])2 月起", "合计 4,919.5 2 月起"),
("合计 [[v:Q1.r0.销售额]]([[see:Q1]])元", "合计 4,919.5元"),
("华南([[see:Q1]])居首", "华南居首"),
("212,[[see:Q1]],5 个区域", "212,5 个区域"),
])
def test_dropping_a_bracket_does_not_glue_numbers_together(catalog, raw, shown):
assert render(raw, catalog) == shown
assert compose(raw, catalog)["markdown"] == shown
def test_a_glued_number_would_have_slipped_through_the_checks(catalog):
"""粘起来的数没有哪项核对查得出:片段本身对(4,919.5 后面是另一段文字),月份不算裸数字。所以只能在渲染时不粘。"""
doc = compose("华东 [[v:Q1.r0.销售额]]([[see:Q1]])2 月起持续下滑。", catalog)
assert doc["markdown"] == "华东 4,919.5 2 月起持续下滑。" and doc["violations"] == []
assert verify_doc(doc, catalog, loader=LOADER)["violations"] == []
[unit] = [unit for _, unit in iter_units(doc)]
assert [s["alias"] for s in unit["see"]] == ["Q1"]
# --------------------------------------------------------------------------
# 粘不粘看去掉之后真正挨上的两个字:越过依据、只装着依据的括号、粗体斜体的星号和下划线(复核第 2 轮)
# --------------------------------------------------------------------------
#: 读者看到的字:前端按 Markdown 渲染,星号、两个下划线不显示
_EMPHASIS = re.compile(r"\*+|__+")
def _visible(text: str) -> str:
return _EMPHASIS.sub("", text)
@pytest.mark.parametrize(("raw", "shown"), [
# 两个只装着依据的括号连着写(全角半角混用、套着的也一样):中间什么都不剩,两边会粘的照样隔开
("华东 [[v:Q1.r0.销售额]]([[see:Q1]])([[see:m:gmv]])2 月起持续下滑。", "华东 4,919.5 2 月起持续下滑。"),
("订单 [[v:Q1.r0.订单数]]([[see:Q1]])([[see:m:gmv]])[[v:Q1.r0.订单数]] 笔。", "订单 212 212 笔。"),
("增幅 [[m:yoy]]([[see:m:yoy]])([[see:Q1]])3 个区域。", "增幅 12.5% 3 个区域。"),
("增幅 [[m:yoy]]([[see:m:yoy]])([[see:Q1]])3 个区域。", "增幅 12.5% 3 个区域。"),
("增幅 [[m:yoy]]([[see:m:yoy]])(([[see:Q1]]))3 个区域。", "增幅 12.5% 3 个区域。"),
("合计 [[v:Q1.r0.销售额]]([[see:Q1]]) ([[see:m:gmv]])2 月", "合计 4,919.5 2 月"),
# 依据和括号连着写、几个依据之间隔着空白:去掉之后两边会粘的,同样隔开
("合计 [[v:Q1.r0.销售额]][[see:Q1]]([[see:m:gmv]])2 月。", "合计 4,919.5 2 月。"),
("合计 [[v:Q1.r0.销售额]][[see:Q1]] [[see:m:gmv]]2 月。", "合计 4,919.5 2 月。"),
# 不会粘的照旧:中文和数字之间
("华南居首([[see:Q1]])([[see:m:gmv]])2 月起回升。", "华南居首2 月起回升。"),
])
def test_see_only_brackets_written_together_do_not_glue_numbers(catalog, raw, shown):
assert render(raw, catalog) == shown
assert compose(raw, catalog)["markdown"] == shown
@pytest.mark.parametrize(("raw", "shown"), [
# 连着写的几个只装着依据的括号当一组收拾:括号前悬着的停顿标点、中间的标点一并去掉
("华南居首,([[see:Q1]])([[see:m:gmv]])。", "华南居首。"),
("华南居首,[[see:Q1]]([[see:m:gmv]])。", "华南居首。"),
("华南居首,([[see:Q1]])、([[see:m:gmv]])。", "华南居首。"),
("结果一致([[see:Q1]])([[see:m:gmv]]),原报告未过滤。", "结果一致,原报告未过滤。"),
("结果一致([[see:Q1]])([[see:m:gmv]],原报告未过滤)。", "结果一致(原报告未过滤)。"),
])
def test_see_only_brackets_written_together_are_tidied_as_one(catalog, raw, shown):
assert render(raw, catalog) == shown
assert compose(raw, catalog)["markdown"] == shown
@pytest.mark.parametrize(("raw", "shown"), [
# 粗体在括号前、后、把括号包在里面:星号不显示,挨上的是两边的数
("销售额 **[[v:Q1.r0.销售额]]**([[see:Q1]])2 月起持续下滑。", "销售额 4,919.5 2 月起持续下滑。"),
("华东 **[[v:Q1.r0.销售额]]**([[see:Q1]])[[v:Q1.r0.订单数]] 笔。", "华东 4,919.5 212 笔。"),
("华东 [[v:Q1.r0.销售额]]([[see:Q1]])**[[v:Q1.r0.订单数]] 笔**", "华东 4,919.5 212 笔"),
("**[[v:Q1.r0.销售额]]([[see:Q1]])**2 月", "4,919.5 2 月"),
("华东 **[[v:Q1.r0.销售额]]**([[see:Q1]])([[see:m:gmv]])**[[v:Q1.r0.订单数]]** 笔", "华东 4,919.5 212 笔"),
("__[[v:Q1.r0.销售额]]__([[see:Q1]])2 月", "4,919.5 2 月"),
("*[[v:Q1.r0.销售额]]*([[see:Q1]])2 月", "4,919.5 2 月"),
# 依据前的空白:后面紧跟着的是星号,越过它看
("合计 [[v:Q1.r0.销售额]] [[see:Q1]]**2 月**起", "合计 4,919.5 2 月起"),
# 不会粘的照旧
("合计 **[[v:Q1.r0.销售额]]**([[see:Q1]])元", "合计 4,919.5元"),
])
def test_bold_around_a_number_does_not_hide_the_glue(catalog, raw, shown):
assert _visible(render(raw, catalog)) == shown
assert _visible(compose(raw, catalog)["markdown"]) == shown
def test_bold_still_wraps_the_number_after_the_bracket_is_dropped(catalog):
doc = compose("销售额 **[[v:Q1.r0.销售额]]**([[see:Q1]])2 月起持续下滑。", catalog)
[number] = [seg for _, unit in iter_units(doc) for seg in unit["segments"] if seg.get("ref")]
assert number["text"] == "4,919.5" and number.get("strong") is True
assert render("销售额 **[[v:Q1.r0.销售额]]**([[see:Q1]])2 月", catalog) == "销售额 **4,919.5** 2 月"
@pytest.mark.xfail(strict=True, reason="已知限制:粗体把括号包在里面时,补的空格落在收尾的星号前面(「**4,919.5 **2 月」)。"
"前端的行内 Markdown 照样认这对星号,组装出的文档去掉了包着标记的星号,读者看到的一样;"
"挪到星号外面得先知道这对星号从哪开始,流式渲染的一块里不一定有")
def test_the_space_lands_outside_a_bold_that_wraps_the_bracket(catalog):
assert render("销售额 **[[v:Q1.r0.销售额]]([[see:Q1]])**2 月", catalog) == "销售额 **4,919.5** 2 月"
# --------------------------------------------------------------------------
# 依据直接夹在两个数之间:两边没有括号、空白、标点,也不能让两个数并成一个(复核第 3 轮)
# --------------------------------------------------------------------------
@pytest.mark.parametrize(("raw", "shown"), [
# 依据两边什么都没有:渲染成空之后两边直接挨上
("华东 [[v:Q1.r0.销售额]][[see:Q1]][[v:Q1.r0.订单数]] 笔。", "华东 4,919.5 212 笔。"),
("华东 [[v:Q1.r0.销售额]][[see:Q1]]2 月起持续下滑。", "华东 4,919.5 2 月起持续下滑。"),
("增幅 [[m:yoy]][[see:m:yoy]]3 个区域。", "增幅 12.5% 3 个区域。"),
("编号 Q1[[see:Q1]]x 区", "编号 Q1 x 区"),
("212,[[see:Q1]]5 个区域", "212, 5 个区域"),
# 连着写的一串当一个看
("华东 [[v:Q1.r0.销售额]][[see:Q1]][[see:m:gmv]]2 月", "华东 4,919.5 2 月"),
# 不会粘的照旧:中文和数字之间、后面是中文或单位、写作者自己留了空白、句号后面
("华南居首[[see:Q1]]2 月起回升。", "华南居首2 月起回升。"),
("合计 [[v:Q1.r0.销售额]][[see:Q1]]元", "合计 4,919.5元"),
("合计 [[v:Q1.r0.销售额]][[see:Q1]]%", "合计 4,919.5%"),
("合计 [[v:Q1.r0.销售额]][[see:Q1]] 2 月", "合计 4,919.5 2 月"),
("合计 [[v:Q1.r0.销售额]] [[see:Q1]]2 月", "合计 4,919.5 2 月"),
("华南居首。[[see:Q1]]2 月起回升。", "华南居首。2 月起回升。"),
])
def test_a_bare_see_between_two_numbers_does_not_glue_them(catalog, raw, shown):
assert render(raw, catalog) == shown
assert compose(raw, catalog)["markdown"] == shown
@pytest.mark.parametrize(("raw", "shown"), [
# 粗体收尾后紧跟依据再跟数字(组装文档时包着标记的星号去掉了,挨上的是两个数)
("**华东 [[v:Q1.r0.销售额]]**[[see:Q1]]2 月", "华东 4,919.5 2 月"),
("华东 **[[v:Q1.r0.销售额]]**[[see:Q1]][[v:Q1.r0.订单数]] 笔", "华东 4,919.5 212 笔"),
("华东 [[v:Q1.r0.销售额]][[see:Q1]]**2 月**起", "华东 4,919.5 2 月起"),
("*[[v:Q1.r0.销售额]]*[[see:Q1]]2 月", "4,919.5 2 月"),
])
def test_a_bare_see_after_bold_does_not_glue_the_numbers(catalog, raw, shown):
assert _visible(render(raw, catalog)) == shown
assert _visible(compose(raw, catalog)["markdown"]) == shown
def test_the_space_goes_before_the_see_and_outside_the_bold(catalog):
"""补的空格落在依据前面、收尾的星号后面:粗体照样成对,依据照样挂在句子上。一片里有去掉的字时落在头一个去掉的字
那里(同复核第 2 轮):粗体在两段依据中间收尾的,空格照样在星号外面。"""
assert render("**华东 [[v:Q1.r0.销售额]]**[[see:Q1]]2 月", catalog) == "**华东 4,919.5** 2 月"
assert render("**华东 [[v:Q1.r0.销售额]][[see:Q1]]**([[see:m:gmv]])2 月", catalog) == "**华东 4,919.5** 2 月"
doc = compose("华东 [[v:Q1.r0.销售额]][[see:Q1]][[v:Q1.r0.订单数]] 笔。", catalog)
[unit] = [unit for _, unit in iter_units(doc)]
assert [s["alias"] for s in unit["see"]] == ["Q1"]
assert [seg["text"] for seg in unit["segments"] if seg.get("ref")] == ["4,919.5", "212"]
assert verify_doc(doc, catalog, loader=LOADER)["violations"] == []
@pytest.mark.parametrize(("raw", "shown"), [
# 右边是紧贴着下文的半角逗号:去掉依据(和只装着依据的括号)之后读成一个数(「1,200,900」),空格补在逗号后面
("分别为 [[v:Q1.r0.订单数]]([[see:Q1]]),5 个区域", "分别为 212, 5 个区域"),
("分别为 [[v:Q1.r0.销售额]][[see:Q1]],[[v:Q1.r1.销售额]]。", "分别为 4,919.5, 1,891.8。"),
("分别为 [[v:Q1.r0.销售额]]【[[see:Q1]]】,[[v:Q1.r1.销售额]]([[see:Q1]])。", "分别为 4,919.5, 1,891.8。"),
("212[[see:Q1]],5 个区域", "212, 5 个区域"),
("分别为 [[v:Q1.r0.销售额]]([[see:Q1]]),**[[v:Q1.r1.销售额]]**。", "分别为 4,919.5, **1,891.8**。"), # 下文包着粗体
# 不会读成一串的照旧:逗号后面已有空白、是中文,逗号前面是中文
("分别为 [[v:Q1.r0.订单数]]([[see:Q1]]), 5 个区域", "分别为 212, 5 个区域"),
("分别为 [[v:Q1.r0.订单数]]([[see:Q1]]),华东第二", "分别为 212,华东第二"),
("分别为 [[v:Q1.r0.订单数]][[see:Q1]],[[see:m:gmv]]华东第二", "分别为 212,华东第二"), # 逗号后面的依据不显示
("华南居首([[see:Q1]]),5 个区域", "华南居首,5 个区域"),
])
def test_a_half_width_comma_left_against_the_next_number_gets_a_space(catalog, raw, shown):
assert render(raw, catalog) == shown
assert _visible(compose(raw, catalog)["markdown"]) == _visible(shown)
def test_a_struck_number_does_not_glue_either(catalog):
"""删除线前端照样画成字(淡色),后面紧跟的数挨着它读成一串:星号、下划线之外,两个波浪线也越过去看。"""
assert render("~~[[v:Q1.r0.销售额]]~~[[see:Q1]]2 月", catalog) == "~~4,919.5~~ 2 月"
assert render("~~[[v:Q1.r0.销售额]]~~([[see:Q1]])2 月", catalog) == "~~4,919.5~~ 2 月"
assert render("[[v:Q1.r0.销售额]][[see:Q1]]~~2 月~~", catalog) == "4,919.5 ~~2 月~~"
# --------------------------------------------------------------------------
# 别的括号:【】[]〔〕[] 只装着依据的,和()一样去掉(复核第 3 轮)
# --------------------------------------------------------------------------
@pytest.mark.parametrize(("raw", "shown"), [
("合计 [[v:Q1.r0.销售额]]【[[see:Q1]]】。", "合计 4,919.5。"),
("合计 [[v:Q1.r0.销售额]][[[see:Q1]]]。", "合计 4,919.5。"),
("合计 [[v:Q1.r0.销售额]]〔[[see:Q1]]〕。", "合计 4,919.5。"),
("合计 [[v:Q1.r0.销售额]][[[see:Q1]]]。", "合计 4,919.5。"),
("结果一致[[[see:Q1]]]。", "结果一致。"), # 方括号一对里全角半角混用
("结果一致【[[see:Q1]],[[see:m:gmv]]】。", "结果一致。"),
("结果一致 【 [[see:Q1]] 】 。", "结果一致。"),
("前一句 【[[see:Q1]]】后一句", "前一句 后一句"),
("末尾[[[see:Q1]]]", "末尾"),
# 去掉之后整对括号当一个依据,外面悬着的停顿标点照原规则收拾
("华南居首,【[[see:Q1]]】。", "华南居首。"),
("华南居首,[[[see:Q1]]],华东第二。", "华南居首,华东第二。"),
# 套着的、和别的括号连着写的
("华南居首(【[[see:Q1]]】)。", "华南居首。"),
("华南居首【[[see:Q1]]】([[see:m:gmv]])〔[[see:Q1]]〕。", "华南居首。"),
# 依据在括号开头、末尾,紧挨着分隔标点
("结果一致【[[see:Q1]],原报告未过滤】。", "结果一致【原报告未过滤】。"),
("复算原查询〔同 SQL,[[see:Q1]]〕。", "复算原查询〔同 SQL〕。"),
("结果一致[[[see:Q1]]; 原报告未过滤]。", "结果一致[原报告未过滤]。"),
# 去掉之后两边的数不能并成一个
("华东 [[v:Q1.r0.销售额]]【[[see:Q1]]】2 月", "华东 4,919.5 2 月"),
("华东 [[v:Q1.r0.销售额]][[[see:Q1]]]2 月", "华东 4,919.5 2 月"),
("华东 [[v:Q1.r0.销售额]]〔[[see:Q1]]〕[[v:Q1.r0.订单数]] 笔", "华东 4,919.5 212 笔"),
])
def test_other_brackets_holding_only_see_are_dropped(catalog, raw, shown):
assert render(raw, catalog) == shown
assert compose(raw, catalog)["markdown"] == shown
@pytest.mark.parametrize("raw", [
"【注】原样,[1]、〔2026〕、[a] 也是。",
"空的【】、[]、〔〕、[] 原样,【,】也是。",
"【证据 Q1】华南居首。",
])
def test_other_brackets_without_see_are_left_alone(catalog, raw):
assert render(raw, catalog) == raw
assert compose(raw, catalog)["markdown"] == raw
@pytest.mark.parametrize(("raw", "shown"), [
# 依据标记的「]」不显示,不当数字:左边真正挨上的是句号、文首
("华南居首。[[see:Q1]]([[see:m:gmv]])2 月起回升。", "华南居首。2 月起回升。"),
("[[see:Q1]]([[see:Q1]])2 月", "2 月"),
("华南居首。[[see:Q1]] ([[see:m:gmv]])2 月起回升。", "华南居首。2 月起回升。"), # 中间的空白同几个依据之间的
# 一片里最多补一个空格;挨上的本来就隔着空白的不补
("212,[[see:Q1]],[[see:m:gmv]],5 个区域", "212, 5 个区域"),
("212,([[see:Q1]]),[[see:m:gmv]],5 个区域", "212, 5 个区域"),
("合计 [[v:Q1.r0.销售额]]([[see:Q1]])([[see:m:gmv]]) 2 月", "合计 4,919.5 2 月"),
])
def test_a_space_is_put_back_once_and_only_where_it_sticks(catalog, raw, shown):
assert render(raw, catalog) == shown
assert compose(raw, catalog)["markdown"] == shown
#: 结构化组合:左边一个渲染出数的标记(可能包着粗体),中间 1–3 段依据、只装着依据的括号(几类括号)、空白、星号,
#: 右边以数字、字母或者渲染出数的标记开头
_LEFTS = ["合计 [[v:Q1.r0.销售额]]", "合计 **[[v:Q1.r0.销售额]]**", "合计 __[[v:Q1.r0.销售额]]__", "增幅 [[m:yoy]]",
"编号 Q1"]
_MIDDLES = ["([[see:Q1]])", "([[see:m:gmv]])", "(([[see:Q1]]))", "【[[see:Q1]]】", "[[[see:m:gmv]]]", "[[see:Q1]]",
"**", " ", ","]
_RIGHTS = ["2 月", "[[v:Q1.r0.订单数]] 笔", "**[[v:Q1.r0.订单数]] 笔**", "x 区"]
#: 写作者原本就隔开了两边的中间段:括号(去掉依据之后还看得见)、空白、标点
_SEPARATING = {"([[see:Q1]])", "([[see:m:gmv]])", "(([[see:Q1]]))", "【[[see:Q1]]】", "[[[see:m:gmv]]]", " ", ","}
def _combos() -> list[tuple[str, str, str]]:
"""写作者隔开了两边、或者中间写了依据的组合(中间只有星号的,是写作者自己把两个数写在了一起,不归这里管)。"""
middles = [[m] for m in _MIDDLES]
middles += [[a, b] for a in _MIDDLES for b in _MIDDLES]
middles += [[a, b, c] for a in _MIDDLES for b in _MIDDLES for c in _MIDDLES]
return [(left, "".join(middle), right) for left in _LEFTS for right in _RIGHTS for middle in middles
if any(m in _SEPARATING or "see" in m for m in middle)]
#: 左边那个数渲染出来的字,后面紧跟着数字、字母,或者半角的逗号、句点再跟数字:读成了另一个数、另一个词
_GLUED = re.compile(r"(?:4,919\.5|12\.5%|Q1)(?=[\dA-Za-z]|[,.]\d)")
def test_two_numbers_never_run_together_once_see_is_dropped(catalog):
"""写作者隔开了的两边(括号、空白、标点),或者中间只隔着依据的,收拾依据之后不能挨成一串;也不会多出两个连着的
空格。"""
bad = []
for left, middle, right in _combos():
raw = f"{left}{middle}{right}。"
shown = _visible(render(raw, catalog))
writer = _visible(re.sub(r"\[\[see:[^\]]*\]\]", "", raw))
if _GLUED.search(shown) or (" " in shown and " " not in writer):
bad.append((raw, shown))
assert not bad, bad[:10]
@pytest.mark.parametrize(("raw", "shown"), [
("华南居首。[[see:Q1]]", "华南居首。"),
("华南居首 [[see:Q1]],华东第二。", "华南居首,华东第二。"),
# 两个依据中间的逗号后面接着字:它是分句的逗号,不是依据之间的分隔,照旧留着
("华南居首 [[see:Q1]],[[see:m:gmv]]华东第二。", "华南居首,华东第二。"),
("华南居首 [[see:Q1]],", "华南居首,"), # 依据后面的逗号是写作者自己的,不动
("数据如下:[[see:Q1]]\n| 区域 | 销售额 |", "数据如下:\n| 区域 | 销售额 |"), # 冒号引出的是下一行
# 换行不算句末:列表项末尾的「;」、软换行处连着两个分句的「,」是写作者自己的
("1. 华东居首;[[see:Q1]]\n2. 华南第二;[[see:Q1]]\n3. 华北第三。[[see:Q1]]",
"1. 华东居首;\n2. 华南第二;\n3. 华北第三。"),
("华东居首,[[see:Q1]]\n华南第二。", "华东居首,\n华南第二。"),
("华南居首、华东第二;[[see:Q1]]\n下一行", "华南居首、华东第二;\n下一行"),
("- 华东居首;([[see:Q1]])\n- 华南第二。", "- 华东居首;\n- 华南第二。"),
])
def test_punctuation_the_writer_put_there_is_kept(catalog, raw, shown):
assert render(raw, catalog) == shown
assert compose(raw, catalog)["markdown"] == shown
#: 引出语悬空:「依据」「见」「取自」「来源」「出处」后面(可能隔着冒号、空白)紧跟着句末、停顿标点或括号
_DANGLING = re.compile(r"(?:依据|见|取自|来源|出处)[::]?[ \t]*(?:[。,、;))!?,;]|$)")
@pytest.mark.xfail(strict=True, reason="已知限制:依据写在引出语后面时,渲染后引出语悬空。引出语在依据前面,渲染时要去掉"
"它,流式渲染得先攒住每个可能是引出语的字;目前靠核查 agent 的提示词避免这种写法,模型"
"不照做时记一条日志(verify_agent._LEAD_IN_SEE)")
@pytest.mark.parametrize("raw", [
"结果见:[[see:Q1]]。",
"依据 [[see:Q1]]。",
"数据取自 [[see:Q1]]。",
"结果取自 [[see:Q1]],完整未截断。",
"按支付日统计(见 [[see:Q1]])。",
"按支付日统计(来源:[[see:Q1]])。",
"按状态拆开(status 取值见 [[see:Q1]])。",
])
def test_see_after_a_lead_in_word_leaves_it_dangling(catalog, raw):
assert not _DANGLING.search(render(raw, catalog)), render(raw, catalog)
@pytest.mark.parametrize("raw", [
"空括号()原样保留,半角 () 也是。",
"说明(含退款,不含测试账号);另见 (a, b)。",
"(,)原样,(;) 也是。",
"甲,乙、丙;丁:戊。",
])
def test_brackets_and_punctuation_without_see_are_left_alone(catalog, raw):
assert render(raw, catalog) == raw
assert compose(raw, catalog)["markdown"] == raw
# --------------------------------------------------------------------------
# 依据仍挂在句子上
# --------------------------------------------------------------------------
def test_the_sentence_still_carries_the_see_that_was_in_brackets(catalog):
doc = compose("先按状态核对口径([[see:Q1]]):已付 [[v:Q1.r0.订单数]] 笔。"
"结果一致([[see:m:gmv]],原报告未过滤)。", catalog)
assert doc["markdown"] == "先按状态核对口径:已付 212 笔。结果一致(原报告未过滤)。"
first, second = [unit for _, unit in iter_units(doc)]
assert [s["alias"] for s in first["see"]] == ["Q1"] and first["cites"] == ["Q1"]
assert [s["alias"] for s in second["see"]] == ["m:gmv"] and second["cites"] == ["m:gmv"]
assert doc["stats"]["see"] == 2 and doc["stats"]["uncited_claims"] == 0
assert verify_doc(doc, catalog, loader=LOADER)["violations"] == []
# --------------------------------------------------------------------------
# 没有依据写进括号的文档:渲染和内容哈希都和改动之前一样
# --------------------------------------------------------------------------
#: 改动之前(基线 58a6759)组装出来的文档的内容哈希
BEFORE = {
"plain": ("## 本周概览\n\n本周([[i:week]])销售额 [[m:gmv]](含退款,不含测试账号),同比 [[m:yoy]];"
"华南 [[v:Q1.r0.销售额]]([[v:Q1.r0.订单数]] 笔)、华东 [[v:Q1.r1.销售额]]。\n\n"
"- 空括号()原样保留,半角 (a, b) 也是\n- 分隔:甲,乙、丙;丁\n\n"
"| 区域 | 销售额(元) |\n| --- | --- |\n| 华南(含线上) | [[v:Q1.r0.销售额]] |",
"49e7a6e4e430f0cbc495b5ab2b5e319f146bbee2c7147d663494922aaf6773ba"),
"see_at_end": ("本周销售额 [[m:gmv]](含退款)。[[see:m:gmv]]同比 [[m:yoy]],增幅放缓 [[see:m:yoy]]。\n\n"
"华南居首([[v:Q1.r0.订单数]] 笔),华东第二。[[see:Q1]]\n- 列表项 [[see:Q1]]\n",
"1bb0bc31fffa6de8e906d24022dae7fa3cf9b43aeda47d319498f1593d5a3684"),
}
@pytest.mark.parametrize("name", list(BEFORE))
def test_documents_without_see_in_brackets_hash_as_before(catalog, name):
raw, digest = BEFORE[name]
assert content_hash(canonical_json(compose(raw, catalog))) == digest
# --------------------------------------------------------------------------
# 流式渲染:分块输出拼起来仍然等于整篇渲染
# --------------------------------------------------------------------------
def _stream(catalog, raw: str, sizes: list[int]) -> list[str]:
renderer, out, i = StreamRenderer(catalog, loader=LOADER), [], 0
for size in sizes:
if i >= len(raw):
break
out.append(renderer.feed(raw[i:i + size]))
i += size
if i < len(raw):
out.append(renderer.feed(raw[i:]))
out.append(renderer.flush())
return out
@pytest.mark.parametrize("raw", [
"结果一致([[see:Q1]],原报告未按状态过滤),合计 [[v:Q1.r0.销售额]]([[see:Q1]])。下一句",
"前一句 ([[see:Q1]])后一句,复算原查询(同 SQL,[[see:Q1]]):华南居首,[[see:Q1]]。",
"口径相同(按支付日,[[see:Q1]],不含退款)。a, [[see:Q1]], b\n华南、[[see:Q1]]\n末尾([[see:Q1]])",
"华东 [[v:Q1.r0.销售额]]([[see:Q1]])2 月,合计 [[v:Q1.r0.销售额]]([[see:Q1]])[[see:m:gmv]][[v:Q1.r0.订单数]] 笔"
",212,[[see:Q1]],5 个",
"华南居首,([[see:Q1]])。华南居首 [[see:Q1]] 。1. 华东居首;[[see:Q1]]\n2. 华南第二,([[see:Q1]]),华东第二",
# 粗体、连着写的括号:往两边找挨上的字要越过星号、下划线,流式渲染得把它们和依据一起攒着
"销售额 **[[v:Q1.r0.销售额]]**([[see:Q1]])2 月,华东 [[v:Q1.r0.销售额]]([[see:Q1]])([[see:m:gmv]])"
"**[[v:Q1.r0.订单数]] 笔**",
"华南居首。[[see:Q1]]([[see:m:gmv]])2 月,212,[[see:Q1]],[[see:m:gmv]],5 个,合计 [[v:Q1.r0.销售额]] [[see:Q1]]"
"__2 月__",
"**[[v:Q1.r0.销售额]]([[see:Q1]])**2 月;华南居首,([[see:Q1]])([[see:m:gmv]])。order_([[see:Q1]])_x",
# 依据直接夹在两个数之间、别的括号(复核第 3 轮):方括号、波浪线也得和依据一起攒着
"华东 [[v:Q1.r0.销售额]][[see:Q1]][[v:Q1.r0.订单数]] 笔,[[v:Q1.r0.销售额]][[see:Q1]][[see:m:gmv]]2 月,"
"**华东 [[v:Q1.r0.销售额]]**[[see:Q1]]2 月",
"合计 [[v:Q1.r0.销售额]]【[[see:Q1]]】2 月,[[v:Q1.r0.销售额]][[[see:Q1]]]2 月,华南居首,[[[see:Q1]]]。"
"结果一致【[[see:Q1]],原报告未过滤】〔[[see:m:gmv]]〕[[v:Q1.r0.订单数]] 笔",
"~~[[v:Q1.r0.销售额]]~~[[see:Q1]]2 月,[[v:Q1.r0.销售额 ]][[see:Q1]][[v:Q1.r0.订单数]],a[0] 与 [b] 原样]",
"分别为 [[v:Q1.r0.销售额]]([[see:Q1]]),[[v:Q1.r1.销售额]],212[[see:Q1]],5 个,[[m:gmv]][[see:Q1]],[[m:gmv]] 元",
])
@pytest.mark.parametrize("size", range(1, 9))
def test_stream_tidies_see_in_brackets_like_the_full_render(catalog, raw, size):
out = _stream(catalog, raw, [size] * len(raw))
assert "".join(out) == render(raw, catalog), out
assert all("[[see:" not in piece for piece in out)
def _corpus(n: int) -> list[str]:
rng = random.Random(20261006)
pieces = ["[[see:Q1]]", "[[see:m:gmv]]", "[[v:Q1.r0.销售额]]", "[[m:gmv]]", "(", ")", "(", ")", ",", "、",
";", ":", ",", ";", " ", " ", "。", "\n", "文字", "元", "[[", "]]", "[", "2", "x", "%", "!",
"**", "*", "__", "_", "([[see:Q1]])", "【", "】", "[", "]", "〔", "〕", "]", "~~", "【[[see:Q1]]】",
"[[[see:m:gmv]]]"]
return ["".join(rng.choice(pieces) for _ in range(rng.randint(1, 18))) for _ in range(n)]
@pytest.mark.parametrize("raw", _corpus(200))
def test_stream_matches_the_full_render_on_random_brackets(catalog, raw):
rng = random.Random(raw)
full = render(raw, catalog)
for _ in range(3):
sizes = [rng.randint(1, 12) for _ in range(len(raw))]
assert "".join(_stream(catalog, raw, sizes)) == full
def test_stream_matches_the_full_render_on_the_structured_combos(catalog):
"""结构化组合(数、依据、只装着依据的括号、空白、星号、下划线)逐字、每 4 个字一块地送进去,拼起来和整篇渲染一样。"""
bad = []
for left, middle, right in _combos():
raw = f"{left}{middle}{right}。"
full = render(raw, catalog)
bad += [(raw, out) for size in (1, 4) if "".join(out := _stream(catalog, raw, [size] * len(raw))) != full]
assert not bad, bad[:5]