tangle 3 дней назад
Родитель
Сommit
b91e2bdbd3

+ 92 - 1
core/construction_review/component/minimal_pipeline/pdf_extractor2.py

@@ -79,6 +79,14 @@ class PdfStructureExtractor:
             "l1": re.compile(r"^第\s*[一二三四五六七八九十百零两]+\s*[章部分篇][\s、]*(.*)"),
             "l2": re.compile(r"^[【\[]\s*(\d+)\s*[\]】][\s]*([\u4e00-\u9fa5A-Za-z].*)"),
         },
+        "Rule_8_cn_list_l1_numeric_l2": {
+            "l1": re.compile(
+                r"^(?:[一二三四五六七八九十百零两]+)[、\)\]\uFF09]\s*[\u4e00-\u9fa5A-Za-z].*"
+            ),
+            "l2": re.compile(
+                r"^\d{1,2}(?:[、\.\uFF0E\u3002\)\]\uFF09])\s*(?!\d)[\u4e00-\u9fa5A-Za-z].*"
+            ),
+        },
     }
     TOC_PATTERN = re.compile(r"\.{3,}|…{2,}")
 
@@ -165,6 +173,10 @@ class PdfStructureExtractor:
         try:
             structure = self._extract_from_doc(doc, progress_callback)
             if result.get("catalog"):
+                # 正文抽取和目录检测是两条独立链路:
+                # 1. 正文抽取更容易拿到连续 content
+                # 2. 目录检测更容易保留顺序和层级
+                # 这里先用目录骨架对齐正文,再按标题边界重建内容,尽量减少漏标题造成的结构缺失。
                 structure["chapters"] = self._reconcile_structure_with_catalog(
                     structure.get("chapters", {}),
                     result["catalog"],
@@ -210,7 +222,13 @@ class PdfStructureExtractor:
         return self._toc_extractor.detect_and_extract(file_content, progress_callback)
 
     def _extract_from_doc(self, doc: fitz.Document, progress_callback=None) -> Dict[str, Any]:
-        """提取文档结构(支持 OCR 异步并发)"""
+        """提取文档结构(支持 OCR 异步并发)。
+
+        整体分三步:
+        1. 先扫描页面,找出需要 OCR 替换的表格区域
+        2. 并发执行 OCR,并把识别结果按页回填
+        3. 重新遍历页面文本,按标题规则切出 chapter / section 结构
+        """
 
         def _emit_progress(stage: str, current: int, message: str):
             """发送进度回调"""
@@ -265,6 +283,7 @@ class PdfStructureExtractor:
 
         # === 阶段3: 提取页面文本(应用 OCR 结果)并切分章节 ===
         structured_data: Dict[str, Dict[str, Dict[str, Any]]] = {}
+        # body_lines 保留过滤页眉页脚后的线性正文,后续目录回填时会再次按标题边界切段。
         body_lines: List[Dict[str, Any]] = []
         current_chapter = "未分类前言"
         current_section = "默认部分"
@@ -313,6 +332,8 @@ class PdfStructureExtractor:
 
                 # 跳过目录阶段
                 if not in_body:
+                    # 只有首次遇到真正的一级标题后,才认为进入正文。
+                    # 这样可以避免目录页虽然命中标题规则,却被误当成正文结构。
                     matched_rules = self._matching_rule_names(line, "l1")
                     if matched_rules and not self.TOC_PATTERN.search(line):
                         in_body = True
@@ -324,6 +345,9 @@ class PdfStructureExtractor:
                 if self.TOC_PATTERN.search(line):
                     continue
 
+                # candidate_rule_names 表示“这篇文档可能使用的标题体系”;
+                # active_rule_name 表示“已经确认正在使用的二级标题规则”。
+                # 先宽松候选、后收敛到单一规则,可以减少混合编号文档里的串匹配。
                 active_scope = [active_rule_name] if active_rule_name else candidate_rule_names
 
                 # 匹配章标题
@@ -394,6 +418,11 @@ class PdfStructureExtractor:
         return result
 
     def _normalize_catalog(self, catalog: Dict[str, Any]) -> Dict[str, Any]:
+        """统一目录来源并择优合并。
+
+        目录检测器输出的 chapters 更像“骨架”,raw_ocr_text 更接近页面原文。
+        这里会分别解析两份结果,判断谁更可信,再做一次合并补齐。
+        """
         if not catalog:
             return {}
 
@@ -425,6 +454,11 @@ class PdfStructureExtractor:
         return normalized
 
     def _parse_catalog_from_raw_text(self, text: str) -> List[Dict[str, Any]]:
+        """把目录页 OCR 原文解析成章节树。
+
+        解析时会先根据首批命中的一级标题推断文档的目录样式,
+        后续再尽量沿用同一套规则收敛二级标题,避免不同编号体系互相污染。
+        """
         if not text or not text.strip():
             return []
 
@@ -616,6 +650,9 @@ class PdfStructureExtractor:
         if existing_is_suspicious:
             return True
 
+        if cls._should_prefer_single_level_parsed_catalog(parsed_chapters, existing_chapters):
+            return True
+
         parsed_score = cls._catalog_structure_score(parsed_chapters)
         existing_score = cls._catalog_structure_score(existing_chapters)
         if parsed_score <= existing_score:
@@ -631,6 +668,29 @@ class PdfStructureExtractor:
 
         return True
 
+    @classmethod
+    def _should_prefer_single_level_parsed_catalog(
+        cls,
+        parsed_chapters: List[Dict[str, Any]],
+        existing_chapters: List[Dict[str, Any]],
+    ) -> bool:
+        """特判“单层目录被误识别成一章多节”的场景。"""
+        if len(parsed_chapters) < 2 or len(existing_chapters) != 1:
+            return False
+
+        if any(chapter.get("subsections") for chapter in parsed_chapters):
+            return False
+
+        existing_subsections = existing_chapters[0].get("subsections", []) or []
+        if len(existing_subsections) < len(parsed_chapters) - 1:
+            return False
+
+        parsed_pages = [
+            cls._safe_page_number(chapter.get("page"), 1)
+            for chapter in parsed_chapters
+        ]
+        return parsed_pages == sorted(parsed_pages)
+
     @classmethod
     def _catalog_has_suspicious_structure(cls, chapters: List[Dict[str, Any]]) -> bool:
         if not chapters:
@@ -991,11 +1051,18 @@ class PdfStructureExtractor:
         chapters: Dict[str, Dict[str, Dict[str, Any]]],
         catalog: Dict[str, Any],
     ) -> Dict[str, Dict[str, Dict[str, Any]]]:
+        """把正文抽取结果挂回目录骨架。
+
+        正文抽取结果通常 content 更完整,但层级可能漏掉;
+        目录结果层级更稳,但 content 为空或不完整。
+        这里按标题归一化后顺序匹配,把正文内容重新映射回目录结构。
+        """
         catalog_chapters = catalog.get("chapters", []) if isinstance(catalog, dict) else []
         if not chapters or not catalog_chapters:
             return chapters
 
         section_title_key = "章节标题"
+        # 将正文结构拆成“章标题内容”和“所有节标题内容”两条索引,方便后续按目录顺序逐项匹配。
         chapter_title_payloads: Dict[str, List[Dict[str, Any]]] = {}
         flat_sections: List[Tuple[str, Dict[str, Any]]] = []
         matched_chapter_count = 0
@@ -1025,6 +1092,7 @@ class PdfStructureExtractor:
                 ))
 
         rebuilt: Dict[str, Dict[str, Dict[str, Any]]] = {}
+        # 优先按顺序向后匹配,找不到时再全局回退一次,兼顾正确率和容错性。
         search_start = 0
         used_indices = set()
 
@@ -1111,6 +1179,7 @@ class PdfStructureExtractor:
 
     @classmethod
     def _prepare_page_lines(cls, text: str) -> List[str]:
+        """清洗页面文本行,并尝试把被换行拆开的标题重新合并。"""
         raw_lines = [line.strip() for line in text.split("\n") if line.strip()]
         prepared_lines: List[str] = []
         index = 0
@@ -1133,6 +1202,7 @@ class PdfStructureExtractor:
         lines: List[str],
         start_index: int,
     ) -> Tuple[Optional[str], int]:
+        """尝试把当前位置开始的 2~3 行拼成完整标题。"""
         first_line = lines[start_index].strip()
         if not first_line:
             return None, 1
@@ -1148,6 +1218,7 @@ class PdfStructureExtractor:
                 continue
             if not (cls._matching_rule_names(candidate_text, "l1") or cls._matching_rule_names(candidate_text, "l2")):
                 continue
+            # 只有首行本身像“半截标题”,或者合并后明显更像标题时才吞并后续行,避免误吃正文。
             if first_is_incomplete or not first_is_heading:
                 return candidate_text, span
 
@@ -1175,10 +1246,17 @@ class PdfStructureExtractor:
         catalog: Dict[str, Any],
         body_lines: List[Dict[str, Any]],
     ) -> Dict[str, Dict[str, Dict[str, Any]]]:
+        """基于目录顺序和正文行号,重新切分 section content。
+
+        当正文结构抽取漏掉部分标题时,直接使用结构化结果容易出现 content 缺段。
+        这里把目录拍平成一条标题时间线,再在线性正文里定位这些标题,
+        用“当前标题到下一个标题”之间的文本作为当前 section 的正文。
+        """
         catalog_chapters = catalog.get("chapters", []) if isinstance(catalog, dict) else []
         if not catalog_chapters or not body_lines:
             return chapters
 
+        # 先把目录展开成顺序列表,方便统一定位每个标题在正文中的起点。
         expected_items: List[Dict[str, Any]] = []
         total_sections = 0
         for chapter in catalog_chapters:
@@ -1260,6 +1338,7 @@ class PdfStructureExtractor:
             if item["kind"] != "section" or item["line_index"] is None:
                 continue
 
+            # 下一个已定位标题就是当前 section 的右边界;没有下一个则取到文末。
             next_heading_index = len(body_lines)
             for later in expected_items[idx + 1:]:
                 if later["line_index"] is not None:
@@ -1295,6 +1374,11 @@ class PdfStructureExtractor:
         heading_kind: str,
         start_index: int,
     ) -> Optional[int]:
+        """在线性正文中查找目标标题行。
+
+        先做归一化后的精确匹配;若 OCR / PDF 抽取给标题前面带了噪声前缀,
+        再退一步做“候选行后缀等于目标标题”的宽松匹配。
+        """
         target_key = self._normalize_heading_key(target_title)
         if not target_key:
             return None
@@ -1313,6 +1397,7 @@ class PdfStructureExtractor:
                 return index
 
             raw_candidate_key = self._normalize_heading_key(candidate_text)
+            # 某些 PDF 会把页码、序号或残余字符拼到标题前面,这里允许有限前缀噪声。
             if raw_candidate_key.endswith(target_key):
                 prefix = raw_candidate_key[:-len(target_key)]
                 if not prefix or re.fullmatch(
@@ -1741,6 +1826,12 @@ class PdfStructureExtractor:
             title = numeric_section_match.group(2).strip()
             return f"{prefix} {title}".strip()
 
+        numeric_list_match = re.match(r"^(\d{1,2})(?:[、\.\uFF0E\u3002\)\]\uFF09])\s*(.*)$", cleaned)
+        if numeric_list_match:
+            prefix = numeric_list_match.group(1)
+            title = numeric_list_match.group(2).strip()
+            return f"{prefix} {title}".strip()
+
         cn_section_match = re.match(r"^(第\s*[一二三四五六七八九十百零两]+\s*节)[\s、::\.-]*(.*)$", cleaned)
         if cn_section_match:
             prefix = re.sub(r"\s+", "", cn_section_match.group(1))