"""Computer-Vision inference + OCR bridge (BONUS, inference-only). Loads the trained résumé-detector (:mod:`src.cv_model`) and exposes the two functions the app needs to fold an *image* into the NLP→ML pipeline: * :func:`classify_document` — "is this page a résumé?" (gate / validator). * :func:`ocr_image` — turn the image into text so NLP can extract from it. Uses OpenAI Vision when ``OPENAI_API_KEY`` is set (no extra OCR engine to ship); returns ``None`` otherwise so the caller can fall back to manual paste. Everything is **lazy & capability-gated**: importing this module never requires torch. :func:`cv_available` tells the app whether to show the image path at all, so the lean Hugging Face Space (no torch) simply hides it. """ from __future__ import annotations import base64 import io from pathlib import Path ROOT = Path(__file__).resolve().parents[1] CV_ARTIFACT = ROOT / "artifacts" / "cv_model.joblib" _BUNDLE = None def cv_available() -> bool: """True iff torch/torchvision are importable *and* the CV artifact exists.""" if not CV_ARTIFACT.exists(): return False import importlib.util return all(importlib.util.find_spec(m) for m in ("torch", "torchvision")) def _bundle(): global _BUNDLE if _BUNDLE is None: import joblib _BUNDLE = joblib.load(CV_ARTIFACT) return _BUNDLE def classify_document(image) -> dict: """Classify a PIL image as ``resume`` vs ``other``. Returns ``{"label", "p_resume", "backbone"}``. ``p_resume`` is the model's probability for the résumé class (calibrated only as well as LogisticRegression). """ from src.cv_model import extract_features # local import: needs torch b = _bundle() feats = extract_features([image], b["backbone"]) clf = b["clf"] classes = list(clf.classes_) proba = clf.predict_proba(feats)[0] p_resume = float(proba[classes.index("resume")]) if "resume" in classes else 0.0 label = "resume" if p_resume >= 0.5 else "other" return {"label": label, "p_resume": p_resume, "backbone": b["backbone"]} _OCR_SYS = ( "You are an OCR engine. Transcribe the résumé in the image to plain UTF-8 text. " "Preserve section headings and bullet points; do not summarise, comment, or invent." ) def ocr_image(image) -> str | None: """Transcribe a résumé image to text via OpenAI Vision (key-gated). Returns the transcribed text, or ``None`` if no key is set or the call fails. """ from src import nlp if not nlp.llm_available(): return None try: buf = io.BytesIO() image.convert("RGB").save(buf, format="PNG") b64 = base64.b64encode(buf.getvalue()).decode("ascii") resp = nlp._client().chat.completions.create( model=nlp._model(), messages=[ {"role": "system", "content": _OCR_SYS}, {"role": "user", "content": [ {"type": "text", "text": "Transcribe this résumé to plain text."}, {"type": "image_url", "image_url": {"url": f"data:image/png;base64,{b64}"}}, ]}, ], temperature=0.0, ) text = (resp.choices[0].message.content or "").strip() return text or None except Exception as exc: # noqa: BLE001 — degrade gracefully, but log the reason print(f"[ocr_image] OpenAI Vision OCR failed: {type(exc).__name__}: {exc}", flush=True) return None