Spaces:
Sleeping
Sleeping
| """utils/pdf_processor.py — PDF ingestion pipeline for the HF Space. | |
| Standalone module mirroring src/rag_system/components/layout_parser.py logic: | |
| PDF -> page images + raw text -> semantic chunk -> ready for embedding. | |
| """ | |
| from __future__ import annotations | |
| import hashlib | |
| import re | |
| from dataclasses import dataclass, field | |
| from pathlib import Path | |
| from typing import List, Optional, Tuple | |
| class DocumentChunk: | |
| text: str | |
| page_number: int | |
| chunk_index: int | |
| source_filename: str | |
| chunk_type: str = "text" | |
| table_data: Optional[str] = None | |
| metadata: dict = field(default_factory=dict) | |
| def chunk_id(self) -> str: | |
| content = f"{self.source_filename}:{self.page_number}:{self.chunk_index}" | |
| return hashlib.sha256(content.encode()).hexdigest()[:12] | |
| class IngestResult: | |
| filename: str | |
| num_pages: int | |
| num_chunks: int | |
| num_tables: int | |
| num_charts: int | |
| chunks: List[DocumentChunk] | |
| page_images: List[object] | |
| processing_steps: List[str] | |
| def extract_text_and_tables(pdf_path: str) -> Tuple[List[dict], List[str]]: | |
| steps = [] | |
| pages = [] | |
| try: | |
| import pdfplumber | |
| steps.append("Opened PDF with pdfplumber") | |
| with pdfplumber.open(pdf_path) as pdf: | |
| steps.append(f"Found {len(pdf.pages)} pages") | |
| for i, page in enumerate(pdf.pages, 1): | |
| text = page.extract_text() or "" | |
| tables = page.extract_tables() or [] | |
| pages.append({"page_num": i, "text": text.strip(), "tables": tables}) | |
| steps.append(f"Extracted text from {len(pages)} pages") | |
| total_tables = sum(len(p["tables"]) for p in pages) | |
| if total_tables: | |
| steps.append(f"Found {total_tables} tables across all pages") | |
| except ImportError: | |
| steps.append("pdfplumber not available - trying PyPDF2 fallback") | |
| try: | |
| import PyPDF2 | |
| with open(pdf_path, "rb") as f: | |
| reader = PyPDF2.PdfReader(f) | |
| for i, page in enumerate(reader.pages, 1): | |
| text = page.extract_text() or "" | |
| pages.append({"page_num": i, "text": text.strip(), "tables": []}) | |
| steps.append(f"Extracted text (no table detection) from {len(pages)} pages") | |
| except Exception as e: | |
| steps.append(f"PDF extraction failed: {str(e)[:80]}") | |
| except Exception as e: | |
| steps.append(f"Extraction error: {str(e)[:80]}") | |
| return pages, steps | |
| def render_page_images(pdf_path: str, max_pages: int = 8) -> Tuple[List[object], List[str]]: | |
| steps = [] | |
| images = [] | |
| try: | |
| from pdf2image import convert_from_path | |
| images = convert_from_path(pdf_path, dpi=150, first_page=1, last_page=max_pages) | |
| steps.append(f"Rendered {len(images)} page images at 150 DPI for vision analysis") | |
| except ImportError: | |
| steps.append("pdf2image not available - skipping page rendering") | |
| except Exception as e: | |
| steps.append(f"Page rendering failed: {str(e)[:80]}") | |
| return images, steps | |
| def semantic_chunk_text( | |
| text: str, | |
| page_num: int, | |
| source: str, | |
| chunk_start_index: int = 0, | |
| max_chars: int = 800, | |
| overlap_chars: int = 100, | |
| ) -> List[DocumentChunk]: | |
| if not text.strip(): | |
| return [] | |
| paragraphs = [p.strip() for p in re.split(r"\n{2,}", text) if p.strip()] | |
| chunks: List[DocumentChunk] = [] | |
| buffer = "" | |
| chunk_idx = chunk_start_index | |
| for para in paragraphs: | |
| if len(buffer) + len(para) < max_chars: | |
| buffer = (buffer + "\n\n" + para).strip() if buffer else para | |
| else: | |
| if buffer: | |
| chunks.append(DocumentChunk( | |
| text=buffer, page_number=page_num, chunk_index=chunk_idx, | |
| source_filename=source, chunk_type="text", | |
| )) | |
| chunk_idx += 1 | |
| buffer = buffer[-overlap_chars:] + "\n\n" + para if len(buffer) > overlap_chars else para | |
| else: | |
| buffer = para | |
| if buffer.strip(): | |
| chunks.append(DocumentChunk( | |
| text=buffer.strip(), page_number=page_num, chunk_index=chunk_idx, | |
| source_filename=source, chunk_type="text", | |
| )) | |
| return chunks | |
| def format_table_as_text(table: List[List[str]]) -> str: | |
| if not table: | |
| return "" | |
| rows = [] | |
| for row in table: | |
| cells = [str(c or "").strip() for c in row] | |
| rows.append(" | ".join(cells)) | |
| return "\n".join(rows) | |
| def chunk_tables( | |
| tables: List[List[List[str]]], | |
| page_num: int, | |
| source: str, | |
| chunk_start_index: int = 0, | |
| ) -> List[DocumentChunk]: | |
| chunks = [] | |
| for t_idx, table in enumerate(tables): | |
| table_text = format_table_as_text(table) | |
| if table_text.strip(): | |
| chunks.append(DocumentChunk( | |
| text=f"[TABLE on page {page_num}]\n{table_text}", | |
| page_number=page_num, chunk_index=chunk_start_index + t_idx, | |
| source_filename=source, chunk_type="table", | |
| table_data=table_text, | |
| )) | |
| return chunks | |
| def ingest_pdf(pdf_path: str, process_vision: bool = True, vision_fn=None) -> IngestResult: | |
| filename = Path(pdf_path).name | |
| steps: List[str] = [f"Processing: {filename}"] | |
| all_chunks: List[DocumentChunk] = [] | |
| page_images = [] | |
| pages, extract_steps = extract_text_and_tables(pdf_path) | |
| steps.extend(extract_steps) | |
| chunk_counter = 0 | |
| num_tables = 0 | |
| for page in pages: | |
| text_chunks = semantic_chunk_text(page["text"], page["page_num"], filename, chunk_counter) | |
| all_chunks.extend(text_chunks) | |
| chunk_counter += len(text_chunks) | |
| table_chunks = chunk_tables(page["tables"], page["page_num"], filename, chunk_counter) | |
| all_chunks.extend(table_chunks) | |
| num_tables += len(table_chunks) | |
| chunk_counter += len(table_chunks) | |
| steps.append(f"Chunked into {len(all_chunks)} semantic chunks ({num_tables} tables)") | |
| num_charts = 0 | |
| if process_vision and vision_fn is not None: | |
| imgs, img_steps = render_page_images(pdf_path) | |
| steps.extend(img_steps) | |
| page_images = imgs | |
| steps.append("Running vision LLM on page images...") | |
| for img_idx, img in enumerate(imgs[:6]): | |
| try: | |
| description = vision_fn(img) | |
| if description and len(description) > 30: | |
| all_chunks.append(DocumentChunk( | |
| text=f"[VISUAL CONTENT - page {img_idx + 1}]\n{description}", | |
| page_number=img_idx + 1, chunk_index=chunk_counter, | |
| source_filename=filename, chunk_type="chart_description", | |
| )) | |
| chunk_counter += 1 | |
| num_charts += 1 | |
| except Exception as e: | |
| steps.append(f"Vision failed for page {img_idx + 1}: {str(e)[:60]}") | |
| if num_charts: | |
| steps.append(f"Generated {num_charts} visual descriptions from page images") | |
| elif process_vision: | |
| steps.append("Vision skipped - no vision model provided") | |
| steps.append(f"Ingestion complete: {len(all_chunks)} total chunks ready for retrieval") | |
| return IngestResult( | |
| filename=filename, num_pages=len(pages), num_chunks=len(all_chunks), | |
| num_tables=num_tables, num_charts=num_charts, chunks=all_chunks, | |
| page_images=page_images, processing_steps=steps, | |
| ) | |