api/modules/services/exam_extract.py
CC Worker c0458f5528 S6-1: /api/exam analyse endpoint — structure → ghost-region suggestions (async)
The canvas "Auto-detect" path (spec R3.1/R3.3/R3.5/R5.4). Async because extraction on
an image-only paper takes ~15-60s (Card-12 grounding):

  POST /api/exam/templates/{id}/analyse -> {job_id}   (BackgroundTasks)
  GET  /api/exam/jobs/{id}              -> {status, counts, suggestions}

suggestions = questions + response_areas + boundaries, each source:'ai',confirmed:false
with confidence + page-fraction bounds + deterministic uuids — rendered as ghosts the
teacher confirms/dismisses; never overwrites manual shapes; persistence stays PUT /templates/{id}.

Access is as-the-user (E1/E2): the template is read under RLS (unseen -> 404, IDOR-safe)
and a job is only visible to the user who started it.

The heavy pipeline stays OUT of the API behind modules/services/exam_extract.py — an HTTP
client (aiohttp) to the exam-structure extraction service (the docling-exam-spike pipeline,
GPU-attached, deterministic), mirroring how the app calls Docling Serve/Ollama. Configured
via EXAM_EXTRACT_URL; unset -> the job fails cleanly as 'extraction unavailable'.

Follow-on (S6-1b): stand up that extraction service (POST /api/extract: source -> suggestions)
wrapping scripts/structure.py + scripts/analyse.py.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 19:45:26 +00:00

72 lines
3.7 KiB
Python

"""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"),
}