kgrag/kg_build_0409/chapter_segmenter.py
2026-07-29 18:10:19 +08:00

532 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
chapter_segmenter.py
对外接口segment_chapters(data) -> List[Chapter]
纯规则章节切分,不依赖大模型,不依赖 extract_util / entity_registry。
输入content_list JSONlist of dict每条含 type/text/text_level/page_idx/bbox 等字段)
输出Chapter 列表,每个 Chapter 包含标题信息、所属 records、以及二级子章节。
多信号融合标题识别v8
- 两遍扫描:先扫描所有候选标题 → 全局校验编号连续性 → 再切分
- 置信度融合text_level + 编号格式 + 文本长度 + 标点密度
- 全局校验:编号跳跃过大的假标题自动剔除
"""
import re
from dataclasses import dataclass, field
from typing import List, Dict, Optional, Set
# ==============================
# 配置(可在调用前修改模块级变量)
# ==============================
# 一级标题含以下关键词时整章跳过
FILTER_KEYWORDS: List[str] = [
"维修工作卡", "维修项目索引",
"常见故障", "维修项目表"
]
# 二级标题含以下关键词时跳过该二级节
FILTER_L2_KEYWORDS: List[str] = [
"常见故障表", "维修项目表", "维修工作卡","操作程序"
]
# 特殊章节关键词:一级标题含以下任一时,不作为整体实体,二级标题各自独立
SPECIAL_CHAPTER_KEYWORDS: List[str] = [
"组成、技术特征及接口",
"组成和技术特征",
"组成与技术特征",
"组成、技术特征",
]
# 目录关键词(去空格后匹配,兼容"目 次"/"目 录"等 OCR 带空格的写法)
TOC_KEYWORDS: List[str] = ["目次", "目录", "contents"]
# 置信度阈值:低于此值的不认为是标题
HEADING_CONFIDENCE_THRESHOLD: float = 0.5
# 一级标题编号最大允许跳跃数(连续性校验)
MAX_L1_GAP: int = 3
# ==============================
# 数据结构
# ==============================
@dataclass
class L2Section:
"""二级子章节"""
title: str # 去编号后的标题名
numbering: str # 编号,如 "3.2"
records: List[dict] = field(default_factory=list) # 该二级节的所有 records
@dataclass
class Chapter:
"""一级章节(含所有二级子节)"""
title: str # 去编号后的标题名(已应用 L1_TITLE_MAPPING 等由调用方处理)
raw_title: str # 原始文本(含编号)
numbering: str # 编号,如 "3"
is_special: bool # 是否特殊章节(二级各自独立)
is_skipped: bool # 是否被 FILTER_KEYWORDS 过滤
records: List[dict] = field(default_factory=list) # 本章所有 records含标题行
loose_records: List[dict] = field(default_factory=list) # 一级标题到第一个二级标题之间的 records
l2_sections: List[L2Section] = field(default_factory=list) # 二级子章节列表
@dataclass
class _HeadingNode:
"""内部用:统一的标题节点"""
raw_text: str
clean_title: str
numbering: str
level: int
index_in_data: int
confidence: float = 1.0
# ==============================
# 正则
# ==============================
_CHAPTER_NUM_RE = re.compile(r'^(\d{1,2}(?:\.\d{1,2})*\.?)')
_LIST_ITEM_RE = re.compile(r'^[\d]+[\)]\s*')
_ALPHA_LIST_RE = re.compile(r'^[a-zA-Z][\)\.]\s*')
_TOC_ENTRY_SUFFIX_RE = re.compile(r'([\s.]+\d{1,4}|\.{2,})\s*$')
# ==============================
# 标题工具函数
# ==============================
def _is_real_chapter_title(text: str) -> bool:
text = text.strip()
if _LIST_ITEM_RE.match(text):
return False
if _ALPHA_LIST_RE.match(text):
return False
if re.search(r'[。!?:]\s*$', text):
return False
chapter_num = r'^\d{1,2}(?:\.\d{1,2})*'
if re.match(chapter_num + r'\s+\S', text):
return True
if re.match(chapter_num + r'\.\s+\S', text):
return True
if re.match(chapter_num + r'\.?[\u4e00-\u9fff]', text):
return True
return False
def _is_toc_entry_line(text: str) -> bool:
if '\n' in text:
return False
return bool(_TOC_ENTRY_SUFFIX_RE.search(text))
def _get_chapter_level(text: str) -> int:
match = _CHAPTER_NUM_RE.match(text.strip())
if match:
num = match.group(1).rstrip('.')
return num.count('.') + 1
return 0
def _get_chapter_numbering(text: str) -> str:
match = _CHAPTER_NUM_RE.match(text.strip())
return match.group(1).rstrip('.') if match else ""
def _clean_title(text: str) -> str:
return re.sub(r'^\d{1,2}(?:\.\d{1,2})*\.?\s*', '', text.strip()).strip()
def _should_skip_l2(title_clean: str) -> bool:
return any(kw in title_clean for kw in FILTER_L2_KEYWORDS)
def _is_special_chapter(title: str) -> bool:
return any(kw in title for kw in SPECIAL_CHAPTER_KEYWORDS)
# ==============================
# 目录页检测
# ==============================
def _is_toc_content(ins: dict) -> bool:
"""
判断是否是目录页内容(三层判断)。
层1text_level>=1 且含目录关键词 → 直接判定
层2严格目录条目格式"文字..页码" ≥2次
层3宽松兜底换行多 + '..'密集 + 排除正文列表/技术参数
"""
text = ins.get("text", "")
text_level = ins.get("text_level", 0)
text_nospace = text.replace(" ", "").replace("\u3000", "")
if text_level and any(kw in text_nospace.lower() for kw in TOC_KEYWORDS):
return True
toc_entry_re = re.compile(
r'[\u4e00-\u9fffa-zA-Z\)]\s*\.{2,}\s*\.?\s*(\d{1,4})\s*$',
re.MULTILINE
)
if len(toc_entry_re.findall(text)) >= 2:
return True
if text.count('\n') > 3 and '..' in text:
has_list_markers = bool(re.search(
r'(?:^|\n)\s*(?:\d+[\)]|[a-zA-Z][\).])', text
))
if has_list_markers:
return False
range_pattern_count = len(re.findall(r'[\d℃%]\.\.[+\-\d]', text))
dot_count = len(re.findall(r'\.{2,}', text))
if range_pattern_count > 0 and range_pattern_count >= dot_count * 0.5:
return False
line_count = text.count('\n') + 1
if dot_count >= max(3, line_count * 0.3):
return True
return False
def _get_toc_pages(data: list) -> Set[int]:
"""找出所有目录页的 page_idx支持多页目录并向后延伸。"""
toc_pages: Set[int] = set()
for ins in data:
if _is_toc_content(ins):
toc_pages.add(ins.get("page_idx", -1))
text = ins.get("text", "")
text_level = ins.get("text_level", 0)
text_nospace = text.replace(" ", "").replace("\u3000", "")
if text_level and any(kw in text_nospace.lower() for kw in TOC_KEYWORDS):
toc_pages.add(ins.get("page_idx", -1))
toc_pages.discard(-1)
if not toc_pages:
return toc_pages
pages_records: Dict[int, list] = {}
for ins in data:
p = ins.get("page_idx", -1)
if p < 0:
continue
pages_records.setdefault(p, []).append(ins)
max_toc = max(toc_pages)
for next_page in range(max_toc + 1, max_toc + 10):
if next_page not in pages_records:
break
records = pages_records[next_page]
text_records = [
r for r in records
if r.get("type") == "text" and (r.get("text") or "").strip()
]
if not text_records:
toc_pages.add(next_page)
continue
toc_like = sum(
1 for r in text_records
if _is_toc_content(r) or _is_toc_entry_line((r.get("text") or ""))
)
if toc_like == len(text_records):
toc_pages.add(next_page)
else:
break
return toc_pages
# ==============================
# 多信号融合标题识别
# ==============================
def _compute_heading_confidence(ins: dict, text: str, level: int) -> float:
"""
4个信号融合0~1
text_level>=10.35+ 编号格式规范0.30+ 长度合理0.20+ 标点密度低0.15
"""
score = 0.0
text_level = ins.get("text_level", 0)
if text_level >= 1:
score += 0.35
normalized = re.sub(r'\s+', ' ', text.replace('\u3000', ' '))
if re.match(r'^\d{1,2}(?:\.\d{1,2})*\.?\s+[\u4e00-\u9fffa-zA-Z]', normalized):
score += 0.30
elif re.match(r'^\d{1,2}(?:\.\d{1,2})*[\u4e00-\u9fff]', normalized):
score += 0.20
clean = _clean_title(text)
if 1 <= len(clean) <= 20:
score += 0.20
elif len(clean) <= 35:
score += 0.10
punct_count = len(re.findall(r'[,。;:、!?,.;:!?]', clean))
if punct_count <= 1:
score += 0.15
elif punct_count <= 3:
score += 0.05
return score
def _scan_all_headings(data: list, toc_pages: Set[int] = None) -> List[_HeadingNode]:
"""第一遍:识别所有置信度足够的标题节点。"""
headings = []
debug_rejected = []
for i, ins in enumerate(data):
if ins.get("type") != "text":
continue
if toc_pages and ins.get("page_idx") in toc_pages:
continue
text = ins.get("text", "").strip()
if not text:
continue
if _is_toc_content(ins):
debug_rejected.append(f" [i={i}] 目录页跳过: {text[:40]!r}")
continue
if _is_toc_entry_line(text):
debug_rejected.append(f" [i={i}] 目录条目行: {text[:60]!r}")
continue
if not _is_real_chapter_title(text):
first_char = text[0] if text else ''
if first_char.isdigit() and len(debug_rejected) < 50:
debug_rejected.append(f" [i={i}] 非章节标题格式: {text[:60]!r}")
continue
level = _get_chapter_level(text)
if level == 0:
continue
numbering = _get_chapter_numbering(text)
clean = _clean_title(text)
confidence = _compute_heading_confidence(ins, text, level)
if confidence >= HEADING_CONFIDENCE_THRESHOLD:
headings.append(_HeadingNode(
raw_text=text,
clean_title=clean,
numbering=numbering,
level=level,
index_in_data=i,
confidence=confidence,
))
else:
debug_rejected.append(
f" [i={i}] 置信度不足({confidence:.2f}): {text[:60]!r} "
f"(text_level={ins.get('text_level', 0)})"
)
print(f"\n [DEBUG] data总条数={len(data)}, "
f"text类型条数={sum(1 for x in data if x.get('type')=='text')}")
print(f" [DEBUG] 候选标题={len(headings)}, 被拒绝={len(debug_rejected)}")
if debug_rejected:
print(" [DEBUG] 被拒绝样本最多20条:")
for msg in debug_rejected[:20]:
print(msg)
return headings
def _validate_heading_sequence(headings: List[_HeadingNode]) -> List[_HeadingNode]:
"""第二遍:全局校验编号连续性,去除可疑标题并去重。"""
if not headings:
return headings
level1_nums = sorted(set(
int(h.numbering) for h in headings if h.level == 1
))
valid_level1: Set[int] = set()
if level1_nums:
for i, num in enumerate(level1_nums):
if i == 0:
valid_level1.add(num)
continue
gap = num - level1_nums[i - 1]
if gap <= MAX_L1_GAP:
valid_level1.add(num)
else:
print(f" ⚠️ 一级标题编号跳跃过大: {level1_nums[i-1]}{num},丢弃 {num}")
validated = []
for h in headings:
if h.level == 1:
if int(h.numbering) in valid_level1:
validated.append(h)
elif h.level >= 2:
parent_num = int(h.numbering.split('.')[0])
if parent_num in valid_level1:
validated.append(h)
else:
validated.append(h)
def _has_lower_l2_after(h: _HeadingNode) -> bool:
if h.level != 1:
return False
h_num = int(h.numbering)
after_l2 = [x for x in validated if x.index_in_data > h.index_in_data and x.level == 2]
for x in after_l2[:8]:
parent = int(x.numbering.split('.')[0])
if parent < h_num:
return True
if parent >= h_num:
break
return False
from collections import defaultdict
by_num: Dict[str, list] = defaultdict(list)
for h in validated:
by_num[h.numbering].append(h)
best: Dict[str, _HeadingNode] = {}
for numbering, candidates in by_num.items():
if len(candidates) == 1:
best[numbering] = candidates[0]
else:
ok = [c for c in candidates if not _has_lower_l2_after(c)]
chosen = ok[0] if ok else candidates[-1]
for c in candidates:
if c is not chosen:
print(f" ⚠️ 编号重复,丢弃误识别: {c.numbering}{c.clean_title}")
best[numbering] = chosen
return [h for h in validated if best.get(h.numbering) is h]
def _build_heading_index(data: list) -> Dict[int, _HeadingNode]:
"""构建 data索引 → HeadingNode 的映射。"""
toc_pages = _get_toc_pages(data)
if toc_pages:
print(f" [TOC页] 检测到目录页 page_idx={sorted(toc_pages)},跳过这些页的标题识别")
headings = _scan_all_headings(data, toc_pages)
headings = _validate_heading_sequence(headings)
if headings:
print(f" [标题识别] 共识别 {len(headings)} 个有效标题:")
for h in headings:
indent = " " * h.level
conf_str = f" (conf={h.confidence:.2f})" if h.confidence < 1.0 else ""
print(f" {indent}{h.numbering} {h.clean_title}{conf_str}")
return {h.index_in_data: h for h in headings}
# ==============================
# 对外接口
# ==============================
def segment_chapters(data: list) -> List[Chapter]:
"""
对外唯一接口。
将 content_list JSON 按章节结构切分,返回 Chapter 列表。
每个 Chapter 包含:
- title / raw_title / numbering标题信息
- is_special是否特殊章节二级节各自独立不合并
- is_skipped是否被 FILTER_KEYWORDS 过滤(内容为空)
- records本章全部 records含标题行不含目录页记录
- loose_records一级标题到第一个二级标题之间的 records
- l2_sectionsList[L2Section],每个二级节含 title/numbering/records
:param data: content_list JSONlist of dict
:return: List[Chapter],按文档顺序排列
"""
toc_pages = _get_toc_pages(data)
heading_index = _build_heading_index(data)
chapters: List[Chapter] = []
# 当前章节状态
cur: Optional[Chapter] = None
cur_l2: Optional[L2Section] = None
has_met_l2 = False
def _flush_l2():
nonlocal cur_l2
if cur_l2 is not None and cur is not None:
if not _should_skip_l2(cur_l2.title):
cur.l2_sections.append(cur_l2)
cur_l2 = None
def _flush_chapter():
nonlocal cur, cur_l2, has_met_l2
if cur is None:
return
_flush_l2()
chapters.append(cur)
cur = None
cur_l2 = None
has_met_l2 = False
for i, ins in enumerate(data):
# 跳过目录页
if toc_pages and ins.get("page_idx") in toc_pages:
continue
if _is_toc_content(ins):
continue
# discarded 记录:仍然归入当前章节(保留位置信息供调用方使用)
if ins.get("type") == "discarded":
if cur is not None and not cur.is_skipped:
cur.records.append(ins)
continue
heading = heading_index.get(i)
if heading is not None and heading.level == 1:
# 新的一级标题 → 刷出上一章
_flush_chapter()
is_skipped = any(kw in heading.raw_text for kw in FILTER_KEYWORDS)
cur = Chapter(
title=heading.clean_title,
raw_title=heading.raw_text,
numbering=heading.numbering,
is_special=_is_special_chapter(heading.raw_text),
is_skipped=is_skipped,
)
has_met_l2 = False
if not is_skipped:
cur.records.append(ins)
tag = " [特殊]" if cur.is_special else ""
print(f"\n[一级标题] {heading.raw_text}{tag} (conf={heading.confidence:.2f})")
continue
if cur is None or cur.is_skipped:
continue
if heading is not None and heading.level == 2:
# 二级标题
_flush_l2()
l2_clean = heading.clean_title
if not _should_skip_l2(l2_clean):
cur_l2 = L2Section(
title=l2_clean,
numbering=heading.numbering,
records=[ins],
)
has_met_l2 = True
cur.records.append(ins)
continue
# 普通内容记录
cur.records.append(ins)
if has_met_l2:
if cur_l2 is not None:
cur_l2.records.append(ins)
else:
cur.loose_records.append(ins)
_flush_chapter()
print(f"\n[chapter_segmenter] 共切分章节: {len(chapters)}")
return chapters