"""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