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 万份和基线比没有新并成的数,流式逐字、随机分块都等于整篇
渲染。
This commit is contained in:
ThearchyHelios
2026-10-12 12:24:14 +08:00
parent 3d85a43301
commit 60be16a0bb
6 changed files with 268 additions and 59 deletions
+79 -44
View File
@@ -1813,8 +1813,10 @@ _UNIT_TAIL = re.compile(r"\d([^\d\s][^\d]*)$")
_BLANK = " \t\u3000"
#: 依据标记 [[see:…]](同 MARKER_RE,只认 MARKER_MAX 以内的)
_SEE_MARK = r"\[\[" + _MARKER_GUARD + r"[ \t]*see[ \t]*:[^\[\]\n]*?\]\]"
#: 括号:全角、半角(模型偶尔一对里混用,也算一对)
_OPEN, _CLOSE = frozenset("(("), frozenset("))")
#: 括号,每类一对:圆括号、方括号(全角、半角,模型偶尔一对里混用,也算一对)、方头括号、六角括号。核查 agent 拿到的
#: 查询结果都以「【证据 Qn】」开头,模型照着写成「【[[see:Q1]]】」并不意外
_PAIRS = (("((", "))"), ("[[", "]]"), ("【", "】"), ("〔", "〕"))
_OPEN, _CLOSE = frozenset("".join(o for o, _ in _PAIRS)), frozenset("".join(c for _, c in _PAIRS))
#: 依据和括号、依据和依据之间的分隔标点(「([[see:Q3]],原报告…)」「[[see:Q1]]、[[see:Q2]]」)
_SEPS = ",、;,;::"
_SEP = frozenset(_SEPS)
@@ -1826,23 +1828,26 @@ _PAUSE = _SEP - frozenset("::")
_ENDING = frozenset("。!?!?")
#: 依据周围、渲染后会挤在一起的字:空白和分隔标点
_GAP = frozenset(_BLANK) | _SEP
#: 去掉依据周围的字之后,挨上的两个字会读成一串(_glue):左边是数字、字母,或者这几个——标记的结尾(渲染出一个数)、
#: 百分号、小数点、半角标点(「212,5」读成一个数;半角标点按排版习惯后面本来就隔一个空格);右边是数字、字母,或者
#: 一个渲染出字的标记。中文和数字之间不加空格也读得对,不算
#: 依据渲染成空、周围的字去掉之后,挨上的两个字会读成一串(_glue):左边是数字、字母,或者这几个——标记的结尾(渲染
#: 出一个数)、百分号、小数点、半角标点(「212,5」读成一个数;半角标点按排版习惯后面本来就隔一个空格);右边是数字、
#: 字母、一个渲染出字的标记,或者紧贴着这几样的半角逗号。中文和数字之间不加空格也读得对,不算
_STICKY = frozenset("]%‰.,;:")
#: 渲染出来什么都没有的东西(依据标记、只装着依据的括号)在遮盖文本里换成这个字,位置和原文一一对应(_voids)
_VOID = "\x00"
#: 只装着依据的括号:里面除了依据(和已经认出来的、套在里面的这种括号)只有空白、分隔标点。头一个依据前面只认空白、
#: 标点,免得一长串依据后面没有右括号时来回试
_VOID_BRACKET = re.compile("[((][" + _BLANK + _SEPS + "]*" + _VOID + "[" + _BLANK + _SEPS + _VOID + "]*[))]")
#: 只装着依据的括号(_PAIRS 里同一类的一对):里面除了依据(和已经认出来的、套在里面的这种括号)只有空白、分隔标点。
#: 头一个依据前面只认空白、标点,免得一长串依据后面没有右括号时来回试
_VOID_BRACKET = re.compile("|".join(
"[" + re.escape(o) + "][" + _BLANK + _SEPS + "]*" + _VOID + "[" + _BLANK + _SEPS + _VOID + "]*"
"[" + re.escape(c) + "]" for o, c in _PAIRS))
#: 连着写的几个:中间只隔着空白、分隔标点
_VOID_GROUP = re.compile(_VOID + "+(?:[" + _BLANK + _SEPS + "]*" + _VOID + "+)*")
#: 其中只隔着空白的一段(两边都是字时,中间的分隔标点可能就是分句的标点,按它断开各算各的)
_VOID_RUN = re.compile(_VOID + "+(?:[" + _BLANK + "]*" + _VOID + "+)*")
#: 遮盖文本里看得见的一段:一组里夹着的空白、标点,括号连同里面的空白、标点
_SOLID = re.compile("[^" + _VOID + "]+")
#: 粗体、斜体的星号和粗体的两个下划线:前端按 Markdown 渲染,不显示(组装文档时包着标记的粗体也去掉了星号)
_EMPHASIS = re.compile(r"\*+|__+")
#: 粗体、斜体的星号,粗体的两个下划线,删除线的两个波浪线:前端按 Markdown 渲染,不显示(组装文档时包着标记的粗体
#: 也去掉了星号)。删除线画出来的字照样挨着后面的字读
_EMPHASIS = re.compile(r"\*+|__+|~~+")
def _rendered_unit(marker: dict[str, Any], catalog: dict[str, Any], snaps: _Snapshots,
@@ -1861,8 +1866,8 @@ def _tidy(text: str, catalog: dict[str, Any], snaps: _Snapshots, cells_allowed:
还跟着写一遍([[m:visits]] 人次),读者看到的是「12,345人次 人次」。单字的单位后面紧跟着字时
不动:写作者写的可能是另一个单位(口径卡是「人」,正文写「人次」),硬删会把不一致藏起来;
- [[see:…]] 渲染成空,它周围留下的空白、括号、标点去掉(_voids、_see_cuts):「最高月份 [[see:Q2]]。」会剩下
「最高月份 。」,「结果一致([[see:Q3]],原报告…)」会剩下「结果一致(,原报告…)」。去掉之后两边挨上的字会读成
一串的,补回一个空格(_glue)。
「最高月份 。」,「结果一致([[see:Q3]],原报告…)」会剩下「结果一致(,原报告…)」。依据(连同去掉的字)两边
挨上的字会读成一串的,补一个空格(_glue):「4,919.5[[see:Q1]]212 笔」会读成「4,919.5212 笔」。
prev 是这段文字前面那个字(流式渲染分块时用),判断空白是不是在行首。
"""
@@ -1885,18 +1890,21 @@ def _tidy(text: str, catalog: dict[str, Any], snaps: _Snapshots, cells_allowed:
for group in _VOID_GROUP.finditer(masked):
see += _see_cuts(text, masked, group.start(), group.end(), prev)
spaces = _glue(text, masked, see, prev)
if not cuts and not see:
if not cuts and not see and not spaces:
return text
# 几处要去掉的字可能重叠、相接(依据前面的空白、它和括号之间的标点):按并集去掉,要补空格的那一片换成一个空格
# 几处要去掉的字可能重叠、相接(依据前面的空白、它和括号之间的标点):按并集去掉,要补空格的那一片换成一个空格;
# 补空格的地方没有要去掉的字(依据两边本来就什么都没有、紧贴着下文的半角逗号后面),在那里插一个
merged: list[list[int]] = []
for a, b in sorted(cuts + see):
if merged and a <= merged[-1][1]:
merged[-1][1] = max(merged[-1][1], b)
else:
merged.append([a, b])
edits = [(a, b, " " if any(a <= x < b for x in spaces) else "") for a, b in merged]
edits += [(x, x, " ") for x in spaces if not any(a <= x < b for a, b in merged)]
out, last = [], 0
for a, b in merged:
out += [text[last:a], " " if any(a <= x < b for x in spaces) else ""]
for a, b, put in sorted(edits):
out += [text[last:a], put]
last = b
out.append(text[last:])
return "".join(out)
@@ -1999,43 +2007,47 @@ def _blank_cut(text: str, start: int, end: int, prev: str) -> list[tuple[int, in
def _glue(text: str, masked: str, see: list[tuple[int, int]], prev: str) -> set[int]:
"""依据周围的字(see)去掉之后,两边真正挨上的两个看得见的字会读成一串的(_STICKY):返回这一片里头一个去掉的字
的位置,_tidy 在那里补回一个空格。「[[v:Q1.r0.销售额]]([[see:Q1]])2 月」去掉括号读成「4,919.52 月」,是另一个
数,而且哪项核对都查不出来。
"""依据渲染成空、它周围的字(see)去掉之后,两边真正挨上的两个看得见的字会读成一串的(_STICKY):返回要补空格的
位置,_tidy 在那里补一个(位置上的字是要去掉的就换成空格,不是就插在它前面)。
「[[v:Q1.r0.销售额]]([[see:Q1]])2 月」去掉括号读成「4,919.52 月」,「[[v:Q1.r0.销售额]][[see:Q1]][[v:Q1.r0.订单数]]
笔」读成「4,919.5212 笔」,是另一个数,而且哪项核对都查不出来。
往两边找挨上的字时越过一切不显示的:去掉的字、依据标记、粗体斜体的星号和下划线。两个括号连着写(「([[see:Q1]])
([[see:m:gmv]])」)、数字包着粗体(「**[[v:…]]**([[see:Q1]])」)都照挨上的字判;依据标记的「]」越过去,不当
数字(「华南居首。[[see:Q1]]([[see:Q2]])2 月」不补)。一片里只补一个(「212,[[see:Q1]],[[see:m:gmv]],5」→
「212, 5」);挨上的是写作者留着的空白的,不补。
每个依据(连着写的一串、只装着依据的括号都算)两边都判,不只看去掉了字的地方:依据前后什么都没有时,两边一样
直接挨上。往两边找挨上的字时越过一切不显示的:去掉的字、依据标记、粗体斜体的星号和下划线、删除线的波浪线。
两个括号连着写(「([[see:Q1]])([[see:m:gmv]])」)、数字包着粗体(「**[[v:…]]**[[see:Q1]]2 月」)都照挨上
的字判;依据标记的「]」越过去,不当数字(「华南居首。[[see:Q1]]([[see:Q2]])2 月」不补)。一片里只补一个
(「212,[[see:Q1]],[[see:m:gmv]],5」→「212, 5」);挨上的是写作者留着的空白的,不补。右边挨上的是紧贴着下文的
半角逗号的(「1,200([[see:Q1]]),900」读成「1,200,900」),空格补在逗号后面,和上面那种读起来一样。
空格补在头一个去掉的字那里。粗体把括号包在里面时(「**[[v:…]]([[see:Q1]])**2 月」)它落在收尾的星号前面:
空格补在这一片头一个去掉的字那里;依据两边本来什么都没有、没有去掉的字的,补在头一个依据前面。粗体收尾的星号
在它左边,粗体照样成对。粗体把依据、括号包在里面时(「**[[v:…]]([[see:Q1]])**2 月」)它落在收尾的星号前面:
前端的行内 Markdown 照样认这对星号,组装文档时包着标记的星号也去掉了,读者看到的一样。要挪到星号外面得先知道
这对星号从哪开始,流式渲染的一块里不一定有。
越过的这些字(空白、括号、分隔标点、依据、星号、下划线)流式渲染都攒到后面的字来了才放(_TRAILING_SEE):一片总
落在同一块里,往左越出这一块就是 prev(它不会是这些字),分块渲染和整篇渲染判得一样。"""
gone = bytearray(len(text))
越过的这些字(空白、括号、分隔标点、依据、星号、下划线、波浪线)流式渲染都攒到后面的字来了才放(_TRAILING_SEE):
一片总落在同一块里,往左越出这一块就是 prev(它不会是这些字),分块渲染和整篇渲染判得一样。"""
cut = bytearray(len(text))
for a, b in see:
gone[a:b] = b"\x01" * (b - a)
def hidden(i: int) -> bool:
return bool(gone[i]) or masked[i] == _VOID
cut[a:b] = b"\x01" * (b - a)
gone = bytearray(cut)
for void in re.finditer(_VOID + "+", masked):
gone[void.start():void.end()] = b"\x01" * (void.end() - void.start())
spaces: set[int] = set()
seen: set[tuple[int, int]] = set()
for run in re.finditer(rb"\x01+", gone):
p, q = run.start(), run.end()
while p > 0:
if hidden(p - 1):
if gone[p - 1]:
p -= 1
elif text[p - 1] == "*" or (p > 1 and text[p - 2:p] == "__"):
elif text[p - 1] == "*" or (p > 1 and text[p - 2:p] in ("__", "~~")):
mark = text[p - 1]
while p > 0 and text[p - 1] == mark:
p -= 1
else:
break
while q < len(text):
if hidden(q):
if gone[q]:
q += 1
elif emphasis := _EMPHASIS.match(text, q):
q = emphasis.end()
@@ -2045,13 +2057,31 @@ def _glue(text: str, masked: str, see: list[tuple[int, int]], prev: str) -> set[
continue
seen.add((p, q))
left = text[p - 1] if p > 0 else prev
right = text[q:q + 1]
sticks = (right == "[" and MARKER_RE.match(text, q) is not None) or _wordish(right)
if sticks and (_wordish(left) or left in _STICKY):
spaces.add(run.start())
if not (_wordish(left) or left in _STICKY):
continue
if _shows_word(text, q):
first = cut.find(b"\x01", run.start(), q)
spaces.add(first if first != -1 else run.start())
elif text[q:q + 1] == ",":
# 右边是写作者紧贴着下文写的半角逗号(「1,200([[see:Q1]]),900」):去掉之后读成「1,200,900」,空格照半角
# 逗号的排版习惯补在它后面(同「212,[[see:Q1]],5」→「212, 5」)。下文包着粗体的越过星号看。逗号、星号都是
# _TRAILING_SEE 攒着的字,再往后那个字和它们落在同一块里
after = q + 1
while emphasis := _EMPHASIS.match(text, after):
after = emphasis.end()
if _shows_word(text, after):
spaces.add(q + 1)
return spaces
def _shows_word(text: str, i: int) -> bool:
"""text[i] 起头的是数字、字母,或者一个渲染出字的标记(依据不算):挨在一个数后面会读成一串。"""
if text[i:i + 1] == "[":
marker = MARKER_RE.match(text, i)
return marker is not None and marker.group("kind") != "see"
return _wordish(text[i:i + 1])
def _wordish(ch: str) -> bool:
"""数字(全角的也算)或者半角字母:两个挨在一起读成一个数、一个词。"""
return ch.isdecimal() or (ch.isascii() and ch.isalnum())
@@ -2080,9 +2110,11 @@ def render_markers(text: str, catalog: dict[str, Any], *, cells_allowed: bool =
# 流式渲染:让 llm.token 里出现的就是最终的数字
# --------------------------------------------------------------------------
#: 文字末尾的空白、括号、分隔标点、星号、下划线和 [[see:…]]:流式渲染先攒着,等后面的字来了再一起交给 _tidy(依据
#: 周围的这些字去不去掉、去掉之后补不补空格,要看再往后的那个字,见 _see_cuts、_glue)
_TRAILING_SEE = re.compile(r"(?:[" + _BLANK + "(())" + _SEPS + "*_" + r"]|" + _SEE_MARK + r")+$")
#: 文字末尾的空白、括号、分隔标点、星号、下划线、波浪线和 [[see:…]]:流式渲染先攒着,等后面的字来了再一起交给 _tidy
#: (依据周围的这些字去不去掉、补不补空格,要看再往后的那个字,见 _see_cuts、_glue)。半角方括号也在里面:标记收尾的
#: 「]]」不算,_safe_cut 另外挑出来
_TRAILING_SEE = re.compile(r"(?:[" + _BLANK + re.escape("".join(sorted(_OPEN | _CLOSE))) + _SEPS + "*_~" + r"]|"
+ _SEE_MARK + r")+$")
def _safe_cut(text: str, has_unit: Callable[[dict[str, Any]], bool] | None = None) -> int:
@@ -2093,8 +2125,8 @@ def _safe_cut(text: str, has_unit: Callable[[dict[str, Any]], bool] | None = Non
开头可能是另一个 [。
整表标记闭合了也先不放:它展开时要看紧跟在后面的那个字(是不是换行),那个字还没到。
_tidy 要往后看的也先不放:末尾的空白、括号、分隔标点、星号、下划线和 [[see:…]](后面来的字决定它们去不
去掉、要不要补空格),以及后面还没攒够 UNIT_LOOKAHEAD 个字的数字标记(后面可能是重复的单位)。has_unit 判断
_tidy 要往后看的也先不放:末尾的空白、括号、分隔标点、星号、下划线、波浪线和 [[see:…]](后面来的字决定它们
去不去掉、要不要补空格),以及后面还没攒够 UNIT_LOOKAHEAD 个字的数字标记(后面可能是重复的单位)。has_unit 判断
一个标记渲染出来带不带单位,不带的(运行输入里的周次、没有单位的格)不用攒;不给就都攒。
"""
cut = len(text)
@@ -2112,7 +2144,10 @@ def _safe_cut(text: str, has_unit: Callable[[dict[str, Any]], bool] | None = Non
if m and m.end() == held and m.group("kind") == "table":
held = start
if (trail := _TRAILING_SEE.search(text, 0, held)) and trail.start() < held:
held = trail.start()
# 攒的一段从一个标记中间开始(标记收尾的「]]」、标记里末尾的空白也是那几种字):标记整个放出去,从它后面攒起
s = text.rfind("[[", 0, trail.start())
inside = MARKER_RE.match(text, s, held) if s != -1 else None
held = inside.end() if inside and inside.end() > trail.start() else trail.start()
numeric = [m for m in MARKER_RE.finditer(text, 0, held) if m.group("kind") in _UNIT_KINDS
and (has_unit is None or has_unit(parse_markers(m.group(0))[0]))]
if numeric and len(text[numeric[-1].end():held]) < UNIT_LOOKAHEAD \
@@ -66,6 +66,21 @@ async def test_see_brackets_written_together_do_not_glue_two_numbers(monkeypatch
assert refuted["rationale_doc"]["violations"] == []
async def test_a_bare_see_between_two_numbers_and_other_brackets_do_not_break_the_rationale(monkeypatch, shop):
"""依据直接夹在两个数之间(两边什么都没有)、写进方头括号(复核第 3 轮):两个数不能挨成一个(「9001,200」),括号
不能剩下「【】」。"""
plays = {
"华南销售额超过华东": Play([NET], "refuted", "扣除退款后华南、华东分别为 [[v:Q1.r1.净销售额]][[see:Q1]]"
"[[v:Q1.r0.净销售额]],华南仍低于华东【[[see:Q1]]】。"),
"居各区域之首": Play([REFUNDS], "stands", "华东退款 [[v:Q1.r0.退款笔数]] 笔,扣除后仍居首。[[see:Q1]]"),
}
record, _ = await _record(monkeypatch, shop, plays)
refuted = next(claim for claim in record["claims"] if claim["verdict"] == "refuted")
assert refuted["rationale"] == "扣除退款后华南、华东分别为 900 1,200,华南仍低于华东。"
assert refuted["rationale_doc"]["markdown"] == refuted["rationale"]
assert refuted["rationale_doc"]["violations"] == []
async def test_a_placeholder_rationale_is_recorded_as_no_rationale(monkeypatch, shop):
"""反方交回的理由只是占位词:记录里理由为空(审批卡据此写「反方没有给出理由」),判「被推翻」的照常不予采信。"""
plays = {"华南销售额超过华东": Play([NET], "refuted", "placeholder"),
@@ -46,6 +46,15 @@ async def test_see_brackets_written_together_do_not_glue_the_number(monkeypatch,
assert doc["markdown"] == mine["rationale"] and doc["violations"] == []
async def test_a_bare_see_before_a_number_and_other_brackets_do_not_break_the_rationale(monkeypatch, shop):
"""依据直接夹在数字和后面的数之间(两边什么都没有)、写进六角括号(复核第 3 轮):数字不能和后面的字挨成一个数
(「3302 月」),括号不能剩下「〔〕」。"""
mine = await _blind_input(monkeypatch, shop, "结果是 {ref}[[see:Q1]]2 月起按支付日统计,已排除退款〔[[see:Q1]]〕。")
assert mine["rationale"] == "结果是 330 2 月起按支付日统计,已排除退款。"
doc = mine["rationale_doc"]
assert doc["markdown"] == mine["rationale"] and doc["violations"] == []
async def test_a_placeholder_rationale_is_not_shown_as_the_explanation(monkeypatch, shop):
mine = await _blind_input(monkeypatch, shop, "placeholder")
assert mine["value"] == 330.0, "数照样采用"
+145 -13
View File
@@ -4,12 +4,14 @@
[[see:Q1]]」,模型就把它写进括号、句中:「结果一致([[see:Q3]],原报告 SQL 未按 status 过滤)」「合计
[[v:Q1.r1.金额合计]]([[see:Q1]])」,渲染后剩下「(,原报告…)」「()」,正式运行里随封存永久留着。
- 括号(全角、半角,一对里混用也算)里只有依据的,连括号一起去掉;去掉之后整对括号当一个依据,外面照下面几条再收拾
一遍(括号前悬着的停顿标点、括号前后的空白)。连着写的几个(「([[see:Q1]])([[see:m:gmv]])」)并成一组收拾
- 去掉之后两边真正挨上的两个字会粘成一串的(「4,919.5([[see:Q1]])2 月」),补回一个空格:粘起来读成另一个数,
哪项核对都查不出来。找挨上的字时越过一切不显示的:依据、只装着依据的括号、粗体斜体的星号和下划线;依据标记的「]」
- 括号(圆括号、方括号的全角半角,一对里混用也算;方头括号【】、六角括号〔〕)里只有依据的,连括号一起去掉;去掉之后
整对括号当一个依据,外面照下面几条再收拾一遍(括号前悬着的停顿标点、括号前后的空白)。连着写的几个
(「([[see:Q1]])([[see:m:gmv]])」)并成一组收拾
- 依据渲染成空、周围的字去掉之后,两边真正挨上的两个字会粘成一串的(「4,919.5([[see:Q1]])2 月」,依据两边什么都
没有的「4,919.5[[see:Q1]]212 笔」也是),补一个空格:粘起来读成另一个数,哪项核对都查不出来。每个依据两边都判,
找挨上的字时越过一切不显示的:依据、只装着依据的括号、粗体斜体的星号和下划线、删除线的波浪线;依据标记的「]」
不当数字。一片里最多补一个,挨上的是写作者留着的空白的不补;半角逗号夹着依据、去掉后一个之后同理
(「212,[[see:Q1]],5」→「212, 5」)
(「212,[[see:Q1]],5」→「212, 5」),右边是紧贴着下文的半角逗号的(「1,200([[see:Q1]]),900」)补在逗号后面
- 依据在括号开头、后面跟着「,」「、」「;」「:」的,标点一起去掉;在括号末尾、前面紧挨着标点的同理
- 依据夹在两个分隔标点中间的,去掉后一个;后面就到句末(句末标点、全文结尾)、前面或几个依据之间有「,」「、」
「;」的,去掉这些标点,到句末只隔着空白的去掉空白;依据后面、句末前面的标点是写作者自己的,不动
@@ -266,6 +268,125 @@ 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 月起回升。"),
@@ -281,30 +402,33 @@ def test_a_space_is_put_back_once_and_only_where_it_sticks(catalog, raw, shown):
assert compose(raw, catalog)["markdown"] == shown
#: 结构化组合:左边一个渲染出数的标记(可能包着粗体),中间 1–3 段依据、只装着依据的括号、空白、星号,右边以数字、
#: 字母或者渲染出数的标记开头
#: 结构化组合:左边一个渲染出数的标记(可能包着粗体),中间 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]]", "**", " ", ","]
_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]]))", " ", ","}
_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 for m in middle)]
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_a_separator_the_writer_wrote_never_disappears_between_two_numbers(catalog):
"""写作者原本隔开了的两边(括号、空白、标点),收拾依据之后不能挨成一串;也不会多出两个连着的空格。"""
def test_two_numbers_never_run_together_once_see_is_dropped(catalog):
"""写作者隔开了的两边(括号、空白、标点),或者中间只隔着依据的,收拾依据之后不能挨成一串;也不会多出两个连着的
空格。"""
bad = []
for left, middle, right in _combos():
raw = f"{left}{middle}{right}。"
@@ -433,6 +557,13 @@ def _stream(catalog, raw: str, sizes: list[int]) -> list[str]:
"华南居首。[[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):
@@ -445,7 +576,8 @@ 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:Q1]]】",
"[[[see:m:gmv]]]"]
return ["".join(rng.choice(pieces) for _ in range(rng.randint(1, 18))) for _ in range(n)]
@@ -45,6 +45,9 @@ BRACKETED_TEXT = "仍居各区域之首(已排除测试账号):华东为 1
TOGETHER = ("华东、华南分别为 **[[v:Q1.r0.销售额]]**([[see:Q1]])([[see:Q1]])[[v:Q1.r1.销售额]],"
"华东仍居各区域之首,([[see:Q1]])([[see:Q1]])。")
TOGETHER_TEXT = "华东、华南分别为 1,200 900,华东仍居各区域之首。"
#: 依据直接夹在两个数之间(两边什么都没有)、写进方头括号(复核第 3 轮):两个数不能挨成一个,括号不能剩下「【】」
BARE = "华东、华南分别为 [[v:Q1.r0.销售额]][[see:Q1]][[v:Q1.r1.销售额]],华东仍居各区域之首【[[see:Q1]]】。"
BARE_TEXT = "华东、华南分别为 1,200 900,华东仍居各区域之首。"
async def _asked(client, shop) -> tuple[dict, list]:
@@ -55,8 +58,8 @@ async def _asked(client, shop) -> tuple[dict, list]:
return r.json()["inquiry"], await events(paused.id, "approval.inquiry")
@pytest.mark.parametrize(("said", "shown"), [(BRACKETED, BRACKETED_TEXT), (TOGETHER, TOGETHER_TEXT)],
ids=["bracketed", "together"])
@pytest.mark.parametrize(("said", "shown"), [(BRACKETED, BRACKETED_TEXT), (TOGETHER, TOGETHER_TEXT), (BARE, BARE_TEXT)],
ids=["bracketed", "together", "bare"])
async def test_see_in_brackets_does_not_break_the_answer(monkeypatch, shop, client, said, shown):
Models(monkeypatch, shop, plays={"排除测试账号": Play([NO_TEST], said)})
item, [event] = await _asked(client, shop)
@@ -108,6 +108,21 @@ async def test_see_brackets_written_together_do_not_glue_two_numbers(monkeypatch
assert out.rationale["violations"] == []
async def test_a_bare_see_between_two_numbers_and_other_brackets_render_clean(monkeypatch, shop):
"""依据直接夹在两个数之间(两边什么都没有)、写进方括号(复核第 3 轮):两个数不能挨成一个(「2303 笔」),括号不能
剩下「[]」;两条依据照样挂在句子上。"""
name, _ = shop
said = "未作废订单合计 [[v:Q1.r0.total]][[see:Q1]][[v:Q2.r0.n]] 笔都已核对[[[see:Q2]]]。"
count = "SELECT COUNT(*) AS n FROM orders WHERE is_deleted = 0"
Script(monkeypatch, [q(name, VALID_SUM, "c1"), q(name, count, "c2")], {**FOUND, "rationale": said})
out = await run(name)
shown = "未作废订单合计 230 3 笔都已核对。"
assert out.result["rationale"] == shown and out.rationale["markdown"] == shown
[unit] = [unit for _, unit in iter_units(out.rationale)]
assert [s["alias"] for s in unit["see"]] == ["Q1", "Q2"]
assert out.rationale["violations"] == []
# --------------------------------------------------------------------------
# 收尾超时
# --------------------------------------------------------------------------