# # Copyright 2025 The InfiniFlow Authors. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import random import re import sys from abc import ABC, abstractmethod from collections import Counter from copy import deepcopy from deepdoc.parser.pdf_parser import RAGFlowPdfParser from deepdoc.parser.utils import extract_pdf_outlines from rag.flow.base import ProcessBase, ProcessParamBase from rag.flow.parser.pdf_chunk_metadata import ( PDF_POSITIONS_KEY, extract_pdf_positions, finalize_pdf_chunk, merge_pdf_positions, restore_pdf_text_previews, ) from rag.nlp import not_bullet, not_title BODY_LEVEL = sys.maxsize - 1 class TitleChunkerParam(ProcessParamBase): def __init__(self): super().__init__() self.levels = [] self.hierarchy = None self.include_heading_content = False self.root_chunk_as_heading = False def check(self): if self.method in {"hierarchy", "group"}: self.check_empty(self.levels, "Hierarchical setups.") if self.method == "hierarchy": self.check_empty(self.hierarchy, "Hierarchy number.") def get_input_form(self) -> dict[str, dict]: return {} class BaseTitleChunker(ABC): start_message = "Start to chunk by title." def __init__(self, process: ProcessBase, from_upstream): self.process = process self.param = process._param self.from_upstream = from_upstream async def invoke(self): self.process.set_output("output_format", "chunks") self.process.callback(random.randint(1, 5) / 100.0, self.start_message) line_records = self.extract_line_records() resolved = self.resolve_levels(line_records) chunks = self.build_chunks(line_records, resolved) await self.set_chunks(chunks) self.process.callback(1, "Done.") def extract_line_records(self): """ Normalize all upstream input payloads into a unified ordered record stream. All level resolution and chunk construction logic operates on this standard stream, decoupling downstream chunking strategies from different upstream output formats. """ import logging logger = logging.getLogger(__name__) payload = None # Extract raw content payload based on upstream output format type if self.from_upstream.output_format == "markdown": payload = self.from_upstream.markdown_result or "" elif self.from_upstream.output_format == "text": payload = self.from_upstream.text_result or "" elif self.from_upstream.output_format == "html": payload = self.from_upstream.html_result or "" # Boundary robustness fix: explicit None check to distinguish `None` and empty string "" # Prevents empty payload from unexpectedly falling through to structured chunk branch if payload is not None: lines = payload.split("\n") input_line_count = len(lines) # Format-branched text processing to preserve original document semantics # Plain text: perform full whitespace stripping and invalid empty line filtering if self.from_upstream.output_format == "text": clean_lines = [line.strip() for line in lines if line.strip()] # Markdown & HTML: retain original indentation/spacing, only filter pure blank lines else: clean_lines = [line for line in lines if line.strip()] output_line_count = len(clean_lines) # Production observability log: added format dimension per project coding guidelines logger.info( f"payload filter: format={self.from_upstream.output_format} before={input_line_count} after={output_line_count}" ) return [ { "text": line, "doc_type_kwd": "text", "img_id": None, "layout": "", PDF_POSITIONS_KEY: [] } for line in clean_lines ] items = self.from_upstream.chunks if self.from_upstream.output_format == "chunks" else self.from_upstream.json_result return [ { "text": item.get("text") or "", "doc_type_kwd": str(item.get("doc_type_kwd") or "text"), "img_id": item.get("img_id"), "layout": "{} {}".format(item.get("layout_type", ""), item.get("layoutno", "")).strip(), PDF_POSITIONS_KEY: extract_pdf_positions(item), } for item in items or [] ] def extract_outlines(self): file = self.from_upstream.file or {} source = ( file.get("blob") or file.get("binary") or file.get("path") or file.get("name") ) if not source: return [] return extract_pdf_outlines(source) @staticmethod def match_regex_level(text, level_group): stripped = text.strip() for level, pattern in enumerate(level_group, start=1): if re.match(pattern, stripped) and not not_bullet(stripped): return level return None @staticmethod def select_level_group(lines, raw_levels): if not raw_levels: return [] # Select one regex family before assigning numeric levels. Mixing # patterns across families would make the level numbers ambiguous and # break downstream comparisons. hits = [0] * len(raw_levels) for i, group in enumerate(raw_levels): for sec in lines: sec = sec.strip() if not sec: continue for pattern in group: if re.match(pattern, sec) and not not_bullet(sec): hits[i] += 1 break maximum = 0 selected = -1 for i, hit in enumerate(hits): if hit <= maximum: continue selected = i maximum = hit if selected < 0: return [] return [pattern for pattern in raw_levels[selected] if pattern] @staticmethod def match_layout_level(text, layout, fallback_level): if re.search(r"(section|title|head)", layout, re.I) and not not_title(text.split("@")[0].strip()): return fallback_level return BODY_LEVEL @staticmethod def _outline_similarity(left, right): left_pairs = {left[i] + left[i + 1] for i in range(len(left) - 1)} right_pairs = {right[i] + right[i + 1] for i in range(min(len(left), len(right) - 1))} return len(left_pairs & right_pairs) / max(len(left_pairs), len(right_pairs), 1) def resolve_outline_levels(self, line_records): outlines = self.extract_outlines() if not line_records or len(outlines) / len(line_records) <= 0.03: return None max_level = max(level for _, level, _ in outlines) + 1 levels = [] for record in line_records: if record["doc_type_kwd"] != "text": levels.append(BODY_LEVEL) continue text = record["text"] for outline_text, level, _ in outlines: if self._outline_similarity(outline_text, text) > 0.8: levels.append(level + 1) break else: levels.append(BODY_LEVEL) return { "levels": levels, "most_level": max(1, max_level - 1), "source": "outline", } def resolve_frequency_levels(self, line_records): level_group = self.select_level_group( [record["text"] for record in line_records], self.param.levels, ) fallback_level = len(level_group) + 1 levels = [] for record in line_records: if record["doc_type_kwd"] != "text": levels.append(BODY_LEVEL) continue level = self.match_regex_level(record["text"], level_group) if level is not None: levels.append(level) continue levels.append( self.match_layout_level( record["text"], record["layout"], fallback_level, ) ) most_level = None for level, _ in Counter(levels).most_common(): if level < BODY_LEVEL: most_level = level break return { "levels": levels, "most_level": most_level, "source": "frequency", } def resolve_title_levels(self, line_records): return self.resolve_outline_levels(line_records) or self.resolve_frequency_levels(line_records) def build_chunks_from_record_groups(self, record_groups): # Strategy code decides record grouping. This method materializes each # group into the output chunk representation. For PDF-like inputs, the # chunk box is defined by merged source positions and the text payload # is normalized by removing parser tags. if self.from_upstream.output_format in ["markdown", "text", "html"]: chunks = [ {"text": "".join(record["text"] + "\n" for record in records)} for records in record_groups if records ] else: chunks = [ ( { "text": RAGFlowPdfParser.remove_tag("".join(record["text"] + "\n" for record in records)), "doc_type_kwd": "text", PDF_POSITIONS_KEY: merge_pdf_positions(records), } if records[0]["doc_type_kwd"] == "text" else { "text": records[0]["text"], "doc_type_kwd": records[0]["doc_type_kwd"], "img_id": records[0]["img_id"], PDF_POSITIONS_KEY: records[0][PDF_POSITIONS_KEY], } ) for records in record_groups if records ] if self.param.root_chunk_as_heading and len(chunks) > 1: root_chunk = chunks[0] root_text = root_chunk.get("text", "") for ck in chunks[1:]: ck['text'] = root_text + "\n" + ck.get("text", "") return chunks[1:] return chunks async def set_chunks(self, chunks): if self.from_upstream.output_format in ["markdown", "text", "html"]: self.process.set_output("chunks", chunks) return # Text grouping runs before visual enrichment. Preview text and final # box metadata are derived here from the merged PDF positions. await restore_pdf_text_previews(chunks, self.from_upstream, self.process._canvas) self.process.set_output("chunks", [finalize_pdf_chunk(deepcopy(chunk)) for chunk in chunks]) @abstractmethod def resolve_levels(self, line_records): raise NotImplementedError() @abstractmethod def build_chunks(self, line_records, resolved): raise NotImplementedError() def resolve_target_level(levels, hierarchy): title_levels = sorted({level for level in levels if 0 < level < BODY_LEVEL}) if not title_levels: return None hierarchy_num = max(int(hierarchy), 1) return title_levels[min(hierarchy_num, len(title_levels)) - 1]