"""Exam structure-EXTRACTION client — the seam between the /api/exam analyse endpoint and the deterministic exam-structure pipeline (Card 12 = GO; verdict in docling-exam-spike). Why a service, not an in-process port: the pipeline is heavy (Docling + OCR + a question/mark/geometry parser) and already exists, GPU-attached, in the spike. Following the same pattern the app uses for Docling Serve and Ollama (§2 infra map), the API calls it over HTTP rather than vendoring thousands of lines. This module is the ONLY place the API knows about that service. Contract (the extraction service returns the app's ghost-region 'analyse' suggestions directly — the mapping lives in the pipeline, `scripts/analyse.py` in the spike; see results/ANALYSE-BRIDGE.md): POST {EXAM_EXTRACT_URL}/api/extract {"source": {...}, "exam_code": "AQA-PHYS-8463-1H-22-JUN"} -> {"status": "complete", "coordinate_space": "page_fraction", "suggestions": {"questions": [...], "response_areas": [...], "boundaries": [...]}} Every suggestion is `source:'ai', confirmed:false` with a `confidence` float and page-fraction bounds (the canvas scales by the PDF render size, R3.2); uuids are deterministic so re-analysing is stable (§2 join keys). The API never trusts these as final — the teacher confirms/dismisses (R3.1/R3.3). """ from __future__ import annotations import os from typing import Any, Dict, Optional import aiohttp from modules.logger_tool import initialise_logger logger = initialise_logger(__name__, os.getenv("LOG_LEVEL"), os.getenv("LOG_PATH"), "default", True) # The exam-structure extraction service (the docling-exam-spike pipeline deployed as a service). EXAM_EXTRACT_URL = os.getenv("EXAM_EXTRACT_URL", "").rstrip("/") EXAM_EXTRACT_TIMEOUT = float(os.getenv("EXAM_EXTRACT_TIMEOUT", "180")) class ExtractionUnavailable(RuntimeError): """The extraction service is not configured/reachable — surfaced to the job as a clean failure.""" async def analyse_source(source: Dict[str, Any], exam_code: Optional[str] = None) -> Dict[str, Any]: """Run the extraction pipeline on a template's source paper → ghost-region suggestions. `source` identifies the paper for the service to fetch/render: a storage-resolvable pointer (e.g. {"storage_url": "..."} or {"file_id": "..."}) and/or a catalogue {"exam_code": "..."}. Resolution + PDF rendering happen service-side; the API only forwards the pointer + exam_code. """ if not EXAM_EXTRACT_URL: raise ExtractionUnavailable( "EXAM_EXTRACT_URL is not set — the exam-structure extraction service is not wired. " "Deploy the docling-exam-spike pipeline as the extraction service and set EXAM_EXTRACT_URL." ) payload = {"source": source, "exam_code": exam_code} try: timeout = aiohttp.ClientTimeout(total=EXAM_EXTRACT_TIMEOUT) async with aiohttp.ClientSession(timeout=timeout) as session: async with session.post(f"{EXAM_EXTRACT_URL}/api/extract", json=payload) as resp: resp.raise_for_status() data = await resp.json() except aiohttp.ClientError as exc: logger.error(f"exam extraction service call failed: {exc}") raise ExtractionUnavailable(f"extraction service error: {exc}") from exc sugg = (data or {}).get("suggestions") or {} # normalise: the three suggestion arrays are always present so the canvas can render partial results return { "coordinate_space": data.get("coordinate_space", "page_fraction"), "questions": sugg.get("questions", []), "response_areas": sugg.get("response_areas", []), "boundaries": sugg.get("boundaries", []), "meta": data.get("meta"), }