feat(ocr): replace Google Vision with Mistral OCR as PDF fallback
This commit was merged in pull request #333.
This commit is contained in:
@@ -275,8 +275,8 @@ HALACHA_CANONICAL_SYNTH_MODEL = os.environ.get("HALACHA_CANONICAL_SYNTH_MODEL",
|
|||||||
HALACHA_CANONICAL_SYNTH_EFFORT = os.environ.get("HALACHA_CANONICAL_SYNTH_EFFORT", "high")
|
HALACHA_CANONICAL_SYNTH_EFFORT = os.environ.get("HALACHA_CANONICAL_SYNTH_EFFORT", "high")
|
||||||
HALACHA_CANONICAL_SYNTH_DRIFT_FLOOR = float(os.environ.get("HALACHA_CANONICAL_SYNTH_DRIFT_FLOOR", "0.80"))
|
HALACHA_CANONICAL_SYNTH_DRIFT_FLOOR = float(os.environ.get("HALACHA_CANONICAL_SYNTH_DRIFT_FLOOR", "0.80"))
|
||||||
|
|
||||||
# Google Cloud Vision (OCR for scanned PDFs)
|
# Mistral OCR (fallback for scanned PDFs — replaces Google Cloud Vision)
|
||||||
GOOGLE_CLOUD_VISION_API_KEY = os.environ.get("GOOGLE_CLOUD_VISION_API_KEY", "")
|
MISTRAL_API_KEY = os.environ.get("MISTRAL_API_KEY", "")
|
||||||
|
|
||||||
# Data directory
|
# Data directory
|
||||||
DATA_DIR = Path(os.environ.get("DATA_DIR", str(Path.home() / "legal-ai" / "data")))
|
DATA_DIR = Path(os.environ.get("DATA_DIR", str(Path.home() / "legal-ai" / "data")))
|
||||||
@@ -361,7 +361,7 @@ PARENT_DOC_CHILD_OVERLAP_TOKENS = int(
|
|||||||
# External service allowlist — case materials may ONLY be sent to these domains
|
# External service allowlist — case materials may ONLY be sent to these domains
|
||||||
ALLOWED_EXTERNAL_SERVICES = {
|
ALLOWED_EXTERNAL_SERVICES = {
|
||||||
"api.voyageai.com", # Voyage AI (embeddings)
|
"api.voyageai.com", # Voyage AI (embeddings)
|
||||||
"vision.googleapis.com", # Google Cloud Vision (OCR)
|
"api.mistral.ai", # Mistral OCR (scanned PDFs)
|
||||||
}
|
}
|
||||||
|
|
||||||
# Audit
|
# Audit
|
||||||
|
|||||||
@@ -144,9 +144,12 @@ def _split_into_sections(text: str) -> list[tuple[str, str]]:
|
|||||||
markers: list[tuple[int, str]] = []
|
markers: list[tuple[int, str]] = []
|
||||||
|
|
||||||
for pattern, section_type in SECTION_PATTERNS:
|
for pattern, section_type in SECTION_PATTERNS:
|
||||||
# ^ + MULTILINE: line start only. Optional leading spaces/tabs and an
|
# ^ + MULTILINE: line start only. Optional leading spaces/tabs, an
|
||||||
|
# optional Markdown ATX header prefix (``## ``/``### ``), and an
|
||||||
# optional ordinal prefix ("5.", "5)", "ג.") before the keyword.
|
# optional ordinal prefix ("5.", "5)", "ג.") before the keyword.
|
||||||
anchored = rf"^[ \t]*(?:\d+[.)]\s*|[א-ת][.)]\s*)?(?:{pattern})"
|
# The Markdown prefix handles Mistral OCR output where section
|
||||||
|
# titles are rendered as ``## נימוקי הערר`` etc.
|
||||||
|
anchored = rf"^[ \t]*(?:#{1,3}\s+)?(?:\d+[.)]\s*|[א-ת][.)]\s*)?(?:{pattern})"
|
||||||
for match in re.finditer(anchored, text, re.MULTILINE):
|
for match in re.finditer(anchored, text, re.MULTILINE):
|
||||||
markers.append((match.start(), section_type))
|
markers.append((match.start(), section_type))
|
||||||
|
|
||||||
|
|||||||
@@ -1,23 +1,33 @@
|
|||||||
"""Text extraction from PDF, DOCX, DOC, and RTF files.
|
"""Text extraction from PDF, DOCX, DOC, and RTF files.
|
||||||
|
|
||||||
Primary PDF extraction: PyMuPDF direct text (for born-digital PDFs).
|
Primary PDF extraction: PyMuPDF direct text (for born-digital PDFs).
|
||||||
Fallback: Google Cloud Vision OCR (for scanned documents).
|
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.
|
DOC files: converted to DOCX via LibreOffice before extraction.
|
||||||
Post-processing: Hebrew abbreviation quote fixer.
|
Post-processing: Hebrew abbreviation quote fixer (PyMuPDF path only;
|
||||||
|
Mistral handles gershayim natively).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import base64
|
||||||
import io
|
import io
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
import fitz # PyMuPDF
|
import fitz # PyMuPDF
|
||||||
|
import httpx
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
from docx import Document as DocxDocument
|
from docx import Document as DocxDocument
|
||||||
from striprtf.striprtf import rtf_to_text
|
from striprtf.striprtf import rtf_to_text
|
||||||
@@ -25,36 +35,65 @@ from striprtf.striprtf import rtf_to_text
|
|||||||
from legal_mcp import config
|
from legal_mcp import config
|
||||||
from legal_mcp.services import storage
|
from legal_mcp.services import storage
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from google.cloud import vision
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# ── Google Cloud Vision client (imported lazily — saves ~550ms at MCP startup) ──
|
# ── Mistral OCR ───────────────────────────────────────────────────
|
||||||
|
|
||||||
_vision_client: "vision.ImageAnnotatorClient | None" = None
|
_MISTRAL_OCR_URL = "https://api.mistral.ai/v1/ocr"
|
||||||
|
_MISTRAL_OCR_MODEL = "mistral-ocr-latest"
|
||||||
|
|
||||||
|
|
||||||
def _get_vision_client() -> "vision.ImageAnnotatorClient":
|
async def _call_mistral_ocr(path: Path) -> list[str]:
|
||||||
global _vision_client
|
"""Call Mistral OCR API on a PDF. Returns per-page Markdown text list.
|
||||||
if _vision_client is None:
|
|
||||||
from google.cloud import vision
|
The Mistral response contains ``pages[i].markdown`` for each page.
|
||||||
_vision_client = vision.ImageAnnotatorClient(
|
If the response has fewer pages than the PDF, trailing pages are
|
||||||
client_options={"api_key": config.GOOGLE_CLOUD_VISION_API_KEY}
|
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."
|
||||||
)
|
)
|
||||||
return _vision_client
|
|
||||||
|
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 text quality detection ────────────────────────────────
|
||||||
|
|
||||||
_HEBREW_RE = re.compile(r'[\u0590-\u05FF]')
|
_HEBREW_RE = re.compile(r'[-]')
|
||||||
_WORD_RE = re.compile(r'\S+')
|
_WORD_RE = re.compile(r'\S+')
|
||||||
|
|
||||||
|
|
||||||
def _text_quality_ok(text: str) -> bool:
|
def _text_quality_ok(text: str) -> bool:
|
||||||
"""Check if extracted text is real content vs broken OCR layer.
|
"""Check if PyMuPDF-extracted text is genuine Hebrew legal content.
|
||||||
|
|
||||||
Returns True if text appears to be genuine Hebrew legal content.
|
Returns True if text appears to be real content.
|
||||||
Broken OCR layers from scanned PDFs often have:
|
Broken OCR layers from scanned PDFs often have:
|
||||||
- Very short words / single-character fragments
|
- Very short words / single-character fragments
|
||||||
- Each word on its own line (high words-per-line ratio)
|
- Each word on its own line (high words-per-line ratio)
|
||||||
@@ -64,26 +103,19 @@ def _text_quality_ok(text: str) -> bool:
|
|||||||
if len(words) < 10:
|
if len(words) < 10:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Average word length — real Hebrew words avg 4-6 chars.
|
|
||||||
avg_len = sum(len(w) for w in words) / len(words)
|
avg_len = sum(len(w) for w in words) / len(words)
|
||||||
if avg_len < 2.5:
|
if avg_len < 2.5:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Percentage of single-character "words"
|
|
||||||
single_char_pct = sum(1 for w in words if len(w) == 1) / len(words)
|
single_char_pct = sum(1 for w in words if len(w) == 1) / len(words)
|
||||||
if single_char_pct > 0.4:
|
if single_char_pct > 0.4:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Words per line — broken OCR puts each word on its own line.
|
|
||||||
# Real text has 5-15 words per line; broken OCR has ~1-2.
|
|
||||||
lines = [l for l in text.split("\n") if l.strip()]
|
lines = [l for l in text.split("\n") if l.strip()]
|
||||||
if lines:
|
if lines and len(words) / len(lines) < 3.0:
|
||||||
words_per_line = len(words) / len(lines)
|
return False
|
||||||
if words_per_line < 3.0:
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Hebrew character ratio among letter characters
|
letters = re.findall(r'[a-zA-Z-]', text)
|
||||||
letters = re.findall(r'[a-zA-Z\u0590-\u05FF]', text)
|
|
||||||
if letters:
|
if letters:
|
||||||
hebrew_pct = sum(1 for c in letters if _HEBREW_RE.match(c)) / len(letters)
|
hebrew_pct = sum(1 for c in letters if _HEBREW_RE.match(c)) / len(letters)
|
||||||
if hebrew_pct < 0.5:
|
if hebrew_pct < 0.5:
|
||||||
@@ -92,7 +124,7 @@ def _text_quality_ok(text: str) -> bool:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
# ── Hebrew abbreviation quote fixer ──────────────────────────────
|
# ── Hebrew abbreviation quote fixer (PyMuPDF path only) ──────────
|
||||||
|
|
||||||
_HEBREW_ABBREV_FIXES: dict[str, str] = {
|
_HEBREW_ABBREV_FIXES: dict[str, str] = {
|
||||||
'עוהייד': 'עוה"ד',
|
'עוהייד': 'עוה"ד',
|
||||||
@@ -111,50 +143,133 @@ _HEBREW_ABBREV_FIXES: dict[str, str] = {
|
|||||||
'יחייד': 'יח"ד',
|
'יחייד': 'יח"ד',
|
||||||
'בייכ': 'ב"כ',
|
'בייכ': 'ב"כ',
|
||||||
# Patterns where double-yod (יי) substitutes for gershayim (״) in born-digital PDFs
|
# Patterns where double-yod (יי) substitutes for gershayim (״) in born-digital PDFs
|
||||||
'בליימ': 'בל"מ', # בקשה להארכת מועד — appears in RTL legal docs
|
'בליימ': 'בל"מ',
|
||||||
'תמייא': 'תמ"א', # תכנית מתאר ארצית
|
'תמייא': 'תמ"א',
|
||||||
}
|
}
|
||||||
|
|
||||||
_ABBREV_PATTERN = re.compile(
|
_ABBREV_PATTERN = re.compile(
|
||||||
'|'.join(re.escape(k) for k in sorted(_HEBREW_ABBREV_FIXES, key=len, reverse=True))
|
'|'.join(re.escape(k) for k in sorted(_HEBREW_ABBREV_FIXES, key=len, reverse=True))
|
||||||
)
|
)
|
||||||
|
|
||||||
# Matches Hebrew law year abbreviations where gershayim was encoded as double-yod.
|
|
||||||
# e.g. תשכייה → תשכ"ה, תשנייב → תשנ"ב
|
|
||||||
_HEBREW_YEAR_RE = re.compile(r'(תש[א-ת]+)יי([א-ת])')
|
_HEBREW_YEAR_RE = re.compile(r'(תש[א-ת]+)יי([א-ת])')
|
||||||
|
|
||||||
|
|
||||||
def _fix_hebrew_quotes(text: str) -> str:
|
def _fix_hebrew_quotes(text: str) -> str:
|
||||||
"""Fix known Hebrew abbreviation quote replacements.
|
"""Fix gershayim encoded as double-yod in born-digital PDFs."""
|
||||||
|
|
||||||
Applied to both Google Vision OCR output and direct PyMuPDF extraction —
|
|
||||||
some born-digital PDFs encode gershayim (״) as double-yod (יי), producing
|
|
||||||
the same corruption patterns as OCR.
|
|
||||||
"""
|
|
||||||
text = _ABBREV_PATTERN.sub(lambda m: _HEBREW_ABBREV_FIXES[m.group()], text)
|
text = _ABBREV_PATTERN.sub(lambda m: _HEBREW_ABBREV_FIXES[m.group()], text)
|
||||||
text = _HEBREW_YEAR_RE.sub(r'\1"\2', text)
|
text = _HEBREW_YEAR_RE.sub(r'\1"\2', text)
|
||||||
return text
|
return text
|
||||||
|
|
||||||
|
|
||||||
# ── Extraction ───────────────────────────────────────────────────
|
# ── Page joining ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
# Separator used when joining per-page text. Constant so chunker /
|
# Separator used when joining per-page text. Constant so chunker /
|
||||||
# retrofit can reproduce the join when computing page offsets.
|
# retrofit can reproduce the join when computing page offsets.
|
||||||
PAGE_SEPARATOR = "\n\n"
|
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]:
|
async def extract_text(file_path: str) -> tuple[str, int, list[int] | None]:
|
||||||
"""Extract text from a document file.
|
"""Extract text from a document file.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
``(text, page_count, page_offsets)`` where:
|
``(text, page_count, page_offsets)`` where:
|
||||||
- ``text``: concatenated extracted text
|
- ``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_count``: number of pages (0 for non-PDF)
|
||||||
- ``page_offsets``: ``page_offsets[i]`` = char start offset of
|
- ``page_offsets``: char start of each page inside ``text``,
|
||||||
page (i+1) inside ``text``. ``None`` for non-PDFs (where the
|
or ``None`` for non-PDF formats
|
||||||
notion of pages doesn't apply). Used by the chunker to assign
|
|
||||||
a ``page_number`` to each chunk.
|
|
||||||
"""
|
"""
|
||||||
path = Path(file_path)
|
path = Path(file_path)
|
||||||
suffix = path.suffix.lower()
|
suffix = path.suffix.lower()
|
||||||
@@ -173,95 +288,11 @@ async def extract_text(file_path: str) -> tuple[str, int, list[int] | None]:
|
|||||||
raise ValueError(f"Unsupported file type: {suffix}")
|
raise ValueError(f"Unsupported file type: {suffix}")
|
||||||
|
|
||||||
|
|
||||||
def _join_pages(pages_text: list[str]) -> tuple[str, list[int]]:
|
# ── Non-PDF formats ───────────────────────────────────────────────
|
||||||
"""Join per-page text with PAGE_SEPARATOR while recording the start
|
|
||||||
offset of each page in the joined output."""
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
async def _extract_pdf(path: Path) -> tuple[str, int, list[int]]:
|
|
||||||
"""Extract text from PDF.
|
|
||||||
|
|
||||||
Try direct text first, fall back to Google Cloud Vision for scanned
|
|
||||||
or broken-OCR pages.
|
|
||||||
"""
|
|
||||||
doc = fitz.open(str(path))
|
|
||||||
page_count = len(doc)
|
|
||||||
pages_text: list[str] = []
|
|
||||||
|
|
||||||
for page_num in range(page_count):
|
|
||||||
page = doc[page_num]
|
|
||||||
text = page.get_text().strip()
|
|
||||||
|
|
||||||
if len(text) > 50 and _text_quality_ok(text):
|
|
||||||
pages_text.append(_fix_hebrew_quotes(text))
|
|
||||||
logger.debug("Page %d: direct extraction (%d chars, quality OK)", page_num + 1, len(text))
|
|
||||||
else:
|
|
||||||
reason = "insufficient text" if len(text) <= 50 else "low quality OCR layer"
|
|
||||||
logger.info("Page %d: Google Vision OCR (%s)", page_num + 1, reason)
|
|
||||||
pix = page.get_pixmap(dpi=300)
|
|
||||||
img_bytes = pix.tobytes("png")
|
|
||||||
ocr_text = await asyncio.to_thread(
|
|
||||||
_ocr_with_google_vision, img_bytes, page_num + 1
|
|
||||||
)
|
|
||||||
pages_text.append(ocr_text)
|
|
||||||
|
|
||||||
doc.close()
|
|
||||||
joined, offsets = _join_pages(pages_text)
|
|
||||||
return joined, page_count, offsets
|
|
||||||
|
|
||||||
|
|
||||||
def page_at_offset(offset: int, page_offsets: list[int]) -> int:
|
|
||||||
"""Look up the page number containing a given char offset.
|
|
||||||
|
|
||||||
page_offsets[i] is the start of page (i+1) in the joined text;
|
|
||||||
a chunk starting at ``offset`` belongs to the highest-indexed page
|
|
||||||
whose start is ``<= offset``. Returns 1-based page number.
|
|
||||||
"""
|
|
||||||
if not page_offsets:
|
|
||||||
return 1
|
|
||||||
# Linear scan is fine — page_offsets is short (≤ ~200 for our PDFs).
|
|
||||||
page = 1
|
|
||||||
for i, start in enumerate(page_offsets):
|
|
||||||
if start <= offset:
|
|
||||||
page = i + 1
|
|
||||||
else:
|
|
||||||
break
|
|
||||||
return page
|
|
||||||
|
|
||||||
|
|
||||||
def _ocr_with_google_vision(image_bytes: bytes, page_num: int) -> str:
|
|
||||||
"""OCR a single page image using Google Cloud Vision API."""
|
|
||||||
from google.cloud import vision # lazy: keeps MCP startup fast
|
|
||||||
client = _get_vision_client()
|
|
||||||
image = vision.Image(content=image_bytes)
|
|
||||||
|
|
||||||
response = client.document_text_detection(
|
|
||||||
image=image,
|
|
||||||
image_context=vision.ImageContext(language_hints=["he"]),
|
|
||||||
)
|
|
||||||
|
|
||||||
if response.error.message:
|
|
||||||
raise RuntimeError(
|
|
||||||
f"Google Vision error on page {page_num}: {response.error.message}"
|
|
||||||
)
|
|
||||||
|
|
||||||
text = response.full_text_annotation.text if response.full_text_annotation else ""
|
|
||||||
return _fix_hebrew_quotes(text)
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_doc(path: Path) -> str:
|
def _extract_doc(path: Path) -> str:
|
||||||
"""Extract text from legacy .doc file by converting to .docx via LibreOffice."""
|
"""Extract text from legacy .doc via LibreOffice → DOCX conversion."""
|
||||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||||
# Isolate the LibreOffice user profile per call: headless soffice
|
# Isolate the LibreOffice user profile per call: headless soffice
|
||||||
# locks a single shared profile, so concurrent .doc conversions would
|
# locks a single shared profile, so concurrent .doc conversions would
|
||||||
@@ -296,13 +327,13 @@ def _extract_rtf(path: Path) -> str:
|
|||||||
|
|
||||||
|
|
||||||
# ── Multimodal page rendering (V9) ───────────────────────────────
|
# ── 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:
|
def _pixmap_to_pil(pix: fitz.Pixmap) -> Image.Image:
|
||||||
"""Convert a PyMuPDF pixmap to PIL.Image (RGB) without going through
|
"""Convert a PyMuPDF pixmap to PIL.Image (RGB)."""
|
||||||
PNG bytes. Faster than tobytes('png') → Image.open()."""
|
|
||||||
if pix.alpha:
|
if pix.alpha:
|
||||||
# Drop alpha channel — voyage multimodal expects RGB.
|
|
||||||
pix = fitz.Pixmap(pix, 0)
|
pix = fitz.Pixmap(pix, 0)
|
||||||
return Image.frombytes("RGB", (pix.width, pix.height), pix.samples)
|
return Image.frombytes("RGB", (pix.width, pix.height), pix.samples)
|
||||||
|
|
||||||
@@ -314,12 +345,9 @@ def render_pages_for_multimodal(
|
|||||||
thumbnail_dir: Path | None = None,
|
thumbnail_dir: Path | None = None,
|
||||||
) -> list[tuple[Image.Image, Path | None]]:
|
) -> list[tuple[Image.Image, Path | None]]:
|
||||||
"""Render each PDF page as PIL.Image at ``embed_dpi`` for the
|
"""Render each PDF page as PIL.Image at ``embed_dpi`` for the
|
||||||
multimodal embedder, and optionally save a smaller JPEG thumbnail
|
multimodal embedder, and optionally save JPEG thumbnails.
|
||||||
at ``thumb_dpi`` to ``thumbnail_dir`` for UI preview.
|
|
||||||
|
|
||||||
Returns ``[(pil_image, thumb_path_or_None), ...]`` in page order.
|
Returns ``[(pil_image, thumb_path_or_None), ...]`` in page order.
|
||||||
The full-DPI image stays in memory only — only the thumbnail is
|
|
||||||
persisted to disk.
|
|
||||||
"""
|
"""
|
||||||
src = Path(pdf_path)
|
src = Path(pdf_path)
|
||||||
if not src.is_file():
|
if not src.is_file():
|
||||||
@@ -338,17 +366,12 @@ def render_pages_for_multimodal(
|
|||||||
thumb_path: Path | None = None
|
thumb_path: Path | None = None
|
||||||
if thumbnail_dir is not None and thumb_dpi:
|
if thumbnail_dir is not None and thumb_dpi:
|
||||||
thumb_path = thumbnail_dir / f"p{page_num:03d}.jpg"
|
thumb_path = thumbnail_dir / f"p{page_num:03d}.jpg"
|
||||||
# Downsample the same render rather than re-rendering
|
|
||||||
# with PyMuPDF — far faster.
|
|
||||||
ratio = thumb_dpi / embed_dpi
|
ratio = thumb_dpi / embed_dpi
|
||||||
thumb_size = (
|
thumb_size = (
|
||||||
max(1, int(img.width * ratio)),
|
max(1, int(img.width * ratio)),
|
||||||
max(1, int(img.height * ratio)),
|
max(1, int(img.height * ratio)),
|
||||||
)
|
)
|
||||||
thumb = img.resize(thumb_size, Image.Resampling.LANCZOS)
|
thumb = img.resize(thumb_size, Image.Resampling.LANCZOS)
|
||||||
# Persist the thumbnail (a DERIVED, regenerable artifact)
|
|
||||||
# through the storage layer (INV-STG1). Under the filesystem
|
|
||||||
# backend it lands at thumb_path exactly as before.
|
|
||||||
_tbuf = io.BytesIO()
|
_tbuf = io.BytesIO()
|
||||||
thumb.save(_tbuf, "JPEG", quality=75, optimize=True)
|
thumb.save(_tbuf, "JPEG", quality=75, optimize=True)
|
||||||
try:
|
try:
|
||||||
@@ -366,44 +389,28 @@ def render_pages_for_multimodal(
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
# ── Nevo preamble stripping ──────────────────────────────────────
|
# ── Nevo preamble stripping ───────────────────────────────────────
|
||||||
|
|
||||||
_NEVO_MARKERS = ("ספרות:", "חקיקה שאוזכרה:", "מיני-רציו:", "פסקי דין שאוזכרו:",
|
_NEVO_MARKERS = ("ספרות:", "חקיקה שאוזכרה:", "מיני-רציו:", "פסקי דין שאוזכרו:",
|
||||||
"כתבי עת:", "הועתק מנבו")
|
"כתבי עת:", "הועתק מנבו")
|
||||||
|
|
||||||
# Markers for where the actual decision body begins (everything before is Nevo
|
|
||||||
# preamble: bibliography + מיני-רציו). Two families:
|
|
||||||
# - ועדת ערר / district openings (בפנינו / הערר שבנדון / ...)
|
|
||||||
# - COURT-RULING openings (#86.1): a פסק-דין header or the authoring judge's
|
|
||||||
# line. Without these, Nevo court judgments — exactly the ones carrying a
|
|
||||||
# מיני-רציו — slipped through unstripped (e.g. בג"ץ 1764/05).
|
|
||||||
#
|
|
||||||
# #86.2 hardening — two over-strip bugs found while backfilling:
|
|
||||||
# 1. ``פסק-דין`` headers are often markdown-wrapped (``**פסק דין**``); the old
|
|
||||||
# ``^פסק[- ]דין`` required the keyword to be the very first char of the line
|
|
||||||
# and allowed only one separator, so it missed the header and fell through
|
|
||||||
# to a citation 32K deep (עמ"נ 50567-07-21). We now tolerate leading
|
|
||||||
# markdown/whitespace and 0-3 separators.
|
|
||||||
# 2. Bare ``השופט``/``הנשיא`` matched *citations* ("השופט מ' חשין, פסקה 23"),
|
|
||||||
# stripping real decision body. The authoring-judge line ends with a COLON
|
|
||||||
# ("השופט י' עמית:"); citations use a comma. We now require the colon.
|
|
||||||
_DECISION_START = re.compile(
|
_DECISION_START = re.compile(
|
||||||
r"^[ \t>*_#]{0,6}(?:"
|
r"^[ \t>*_#]{0,6}(?:"
|
||||||
r"בפנינו|לפנינו|לפניי|הערר שבנדון|ועדת הערר לתכנון|רקע עובדתי|עסקינן|"
|
r"בפנינו|לפנינו|לפניי|הערר שבנדון|ועדת הערר לתכנון|רקע עובדתי|עסקינן|"
|
||||||
r"פסק[ \t\-]{0,3}די(?:ן|נו)|" # פסק-דין / פסק דין / **פסק דין** header (final-nun ן vs דינו)
|
r"פסק[ \t\-]{0,3}די(?:ן|נו)|"
|
||||||
r"(?:כב(?:וד)?['׳\"]?\s*)?(?:ה?שופט[ת]?|ה?נשיא[ה]?|המשנה לנשיא)\s+[^\n,]{1,40}:" # author line → colon
|
r"(?:כב(?:וד)?['׳\"]?\s*)?(?:ה?שופט[ת]?|ה?נשיא[ה]?|המשנה לנשיא)\s+[^\n,]{1,40}:"
|
||||||
r")",
|
r")",
|
||||||
re.MULTILINE,
|
re.MULTILINE,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def strip_nevo_preamble(text: str) -> str:
|
def strip_nevo_preamble(text: str) -> str:
|
||||||
"""Remove Nevo database preamble (bibliography, legislation, mini-ratio) from decision text.
|
"""Remove Nevo database preamble (bibliography, legislation, mini-ratio).
|
||||||
|
|
||||||
Returns the original text unchanged if no preamble is detected.
|
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}``.
|
||||||
"""
|
"""
|
||||||
# Window wide enough to catch the Nevo markers even when a long court/parties
|
|
||||||
# header precedes them (court rulings push חקיקה שאוזכרה:/מיני-רציו: down).
|
|
||||||
head = text[:1500]
|
head = text[:1500]
|
||||||
if not any(marker in head for marker in _NEVO_MARKERS):
|
if not any(marker in head for marker in _NEVO_MARKERS):
|
||||||
return text
|
return text
|
||||||
@@ -419,17 +426,7 @@ _RATIO_MARKER = "מיני-רציו:"
|
|||||||
|
|
||||||
|
|
||||||
def extract_nevo_ratio(text: str) -> str:
|
def extract_nevo_ratio(text: str) -> str:
|
||||||
"""Return the Nevo מיני-רציו block (editorial holdings summary), or ''.
|
"""Return the Nevo מיני-רציו block (editorial holdings summary), or ''."""
|
||||||
|
|
||||||
The mini-ratio is Nevo's own headnote — a concise, professionally-written
|
|
||||||
list of the holdings. We capture it *before* :func:`strip_nevo_preamble`
|
|
||||||
discards it, to serve as a free gold-set for benchmarking how well our
|
|
||||||
halacha extractor covers the real holdings (#86.3).
|
|
||||||
|
|
||||||
The block runs from the ``מיני-רציו:`` marker to whichever comes first:
|
|
||||||
the decision body (``_DECISION_START``) or the next preamble marker
|
|
||||||
(bibliography / legislation). Returns '' when there is no mini-ratio.
|
|
||||||
"""
|
|
||||||
if not text:
|
if not text:
|
||||||
return ""
|
return ""
|
||||||
start = text.find(_RATIO_MARKER)
|
start = text.find(_RATIO_MARKER)
|
||||||
@@ -437,9 +434,6 @@ def extract_nevo_ratio(text: str) -> str:
|
|||||||
return ""
|
return ""
|
||||||
body = text[start + len(_RATIO_MARKER):]
|
body = text[start + len(_RATIO_MARKER):]
|
||||||
|
|
||||||
# End at the earliest of: decision body start, or a following preamble
|
|
||||||
# marker (ספרות: / חקיקה שאוזכרה: / ...). Both are measured relative to
|
|
||||||
# the ratio body so we never run past it into the judgment itself.
|
|
||||||
end = len(body)
|
end = len(body)
|
||||||
dm = _DECISION_START.search(body)
|
dm = _DECISION_START.search(body)
|
||||||
if dm:
|
if dm:
|
||||||
|
|||||||
@@ -153,6 +153,7 @@
|
|||||||
|
|
||||||
| Script | Type | Purpose | Scheduled |
|
| Script | Type | Purpose | Scheduled |
|
||||||
|--------|------|---------|-----------|
|
|--------|------|---------|-----------|
|
||||||
|
| `ocr_benchmark_mistral.py` | python | **בנצ'מרק OCR — Mistral מול Google Vision** (מחקר חד-פעמי, הוביל למעבר ל-Mistral). מוריד מסמכים מ-MinIO, קורא טקסט קיים מה-DB, שולח ל-Mistral OCR, מחשב מטריקות (כיסוי/ניקיון/עברית%), שומר דוח ל-`data/audit/ocr-benchmark-mistral.md` + טקסטים גולמיים ל-`data/audit/ocr-benchmark-raw/`. הרצה: `mcp-server/.venv/bin/python scripts/ocr_benchmark_mistral.py`. דורש `MISTRAL_API_KEY` ו-mcli alias `legalminio`. **ממצא:** Mistral מנצח ב-4/5 תיקים בדוגמה ומטפל נכון ב-OCR שבור (1044-03-26 שהחזיר "English garbage" ב-Vision). | חד-פעמי (בוצע 2026-06-27) |
|
||||||
| `backfill_missing_precedents.py` | python | **הזנת `missing_precedents` פתוחים לתור-האחזור (X13)** — מסווג כל פער-פתוח; עליון-סדרתי→Tier-0(supremedecisions), נט-format→Tier-1; ועדת-ערר/לא-מזוהה→דילוג. יוצר `court_fetch_jobs` (idempotent). `--apply` (ברירת-מחדל dry-run). אחרי הרצה: drain-court-fetch קולט. | ידני (חד-פעמי/לפי-צורך) |
|
| `backfill_missing_precedents.py` | python | **הזנת `missing_precedents` פתוחים לתור-האחזור (X13)** — מסווג כל פער-פתוח; עליון-סדרתי→Tier-0(supremedecisions), נט-format→Tier-1; ועדת-ערר/לא-מזוהה→דילוג. יוצר `court_fetch_jobs` (idempotent). `--apply` (ברירת-מחדל dry-run). אחרי הרצה: drain-court-fetch קולט. | ידני (חד-פעמי/לפי-צורך) |
|
||||||
| `derive_missing_from_cited_only.py` | python | **#143 — איחוד cited_only↔missing_precedents (G2)**: גוזר רשומת `missing_precedents` 'open' לכל stub `cited_only` (פסיקה מצוטטת ללא טקסט), כך ש-31 ה-stubs מופיעים בדף "פסיקה חסרה" (היו היו חפיפה≈0). (1) backfill `citation_norm` (מפתח-dedup designator-aware — `court_citation.citation_dedup_key`) ל-291 הקיימים; (2) לכל stub → `create_missing_precedent(discovery_source='cited_only', linked_case_law_id=stub, notes=מצטטים)` עם dedup. `linked_case_law_id`=זהות-קנונית-ידועה, `status='open'` עד העלאת-טקסט (→ promote-in-place דרך ON CONFLICT). אידמפוטנטי, dry-run / `--apply`. הרצה: `HOME=/home/chaim mcp-server/.venv/bin/python scripts/derive_missing_from_cited_only.py --apply`. | חד-פעמי / re-runnable |
|
| `derive_missing_from_cited_only.py` | python | **#143 — איחוד cited_only↔missing_precedents (G2)**: גוזר רשומת `missing_precedents` 'open' לכל stub `cited_only` (פסיקה מצוטטת ללא טקסט), כך ש-31 ה-stubs מופיעים בדף "פסיקה חסרה" (היו היו חפיפה≈0). (1) backfill `citation_norm` (מפתח-dedup designator-aware — `court_citation.citation_dedup_key`) ל-291 הקיימים; (2) לכל stub → `create_missing_precedent(discovery_source='cited_only', linked_case_law_id=stub, notes=מצטטים)` עם dedup. `linked_case_law_id`=זהות-קנונית-ידועה, `status='open'` עד העלאת-טקסט (→ promote-in-place דרך ON CONFLICT). אידמפוטנטי, dry-run / `--apply`. הרצה: `HOME=/home/chaim mcp-server/.venv/bin/python scripts/derive_missing_from_cited_only.py --apply`. | חד-פעמי / re-runnable |
|
||||||
| `backfill_digest_missing_precedents.py` | python | **#136 — חיבור יומונים-לא-מקושרים ל"פסיקה חסרה"**: לכל digest עם `underlying_citation` ו-`linked_case_law_id IS NULL` (461) מריץ את `digest_library.try_autolink` הקנוני (G2) — מקשר אם אפשר, אחרת פותח gap: ערר/בל"מ/unknown → `missing_precedent` (discovery_source='digest', dedup designator-aware), פס"ד בתי-משפט → `court_fetch_job` (X13). dry-run מציג פילוח-tier (369 ערר + 21 unknown → MP; 71 fetchable → court_fetch). אידמפוטנטי. הרצה: `HOME=/home/chaim mcp-server/.venv/bin/python scripts/backfill_digest_missing_precedents.py --apply`. | חד-פעמי / re-runnable |
|
| `backfill_digest_missing_precedents.py` | python | **#136 — חיבור יומונים-לא-מקושרים ל"פסיקה חסרה"**: לכל digest עם `underlying_citation` ו-`linked_case_law_id IS NULL` (461) מריץ את `digest_library.try_autolink` הקנוני (G2) — מקשר אם אפשר, אחרת פותח gap: ערר/בל"מ/unknown → `missing_precedent` (discovery_source='digest', dedup designator-aware), פס"ד בתי-משפט → `court_fetch_job` (X13). dry-run מציג פילוח-tier (369 ערר + 21 unknown → MP; 71 fetchable → court_fetch). אידמפוטנטי. הרצה: `HOME=/home/chaim mcp-server/.venv/bin/python scripts/backfill_digest_missing_precedents.py --apply`. | חד-פעמי / re-runnable |
|
||||||
|
|||||||
Reference in New Issue
Block a user