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
@@ -8,9 +8,11 @@ from fastapi import APIRouter
|
||||
|
||||
from routers.exam.templates import router as templates_router
|
||||
from routers.exam.batches import router as batches_router
|
||||
from routers.exam.analyse import router as analyse_router
|
||||
|
||||
router = APIRouter()
|
||||
router.include_router(templates_router)
|
||||
router.include_router(batches_router)
|
||||
router.include_router(analyse_router)
|
||||
|
||||
__all__ = ["router"]
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user