核心修复: - _post_process_chunks: 用 _buffer_has_body 标志替代 buffer.text_level=0, 标题+正文合并后保留 text_level 不置零,修复层级信息丢失 - heading_rules numeric_level2: 正则从 ^\d+\.\d+[\.、\s] 改为 ^\d+\.\d+(?!\.\d),修复无空格标题如"2.1运行调度"无法匹配 - V2 title handler: heading_rules 优先(模式匹配可靠),VLM 仅兜底 (VLM 常给所有标题 level=1) MinerU V2 解析增强: - 两轮 TOC 过滤:多行目录块检测 + 孤立标题/单字符残留清理 - 封面 logo 过滤、重复标题去重 - chart VLM 描述和 Markdown 数据表提取 - ChromaDB metadata 新增 text_level/bbox/table_type/sub_type 字段 配置调整: - CLUSTER_SECTION_PREFIX_LEVELS 1→2(两级 section 聚类更精确) - MINERU_LOCAL_BACKEND 默认改为 pipeline 文档: - RAG检索流程逻辑.md 更新 MinerUChunk 字段、ChromaDB metadata、 MinerU 解析策略等章节 - 新增 RAG引用跳转-优化计划.md、云端MinerU输出分析与优化方案.md
375 lines
14 KiB
Python
375 lines
14 KiB
Python
# -*- 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
|