| """ |
| 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 |
|
|
| |
| _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) |
|
|
|
|
| |
| |
| |
|
|
| 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) |
| text = re.sub(r"[|]{3,}", "", text) |
| 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 |
|
|
| |
| if total_chars >= 2000: |
| score += 0.4 |
| elif total_chars >= 1000: |
| score += 0.25 |
| elif total_chars >= 500: |
| score += 0.1 |
|
|
| |
| if word_count >= 300: |
| score += 0.3 |
| elif word_count >= 150: |
| score += 0.2 |
| elif word_count >= 75: |
| score += 0.1 |
|
|
| |
| 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 |
|
|
|
|
| |
| |
| |
|
|
| 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 |
|
|
|
|
| |
| |
| |
|
|
| 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. |
| """ |
|
|
| |
| |
| |
|
|
| 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), |
| ) |
|
|
| |
| |
| |
| if score >= 0.4: |
| return sections |
|
|
| |
| |
| |
|
|
| 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 |
|
|
| |
| |
| |
|
|
| 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 |
|
|
| |
| |
| |
|
|
| 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("."))}, |
| )] |
|
|
|
|
| |
| |
| |
|
|
| 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, |
| } |
|
|
|
|
| |
| |
| |
|
|
| 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)) |
|
|