Compare commits

...
3 Commits
Author SHA1 Message Date
sansen 6078beb4c3 Merge remote-tracking branch 'origin/main' 2026-09-23 17:43:36 +08:00
sansenandClaude Code 4e68372518 日志改为摘要 + 有界预览:单次请求日志 6940KB → 2.8KB
/convert/start 原先 logger.info 直接 dump 整份结果(json.dumps(indent=2)):实测
1MB 输入 → 20188 条 → 6.9MB 日志,是原文的 15%,indent 还要再乘 1.4。转几份族谱
日志文件就会被撑爆。

改为:
- 一行摘要:输入字数、结果条数、类型分布、耗时(格式与 batch 接口的日志保持一致)
- 前 LOG_PREVIEW_ITEMS(=10) 条结果预览,体积有硬上界
- 新增 _result_summary() helper

类型分布一行能看出解析形态(如 {'干支纪年': 15624, '古代纪年': 147, ...}),
排查"整批变成未匹配"这类回归比翻全量结果还快。明细请用接口返回值。

验证:单次 1MB 请求日志 2.8KB(降 2428x);/convert/start、/convert/batch、
403 鉴权、空文本四条路径均正常;pytest 260 passed。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-23 17:42:42 +08:00
sansenandClaude Code b264073f2b 大交替串加首字符预筛:convert_text 提速 3.05x,输出零差异
CPython 的 re 不对大交替串 (?:A|B|...) 做首字符预筛——460 个年号的交替串在每个
位置都要把全部分支逐个试完才移向下一位。实测这类扫描占 convert_text 总耗时的 82%
(sample_2.txt 1.03M 字符:扫描 7.05 / 总计 8.54 µs/字符),而其中多数模式只命中
0~134 处。

改法:给每个"以年号或朝代名开头"的模式前面加一个零宽前瞻 (?=[首字集合])。前瞻不
消耗字符,只在当前位置断言下一个字符属于集合。预筛字符集从与模式**同一份源数据**
推导(_dynasty_names / _dynasty_prefix / KNOWN_ERAS_ALL),CSV 加新年号时自动同步。
三种预筛:_GATE_DYNASTY(朝代组必选)/ _GATE_DYNASTY_OPT(_dynasty_opt 可空)/
_GATE_ERA(朝代组为空)。

结果为什么不变(结构性保证,非经验之谈):前瞻只拒绝「原模式根本不可能开始匹配」的
位置 → 匹配集合完全相同 → 捕获组相同 → 后续执行序列一致 → 输出逐字段一致。

不加预筛者(前提不满足):公元段(前缀整个可选)、裸干支段(以干支对开头)、
民国段(以字面量 民 开头,re 自带前缀优化)。

顺带把 2b/3/3b 段的 4 个正则从 extract_era_years 内提到模块级:只编译一次,且差分
测试能取到它们。

验证:
- 全字段快照比对:sample.txt 1766 / sample_2.txt 20188 / test/sample.txt 14 /
  test/sample_crosscheck.txt 11,全部 19 个字段逐条一致,零差异
- pytest 260 passed(新增 TestFirstCharGate 3 条)、summary.py 96/96
- 速度:sample_2.txt 8.53s → 2.80s(3.05x)、sample.txt 0.64s → 0.21s(2.99x)
- 3.8.10 下编译通过(部署目标)
- 变异测试:手动砍掉预筛里一个首字符后,差分测试立即失败并指出漏掉的 "万历二十三年"
  —— 证明这道护栏真的能挡住坏的预筛

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-23 17:14:13 +08:00
2 changed files with 136 additions and 43 deletions
+23 -1
View File
@@ -1,4 +1,5 @@
import json
from collections import Counter
from time import perf_counter
from fastapi import FastAPI, HTTPException, Header, Request
from fastapi.responses import JSONResponse
@@ -20,12 +21,26 @@ app.add_middleware(
# batch 接口单次请求的条目上限(防滥用)
BATCH_MAX_ITEMS = 1000
# 日志里最多预览多少条结果。完整结果序列化后体积可达数 MB
# (实测 1MB 输入 → 20188 条 → 6.9MB JSON,是原文的 15%,含缩进还要再乘 1.4),
# 全量落盘会迅速撑爆日志文件。
LOG_PREVIEW_ITEMS = 10
def _check_auth(Authorization: str):
if Authorization != "3FFA6A4B073CF065969630692331A873":
raise HTTPException(status_code=403, detail="未授权的操作")
def _result_summary(res: list) -> str:
"""一行摘要:结果条数 + 类型分布(如 {'古代纪年': 12, '干支纪年': 3})。
/convert/start 的日志只记摘要与有界预览,不记全量结果——看明细请用接口返回值。
"""
kinds = Counter(r["类型"] for r in res)
return f"{len(res)} 条, 类型分布 {dict(kinds)}"
def _parse_urlencoded_content(body: bytes) -> str:
"""手动解析 x-www-form-urlencoded 的 content 字段:绕过 starlette 1MB 表单限制"""
from urllib.parse import parse_qs
@@ -47,8 +62,15 @@ async def convert_str(
try:
content = _parse_urlencoded_content(await request.body())
t0 = perf_counter()
res = convert_text(content)
logger.info(f"转换结果: {json.dumps(res, ensure_ascii=False, indent=2)}")
dt = perf_counter() - t0
# 只记摘要 + 前 N 条预览(格式与 batch 接口的日志保持一致)
logger.info(f"转换完成: 输入 {len(content)} 字, {_result_summary(res)}, "
f"耗时 {dt * 1000:.0f}ms")
if res:
head = res[:LOG_PREVIEW_ITEMS]
logger.info(f"结果前 {len(head)} 条: {json.dumps(head, ensure_ascii=False)}")
return JSONResponse(content={"code": 0, "result": res})
except Exception as e:
logger.error(f"转换异常: {str(e)}")
+113 -42
View File
@@ -331,27 +331,78 @@ _bare_year_after = (
)
_year_suffix = r'(?:年|(?=[' + re.escape(_bare_year_after) + r'\s]|$))'
# === 首字符预筛 ===
# 大交替串((?:A|B|...) 里塞了几百个年号/朝代名)在 CPython 的 re 里没有首字符预筛:
# 引擎在每个位置都要把全部分支逐个试完才移向下一位。实测 1MB 文本上这类扫描占
# convert_text 总耗时的 82%,而其中多数模式只命中 0~134 处。
#
# 做法:在模式开头加一个**零宽前瞻** (?=[首字集合])。前瞻不消耗字符,只在当前位置断言
# "下一个字符属于该集合",断言失败就放弃这个起点。
#
# 结果为什么不变(这是结构性保证,不是经验之谈):集合从与模式**同一份源数据**推导,
# 覆盖了模式第一个被消耗字符的所有来源,因此只可能拒绝「原模式根本不可能开始匹配」的
# 位置 → 匹配集合完全相同 → 捕获组相同 → extract_era_years 后续执行序列一致 → 输出
# 逐字段一致。集合随 CSV/白名单变化自动同步(都从 _dynasty_names / KNOWN_ERAS_ALL 推导)。
#
# 适用前提(新增模式时必须逐条核对):
# ① 模式至少消耗 1 个字符、不能匹配空串——否则前瞻会挡掉零宽匹配;
# ② 第一个被消耗的字符只可能来自朝代名或年号名。
# 故以下模式**不加**预筛:
# - 公元段 (?:公元|西元|公历)?...:前缀整个可选,不满足前提①;
# - 裸干支段:以干支对开头,不满足前提②;
# - 民国段:以字面量 民 开头,re 自带前缀优化,加了也无收益。
# 三种预筛对应"朝代组"的三种写法:
# _GATE_DYNASTY 朝代组必选((?P<dynasty>(?:...|...)) 后无 `?`)
# _GATE_DYNASTY_OPT 朝代组可选(用 _dynasty_opt,见 2b/2c 段)
# _GATE_ERA 朝代组为空((?P<dynasty>) 后直接贴年号)
def _first_char_gate(names):
"""从名称集合推导首字符预筛前瞻。字符类里 ]、反斜杠、^、- 四个字符需转义。"""
chars = {n[0] for n in names if n}
body = ''.join('\\' + c if c in ']\\^-' else c for c in sorted(chars))
return '(?=[' + body + '])'
_dynasty_first = list(_dynasty_names) + list(_dynasty_prefix)
_GATE_DYNASTY = _first_char_gate(_dynasty_first)
_GATE_ERA = _first_char_gate(KNOWN_ERAS_ALL)
_GATE_DYNASTY_OPT = _first_char_gate(_dynasty_first + list(KNOWN_ERAS_ALL))
# 差分测试用:登记 (预筛, 未加预筛的模式串)。test_parser.py 逐个比对两者在语料上的
# 匹配序列(含捕获组),防止预筛与模式脱节——例如将来改了模式首部却忘了改预筛。
_GATED_PATTERNS = []
def _compile_gated(pattern, gate):
"""编译加过首字符预筛的模式,并登记 (预筛, 原串) 供差分测试比对。"""
compiled = re.compile(gate + pattern)
_GATED_PATTERNS.append((compiled, pattern, gate))
return compiled
def _build_pattern(with_dynasty=True):
"""构建年号正则:已知年号优先 + 通用兜底(仅限有朝代前缀时)"""
if with_dynasty:
# 朝代前缀模式:加(?<![一-鿿])防止"元"在"开元"中被当朝代
dynasty_part = r'(?P<dynasty>(?:(?<![一-鿿])[' + _dynasty_prefix + r'](?:' + _dynasty_alt + r'))|(?:(?<![一-鿿])' + _dynasty_alt + r'))'
boundary = ''
gate = _GATE_DYNASTY # 朝代组必选 → 首个消耗字符只能是朝代名首字
else:
dynasty_part = r'(?P<dynasty>)'
# 无朝代模式不加边界(允许年号跟在其他汉字后面,如"永乐大帝在永乐三年")
boundary = ''
gate = _GATE_ERA # 朝代组为空 → 首个消耗字符是年号首字
if with_dynasty:
# 有朝代前缀:年号后直接跟年份数字,无需额外约束
era_year = _known_era_group + r'(?P<year>' + _yd + r'+)' + _year_suffix
era_yuan = _known_era_group + r'元年'
patterns = [
re.compile(dynasty_part + boundary + era_year),
re.compile(dynasty_part + boundary + era_yuan),
_compile_gated(dynasty_part + boundary + era_year, gate),
_compile_gated(dynasty_part + boundary + era_yuan, gate),
# 通用兜底(2-8字)
re.compile(dynasty_part + boundary + r'(?P<era>[一-鿿]{2,8})' + r'(?P<year>' + _yd + r'+)年'),
re.compile(dynasty_part + boundary + r'(?P<era>[一-鿿]{2,8})元年'),
_compile_gated(dynasty_part + boundary + r'(?P<era>[一-鿿]{2,8})' + r'(?P<year>' + _yd + r'+)年', gate),
_compile_gated(dynasty_part + boundary + r'(?P<era>[一-鿿]{2,8})元年', gate),
]
else:
# 无朝代前缀:年号后必须紧跟年份数字(前瞻断言),防止"永乐大帝"误匹配
@@ -359,8 +410,8 @@ def _build_pattern(with_dynasty=True):
era_year = _known_era_group + _year_ahead + r'(?P<year>' + _yd + r'+)' + _year_suffix
era_yuan = _known_era_group + _year_ahead + r'元年'
patterns = [
re.compile(dynasty_part + boundary + era_year),
re.compile(dynasty_part + boundary + era_yuan),
_compile_gated(dynasty_part + boundary + era_year, gate),
_compile_gated(dynasty_part + boundary + era_yuan, gate),
]
return patterns
@@ -397,12 +448,13 @@ _gz_gy_sep = (
)
# 干支前的修饰词(如"光绪岁次辛丑""岁在壬戌"):碑刻/谱牒常见
_gz_mod = r'(?:岁[次在])?'
_ganzhi_gongyuan_re = re.compile(
_ganzhi_gongyuan_re = _compile_gated(
_dynasty_opt
+ _gz_era_group
+ _gz_mod
+ r'(?P<ganzhi>' + _gz_pair + r')(?:年)?'
+ _gz_gy_sep
+ _gz_gy_sep,
_GATE_DYNASTY_OPT, # _dynasty_opt 可空 → 朝代首字与年号首字并集
)
# 裸干支+(对照):无年号可锚定(如"壬申(1872)"),公元取对照值
_ganzhi_bare_gongyuan_re = re.compile(
@@ -410,6 +462,51 @@ _ganzhi_bare_gongyuan_re = re.compile(
+ _gz_gy_sep
)
# === 2b / 3 / 3b 段的正则 ===
# 这些原先在 extract_era_years 里每次调用都重新编译、且无法被差分测试取到;
# 提到模块级后只编译一次,并统一登记进 _GATED_PATTERNS。
# 2b. 年号+公元(如"清康熙公元一六八七年丁卯"):年号后直接给出公元年份,
# 年数 = 公元 - 年号起始 + 1,紧跟的干支用于校验年份一致性。
_era_gongyuan_re = _compile_gated(
r'(?P<dynasty>(?:(?<![一-鿿])[' + _dynasty_prefix + r'](?:' + _dynasty_alt + r'))|(?:(?<![一-鿿])' + _dynasty_alt + r'))?'
+ r'(?P<era>' + _known_era_alt + r')'
+ r'(?:公元|西元|公历)\s*(?P<gy_year>' + _gz_gy_year + r')\s*年?'
+ r'(?P<gy_ganzhi>' + _gz_pair + r')?',
_GATE_DYNASTY_OPT, # 朝代前缀可空 → 并集
)
# 3-A. 有朝代前缀的干支纪年(如"清雍正乙巳");年号组 _gz_era_group 见模块级定义
_ganzhi_dynasty_re = _compile_gated(
r'(?P<dynasty>(?:(?<![一-鿿])[' + _dynasty_prefix + r'](?:' + _dynasty_alt + r'))|(?:(?<![一-鿿])' + _dynasty_alt + r'))'
+ _gz_era_group
+ _gz_mod
+ r'(?P<ganzhi>' + _gz_pair + r')(?:年)?',
_GATE_DYNASTY, # 朝代组必选 → 朝代名首字
)
# 3-B. 无朝代前缀的干支纪年(如"嘉庆癸亥"),年号≥2字符避免天干误匹配
_long_era_names = [e for e in KNOWN_ERAS_ALL if len(e) >= 2]
_long_era_alt = '|'.join(sorted(_long_era_names, key=len, reverse=True))
_ganzhi_no_dynasty_re = _compile_gated(
r'(?P<dynasty>)'
+ r'(?P<era>' + _long_era_alt + r')'
+ _gz_mod
+ r'(?P<ganzhi>' + _gz_pair + r')(?:年)?',
_GATE_ERA, # 朝代组为空 → 年号首字
)
# 3b. 庙号纪年(如"宋高宗""清圣祖",指整个在位期,公元=即位首年)
# 庙号必须带朝代前缀("高宗"在唐/宋/清均有,单独出现有歧义)
_temple_names = sorted({t for d in temple_dict for t in temple_dict[d]}, key=len, reverse=True)
_temple_alt = '|'.join(re.escape(t) for t in _temple_names)
_temple_pattern_re = _compile_gated(
r'(?P<dynasty>(?:(?<![一-鿿])[' + _dynasty_prefix + r'](?:' + _dynasty_alt + r'))|(?:(?<![一-鿿])' + _dynasty_alt + r'))'
+ r'(?P<temple>' + _temple_alt + r')'
# 后面紧跟"X年/元年"时不按庙号匹配,交给古代纪年按年号解析(如"元泰定三年")
+ r'(?!(?:' + _yd + r'+年|元年))',
_GATE_DYNASTY, # 朝代组必选 → 朝代名首字
)
# === 主函数 ===
def extract_era_years(text):
@@ -536,13 +633,8 @@ def extract_era_years(text):
# === 2b. 年号+公元(如"清康熙公元一六八七年丁卯") ===
# 年号后直接给出公元年份:年数=公元-年号起始+1,与"年号+年数"公式自洽;
# 紧跟的干支用于校验年份一致性。置于公元段之前,合并结果优先、避免重复摘出。
_era_gongyuan = re.compile(
r'(?P<dynasty>(?:(?<![一-鿿])[' + _dynasty_prefix + r'](?:' + _dynasty_alt + r'))|(?:(?<![一-鿿])' + _dynasty_alt + r'))?'
+ r'(?P<era>' + _known_era_alt + r')'
+ r'(?:公元|西元|公历)\s*(?P<gy_year>' + _gz_gy_year + r')\s*年?'
+ r'(?P<gy_ganzhi>' + _gz_pair + r')?'
)
for m in _era_gongyuan.finditer(simplified_text):
# 正则见模块级 _era_gongyuan_re(含首字符预筛)
for m in _era_gongyuan_re.finditer(simplified_text):
start, end = m.start(), m.end()
if any(pos in covered for pos in range(start, end)):
continue
@@ -768,39 +860,18 @@ def extract_era_years(text):
result["超出使用期"] = True
results.append(result)
# 模式A:有朝代前缀(如"清雍正乙巳");年号组 _gz_era_group 见模块级定义
ganzhi_dynasty = re.compile(
r'(?P<dynasty>(?:(?<![一-鿿])[' + _dynasty_prefix + r'](?:' + _dynasty_alt + r'))|(?:(?<![一-鿿])' + _dynasty_alt + r'))'
+ _gz_era_group
+ _gz_mod
+ r'(?P<ganzhi>' + _gz_pair + r')(?:年)?'
)
for m in ganzhi_dynasty.finditer(simplified_text):
# 模式A:有朝代前缀(如"清雍正乙巳");正则见模块级 _ganzhi_dynasty_re
for m in _ganzhi_dynasty_re.finditer(simplified_text):
_process_ganzhi_match(m)
# 模式B:无朝代前缀(如"嘉庆癸亥"),年号≥2字符避免天干误匹配
_long_era_names = [e for e in KNOWN_ERAS_ALL if len(e) >= 2]
_long_era_alt = '|'.join(sorted(_long_era_names, key=len, reverse=True))
ganzhi_no_dynasty = re.compile(
r'(?P<dynasty>)'
+ r'(?P<era>' + _long_era_alt + r')'
+ _gz_mod
+ r'(?P<ganzhi>' + _gz_pair + r')(?:年)?'
)
for m in ganzhi_no_dynasty.finditer(simplified_text):
# 模式B:无朝代前缀(如"嘉庆癸亥");正则见模块级 _ganzhi_no_dynasty_re
for m in _ganzhi_no_dynasty_re.finditer(simplified_text):
_process_ganzhi_match(m)
# === 3b. 庙号纪年(如"宋高宗""清圣祖",指整个在位期,公元=即位首年)===
# 庙号必须带朝代前缀("高宗"在唐/宋/清均有,单独出现有歧义)
_temple_names = sorted({t for d in temple_dict for t in temple_dict[d]}, key=len, reverse=True)
_temple_alt = '|'.join(re.escape(t) for t in _temple_names)
_temple_pattern = re.compile(
r'(?P<dynasty>(?:(?<![一-鿿])[' + _dynasty_prefix + r'](?:' + _dynasty_alt + r'))|(?:(?<![一-鿿])' + _dynasty_alt + r'))'
+ r'(?P<temple>' + _temple_alt + r')'
# 后面紧跟"X年/元年"时不按庙号匹配,交给古代纪年按年号解析(如"元泰定三年")
+ r'(?!(?:' + _yd + r'+年|元年))'
)
for m in _temple_pattern.finditer(simplified_text):
# 正则见模块级 _temple_pattern_re
for m in _temple_pattern_re.finditer(simplified_text):
start, end = m.start(), m.end()
if any(pos in covered for pos in range(start, end)):
continue