api/routers/exam/analyse.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

102 lines
4.7 KiB
Python

"""AI auto-map: analyse a template's source paper → ghost-region suggestions (spec R3.1/R3.3/R3.5/R5.4).
The teacher's "Auto-detect remaining" button hits this. Because extraction on an image-only paper takes
~15-60s (Card-12 grounding), it runs ASYNC: POST returns a job_id, the canvas polls GET /jobs/{id}, and on
completion gets `suggestions` (questions + response_areas + boundaries) to render as GHOSTS
(`source:'ai', confirmed:false`, opacity by confidence). The teacher confirms/dismisses; nothing here is
authoritative and it never overwrites manual shapes (R3.1/R3.3). Persisting confirmed shapes is the
existing PUT /templates/{id} path.
Access is as-the-user (E1/E2): the template is read under RLS (a template the caller can't see reads back
absent → 404, IDOR-safe), and a job is only visible to the user who started it. The heavy pipeline lives
behind `modules.services.exam_extract` (the docling-exam-spike deployed as the extraction service).
"""
from __future__ import annotations
import os
import time
import uuid
from typing import Any, Dict
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
from modules.logger_tool import initialise_logger
from modules.services.exam_extract import ExtractionUnavailable, analyse_source
from routers.exam.dependencies import ExamContext, get_exam_context
logger = initialise_logger(__name__, os.getenv("LOG_LEVEL"), os.getenv("LOG_PATH"), "default", True)
router = APIRouter()
# In-process job store. R5.4 allows BackgroundTasks + a status row for Sprint-4; a durable `exam_jobs`
# table (survives restarts / multiple workers) is the follow-on. Keyed by job_id; scoped to its user.
JOBS: Dict[str, Dict[str, Any]] = {}
_JOB_TTL = 3600 # forget finished jobs after an hour so the dict doesn't grow unbounded
def _gc() -> None:
now = time.time()
for jid in [j for j, v in JOBS.items() if now - v.get("created", now) > _JOB_TTL]:
JOBS.pop(jid, None)
def _template_or_404(ctx: ExamContext, template_id: str) -> Dict[str, Any]:
"""Read the template AS THE USER — RLS makes a non-owned/other-institute template read as absent."""
res = (
ctx.supabase.table("exam_templates")
.select("id, exam_code, source_file_id, institute_id")
.eq("id", template_id)
.execute()
)
rows = getattr(res, "data", None) or []
if not rows:
raise HTTPException(status_code=404, detail="Template not found")
return rows[0] if isinstance(rows, list) else rows
async def _run_job(job_id: str, source: Dict[str, Any], exam_code: str | None) -> None:
job = JOBS.get(job_id)
if not job:
return
job["status"] = "running"
try:
sugg = await analyse_source(source, exam_code)
job.update(status="complete", progress=100, suggestions=sugg,
counts={k: len(sugg.get(k, [])) for k in ("questions", "response_areas", "boundaries")})
except ExtractionUnavailable as exc:
logger.warning(f"analyse job {job_id}: extraction unavailable: {exc}")
job.update(status="error", error=str(exc))
except Exception as exc: # noqa: BLE001 - the job must record any failure, not crash the worker
logger.error(f"analyse job {job_id} failed: {exc}")
job.update(status="error", error=f"{type(exc).__name__}: {exc}")
@router.post("/templates/{template_id}/analyse")
async def start_analyse(
template_id: str,
background: BackgroundTasks,
ctx: ExamContext = Depends(get_exam_context),
) -> Dict[str, Any]:
"""Kick off extraction for this template's source paper → {job_id}. Poll GET /jobs/{job_id}."""
_gc()
tmpl = _template_or_404(ctx, template_id)
job_id = str(uuid.uuid4())
JOBS[job_id] = {"id": job_id, "template_id": template_id, "user_id": ctx.user_id,
"status": "queued", "progress": 0, "created": time.time()}
# the extraction service resolves + renders the source itself; the API only forwards the pointer
source = {"file_id": tmpl.get("source_file_id"), "exam_code": tmpl.get("exam_code")}
background.add_task(_run_job, job_id, source, tmpl.get("exam_code"))
return {"job_id": job_id, "status": "queued"}
@router.get("/jobs/{job_id}")
async def get_job(job_id: str, ctx: ExamContext = Depends(get_exam_context)) -> Dict[str, Any]:
"""Poll an analyse job. On `complete`, `suggestions` = ghost regions for the canvas to render."""
job = JOBS.get(job_id)
if not job or job.get("user_id") != ctx.user_id: # a job is only visible to the user who started it
raise HTTPException(status_code=404, detail="Job not found")
out = {k: job.get(k) for k in ("id", "template_id", "status", "progress", "error", "counts")}
if job.get("status") == "complete":
out["suggestions"] = job.get("suggestions")
return out