""" chapter_segmenter.py 对外接口:segment_chapters(data) -> List[Chapter] 纯规则章节切分,不依赖大模型,不依赖 extract_util / entity_registry。 输入:content_list JSON(list 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: """ 判断是否是目录页内容(三层判断)。 层1:text_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>=1(0.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_sections:List[L2Section],每个二级节含 title/numbering/records :param data: content_list JSON,list 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