出具校验认得中文数字了:"增长三成、客户两千五百家"不再能盖 formal 章

校验器只抽阿拉伯数字。strict 契约下"本周营收增长三成、客户两千五百家"一个数
都抽不出来,判 formal;同样的意思写成 2500 却会被 withheld。叙述层换一种写法
就绕过了"不许现编数字"这条地基,而"出错自动改一次"的复核回路会把模型往这条
路上推——被拦一次,它学会的是写汉字。review.check_numbers 复用同一套抽取,
同样的洞。

中文数字难在两头,规则按"这是不是在报一个量"来定(文件头有完整说明):
- 多字且解析得通的一律抽:十二、两千五百、两千五(省略写法)、一万二、三点五万;
- 单字只在后面跟计数单位时抽:两家、五天。"一"加量词多半是冠词(一个问题),
  不抽——放过一个值为 1 的现编,代价远小于满篇误杀;
- 成、倍、百分之、分之、一半单字也抽,按口语精度给容差(三成 = 30% ± 半成);
- 余 / 多 / 来按区间回指(两千余 = [2000, 3000]),不跳过——跳过就是新的绕法;
- 不抽:序数、星期、月日、季度名(三季度 = Q3)、"从三个方面看"这种结构性列举、
  几十 / 数百 / 上千这种虚指、成语(五花八门、千方百计、万一、十分重要)。

顺带修了阿拉伯数字的两处:
- 万 / 亿后缀的容差按书写精度算。3.5万 写到千位,应是 ±500,以前按 3.5 的一位
  小数给 ±0.05,只认 35000 整——README 承诺的"按书写精度"对带后缀的数不成立。
- 补上千 / 百万 / 千万后缀:"2千家"以前抽出来是 2,回指的是一个错的值。

出具校验以前没有一条直接测试(只经 check_numbers 间接覆盖)。新增 test_issuance.py
39 例:解析、该抽的、不该抽的两头都守,外加当初那条绕过(旧代码实测:抽不到、判
formal)。374 → 413 passed。
This commit is contained in:
ThearchyHelios
2026-09-25 15:30:32 +08:00
parent a819f92903
commit 4559c96fdd
3 changed files with 341 additions and 13 deletions
+5 -1
View File
@@ -115,9 +115,13 @@ flowchart LR
![出具链路模板](docs/images/graph-issuance.png)
校验器从叙述中抽取全部数字逐个回指指标集,支持千分位、百分数与比率互转、万/亿后缀、
校验器从叙述中抽取全部数字逐个回指指标集,支持千分位、百分数与比率互转、千/万/亿后缀、
按书写精度的舍入容差;日期、枚举序号、引用标记不参与回指。三档判定与缺数据声明随成果印出。
中文数字同样回指——否则换一种写法就绕过去了("增长三成、客户两千五百家"以前能盖 formal 章)。
成、倍、百分之、分之、一半按比例回指,"两千余"按区间 [2000, 3000] 回指;"一个问题""十分重要"
"从三个方面看""三季度""几十家"这类虚指、序数、季度名、结构性列举不算结论数字。
实测记录:Opus 5 撰写的正文数字全部回指成功,但结尾「(约 100 字)」中的 `100` 被判定为
集合外数字并触发降档。处置方式是修改 prompt 而非扩大白名单——这正是校验器应有的行为。
+235 -12
View File
@@ -19,7 +19,10 @@ from typing import Any
# 整个 token 都抽不出来。那等于说校验器只对西式排版生效,中文叙述完全不设防——
# 而这套机制的全部意义就是拦住叙述层现编数字。
# 排 ASCII 字母是为了放过 v2 / sha1 这类标识符,排 . 是为了放过 v1.2.3 的版本号。
_NUM_RE = re.compile(r"(?<![0-9A-Za-z_.])(-?\d[\d,]*(?:\.\d+)?)\s*(%|%|万亿|亿|万)?")
# 后缀里的千 / 百万 / 千万:不认的话 "2千家" 抽出来是 2,回指的是一个错的值。
_NUM_RE = re.compile(
r"(?<![0-9A-Za-z_.])(-?\d[\d,]*(?:\.\d+)?)\s*(%|%|万亿|亿|千万|百万|万|千)?"
)
# 不参与回指的数字形态——这些不是"结论数字"。
#
@@ -43,7 +46,132 @@ _SKIP_PATTERNS = [
re.compile(r"\d{1,2}:\d{2}(:\d{2})?"), # 时间
]
_SUFFIX_MULT = {"万": 1e4, "亿": 1e8, "万亿": 1e12}
_SUFFIX_MULT = {"千": 1e3, "万": 1e4, "百万": 1e6, "千万": 1e7, "亿": 1e8, "万亿": 1e12}
# --------------------------------------------------------------------------
# 中文数字
#
# 只认阿拉伯数字的话,换一种写法就绕过去了:"增长三成""客户两千五百家"以前全判
# formal,同样的意思写成 2500 却会被拦——"出错自动改一次"的复核回路甚至会把
# 模型往这条路上推。所以中文数字也得抽出来回指。
#
# 难处在误杀:中文里的数字字大量是虚指(一些、一起、十分重要、万一、五花八门、
# 从三个方面看)。规则按"这是不是在报一个量"来定:
# - 多字且解析得通的(十二、两千五百、一万二)一律抽;
# - 单字只在后面跟计数单位时抽(两家、五天)。"一"加量词多半是"一个问题"里的
# 冠词用法,不抽——放过一个值为 1 的现编,代价远小于满篇误杀;
# - 成 / 倍 / 百分之 / 分之 / 一半这些比例写法单字也抽,容差按口语精度给;
# - 余 / 多 / 来按区间回指(两千余 = [2000, 3000]),不是跳过——跳过就是新的绕法;
# - 序数(第三)、星期(周三)、月份(十二月)、季度名(三季度)、日期、
# "从三个方面看"这种结构性列举、几十 / 数百这种虚指,不是结论数字。
# --------------------------------------------------------------------------
_CN_DIGIT = {
"零": 0, "〇": 0, "一": 1, "壹": 1, "二": 2, "贰": 2, "两": 2, "三": 3, "叁": 3,
"四": 4, "肆": 4, "五": 5, "伍": 5, "六": 6, "陆": 6, "七": 7, "柒": 7,
"八": 8, "捌": 8, "九": 9, "玖": 9,
}
_CN_UNIT = {"十": 10, "拾": 10, "百": 100, "佰": 100, "千": 1000, "仟": 1000}
_CN_BIG = {"亿": 10**8, "万": 10**4}
_CN_CHARS = "".join([*_CN_DIGIT, *_CN_UNIT, *_CN_BIG])
_CN_DIGITS = "".join(_CN_DIGIT)
_CN_N = rf"[{_CN_CHARS}]+(?:点[{_CN_DIGITS}]+[万亿]?)?"
_CN_RE = re.compile(
rf"百分之(?P<pct>{_CN_N})"
rf"|(?P<den>{_CN_N})分之(?P<numr>{_CN_N})"
rf"|(?P<half>一半)"
rf"|(?P<num>{_CN_N})(?P<suffix>成|倍|个百分点|[余多来])?"
)
#: 单字数字后面跟着这些,才算在报一个量
_CN_COUNT_UNIT = re.compile(
r"(个月|个季度|个工作日|小时|分钟|万元|亿元|美元|公里|公斤|千克"
r"|[家个名位人次笔单台件项条款种类批辆套份张户所座间天周年秒元块吨米克升岁页篇起宗例箱瓶包支只盒袋场轮届])"
)
#: "从三个方面看":列举的是论述的结构,不是数据
_CN_STRUCTURAL = re.compile(
r"个(方面|原因|问题|建议|要点|维度|阶段|步骤|部分|层面|角度|环节|关键|特点|优势|挑战|结论|措施)"
)
def _cn_section(text: str) -> int | None:
"""万以内的一段:十二、两千五百、一百零五、两千五(省略写法 = 2500)。"""
total, digit, last_unit, zero = 0, None, None, False
for ch in text:
if ch in ("零", "〇"):
if digit is not None:
return None
zero = True
elif ch in _CN_DIGIT:
if digit is not None: # 两个数字挨着(一一、五一):不是数
return None
digit = _CN_DIGIT[ch]
elif ch in _CN_UNIT:
unit = _CN_UNIT[ch]
if last_unit is not None and unit >= last_unit:
return None # 单位只能往下走:十百、百千都不是数
total += (1 if digit is None else digit) * unit # 开头的"十" = 一十
digit, last_unit, zero = None, unit, False
else:
return None
if digit is not None:
# 两千五 = 2500:单位后面省掉了下一级单位;有"零"隔开时(一百零五)才是个位
total += digit * (last_unit // 10 if last_unit and not zero else 1)
return total
def _cn_integer(text: str) -> int | None:
for char, mult in _CN_BIG.items():
if char in text:
head, tail = text.split(char, 1)
if not head:
# 光一个"万"是一万;"万一"不是一万一千,是"万一"
return mult if not tail else None
head_value = _cn_integer(head)
if head_value is None:
return None
if len(tail) == 1 and tail in _CN_DIGIT: # 一万二 = 12000
tail_value: int | None = _CN_DIGIT[tail] * mult // 10
else:
tail_value = _cn_integer(tail) if tail else 0
if tail_value is None:
return None
return head_value * mult + tail_value
return _cn_section(text)
def cn_to_number(text: str) -> float | None:
"""两千五百 → 2500,一万二 → 12000,三点五万 → 35000。不是数返回 None。"""
integer, _, frac = text.partition("点")
mult = 1
if frac and frac[-1] in _CN_BIG:
mult, frac = _CN_BIG[frac[-1]], frac[:-1]
value = _cn_integer(integer)
if value is None or (not integer):
return None
if frac:
return (value + float("0." + "".join(str(_CN_DIGIT[c]) for c in frac))) * mult
return float(value * mult)
def _cn_precision(text: str, value: float) -> float:
"""这个中文数字写到哪一位:三万 → 10000,两千五百 → 100,三点五万 → 1000。
中文不写末尾的零,最后一个数字落在哪一位,精度就是哪一位。十以内的整数
(十二、二十)按精确数处理——"十家"就是十家。
"""
if "点" in text:
integer, _, frac = text.partition("点")
mult = _CN_BIG.get(frac[-1], 1) if frac else 1
digits = len(frac) - (1 if mult != 1 else 0)
return 10 ** (-digits) * mult
whole = int(value)
if whole < 100:
return 1
step = 1
while whole % (step * 10) == 0:
step *= 10
return step
@dataclass
@@ -54,6 +182,8 @@ class NumberToken:
is_percent: bool
context: str
start: int
#: 回指时允许的偏差。缺省按写出的小数位数算;中文数字、带万亿后缀的数有自己的书写精度
tolerance: float | None = None
@dataclass
@@ -74,10 +204,15 @@ def _skip_spans(text: str) -> list[tuple[int, int]]:
return spans
def _context(text: str, start: int, end: int) -> str:
return text[max(0, start - 18) : min(len(text), end + 18)].replace("\n", " ")
def extract_numbers(text: str) -> list[NumberToken]:
"""抽出叙述里所有需要回指的数字,剔除日期、枚举序号、引用标记这类形态。"""
skip = _skip_spans(text)
tokens: list[NumberToken] = []
taken: list[tuple[int, int]] = []
for m in _NUM_RE.finditer(text):
start, end = m.span()
if any(s <= start < e for s, e in skip):
@@ -88,36 +223,124 @@ def extract_numbers(text: str) -> list[NumberToken]:
value = float(raw_num)
except ValueError:
continue
decimals = len(raw_num.split(".")[1]) if "." in raw_num else 0
tolerance = None
if suffix in _SUFFIX_MULT:
value *= _SUFFIX_MULT[suffix]
decimals = len(raw_num.split(".")[1]) if "." in raw_num else 0
ctx_lo = max(0, start - 18)
# 书写精度跟着后缀走:3.5万写到千位,容差 ±500,而不是 3.5 那一位小数的 ±0.05
tolerance = 0.5 * 10 ** (-decimals) * _SUFFIX_MULT[suffix]
taken.append((start, end))
tokens.append(
NumberToken(
raw=m.group(0).strip(),
value=value,
decimals=decimals,
is_percent=suffix in ("%", "%"),
context=text[ctx_lo : min(len(text), end + 18)].replace("\n", " "),
is_percent=suffix in ("%", "%") or text[max(0, start - 3) : start] == "百分之",
context=_context(text, start, end),
start=start,
tolerance=tolerance,
)
)
tokens.extend(_cn_numbers(text, skip + taken))
tokens.sort(key=lambda t: t.start)
return tokens
def _close(a: float, b: float, decimals: int) -> bool:
"""按 token 自己写出的小数位数给舍入容差:写了 12.3 就允许 |Δ|≤0.05。"""
tolerance = 0.5 * 10 ** (-decimals)
def _cn_not_a_quantity(numeral: str, suffix: str, value: float, before: str, after: str) -> bool:
"""这串中文数字字是不是在报一个量。规则见文件头"中文数字"一节。"""
if suffix in ("成", "倍", "个百分点"):
return False # 三成、两倍:比例写法,单字也是量
if before.endswith("第"):
return True # 第三
if before.endswith(("周", "星期", "礼拜")) and len(numeral) == 1:
return True # 周三
if before[-1:] in ("几", "数", "上") or after.startswith("几"):
return True # 几十、数百、上千、十几:虚指
if not suffix:
if (after.startswith("月") and value <= 12) or (after.startswith(("日", "号")) and value <= 31):
return True # 十二月、十五日
if after.startswith("季度"):
return True # 三季度 = Q3("三个季度"不走这里)
if numeral in ("十一", "五一") and after.startswith(("假", "长假", "期间", "黄金周")):
return True
if _CN_STRUCTURAL.match(after):
return True # 从三个方面看
# 单字,或只有单位没有数字的(千万、百万):后面跟着计数单位才是量。
# "一"再加一道:一个问题、一次调用多半是冠词用法
bare = len(numeral) == 1 or not any(c in _CN_DIGIT for c in numeral)
if bare and "点" not in numeral:
if numeral == "一":
return True
if not _CN_COUNT_UNIT.match(after):
return True # 五花八门、十分重要、千方百计、千万不要
return False
def _cn_numbers(text: str, blocked: list[tuple[int, int]]) -> list[NumberToken]:
tokens: list[NumberToken] = []
def add(m: re.Match[str], value: float, tolerance: float, *, is_percent: bool) -> None:
tokens.append(NumberToken(
raw=m.group(0), value=value, decimals=0, is_percent=is_percent,
context=_context(text, m.start(), m.end()), start=m.start(), tolerance=tolerance,
))
for m in _CN_RE.finditer(text):
start, end = m.span()
if any(s < end and start < e for s, e in blocked):
continue
if m.group("pct"): # 百分之三十:和 30% 同一个精度
value = cn_to_number(m.group("pct"))
if value is not None:
decimals = len(m.group("pct").partition("点")[2])
add(m, value, 0.5 * 10 ** -decimals, is_percent=True)
continue
if m.group("den"): # 三分之一:按百分数、±5 个点回指
den, numr = cn_to_number(m.group("den")), cn_to_number(m.group("numr"))
if den and numr is not None:
add(m, numr / den * 100, 5, is_percent=True)
continue
if m.group("half"): # 一半
add(m, 50, 5, is_percent=True)
continue
numeral, suffix = m.group("num"), m.group("suffix") or ""
value = cn_to_number(numeral)
if value is None:
continue
if _cn_not_a_quantity(numeral, suffix, value, text[max(0, start - 2) : start], text[end : end + 8]):
continue
if suffix == "成": # 三成 = 30%,口语精度半成
add(m, value * 10, 5, is_percent=True)
elif suffix in ("余", "多", "来"): # 两千余 = [2000, 3000]
step = 1
while value >= step * 10 and value % (step * 10) == 0:
step *= 10
add(m, value + step / 2, step / 2, is_percent=False)
else:
add(m, value, 0.5 * _cn_precision(numeral, value), is_percent=suffix == "个百分点")
return tokens
def _tolerance(token: NumberToken) -> float:
"""按 token 写出的精度给舍入容差:写了 12.3 就允许 |Δ|≤0.05。"""
if token.tolerance is not None:
return token.tolerance
return 0.5 * 10 ** (-token.decimals)
def _close(a: float, b: float, tolerance: float) -> bool:
return abs(a - b) <= tolerance + 1e-9
def _token_matches(token: NumberToken, metric_value: float) -> bool:
tolerance = _tolerance(token)
# 直接比对(含"叙述写百分数、指标本身就是百分数"的情形)
if _close(token.value, metric_value, token.decimals):
if _close(token.value, metric_value, tolerance):
return True
if token.is_percent:
# 指标存的是比率(0.123),叙述写成 12.3%
if _close(token.value, metric_value * 100, token.decimals):
if _close(token.value, metric_value * 100, tolerance):
return True
return False
@@ -150,7 +373,7 @@ def trace_numbers(
for token in extract_numbers(narrative):
if token.raw in allowed or token.raw.rstrip("%%").strip() in allowed:
continue
if any(_close(token.value, av, token.decimals) for av in allowed_values):
if any(_close(token.value, av, _tolerance(token)) for av in allowed_values):
continue
hit = next(
(
+101
View File
@@ -0,0 +1,101 @@
"""出具校验的数字回指:叙述里写出来的每个量都得在指标集里找得到。
以前只认阿拉伯数字——"增长三成、客户两千五百家"在 strict 契约下判 formal,
同样的意思写成 2500 却会被 withheld。叙述层换一种写法就绕过了整套"不许现编
数字"的机制,而"出错自动改一次"的复核回路甚至会把模型往这条路上推。
中文数字难在两头:漏抽就是绕法,误抽就是满篇降档(一个问题、十分重要、从三个
方面看都不是数据)。所以这组用例两头都守。
"""
from __future__ import annotations
import pytest
from app.engine import review as rv
from app.engine.issuance import cn_to_number, decide_tier, extract_numbers, trace_numbers
def values(text: str) -> list[tuple[str, float]]:
return [(t.raw, round(t.value, 2)) for t in extract_numbers(text)]
@pytest.mark.parametrize("text, expected", [
("十二", 12), ("二十三", 23), ("一百零五", 105), ("两千五百", 2500),
("两千五", 2500), # 省略写法:千后面省掉了"百"
("一万二", 12000), ("三万零五百", 30500), ("一亿两千万", 1.2e8),
("三点五", 3.5), ("三点五万", 35000), ("三万亿", 3e12), ("万", 1e4),
("五一", None), ("三三两两", None), ("一一", None), ("十十", None),
("万一", None), # 不是一万一千
("二〇二六", None), # 年份的逐位写法不是一个数
])
def test_cn_to_number(text, expected):
assert cn_to_number(text) == (None if expected is None else float(expected))
@pytest.mark.parametrize("text, expected", [
("本周营收增长三成", [("三成", 30.0)]),
("客户两千五百家", [("两千五百", 2500.0)]),
("其中一半来自华东", [("一半", 50.0)]),
("翻了两倍", [("两倍", 2.0)]),
("约三分之一的门店", [("三分之一", 33.33)]),
("达到百分之三十", [("百分之三十", 30.0)]),
("关闭了两家门店,用时五天", [("两", 2.0), ("五", 5.0)]),
("新增两千余家", [("两千余", 2500.0)]), # 按区间 [2000, 3000] 回指
("营收 3.5万,客户 2千家", [("3.5万", 35000.0), ("2千", 2000.0)]),
])
def test_quantities_written_in_chinese_are_extracted(text, expected):
assert values(text) == expected
@pytest.mark.parametrize("text", [
"这是一个积极的信号,一些门店一起统一调价,十分重要",
"万一出问题千万不要慌,五花八门、千方百计、独一无二",
"第三季度和三季度营收,周三开会",
"十二月十五日,二〇二六年九月",
"从三个方面看,主要原因之一",
"几十家、数百家、上千家、十几家",
"十一假期期间,下午三点给出三点建议",
])
def test_figurative_numerals_are_not_extracted(text):
assert values(text) == [], "误抽了"
def test_the_bypass_that_used_to_pass_as_formal():
"""strict 契约下,指标集里没有的中文数字要让它 withheld,而不是盖 formal 章。"""
narrative = "本周营收增长三成、客户两千五百家。"
report = trace_numbers(narrative, [{"id": "revenue", "value": 1_234_567}])
assert [u["token"] for u in report.unmatched] == ["三成", "两千五百"]
tier = decide_tier(missing_required=[], missing_expected=[], unmatched=report.unmatched,
strict=True)
assert tier == "withheld"
def test_chinese_numerals_that_trace_back_pass():
"""换成中文写法本身不是问题——回指得上就照常 formal。"""
narrative = "本周营收增长三成、客户两千五百家,近一半来自华东。"
metrics = [
{"id": "growth", "value": 0.2987}, # 比率:三成 = 30% ± 半成
{"id": "customers", "value": 2500},
{"id": "east_share", "value": 48.6}, # 百分数:一半 = 50% ± 5
]
report = trace_numbers(narrative, metrics)
assert report.ok, report.unmatched
assert decide_tier(missing_required=[], missing_expected=[], unmatched=[], strict=True) == "formal"
def test_wan_suffix_tolerance_follows_written_precision():
"""3.5万写到千位,按 ±500 回指——以前按 3.5 的一位小数给 ±0.05,只认 35000 整。"""
assert trace_numbers("营收 3.5万", [{"id": "r", "value": 35123}]).ok
assert not trace_numbers("营收 3.5万", [{"id": "r", "value": 36000}]).ok
assert trace_numbers("营收 35万", [{"id": "r", "value": 351234}]).ok
def test_ranges_trace_within_their_span():
assert trace_numbers("新增两千余家", [{"id": "n", "value": 2150}]).ok
assert not trace_numbers("新增两千余家", [{"id": "n", "value": 3150}]).ok
def test_review_may_rewrite_numbers_in_chinese_but_not_invent_them():
"""复核重写时把 6 写成"六"不算新数字;多出来一个"五家"才算。"""
assert rv.check_numbers("管理员有 6 个。", "管理员有六个。") == []
assert rv.check_numbers("管理员有 6 个。", "管理员有六个,商家有五家。") == ["五"]