pdfparsing / pdf_processor.py
Hritam-Ai
Added pdf parsing full backend
c86be6a
Raw
History Blame Contribute Delete
12.9 kB
import fitz # PyMuPDF - keep for fallback
import tiktoken
import os
from typing import List, Dict, Any
from pathlib import Path
import logging
# SmolDocling imports
import torch
from docling_core.types.doc import DoclingDocument
from docling_core.types.doc.document import DocTagsDocument
from transformers import AutoProcessor, AutoModelForVision2Seq
from pdf2image import convert_from_path
from PIL import Image
import tempfile
logger = logging.getLogger(__name__)
class PDFProcessor:
def __init__(self):
self.encoding = tiktoken.encoding_for_model("gpt-3.5-turbo")
self.max_tokens = int(os.getenv("MAX_TOKENS", "180000"))
self.chunk_size = int(os.getenv("CHUNK_SIZE", "8000"))
# Initialize SmolDocling model
self.device = "cuda" if torch.cuda.is_available() else "cpu"
self.model_path = "ds4sd/SmolDocling-256M-preview"
# Load SmolDocling model and processor
try:
logger.info(f"Loading SmolDocling model on {self.device}")
self.processor = AutoProcessor.from_pretrained(self.model_path)
self.model = AutoModelForVision2Seq.from_pretrained(
self.model_path,
torch_dtype=torch.bfloat16 if self.device == "cuda" else torch.float32,
_attn_implementation="flash_attention_2" if self.device == "cuda" else "eager",
).to(self.device)
logger.info("SmolDocling model loaded successfully")
except Exception as e:
logger.error(f"Failed to load SmolDocling model: {str(e)}")
logger.info("Falling back to PyMuPDF for extraction")
self.model = None
self.processor = None
def extract_text(self, pdf_path: Path) -> str:
"""Extract text from PDF using SmolDocling (with PyMuPDF fallback)"""
try:
if self.model is None or self.processor is None:
logger.info("Using PyMuPDF fallback for text extraction")
return self._extract_text_pymupdf(pdf_path)
logger.info(f"Extracting text from PDF using SmolDocling: {pdf_path}")
# Convert PDF to images
images = convert_from_path(str(pdf_path))
all_text = ""
for page_num, image in enumerate(images):
logger.info(f"Processing page {page_num + 1}/{len(images)}")
# Create input messages for SmolDocling
messages = [
{
"role": "user",
"content": [
{"type": "image"},
{"type": "text", "text": "Convert this page to docling."}
]
},
]
# Prepare inputs
prompt = self.processor.apply_chat_template(messages, add_generation_prompt=True)
inputs = self.processor(text=prompt, images=[image], return_tensors="pt")
inputs = inputs.to(self.device)
# Generate outputs
with torch.no_grad():
generated_ids = self.model.generate(**inputs, max_new_tokens=8192)
prompt_length = inputs.input_ids.shape[1]
trimmed_generated_ids = generated_ids[:, prompt_length:]
doctags = self.processor.batch_decode(
trimmed_generated_ids,
skip_special_tokens=False,
)[0].lstrip()
# Convert DocTags to text
try:
doctags_doc = DocTagsDocument.from_doctags_and_image_pairs([doctags], [image])
doc = DoclingDocument(name=f"Page_{page_num + 1}")
doc.load_from_doctags(doctags_doc)
# Export as markdown and extract text
page_text = doc.export_to_markdown()
# Add page separator and content
all_text += f"\n--- Page {page_num + 1} ---\n{page_text}\n"
except Exception as e:
logger.warning(f"Failed to convert DocTags for page {page_num + 1}: {str(e)}")
# Fallback: extract text directly from DocTags
page_text = self._extract_text_from_doctags(doctags)
all_text += f"\n--- Page {page_num + 1} ---\n{page_text}\n"
logger.info(f"Successfully extracted text from {len(images)} pages using SmolDocling")
return all_text.strip()
except Exception as e:
logger.error(f"Error extracting text with SmolDocling: {str(e)}")
logger.info("Falling back to PyMuPDF")
return self._extract_text_pymupdf(pdf_path)
def _extract_text_pymupdf(self, pdf_path: Path) -> str:
"""Fallback method using PyMuPDF"""
try:
doc = fitz.open(pdf_path)
text = ""
for page_num in range(len(doc)):
page = doc.load_page(page_num)
page_text = page.get_text()
text += f"\n--- Page {page_num + 1} ---\n{page_text}\n"
doc.close()
return text.strip()
except Exception as e:
logger.error(f"Error extracting text from PDF with PyMuPDF: {str(e)}")
raise Exception(f"Failed to extract text from PDF: {str(e)}")
def _extract_text_from_doctags(self, doctags: str) -> str:
"""Extract plain text from DocTags as fallback"""
try:
# Simple text extraction from DocTags
import re
# Remove XML-like tags and extract text content
text = re.sub(r'<[^>]+>', '', doctags)
text = re.sub(r'\s+', ' ', text) # Normalize whitespace
return text.strip()
except Exception as e:
logger.warning(f"Failed to extract text from DocTags: {str(e)}")
return "Failed to extract text from this page"
def count_tokens(self, text: str) -> int:
"""Count tokens in text"""
return len(self.encoding.encode(text))
def chunk_text(self, text: str) -> List[Dict[str, Any]]:
"""Split text into chunks based on token limits with proper management"""
chunks = []
# Calculate total tokens for the entire document
total_tokens = self.count_tokens(text)
logger.info(f"Total document tokens: {total_tokens}")
# If text is within token limit, return as single chunk
if total_tokens <= self.chunk_size:
return [{
"chunk_id": 0,
"text": text,
"tokens": total_tokens,
"page_range": "all",
"original_length": len(text)
}]
# Split by pages first
pages = text.split("--- Page ")
current_chunk = ""
current_tokens = 0
chunk_id = 0
start_page = 1
total_processed_tokens = 0
logger.info(f"Processing {len(pages)-1} pages into chunks")
for i, page in enumerate(pages):
if i == 0: # Skip empty first split
continue
page_text = f"--- Page {page}"
page_tokens = self.count_tokens(page_text)
# If single page exceeds chunk size, split it further
if page_tokens > self.chunk_size:
logger.info(f"Page {i} has {page_tokens} tokens, splitting further")
# Save current chunk if it has content
if current_chunk:
chunks.append({
"chunk_id": chunk_id,
"text": current_chunk,
"tokens": current_tokens,
"page_range": f"{start_page}-{i-1}",
"original_length": len(current_chunk)
})
total_processed_tokens += current_tokens
chunk_id += 1
# Split large page into smaller chunks
page_chunks = self._split_large_page(page_text, page_tokens, chunk_id, i)
chunks.extend(page_chunks)
chunk_id += len(page_chunks)
total_processed_tokens += page_tokens
# Reset for next chunk
current_chunk = ""
current_tokens = 0
start_page = i + 1
# If adding this page would exceed chunk size, save current chunk
elif current_tokens + page_tokens > self.chunk_size:
if current_chunk:
chunks.append({
"chunk_id": chunk_id,
"text": current_chunk,
"tokens": current_tokens,
"page_range": f"{start_page}-{i-1}",
"original_length": len(current_chunk)
})
total_processed_tokens += current_tokens
chunk_id += 1
# Start new chunk with current page
current_chunk = page_text
current_tokens = page_tokens
start_page = i
else:
# Add page to current chunk
if current_chunk:
current_chunk += "\n" + page_text
else:
current_chunk = page_text
current_tokens += page_tokens
# Add final chunk if it has content
if current_chunk:
chunks.append({
"chunk_id": chunk_id,
"text": current_chunk,
"tokens": current_tokens,
"page_range": f"{start_page}-{len(pages)-1}",
"original_length": len(current_chunk)
})
total_processed_tokens += current_tokens
logger.info(f"Created {len(chunks)} chunks, total processed tokens: {total_processed_tokens}")
# Verify we didn't lose content
if abs(total_processed_tokens - total_tokens) > 100: # Allow small variance
logger.warning(f"Token count mismatch: original={total_tokens}, processed={total_processed_tokens}")
return chunks
def _split_large_page(self, page_text: str, page_tokens: int, start_chunk_id: int, page_num: int) -> List[Dict[str, Any]]:
"""Split a large page into smaller chunks"""
chunks = []
lines = page_text.split('\n')
current_chunk = ""
current_tokens = 0
chunk_id = start_chunk_id
logger.info(f"Splitting page {page_num} with {page_tokens} tokens into smaller chunks")
for line in lines:
line_tokens = self.count_tokens(line)
if current_tokens + line_tokens > self.chunk_size:
if current_chunk:
chunks.append({
"chunk_id": chunk_id,
"text": current_chunk,
"tokens": current_tokens,
"page_range": f"page-{page_num}-part-{chunk_id-start_chunk_id+1}",
"original_length": len(current_chunk)
})
chunk_id += 1
current_chunk = line
current_tokens = line_tokens
else:
current_chunk += "\n" + line if current_chunk else line
current_tokens += line_tokens
# Add final chunk
if current_chunk:
chunks.append({
"chunk_id": chunk_id,
"text": current_chunk,
"tokens": current_tokens,
"page_range": f"page-{page_num}-part-{chunk_id-start_chunk_id+1}",
"original_length": len(current_chunk)
})
logger.info(f"Split page {page_num} into {len(chunks)} chunks")
return chunks
def get_text_preview(self, text: str, max_chars: int = 500) -> str:
"""Get a preview of the text"""
if len(text) <= max_chars:
return text
return text[:max_chars] + "..."