DocWeave / backend /app /services /document_processor.py
shak3008's picture
perf: background processing + model preload + ingestion optimizations
906cebc
Raw
History Blame Contribute Delete
19 kB
"""
document_processor.py
Multi-format document processor for DocWeave.
Migrated from PilotMaster/DocPilot/backend/app/services/ingestion.py.
Removed:
- process_document() RAG pipeline entry point
- add_chunks() FAISS embedding call
- TracePilot HTTP callbacks
- All pilotcore imports
Preserved:
- All format extractors (PDF, DOCX, PPTX, TXT, CSV, XLSX, images, code)
- PyMuPDF → Docling → OCR fallback cascade
- TextSection dataclass
- clean_text()
- detect_section_title()
- SECTION_TYPES mapping
- extract_text_sections() primary public API
- extract_text() convenience flat-text API
"""
from dataclasses import dataclass, field
from statistics import median
import logging
import mimetypes
import os
import re
import shutil
import pandas as pd
import pytesseract
# Locate tesseract binary for Linux/Docker and Windows
_tesseract = shutil.which("tesseract")
if not _tesseract and os.name == "nt":
_tesseract = r"C:\Program Files\Tesseract-OCR\tesseract.exe"
if _tesseract:
pytesseract.pytesseract.tesseract_cmd = _tesseract
from docx import Document as DocxDocument
from pdf2image import convert_from_path
from PIL import Image
from pypdf import PdfReader
try:
from docling.document_converter import DocumentConverter
except ImportError:
DocumentConverter = None
try:
from pptx import Presentation
except ImportError:
Presentation = None
try:
import fitz
except ImportError:
fitz = None
logger = logging.getLogger(__name__)
SECTION_TYPES = {
"abstract": "abstract",
"introduction": "introduction",
"background": "background",
"related work": "related_work",
"literature review": "related_work",
"methods": "methods",
"methodology": "methods",
"experimental setup": "methods",
"experiments": "experiments",
"evaluation": "evaluation",
"results": "results",
"discussion": "discussion",
"conclusion": "conclusion",
"future work": "future_work",
"limitations": "limitations",
"references": "references",
"bibliography": "references",
"appendix": "appendix",
}
class TextExtractionError(Exception):
pass
@dataclass
class TextSection:
text: str
metadata: dict = field(default_factory=dict)
# ---------------------------------------------------------------------------
# Text utilities
# ---------------------------------------------------------------------------
def clean_text(text: str) -> str:
text = (text or "").replace("\x00", "")
text = text.replace("\r\n", "\n").replace("\r", "\n")
text = re.sub(r"(\w+)-\s*\n\s*(\w+)", r"\1\2", text) # PDF hyphenation
text = re.sub(r"[|]{3,}", "", text) # OCR garbage
text = re.sub(r"[ \t]+", " ", text)
text = re.sub(r"\n{3,}", "\n\n", text)
return text.strip()
def extraction_quality(sections: list[TextSection]) -> float:
"""
Estimate whether extracted PDF text is substantial enough
to be considered usable.
Returns a score from 0.0 to 1.0.
"""
if not sections:
return 0.0
total_chars = sum(len(section.text) for section in sections)
if total_chars == 0:
return 0.0
text = "\n".join(section.text for section in sections)
words = re.findall(r"\b\w+\b", text)
if not words:
return 0.0
alphanumeric_chars = sum(
char.isalnum()
for char in text
)
alphanumeric_ratio = alphanumeric_chars / max(len(text), 1)
word_count = len(words)
score = 0.0
# Amount of actual text
if total_chars >= 2000:
score += 0.4
elif total_chars >= 1000:
score += 0.25
elif total_chars >= 500:
score += 0.1
# Number of words
if word_count >= 300:
score += 0.3
elif word_count >= 150:
score += 0.2
elif word_count >= 75:
score += 0.1
# Mostly actual text rather than symbols/noise
if alphanumeric_ratio >= 0.75:
score += 0.3
elif alphanumeric_ratio >= 0.60:
score += 0.2
elif alphanumeric_ratio >= 0.45:
score += 0.1
return min(score, 1.0)
def detect_type(file_path: str, mime_type: str = None):
extension = os.path.splitext(file_path)[1].lower()
detected_mime = mime_type or mimetypes.guess_type(file_path)[0] or ""
return extension, detected_mime
# ---------------------------------------------------------------------------
# Section title detection (PyMuPDF page dict)
# ---------------------------------------------------------------------------
def detect_section_title(page_dict: dict):
"""
Return the highest-confidence heading on a page, or None.
Uses font size ratio, bold flags, text length, and position scoring.
"""
font_sizes = []
for block in page_dict.get("blocks", []):
if block.get("type") != 0:
continue
for line in block.get("lines", []):
for span in line.get("spans", []):
size = span.get("size")
if size:
font_sizes.append(size)
if not font_sizes:
return None
median_size = median(font_sizes)
page_height = page_dict.get("height", 1)
candidates = []
for block in page_dict.get("blocks", []):
if block.get("type") != 0:
continue
lines = block.get("lines", [])
if not lines:
continue
text_parts = []
max_score = 0
for line in lines:
for span in line.get("spans", []):
text = (span.get("text") or "").strip()
if not text:
continue
score = 0
size = span.get("size", 0)
flags = span.get("flags", 0)
font = span.get("font", "")
ratio = size / median_size if median_size else 1
if ratio >= 1.4:
score += 4
elif ratio >= 1.1:
score += 2
if flags & 16:
score += 3
if any(t in font.lower() for t in ["bold", "medi"]):
score += 2
if len(text) <= 60:
score += 2
elif len(text) <= 120:
score += 1
else:
score -= 2
if len(text) > 2 and text.isupper():
score += 2
if len(text.split()) > 1 and text.istitle():
score += 1
text_parts.append(text)
max_score = max(max_score, score)
candidate_text = " ".join(text_parts).strip()
if not candidate_text or len(candidate_text.split()) > 12:
continue
if len(lines) == 1:
max_score += 1
y0 = block.get("bbox", [0, 0, 0, 0])[1]
if y0 < page_height * 0.15:
max_score += 1
if re.fullmatch(r"[\d.]+", candidate_text):
continue
candidates.append({"text": candidate_text, "score": max_score})
if not candidates:
return None
candidates.sort(key=lambda x: x["score"], reverse=True)
filtered = [c for c in candidates if len(c["text"].split()) <= 8]
best = filtered[0] if filtered else candidates[0]
return best["text"] if best["score"] >= 8 else None
# ---------------------------------------------------------------------------
# PDF extractors
# ---------------------------------------------------------------------------
def extract_pdf_text_pymupdf(file_path: str) -> list[TextSection]:
sections = []
with fitz.open(file_path) as doc:
for page_index, page in enumerate(doc, start=1):
page_dict = page.get_text("dict")
text = clean_text(page.get_text("text"))
section_title = detect_section_title(page_dict)
metadata = {"page": page_index}
if section_title:
metadata["section_title"] = section_title
lower = section_title.lower()
for key, value in SECTION_TYPES.items():
if key in lower:
metadata["section_type"] = value
break
if text:
sections.append(TextSection(text=text, metadata={"element_type": "paragraph", **metadata}))
logger.info("PyMuPDF processed %s pages", doc.page_count)
return sections
def extract_pdf_text_pypdf(file_path: str) -> list[TextSection]:
reader = PdfReader(file_path)
sections = []
for page_number, page in enumerate(reader.pages, start=1):
text = clean_text(page.extract_text() or "")
if text:
sections.append(TextSection(
text=text,
metadata={"page": page_number, "element_type": "paragraph"},
))
logger.info("pypdf processed %s pages", len(reader.pages))
return sections
def extract_pdf_docling(file_path: str) -> list[TextSection]:
if DocumentConverter is None:
return []
try:
result = DocumentConverter().convert(file_path)
text = clean_text(result.document.export_to_markdown())
if not text:
return []
return [TextSection(text=text, metadata={"extractor": "docling", "element_type": "document"})]
except Exception as e:
logger.exception("Docling extraction failed: %s", e)
return []
def extract_pdf_ocr(file_path: str) -> list[TextSection]:
try:
images = convert_from_path(
file_path,
poppler_path=os.getenv("POPPLER_PATH"),
)
sections = []
for page_number, image in enumerate(images, start=1):
text = clean_text(pytesseract.image_to_string(image))
if text:
sections.append(TextSection(
text=text,
metadata={"page": page_number, "ocr": True, "element_type": "ocr"},
))
logger.info("OCR processed %s pages", len(images))
return sections
except Exception as e:
logger.exception("OCR failed: %s", e)
return []
def extract_pdf_sections(file_path: str) -> list[TextSection]:
"""
Extract PDF text using a quality-aware cascade:
PyMuPDF → Docling → OCR
PyMuPDF is fast (< 1s for most PDFs). Docling and OCR are only
triggered when native text extraction quality is genuinely poor.
"""
# ------------------------------------------------------------
# 1. PyMuPDF (fast path — handles most text-based PDFs)
# ------------------------------------------------------------
sections = (
extract_pdf_text_pymupdf(file_path)
if fitz
else extract_pdf_text_pypdf(file_path)
)
score = extraction_quality(sections)
logger.info(
"PyMuPDF extraction quality: %.2f (%s chars)",
score,
sum(len(s.text) for s in sections),
)
# Lowered from 0.6 to 0.4 — PyMuPDF text with any reasonable
# content is usually good enough. Only truly broken/scanned
# PDFs need the heavier extractors.
if score >= 0.4:
return sections
# ------------------------------------------------------------
# 2. Docling (slower, better for complex layouts)
# ------------------------------------------------------------
logger.info(
"PyMuPDF extraction quality insufficient, trying Docling"
)
sections = extract_pdf_docling(file_path)
score = extraction_quality(sections)
logger.info(
"Docling extraction quality: %.2f (%s chars)",
score,
sum(len(s.text) for s in sections),
)
if score >= 0.3:
return sections
# ------------------------------------------------------------
# 3. OCR (slowest — only for scanned/image PDFs)
# ------------------------------------------------------------
logger.info(
"Docling extraction quality insufficient, triggering OCR"
)
sections = extract_pdf_ocr(file_path)
score = extraction_quality(sections)
logger.info(
"OCR extraction quality: %.2f (%s chars)",
score,
sum(len(s.text) for s in sections),
)
return sections
# ---------------------------------------------------------------------------
# Other format extractors
# ---------------------------------------------------------------------------
def extract_docx_sections(file_path: str) -> list[TextSection]:
doc = DocxDocument(file_path)
text = "\n".join(para.text for para in doc.paragraphs)
return [TextSection(text=clean_text(text), metadata={"element_type": "paragraph"})]
def extract_pptx_sections(file_path: str) -> list[TextSection]:
if Presentation is None:
raise TextExtractionError("PPTX extraction dependency is not installed")
presentation = Presentation(file_path)
sections = []
for slide_number, slide in enumerate(presentation.slides, start=1):
parts = []
for shape in slide.shapes:
if hasattr(shape, "text") and shape.text:
parts.append(shape.text)
if getattr(shape, "has_table", False):
for row in shape.table.rows:
cells = [cell.text.strip() for cell in row.cells if cell.text.strip()]
if cells:
parts.append(" | ".join(cells))
try:
notes = slide.notes_slide.notes_text_frame.text
if notes:
parts.append(notes)
except Exception:
pass
text = clean_text("\n".join(parts))
if text:
sections.append(TextSection(
text=text,
metadata={"slide": slide_number, "element_type": "slide"},
))
return sections
def extract_txt_sections(file_path: str) -> list[TextSection]:
with open(file_path, "r", encoding="utf-8", errors="ignore") as f:
return [TextSection(text=clean_text(f.read()), metadata={"element_type": "paragraph"})]
def extract_csv_sections(file_path: str) -> list[TextSection]:
return _dataframe_to_sections(pd.read_csv(file_path))
def extract_xlsx_sections(file_path: str) -> list[TextSection]:
sheets = pd.read_excel(file_path, sheet_name=None)
sections = []
for sheet_name, df in sheets.items():
for section in _dataframe_to_sections(df):
section.metadata["sheet"] = sheet_name
sections.append(section)
return sections
def _dataframe_to_sections(df) -> list[TextSection]:
sections = []
df = df.fillna("")
for row_number, row in df.iterrows():
parts = [f"{col}: {str(val).strip()}" for col, val in row.items() if str(val).strip()]
text = clean_text("\n".join(parts))
if text:
sections.append(TextSection(
text=text,
metadata={"row": int(row_number) + 1, "element_type": "table_row"},
))
return sections
def extract_image_sections(file_path: str) -> list[TextSection]:
try:
text = clean_text(pytesseract.image_to_string(Image.open(file_path)))
return [TextSection(text=text, metadata={"ocr": True, "element_type": "image"})]
except Exception as e:
logger.exception("Image OCR failed: %s", e)
return []
def extract_code_sections(file_path: str) -> list[TextSection]:
with open(file_path, "r", encoding="utf-8", errors="ignore") as f:
text = clean_text(f.read())
if not text:
return []
language_map = {
".py": "python", ".js": "javascript", ".jsx": "javascript",
".ts": "typescript", ".tsx": "typescript", ".java": "java",
".cpp": "cpp", ".c": "c", ".h": "c_header", ".go": "go",
".rs": "rust", ".json": "json", ".yaml": "yaml", ".yml": "yaml",
".sql": "sql", ".css": "css", ".html": "html",
}
extension = os.path.splitext(file_path)[1].lower()
return [TextSection(
text=text,
metadata={"element_type": "code", "language": language_map.get(extension, extension.lstrip("."))},
)]
# ---------------------------------------------------------------------------
# Extractor registry
# ---------------------------------------------------------------------------
EXTRACTORS = {
".pdf": extract_pdf_sections,
".docx": extract_docx_sections,
".pptx": extract_pptx_sections,
".txt": extract_txt_sections,
".md": extract_txt_sections,
".csv": extract_csv_sections,
".xlsx": extract_xlsx_sections,
".py": extract_code_sections,
".js": extract_code_sections,
".jsx": extract_code_sections,
".ts": extract_code_sections,
".tsx": extract_code_sections,
".java": extract_code_sections,
".cpp": extract_code_sections,
".c": extract_code_sections,
".h": extract_code_sections,
".go": extract_code_sections,
".rs": extract_code_sections,
".json": extract_code_sections,
".yaml": extract_code_sections,
".yml": extract_code_sections,
".sql": extract_code_sections,
".css": extract_code_sections,
".html": extract_code_sections,
".png": extract_image_sections,
".jpg": extract_image_sections,
".jpeg": extract_image_sections,
".webp": extract_image_sections,
}
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def extract_text_sections(file_path: str, mime_type: str = None) -> list[TextSection]:
"""
Primary public API.
Returns a list of TextSection objects extracted from the given file.
Raises TextExtractionError on unsupported or unreadable files.
"""
extension, detected_mime = detect_type(file_path, mime_type)
extractor = EXTRACTORS.get(extension)
if not extractor:
raise TextExtractionError(f"Unsupported file type: {extension or detected_mime}")
logger.info("Extractor: %s extension=%s mime=%s", extractor.__name__, extension, detected_mime)
try:
sections = extractor(file_path)
except TextExtractionError:
raise
except Exception as e:
logger.exception("Extraction failed: %s", e)
raise TextExtractionError("Could not extract text from document") from e
cleaned = [s for s in (TextSection(text=clean_text(s.text), metadata=s.metadata) for s in sections) if s.text]
if not cleaned:
raise TextExtractionError(
"Could not extract text from PDF" if extension == ".pdf"
else "Could not extract text from document"
)
logger.info("Extracted %s sections, %s chars", len(cleaned), sum(len(s.text) for s in cleaned))
return cleaned
def extract_text(file_path: str, mime_type: str = None) -> str:
"""Convenience API. Returns all extracted text as a single string."""
return "\n\n".join(s.text for s in extract_text_sections(file_path, mime_type))