"""Surya OCR intake (PRD FR-1). Accepts .png/.jpg/.pdf and extracts candidate demographic fields. Any field whose OCR confidence < threshold is blanked and flagged `needs_review=True` so the frontend forces manual validation. Surya's dependencies (transformers 5.x + llama.cpp) conflict with the embedding stack, so the actual OCR runs in an isolated interpreter (backend/.venv-ocr) via `ocr_worker.py`, invoked here as a subprocess. PHIPA: the upload bytes are written to a temp file only for the duration of the call and deleted immediately afterwards (ephemeral retention). """ from __future__ import annotations import json import logging import os import re import subprocess import sys import tempfile from pathlib import Path from .config import settings from .schemas import ExtractedField, IntakeResponse logger = logging.getLogger(__name__) _WORKER = Path(__file__).with_name("ocr_worker.py") _ISOLATED_PY = Path(__file__).resolve().parents[1] / ".venv-ocr" / "bin" / "python" # Lightweight regex heuristics to pull structured demographics from OCR text. FIELD_PATTERNS: dict[str, re.Pattern] = { # Ontario health card: 10 digits + optional 2-letter version code "health_card_number": re.compile(r"\b(\d{4}[-\s]?\d{3}[-\s]?\d{3}[-\s]?[A-Z]{0,2})\b"), "date_of_birth": re.compile( r"\b(\d{4}[-/]\d{2}[-/]\d{2}|\d{2}[-/]\d{2}[-/]\d{4})\b" ), } def _ocr_interpreter() -> str: if settings.ocr_python: return settings.ocr_python if _ISOLATED_PY.exists(): return str(_ISOLATED_PY) return sys.executable # fall back to current venv (needs surya installed) def _run_worker(content: bytes, filename: str) -> tuple[str, list[tuple[str, float]]]: suffix = os.path.splitext(filename)[1] or ".png" in_fd, in_path = tempfile.mkstemp(suffix=suffix) out_path = in_path + ".json" try: with os.fdopen(in_fd, "wb") as f: f.write(content) subprocess.run( [_ocr_interpreter(), str(_WORKER), in_path, out_path], check=True, capture_output=True, timeout=300, ) data = json.loads(Path(out_path).read_text()) lines = [(l["text"], float(l["confidence"])) for l in data["lines"]] return data["raw_text"], lines except subprocess.CalledProcessError as exc: # noqa: BLE001 logger.error("OCR worker failed: %s", exc.stderr.decode(errors="ignore")[-500:]) raise RuntimeError("OCR processing failed") from exc finally: for p in (in_path, out_path): try: os.unlink(p) except OSError: pass def run_ocr(content: bytes, filename: str) -> IntakeResponse: raw_text, lines = _run_worker(content, filename) fields = _extract_fields(raw_text, lines) return IntakeResponse(fields=fields, raw_text=raw_text) def _extract_fields( raw_text: str, lines: list[tuple[str, float]] ) -> list[ExtractedField]: """Match demographic patterns and attach the confidence of the source line.""" threshold = settings.ocr_confidence_threshold out: list[ExtractedField] = [] for field_name, pattern in FIELD_PATTERNS.items(): match = None line_conf = 0.0 for text, conf in lines: m = pattern.search(text) if m: match = m.group(1) line_conf = conf break needs_review = (match is None) or (line_conf < threshold) out.append( ExtractedField( field_name=field_name, # FR-1 fallback: blank the box when confidence is too low value=None if needs_review else match, confidence=round(line_conf, 3), needs_review=needs_review, ) ) return out