"""Client for the exam extraction SERVICE (docling-exam-spike, P1). The service runs the spike's FULL recognition pipeline (textlayer → question tree → OMR/figure/table sidecars → structure fusion → analyse) and returns the ghost-region contract the app already consumes. This module is a thin HTTP client: POST the paper, poll, return the `analyse` suggestions. The app's auto-map merges them (coordinate-adapted, id-remapped) in routers/exam/templates.py. Config: EXAM_EXTRACT_URL (e.g. http://192.168.0.203:8899). If unset, the app keeps its thin first-pass. """ from __future__ import annotations import base64 import os import time from typing import Any, Dict, Optional import requests from modules.logger_tool import initialise_logger logger = initialise_logger(__name__, os.getenv("LOG_LEVEL"), os.getenv("LOG_PATH"), "default", True) class ExtractError(RuntimeError): pass def service_url() -> Optional[str]: url = os.getenv("EXAM_EXTRACT_URL") return url.rstrip("/") if url else None def is_enabled() -> bool: return bool(service_url()) def _get(base: str, slug: str, timeout: int = 30) -> Dict[str, Any]: r = requests.get(f"{base}/api/extract/{slug}", timeout=timeout) r.raise_for_status() return r.json() def extract_suggestions(slug: str, pdf_bytes: bytes, *, force: bool = False, poll_timeout: int = 1500, poll_interval: int = 5) -> Dict[str, Any]: """POST the paper to the service and poll until the analyse contract is ready. Returns the full analyse payload: {status, coordinate_space:'page_fraction', margins, suggestions:{questions[], response_areas[], boundaries[]}, meta}. Raises ExtractError on failure/timeout. A cold paper is ~15 min (Docling per masked page); cached papers return instantly. """ base = service_url() if not base: raise ExtractError("EXAM_EXTRACT_URL not configured") payload = {"slug": slug, "pdf_b64": base64.b64encode(pdf_bytes).decode(), "force": force} r = requests.post(f"{base}/api/extract", json=payload, timeout=120) r.raise_for_status() started = r.json() if not started.get("ok", True): raise ExtractError(started.get("error") or "service rejected the request") # cached → fetch the contract straight away; otherwise poll the running job deadline = time.time() + poll_timeout while True: d = _get(base, slug) status = d.get("status") if status == "complete": if not (d.get("suggestions") or {}): raise ExtractError("service returned complete with no suggestions") return d if status == "error": raise ExtractError(f"extraction failed: {d.get('error')}") if time.time() >= deadline: raise ExtractError(f"extraction timed out after {poll_timeout}s (slug={slug})") time.sleep(poll_interval)