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>
72 lines
3.7 KiB
Python
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"),
|
|
}
|