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 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
e269e67f27
commit
c0458f5528
@@ -0,0 +1,71 @@
|
||||
"""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"),
|
||||
}
|
||||
Reference in New Issue
Block a user