# -*- coding: utf-8 -*- """ 标题识别规则引擎 将 _detect_heading_level 的硬编码正则提取为可配置的规则列表。 规则按优先级从高到低排序,第一个匹配即返回。 MinerU 解析 DOCX 等 Office 格式时通常不提供 text_level(全部为 0), 此时需要启发式识别标题层级。本模块提供可配置的规则引擎替代原来的 硬编码 if-elif 链。 设计要点: - HeadingRule 数据类支持正向匹配(pattern)和反向排除(exclude_pattern) - 长度约束(min_length / max_length)可精确控制匹配范围 - 规则可单独禁用(enabled=False),便于调试 - 全局单例通过 config.py 覆盖默认值 MinerU v2 格式备注: content_list_v2.json 中的 paragraph_content 包含 style=["bold"] 信息, layout.json 中的 spans 也有 style 信息。这些信息比正则匹配 **加粗** 更可靠, 但当前代码使用 v1 格式(content_list.json),暂不利用 v2 的 style。 HeadingRuleEngine.detect() 签名预留了 style 参数,未来切换到 v2 格式后 可直接利用 style 信息辅助判断。 """ import re import logging from dataclasses import dataclass from typing import Optional, List, Tuple logger = logging.getLogger(__name__) @dataclass class HeadingRule: """ 标题识别规则 每条规则定义一个文本模式到标题级别的映射。 规则引擎按列表顺序逐条匹配,第一个命中即返回。 Attributes: pattern: 编译后的正则(match 语义,从文本开头匹配) level: 匹配时返回的标题级别 (1=h1, 2=h2, 3=h3) name: 规则名称(用于日志和配置覆盖) max_length: 文本最大长度,0=不限 min_length: 文本最小长度,0=不限 enabled: 是否启用 exclude_pattern: 匹配此模式则排除(反向过滤) Example: >>> rule = HeadingRule( ... pattern=re.compile(r'^第[一二三四五六七八九十百千万]+[章节篇部]'), ... level=1, ... name="chinese_chapter", ... ) >>> rule.match("第一章 总则") 1 >>> rule.match("这是正文") 0 """ pattern: re.Pattern level: int name: str max_length: int = 0 min_length: int = 0 enabled: bool = True exclude_pattern: Optional[re.Pattern] = None def match(self, text: str) -> int: """ 检查文本是否匹配此规则 Args: text: 待检测文本(调用前应已 strip) Returns: 标题级别,0 表示不匹配 """ if not self.enabled: return 0 if self.min_length > 0 and len(text) < self.min_length: return 0 if self.max_length > 0 and len(text) > self.max_length: return 0 if self.exclude_pattern and self.exclude_pattern.search(text): return 0 if self.pattern.match(text): return self.level return 0 # 默认规则列表(按优先级从高到低) # # 注意事项: # - 数字三级标题 (1.1.1) 必须在二级 (1.1) 之前,因为 1.1.1 也匹配 ^\d+\.\d+ # - short_chinese_heading 是最宽泛的规则,放在最后作为兜底 # - 第 9 条规则相比原版增加了 exclude_pattern,排除以句末标点结尾的短文本 DEFAULT_HEADING_RULES: List[HeadingRule] = [ # 1. 中文章节标题 -> h1 # 匹配:第一章、第二章、第十节、第三篇 等 HeadingRule( pattern=re.compile(r'^第[一二三四五六七八九十百千万]+[章节篇部]'), level=1, name="chinese_chapter", ), # 2. 中文条款编号 -> h2 # 匹配:第一条、第三款 等(仅短标题,长正文段落不算标题) HeadingRule( pattern=re.compile(r'^第[一二三四五六七八九十百千万]+[条款]'), level=2, name="chinese_article", max_length=30, ), # 3. 数字三级标题 -> h3(必须在二级之前匹配) # 匹配:1.1.1 背景、2.3.4 方案 等 HeadingRule( pattern=re.compile(r'^\d+\.\d+\.\d+[\.、\s]'), level=3, name="numeric_level3", max_length=100, ), # 4. 数字二级标题 -> h2(必须在一级之前匹配) # 匹配:1.1 背景、2.3 方案、2.1运行调度(无空格) 等 # 使用负向前瞻排除三级标题(由 numeric_level3 处理) HeadingRule( pattern=re.compile(r'^\d+\.\d+(?!\.\d)'), level=2, name="numeric_level2", max_length=80, ), # 5. 数字一级标题 -> h1 # 匹配:1. 概述、2、背景 等 # 排除:以 ;;。,、: 结尾的文本(这些是编号列表项/子条目,不是独立标题) HeadingRule( pattern=re.compile(r'^\d+[\.、\s]'), level=1, name="numeric_level1", max_length=50, exclude_pattern=re.compile(r'[;;。,、::]$'), ), # 6. 英文章节标题 -> h1 # 匹配:Chapter 1、Section 2、Part 3 等 HeadingRule( pattern=re.compile(r'^(Chapter|Section|Part|Chapter\s+\d+|Section\s+\d+)', re.IGNORECASE), level=1, name="english_chapter", ), # 7. 分类标题 -> h3(必须在 bold_short_text 之前,否则 **A2类:** 会被加粗规则抢先匹配) # 匹配:A1类:公园、**A2类**:各类卫生医疗机构、**B1类:** 道路 等 HeadingRule( pattern=re.compile(r'^\*{0,2}[A-Z]\d+[类類]\*{0,2}[::]'), level=3, name="category_heading", ), # 8. 加粗短文本 -> h2 # 匹配:**重要通知**、**概述** 等(Markdown 加粗标记) # 注意:**A2类:** 已被分类标题规则优先匹配,不会误判为 h2 HeadingRule( pattern=re.compile(r'^\*\*.+\*\*$'), level=2, name="bold_short_text", max_length=50, ), # 9. 短中文文本 -> h2(替代原"任何 <20 字符含中文"规则) # 关键改进:排除以句末标点结尾的文本 # 原规则将 "这是一段正文。" 也识别为 h2,导致大量误判 # 新规则:包含中文 + 长度 2-20 + 不以句末标点结尾 → h2 HeadingRule( pattern=re.compile(r'[一-鿿]'), level=2, name="short_chinese_heading", max_length=20, min_length=2, exclude_pattern=re.compile(r'[。!?;…]$'), enabled=True, ), ] class HeadingRuleEngine: """ 标题识别规则引擎 按规则列表顺序逐条匹配,第一个命中即返回标题级别。 支持从 config.py 加载自定义规则或覆盖默认规则参数。 Example: >>> engine = HeadingRuleEngine() >>> engine.detect("第一章 总则") (1, 'chinese_chapter') >>> engine.detect("这是普通正文。") (0, None) """ def __init__(self, rules: Optional[List[HeadingRule]] = None) -> None: """ Args: rules: 规则列表,None 则使用默认规则的深拷贝 """ if rules is not None: self.rules: List[HeadingRule] = rules else: import copy self.rules = copy.deepcopy(DEFAULT_HEADING_RULES) def _validate_level(self, level: int, text: str, rule_name=None): """各级别标题长度防护:超长文本不应作为标题,降为正文。 H1 > 40字, H2 > 60字, H3 > 50字 → 降为正文。 统一覆盖 v1 常规匹配、v2 style 匹配、bold_short_text 兜底所有返回路径。""" text_len = len(text) if level == 1 and text_len > 40: logger.debug(f"标题识别: '{text[:30]}...' H1 但超长({text_len}字),降为正文") return 0, None if level == 2 and text_len > 60: logger.debug(f"标题识别: '{text[:30]}...' H2 但超长({text_len}字),降为正文") return 0, None if level == 3 and text_len > 50: logger.debug(f"标题识别: '{text[:30]}...' H3 但超长({text_len}字),降为正文") return 0, None return level, rule_name def detect(self, text: str, style: Optional[List[str]] = None) -> Tuple[int, Optional[str]]: """ 检测文本的标题级别 Args: text: 待检测文本 style: MinerU v2 格式中的 style 信息(如 ["bold"]), 当文本标记为 bold 且较短时,可直接判定为标题, 无需依赖 Markdown **...** 标记。 Returns: (level, rule_name): 标题级别和匹配的规则名 level=0 表示不是标题 """ text = text.strip() if not text: return 0, None # v2 style 信息:如果文本标记为 bold 且较短,优先尝试加粗规则 if style and 'bold' in style and 2 <= len(text) <= 50: # 先检查是否匹配更高优先级的分类标题规则 for rule in self.rules: if rule.name == 'category_heading' and rule.enabled: level = rule.match(text) if level > 0: logger.debug(f"标题识别(v2 style): '{text[:30]}' -> h{level} (规则: {rule.name})") return self._validate_level(level, text, rule.name) # 再检查是否匹配中文章节/条款等高优先级规则 for rule in self.rules: if rule.name in ('chinese_chapter', 'chinese_article', 'numeric_level3', 'numeric_level2', 'numeric_level1', 'english_chapter') and rule.enabled: level = rule.match(text) if level > 0: logger.debug(f"标题识别(v2 style): '{text[:30]}' -> h{level} (规则: {rule.name})") return self._validate_level(level, text, rule.name) # 最后兜底:加粗短文本 → h2 # 但需先检查所有规则的 exclude_pattern,防止编号列表项被误判为标题 # 例如 "3.完全满足品规。指..." 虽有 bold 样式,但属于列表项而非标题 for rule in self.rules: if rule.enabled and rule.exclude_pattern and rule.exclude_pattern.search(text): logger.debug(f"标题识别(v2 style): '{text[:30]}' 被 {rule.name} 的 exclude_pattern 排除") return 0, None logger.debug(f"标题识别(v2 style): '{text[:30]}' -> h2 (规则: bold_short_text_via_style)") return 2, 'bold_short_text' # 常规规则匹配(v1 格式或无 style 信息时) for rule in self.rules: level = rule.match(text) if level > 0: logger.debug(f"标题识别: '{text[:30]}' -> h{level} (规则: {rule.name})") return self._validate_level(level, text, rule.name) return 0, None # ==================== 全局单例 ==================== _engine: Optional[HeadingRuleEngine] = None def get_heading_engine() -> HeadingRuleEngine: """获取全局标题识别引擎(延迟初始化,线程安全)""" global _engine if _engine is None: _engine = _create_engine_from_config() return _engine def _create_engine_from_config() -> HeadingRuleEngine: """ 从 config 创建引擎(支持配置覆盖) 优先级: 1. config.HEADING_RULES_CONFIG 不为 None → 使用自定义规则 2. config 细粒度参数覆盖默认规则(如 HEADING_SHORT_TEXT_ENABLED) 3. 使用默认规则 """ # 尝试加载完整自定义规则 try: from config import HEADING_RULES_CONFIG if HEADING_RULES_CONFIG is not None: rules = _build_rules_from_config(HEADING_RULES_CONFIG) logger.info(f"使用自定义标题规则: {len(rules)} 条") return HeadingRuleEngine(rules) except ImportError: pass # 使用默认规则,应用细粒度配置覆盖 rules = list(DEFAULT_HEADING_RULES) try: from config import HEADING_SHORT_TEXT_ENABLED for rule in rules: if rule.name == "short_chinese_heading": rule.enabled = HEADING_SHORT_TEXT_ENABLED logger.debug(f"配置覆盖: short_chinese_heading.enabled={HEADING_SHORT_TEXT_ENABLED}") except ImportError: pass try: from config import HEADING_SHORT_TEXT_MAX_LENGTH for rule in rules: if rule.name == "short_chinese_heading": rule.max_length = HEADING_SHORT_TEXT_MAX_LENGTH logger.debug(f"配置覆盖: short_chinese_heading.max_length={HEADING_SHORT_TEXT_MAX_LENGTH}") except ImportError: pass return HeadingRuleEngine(rules) def _build_rules_from_config(config: list) -> List[HeadingRule]: """ 从配置字典列表构建规则列表 Args: config: 规则配置列表,每项为 dict,包含: - pattern (str): 正则表达式字符串 - level (int): 标题级别 - name (str): 规则名称 - max_length (int, 可选): 文本最大长度 - min_length (int, 可选): 文本最小长度 - enabled (bool, 可选): 是否启用 - exclude_pattern (str, 可选): 排除正则 Returns: 规则列表 """ rules = [] for item in config: exclude = None if 'exclude_pattern' in item: exclude = re.compile(item['exclude_pattern']) rules.append(HeadingRule( pattern=re.compile(item['pattern']), level=item['level'], name=item['name'], max_length=item.get('max_length', 0), min_length=item.get('min_length', 0), enabled=item.get('enabled', True), exclude_pattern=exclude, )) return rules def reset_heading_engine() -> None: """重置引擎(用于测试)""" global _engine _engine = None