"""Text extraction from PDF, DOCX, DOC, and RTF files. Primary PDF extraction: PyMuPDF direct text (for born-digital PDFs). Fallback: Mistral OCR (for scanned documents or broken OCR layers). Routing logic (document-level, not per-page): 1. PyMuPDF extracts text from every page. 2. Pages are quality-checked via _text_quality_ok(). 3. If ALL pages pass → use PyMuPDF output (free, ~50ms, no API call). 4. If ANY page fails → call Mistral OCR once for the entire PDF. Mistral returns per-page Markdown; page_offsets are computed from it. DOC files: converted to DOCX via LibreOffice before extraction. Post-processing: Hebrew abbreviation quote fixer (PyMuPDF path only; Mistral handles gershayim natively). """ from __future__ import annotations import asyncio import base64 import io import logging import re import subprocess import tempfile from pathlib import Path import fitz # PyMuPDF import httpx from PIL import Image from docx import Document as DocxDocument from striprtf.striprtf import rtf_to_text from legal_mcp import config from legal_mcp.services import storage logger = logging.getLogger(__name__) # ── Mistral OCR ─────────────────────────────────────────────────── _MISTRAL_OCR_URL = "https://api.mistral.ai/v1/ocr" _MISTRAL_OCR_MODEL = "mistral-ocr-latest" async def _call_mistral_ocr(path: Path) -> list[str]: """Call Mistral OCR API on a PDF. Returns per-page Markdown text list. The Mistral response contains ``pages[i].markdown`` for each page. If the response has fewer pages than the PDF, trailing pages are padded with empty strings by the caller. """ if not config.MISTRAL_API_KEY: raise RuntimeError( "MISTRAL_API_KEY not configured — cannot OCR scanned PDF. " "Set the env var in Coolify." ) pdf_b64 = base64.b64encode(path.read_bytes()).decode() async with httpx.AsyncClient(timeout=300.0) as client: resp = await client.post( _MISTRAL_OCR_URL, headers={ "Authorization": f"Bearer {config.MISTRAL_API_KEY}", "Content-Type": "application/json", }, json={ "model": _MISTRAL_OCR_MODEL, "document": { "type": "document_url", "document_url": f"data:application/pdf;base64,{pdf_b64}", }, "include_image_base64": False, }, ) if resp.status_code != 200: raise RuntimeError( f"Mistral OCR returned {resp.status_code}: {resp.text[:400]}" ) pages = resp.json().get("pages", []) return [p.get("markdown", "") for p in pages] # ── Hebrew text quality detection ──────────────────────────────── _HEBREW_RE = re.compile(r'[֐-׿]') _WORD_RE = re.compile(r'\S+') def _text_quality_ok(text: str) -> bool: """Check if PyMuPDF-extracted text is genuine Hebrew legal content. Returns True if text appears to be real content. Broken OCR layers from scanned PDFs often have: - Very short words / single-character fragments - Each word on its own line (high words-per-line ratio) - Non-Hebrew characters mixed in """ words = _WORD_RE.findall(text) if len(words) < 10: return False avg_len = sum(len(w) for w in words) / len(words) if avg_len < 2.5: return False single_char_pct = sum(1 for w in words if len(w) == 1) / len(words) if single_char_pct > 0.4: return False lines = [l for l in text.split("\n") if l.strip()] if lines and len(words) / len(lines) < 3.0: return False letters = re.findall(r'[a-zA-Z֐-׿]', text) if letters: hebrew_pct = sum(1 for c in letters if _HEBREW_RE.match(c)) / len(letters) if hebrew_pct < 0.5: return False return True # ── Hebrew abbreviation quote fixer (PyMuPDF path only) ────────── _HEBREW_ABBREV_FIXES: dict[str, str] = { 'עוהייד': 'עוה"ד', 'עוייד': 'עו"ד', 'הנייל': 'הנ"ל', 'מצייב': 'מצ"ב', 'ביהמייש': 'ביהמ"ש', 'תייז': 'ת"ז', 'עייי': 'ע"י', 'אחייכ': 'אח"כ', 'סייק': 'ס"ק', 'דייר': 'ד"ר', 'כדוייח': 'כדו"ח', 'חווייד': 'חוו"ד', 'מייר': 'מ"ר', 'יחייד': 'יח"ד', 'בייכ': 'ב"כ', # Patterns where double-yod (יי) substitutes for gershayim (״) in born-digital PDFs 'בליימ': 'בל"מ', 'תמייא': 'תמ"א', } _ABBREV_PATTERN = re.compile( '|'.join(re.escape(k) for k in sorted(_HEBREW_ABBREV_FIXES, key=len, reverse=True)) ) _HEBREW_YEAR_RE = re.compile(r'(תש[א-ת]+)יי([א-ת])') def _fix_hebrew_quotes(text: str) -> str: """Fix gershayim encoded as double-yod in born-digital PDFs.""" text = _ABBREV_PATTERN.sub(lambda m: _HEBREW_ABBREV_FIXES[m.group()], text) text = _HEBREW_YEAR_RE.sub(r'\1"\2', text) return text # ── Page joining ────────────────────────────────────────────────── # Separator used when joining per-page text. Constant so chunker / # retrofit can reproduce the join when computing page offsets. PAGE_SEPARATOR = "\n\n" def _join_pages(pages_text: list[str]) -> tuple[str, list[int]]: """Join per-page text with PAGE_SEPARATOR while recording start offsets.""" offsets: list[int] = [] parts: list[str] = [] cursor = 0 for i, pg in enumerate(pages_text): offsets.append(cursor) parts.append(pg) cursor += len(pg) if i < len(pages_text) - 1: parts.append(PAGE_SEPARATOR) cursor += len(PAGE_SEPARATOR) return "".join(parts), offsets # ── PDF extraction ──────────────────────────────────────────────── async def _extract_pdf(path: Path) -> tuple[str, int, list[int]]: """Extract text from PDF using document-level routing. Stage 1 — PyMuPDF pre-screen (free, ~50ms, no API call): Run on every page. Collect per-page text; flag pages where PyMuPDF returns < 50 chars or _text_quality_ok() fails (scanned, blank, or broken embedded OCR layer). Stage 2 — Mistral OCR (triggered when any page fails Stage 1): Send the entire PDF to Mistral once. Use its per-page Markdown for ALL pages — consistent source, no mixed plain/Markdown formats. Mistral handles gershayim natively; no quote-fix applied. Page offsets are always computed so the chunker can attribute each chunk to its source page number (multimodal hybrid retrieval). """ doc = fitz.open(str(path)) page_count = len(doc) # Stage 1: PyMuPDF pre-screen pymupdf_pages: list[str] = [] failed: list[int] = [] for i in range(page_count): text = doc[i].get_text().strip() if len(text) > 50 and _text_quality_ok(text): pymupdf_pages.append(_fix_hebrew_quotes(text)) else: pymupdf_pages.append("") failed.append(i) doc.close() if not failed: logger.debug( "PDF %s: all %d pages digital — PyMuPDF only", path.name, page_count ) joined, offsets = _join_pages(pymupdf_pages) return joined, page_count, offsets # Stage 2: Mistral OCR for entire document logger.info( "PDF %s: %d/%d pages failed quality check → Mistral OCR", path.name, len(failed), page_count, ) mistral_pages = await _call_mistral_ocr(path) # Pad if Mistral returns fewer pages than PyMuPDF counted while len(mistral_pages) < page_count: mistral_pages.append("") joined, offsets = _join_pages(mistral_pages[:page_count]) return joined, page_count, offsets def page_at_offset(offset: int, page_offsets: list[int]) -> int: """Return the 1-based page number containing a given char offset. page_offsets[i] is the start of page (i+1) in the joined text. """ if not page_offsets: return 1 page = 1 for i, start in enumerate(page_offsets): if start <= offset: page = i + 1 else: break return page # ── Public entry point ──────────────────────────────────────────── async def extract_text(file_path: str) -> tuple[str, int, list[int] | None]: """Extract text from a document file. Returns: ``(text, page_count, page_offsets)`` where: - ``text``: extracted text. Plain text for PyMuPDF path; Markdown for Mistral path (tables, ``##`` headers preserved). - ``page_count``: number of pages (0 for non-PDF) - ``page_offsets``: char start of each page inside ``text``, or ``None`` for non-PDF formats """ path = Path(file_path) suffix = path.suffix.lower() if suffix == ".pdf": return await _extract_pdf(path) elif suffix == ".docx": return _extract_docx(path), 0, None elif suffix == ".doc": return _extract_doc(path), 0, None elif suffix == ".rtf": return _extract_rtf(path), 0, None elif suffix in (".txt", ".md"): return path.read_text(encoding="utf-8"), 0, None else: raise ValueError(f"Unsupported file type: {suffix}") # ── Non-PDF formats ─────────────────────────────────────────────── def _extract_doc(path: Path) -> str: """Extract text from legacy .doc via LibreOffice → DOCX conversion.""" with tempfile.TemporaryDirectory() as tmp_dir: # Isolate the LibreOffice user profile per call: headless soffice # locks a single shared profile, so concurrent .doc conversions would # otherwise fail with a profile-lock error. result = subprocess.run( [ "libreoffice", f"-env:UserInstallation=file://{tmp_dir}/lo-profile", "--headless", "--convert-to", "docx", str(path), "--outdir", tmp_dir, ], capture_output=True, text=True, timeout=120, ) if result.returncode != 0: raise RuntimeError(f"LibreOffice conversion failed: {result.stderr}") docx_path = Path(tmp_dir) / f"{path.stem}.docx" if not docx_path.exists(): raise FileNotFoundError(f"Converted file not found: {docx_path}") return _extract_docx(docx_path) def _extract_docx(path: Path) -> str: """Extract text from DOCX file.""" doc = DocxDocument(str(path)) paragraphs = [p.text for p in doc.paragraphs if p.text.strip()] return "\n\n".join(paragraphs) def _extract_rtf(path: Path) -> str: """Extract text from RTF file.""" rtf_content = path.read_text(encoding="utf-8", errors="replace") return rtf_to_text(rtf_content) # ── Multimodal page rendering (V9) ─────────────────────────────── # Unchanged — multimodal embedding always uses PyMuPDF-rendered images # regardless of whether text extraction used PyMuPDF or Mistral. def _pixmap_to_pil(pix: fitz.Pixmap) -> Image.Image: """Convert a PyMuPDF pixmap to PIL.Image (RGB).""" if pix.alpha: pix = fitz.Pixmap(pix, 0) return Image.frombytes("RGB", (pix.width, pix.height), pix.samples) def render_pages_for_multimodal( pdf_path: str | Path, embed_dpi: int, thumb_dpi: int | None = None, thumbnail_dir: Path | None = None, ) -> list[tuple[Image.Image, Path | None]]: """Render each PDF page as PIL.Image at ``embed_dpi`` for the multimodal embedder, and optionally save JPEG thumbnails. Returns ``[(pil_image, thumb_path_or_None), ...]`` in page order. """ src = Path(pdf_path) if not src.is_file(): raise FileNotFoundError(f"PDF not found: {src}") if thumbnail_dir is not None: thumbnail_dir.mkdir(parents=True, exist_ok=True) out: list[tuple[Image.Image, Path | None]] = [] doc = fitz.open(str(src)) try: for page_idx, page in enumerate(doc): page_num = page_idx + 1 pix = page.get_pixmap(dpi=embed_dpi) img = _pixmap_to_pil(pix) thumb_path: Path | None = None if thumbnail_dir is not None and thumb_dpi: thumb_path = thumbnail_dir / f"p{page_num:03d}.jpg" ratio = thumb_dpi / embed_dpi thumb_size = ( max(1, int(img.width * ratio)), max(1, int(img.height * ratio)), ) thumb = img.resize(thumb_size, Image.Resampling.LANCZOS) _tbuf = io.BytesIO() thumb.save(_tbuf, "JPEG", quality=75, optimize=True) try: _tkey = thumb_path.resolve().relative_to( Path(config.DATA_DIR).resolve()).as_posix() storage.put_bytes_sync( _tkey, _tbuf.getvalue(), bucket=storage.Bucket.DERIVED, content_type="image/jpeg") except ValueError: thumb.save(thumb_path, "JPEG", quality=75, optimize=True) out.append((img, thumb_path)) finally: doc.close() return out # ── Nevo preamble stripping ─────────────────────────────────────── _NEVO_MARKERS = ("ספרות:", "חקיקה שאוזכרה:", "מיני-רציו:", "פסקי דין שאוזכרו:", "כתבי עת:", "הועתק מנבו") _DECISION_START = re.compile( r"^[ \t>*_#]{0,6}(?:" r"בפנינו|לפנינו|לפניי|הערר שבנדון|ועדת הערר לתכנון|רקע עובדתי|עסקינן|" r"פסק[ \t\-]{0,3}די(?:ן|נו)|" r"(?:כב(?:וד)?['׳\"]?\s*)?(?:ה?שופט[ת]?|ה?נשיא[ה]?|המשנה לנשיא)\s+[^\n,]{1,40}:" r")", re.MULTILINE, ) def strip_nevo_preamble(text: str) -> str: """Remove Nevo database preamble (bibliography, legislation, mini-ratio). Returns the original text unchanged if no preamble is detected. Works on both plain text (PyMuPDF) and Markdown (Mistral) since _DECISION_START already tolerates leading ``[ \t>*_#]{0,6}``. """ head = text[:1500] if not any(marker in head for marker in _NEVO_MARKERS): return text m = _DECISION_START.search(text) if m and m.start() > 50: stripped = text[m.start():] logger.debug("Stripped %d chars of Nevo preamble", m.start()) return stripped return text _RATIO_MARKER = "מיני-רציו:" def extract_nevo_ratio(text: str) -> str: """Return the Nevo מיני-רציו block (editorial holdings summary), or ''.""" if not text: return "" start = text.find(_RATIO_MARKER) if start == -1: return "" body = text[start + len(_RATIO_MARKER):] end = len(body) dm = _DECISION_START.search(body) if dm: end = min(end, dm.start()) for marker in _NEVO_MARKERS: if marker == _RATIO_MARKER: continue pos = body.find(marker) if pos != -1: end = min(end, pos) return body[:end].strip()