From 4559c96fdd509917eeefba8517558c10344cc8f4 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Fri, 25 Sep 2026 15:30:32 +0800 Subject: [PATCH] =?UTF-8?q?=E5=87=BA=E5=85=B7=E6=A0=A1=E9=AA=8C=E8=AE=A4?= =?UTF-8?q?=E5=BE=97=E4=B8=AD=E6=96=87=E6=95=B0=E5=AD=97=E4=BA=86=EF=BC=9A?= =?UTF-8?q?"=E5=A2=9E=E9=95=BF=E4=B8=89=E6=88=90=E3=80=81=E5=AE=A2?= =?UTF-8?q?=E6=88=B7=E4=B8=A4=E5=8D=83=E4=BA=94=E7=99=BE=E5=AE=B6"?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E8=83=BD=E7=9B=96=20formal=20=E7=AB=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 校验器只抽阿拉伯数字。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。 --- README.md | 6 +- backend/app/engine/issuance.py | 247 +++++++++++++++++++++++++++++++-- backend/tests/test_issuance.py | 101 ++++++++++++++ 3 files changed, 341 insertions(+), 13 deletions(-) create mode 100644 backend/tests/test_issuance.py diff --git a/README.md b/README.md index 824995dc..4b7b0379 100644 --- a/README.md +++ b/README.md @@ -115,9 +115,13 @@ flowchart LR ![出具链路模板](docs/images/graph-issuance.png) -校验器从叙述中抽取全部数字逐个回指指标集,支持千分位、百分数与比率互转、万/亿后缀、 +校验器从叙述中抽取全部数字逐个回指指标集,支持千分位、百分数与比率互转、千/万/亿后缀、 按书写精度的舍入容差;日期、枚举序号、引用标记不参与回指。三档判定与缺数据声明随成果印出。 +中文数字同样回指——否则换一种写法就绕过去了("增长三成、客户两千五百家"以前能盖 formal 章)。 +成、倍、百分之、分之、一半按比例回指,"两千余"按区间 [2000, 3000] 回指;"一个问题""十分重要" +"从三个方面看""三季度""几十家"这类虚指、序数、季度名、结构性列举不算结论数字。 + 实测记录:Opus 5 撰写的正文数字全部回指成功,但结尾「(约 100 字)」中的 `100` 被判定为 集合外数字并触发降档。处置方式是修改 prompt 而非扩大白名单——这正是校验器应有的行为。 diff --git a/backend/app/engine/issuance.py b/backend/app/engine/issuance.py index 24f091bd..998a25cf 100644 --- a/backend/app/engine/issuance.py +++ b/backend/app/engine/issuance.py @@ -19,7 +19,10 @@ from typing import Any # 整个 token 都抽不出来。那等于说校验器只对西式排版生效,中文叙述完全不设防—— # 而这套机制的全部意义就是拦住叙述层现编数字。 # 排 ASCII 字母是为了放过 v2 / sha1 这类标识符,排 . 是为了放过 v1.2.3 的版本号。 -_NUM_RE = re.compile(r"(?{_CN_N})" + rf"|(?P{_CN_N})分之(?P{_CN_N})" + rf"|(?P一半)" + rf"|(?P{_CN_N})(?P成|倍|个百分点|[余多来])?" +) + +#: 单字数字后面跟着这些,才算在报一个量 +_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( ( diff --git a/backend/tests/test_issuance.py b/backend/tests/test_issuance.py new file mode 100644 index 00000000..c2e9378b --- /dev/null +++ b/backend/tests/test_issuance.py @@ -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 个。", "管理员有六个,商家有五家。") == ["五"]