RAG-Financial-Multimodal / utils /pdf_processor.py
Mattral's picture
Add Multimodal Financial RAG app
2fe3a9e
Raw
History Blame Contribute Delete
7.52 kB
"""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
@dataclass
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)
@property
def chunk_id(self) -> str:
content = f"{self.source_filename}:{self.page_number}:{self.chunk_index}"
return hashlib.sha256(content.encode()).hexdigest()[:12]
@dataclass
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,
)