Compare commits
22 Commits
agent/fx-2
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| e48dd73fdf | |||
| 547836e04b | |||
| df128508a3 | |||
| 81bf44c6cc | |||
| 544d858f62 | |||
| 7d1876b799 | |||
| c79a161119 | |||
| 08af7c8ca1 | |||
| 98210f2cff | |||
| d3d0639f44 | |||
| 52801a5d34 | |||
| 6a6d12875d | |||
| df827b990c | |||
| 4f66fb83ce | |||
| a0f77333b9 | |||
| e4b0477b77 | |||
| e95b148e0f | |||
| b0b59077bc | |||
|
|
931e254b93 | ||
|
|
47e45f59e8 | ||
|
|
41614b78ef | ||
|
|
1671518ca8 |
@ -55,6 +55,8 @@ services:
|
||||
- CC_COMPOSE_SERVICE=backend-dev
|
||||
- RUN_INIT=false
|
||||
- INIT_MODE=infra
|
||||
# P2: route exam auto-map through the spike's full recognition pipeline (extraction service)
|
||||
- EXAM_EXTRACT_URL=${EXAM_EXTRACT_URL:-http://192.168.0.203:8899}
|
||||
ports:
|
||||
- "18000:8000"
|
||||
depends_on:
|
||||
|
||||
86
modules/services/exam_extract.py
Normal file
86
modules/services/exam_extract.py
Normal file
@ -0,0 +1,86 @@
|
||||
"""Client for the exam extraction SERVICE (docling-exam-spike, P1).
|
||||
|
||||
The service runs the spike's FULL recognition pipeline (textlayer → question tree → OMR/figure/table
|
||||
sidecars → structure fusion → analyse) and returns the ghost-region contract the app already consumes.
|
||||
This module is a thin HTTP client: POST the paper, poll, return the `analyse` suggestions. The app's
|
||||
auto-map merges them (coordinate-adapted, id-remapped) in routers/exam/templates.py.
|
||||
|
||||
Config: EXAM_EXTRACT_URL (e.g. http://192.168.0.203:8899). If unset, the app keeps its thin first-pass.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import os
|
||||
import time
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import requests
|
||||
|
||||
from modules.logger_tool import initialise_logger
|
||||
|
||||
logger = initialise_logger(__name__, os.getenv("LOG_LEVEL"), os.getenv("LOG_PATH"), "default", True)
|
||||
|
||||
|
||||
class ExtractError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def service_url() -> Optional[str]:
|
||||
url = os.getenv("EXAM_EXTRACT_URL")
|
||||
return url.rstrip("/") if url else None
|
||||
|
||||
|
||||
def is_enabled() -> bool:
|
||||
return bool(service_url())
|
||||
|
||||
|
||||
def _get(base: str, slug: str, timeout: int = 30) -> Dict[str, Any]:
|
||||
r = requests.get(f"{base}/api/extract/{slug}", timeout=timeout)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
|
||||
def get_replica(slug: str, timeout: int = 30) -> Dict[str, Any]:
|
||||
"""The digital-replica markdown for a paper (P4): {slug, title, n_questions, total_marks, markdown,
|
||||
questions:[{label, marks, markdown}]}. Raises ExtractError if the paper has no replica yet."""
|
||||
base = service_url()
|
||||
if not base:
|
||||
raise ExtractError("EXAM_EXTRACT_URL not configured")
|
||||
r = requests.get(f"{base}/api/replica/{slug}", timeout=timeout)
|
||||
if r.status_code == 404:
|
||||
raise ExtractError(f"no digital replica for {slug}")
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
|
||||
def extract_suggestions(slug: str, pdf_bytes: bytes, *, force: bool = False,
|
||||
poll_timeout: int = 1500, poll_interval: int = 5) -> Dict[str, Any]:
|
||||
"""POST the paper to the service and poll until the analyse contract is ready.
|
||||
|
||||
Returns the full analyse payload: {status, coordinate_space:'page_fraction', margins,
|
||||
suggestions:{questions[], response_areas[], boundaries[]}, meta}. Raises ExtractError on
|
||||
failure/timeout. A cold paper is ~15 min (Docling per masked page); cached papers return instantly.
|
||||
"""
|
||||
base = service_url()
|
||||
if not base:
|
||||
raise ExtractError("EXAM_EXTRACT_URL not configured")
|
||||
payload = {"slug": slug, "pdf_b64": base64.b64encode(pdf_bytes).decode(), "force": force}
|
||||
r = requests.post(f"{base}/api/extract", json=payload, timeout=120)
|
||||
r.raise_for_status()
|
||||
started = r.json()
|
||||
if not started.get("ok", True):
|
||||
raise ExtractError(started.get("error") or "service rejected the request")
|
||||
# cached → fetch the contract straight away; otherwise poll the running job
|
||||
deadline = time.time() + poll_timeout
|
||||
while True:
|
||||
d = _get(base, slug)
|
||||
status = d.get("status")
|
||||
if status == "complete":
|
||||
if not (d.get("suggestions") or {}):
|
||||
raise ExtractError("service returned complete with no suggestions")
|
||||
return d
|
||||
if status == "error":
|
||||
raise ExtractError(f"extraction failed: {d.get('error')}")
|
||||
if time.time() >= deadline:
|
||||
raise ExtractError(f"extraction timed out after {poll_timeout}s (slug={slug})")
|
||||
time.sleep(poll_interval)
|
||||
@ -8,9 +8,13 @@ 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.bank import router as bank_router
|
||||
from routers.exam.corpus import router as corpus_router
|
||||
|
||||
router = APIRouter()
|
||||
router.include_router(templates_router)
|
||||
router.include_router(batches_router)
|
||||
router.include_router(bank_router)
|
||||
router.include_router(corpus_router)
|
||||
|
||||
__all__ = ["router"]
|
||||
|
||||
152
routers/exam/bank.py
Normal file
152
routers/exam/bank.py
Normal file
@ -0,0 +1,152 @@
|
||||
"""Question bank + custom-paper assembly (/api/exam/bank, /api/exam/custom-papers) — mode 3.
|
||||
|
||||
Build-your-own-from-the-spec. The BANK is a browsable index over the leaf questions across the templates
|
||||
the caller can see (RLS-scoped, institute-wide); a CUSTOM PAPER is a normal exam_template whose questions
|
||||
are COPIES of the selected bank questions, so it flows through the existing setup / marking / projection
|
||||
pipeline unchanged. Copy-on-assemble (v1) avoids decoupling question identity from a template; a shared-
|
||||
question model is Phase-2. Design: ~/cc/ideas/2026-07-02-mode3-question-bank-design.md.
|
||||
|
||||
All access is as-the-user (E1): the bank query returns only questions in templates RLS lets the caller see,
|
||||
and a custom paper is created owned by the caller + institute-scoped (writes pass the same RLS with-check).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import uuid
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from modules.database.services.exam_projection import project_template_safe
|
||||
from modules.logger_tool import initialise_logger
|
||||
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()
|
||||
|
||||
|
||||
def _rows(result: Any) -> List[Dict[str, Any]]:
|
||||
data = getattr(result, "data", None)
|
||||
if not data:
|
||||
return []
|
||||
return data if isinstance(data, list) else [data]
|
||||
|
||||
|
||||
def _first(result: Any) -> Optional[Dict[str, Any]]:
|
||||
rows = _rows(result)
|
||||
return rows[0] if rows else None
|
||||
|
||||
|
||||
@router.get("/bank")
|
||||
async def list_bank(
|
||||
spec_ref: Optional[str] = None,
|
||||
subject: Optional[str] = None,
|
||||
ctx: ExamContext = Depends(get_exam_context),
|
||||
) -> Dict[str, Any]:
|
||||
"""Leaf questions across the caller's institute templates, with their source-paper context.
|
||||
|
||||
Filter by `spec_ref` (spec point) and/or `subject`. Facets over the full visible set drive the UI
|
||||
filters (so filtering by one axis doesn't hide the others' options). This is the spec-planning surface:
|
||||
a teacher picks a spec point → gets every question tagged it across their papers → assembles a paper.
|
||||
"""
|
||||
sel = (
|
||||
"id, template_id, label, max_marks, answer_type, spec_ref, bounds, page, "
|
||||
"exam_templates(id, title, subject, exam_code)"
|
||||
)
|
||||
query = ctx.supabase.table("exam_questions").select(sel).eq("is_container", False)
|
||||
if spec_ref:
|
||||
query = query.eq("spec_ref", spec_ref)
|
||||
rows = _rows(query.execute())
|
||||
|
||||
facets_spec: Dict[str, int] = {}
|
||||
facets_subject: Dict[str, int] = {}
|
||||
items: List[Dict[str, Any]] = []
|
||||
for r in rows:
|
||||
tmpl = r.get("exam_templates") or {}
|
||||
subj = tmpl.get("subject")
|
||||
if r.get("spec_ref"):
|
||||
facets_spec[r["spec_ref"]] = facets_spec.get(r["spec_ref"], 0) + 1
|
||||
if subj:
|
||||
facets_subject[subj] = facets_subject.get(subj, 0) + 1
|
||||
if subject and subj != subject:
|
||||
continue
|
||||
items.append({
|
||||
"id": r["id"], "template_id": r["template_id"], "label": r.get("label"),
|
||||
"max_marks": r.get("max_marks"), "answer_type": r.get("answer_type"),
|
||||
"spec_ref": r.get("spec_ref"), "bounds": r.get("bounds"), "page": r.get("page"),
|
||||
"paper": {"id": tmpl.get("id"), "title": tmpl.get("title"),
|
||||
"subject": subj, "exam_code": tmpl.get("exam_code")},
|
||||
})
|
||||
return {"questions": items, "n": len(items),
|
||||
"facets": {"spec_ref": facets_spec, "subject": facets_subject}}
|
||||
|
||||
|
||||
class CustomPaperRequest(BaseModel):
|
||||
title: str
|
||||
subject: Optional[str] = None
|
||||
institute_id: Optional[str] = None
|
||||
question_ids: List[str]
|
||||
|
||||
|
||||
@router.post("/custom-papers")
|
||||
async def create_custom_paper(
|
||||
body: CustomPaperRequest,
|
||||
ctx: ExamContext = Depends(get_exam_context),
|
||||
) -> Dict[str, Any]:
|
||||
"""Assemble the selected bank questions into a new template (copy-on-assemble), then project it."""
|
||||
if not body.question_ids:
|
||||
raise HTTPException(status_code=400, detail="question_ids is required")
|
||||
institute_id = ctx.resolve_institute(body.institute_id)
|
||||
|
||||
# RLS makes this return only leaf questions the caller may see; unknown/forbidden ids are silently dropped.
|
||||
srcs = _rows(
|
||||
ctx.supabase.table("exam_questions").select("*")
|
||||
.in_("id", body.question_ids).eq("is_container", False).execute()
|
||||
)
|
||||
by_id = {s["id"]: s for s in srcs}
|
||||
ordered = [by_id[qid] for qid in body.question_ids if qid in by_id] # preserve the caller's order
|
||||
if not ordered:
|
||||
raise HTTPException(status_code=404, detail="No accessible questions for the given ids")
|
||||
|
||||
template_id = str(uuid.uuid4())
|
||||
ctx.supabase.table("exam_templates").insert({
|
||||
"id": template_id, "title": body.title, "subject": body.subject,
|
||||
"institute_id": institute_id, "teacher_id": ctx.user_id, "status": "draft",
|
||||
}).execute()
|
||||
|
||||
id_map: Dict[str, str] = {}
|
||||
q_rows: List[Dict[str, Any]] = []
|
||||
for order, s in enumerate(ordered):
|
||||
new_id = str(uuid.uuid4())
|
||||
id_map[s["id"]] = new_id
|
||||
q_rows.append({
|
||||
"id": new_id, "template_id": template_id, "parent_id": None,
|
||||
"label": s.get("label"), "order": order, "max_marks": s.get("max_marks") or 0,
|
||||
"answer_type": s.get("answer_type"), "mcq_options": s.get("mcq_options"),
|
||||
"mark_scheme": s.get("mark_scheme") or {}, "is_container": False,
|
||||
"spec_ref": s.get("spec_ref"), "bounds": s.get("bounds"), "page": s.get("page"),
|
||||
"source": "manual", "confirmed": True,
|
||||
})
|
||||
ctx.supabase.table("exam_questions").insert(q_rows).execute()
|
||||
|
||||
# Copy each selected question's response areas onto its new copy (geometry travels with the question).
|
||||
ra_rows: List[Dict[str, Any]] = []
|
||||
if id_map:
|
||||
for ra in _rows(
|
||||
ctx.supabase.table("exam_response_areas").select("*")
|
||||
.in_("question_id", list(id_map.keys())).execute()
|
||||
):
|
||||
ra_rows.append({
|
||||
"id": str(uuid.uuid4()), "question_id": id_map[ra["question_id"]], "template_id": template_id,
|
||||
"page": ra.get("page"), "bounds": ra.get("bounds"), "kind": ra.get("kind"),
|
||||
"response_form": ra.get("response_form"), "context_type": ra.get("context_type"),
|
||||
"source": "manual", "confirmed": True,
|
||||
})
|
||||
if ra_rows:
|
||||
ctx.supabase.table("exam_response_areas").insert(ra_rows).execute()
|
||||
|
||||
project_template_safe(template_id) # a custom paper is a normal paper in the graph
|
||||
return {"id": template_id, "title": body.title, "subject": body.subject,
|
||||
"n_questions": len(q_rows), "n_response_areas": len(ra_rows)}
|
||||
@ -245,6 +245,36 @@ async def batch_csv(
|
||||
|
||||
# ─── marks ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def _advance_completion(ctx: ExamContext, batch_id: str, submission_id: str) -> None:
|
||||
"""After a mark upsert, advance statuses: a submission with a mark for every markable (leaf)
|
||||
question → complete; a batch whose every non-absent submission is complete → complete. Nothing
|
||||
here regresses a status (only promotes to complete), so it is safe to run on every upsert."""
|
||||
batch = _first(
|
||||
ctx.supabase.table("marking_batches").select("id, template_id, status").eq("id", batch_id).limit(1).execute()
|
||||
)
|
||||
if not batch:
|
||||
return
|
||||
markable = {
|
||||
q["id"] for q in _rows(
|
||||
ctx.supabase.table("exam_questions").select("id, is_container").eq("template_id", batch["template_id"]).execute()
|
||||
) if not q.get("is_container")
|
||||
}
|
||||
if not markable:
|
||||
return
|
||||
marked = {
|
||||
m["question_id"] for m in _rows(
|
||||
ctx.supabase.table("mark_entries").select("question_id").eq("submission_id", submission_id).execute()
|
||||
)
|
||||
}
|
||||
if not markable.issubset(marked):
|
||||
return
|
||||
ctx.supabase.table("student_submissions").update({"status": "complete"}).eq("id", submission_id).execute()
|
||||
subs = _rows(ctx.supabase.table("student_submissions").select("status").eq("batch_id", batch_id).execute())
|
||||
active = [s for s in subs if s.get("status") != "absent"]
|
||||
if active and all(s.get("status") == "complete" for s in active) and batch.get("status") != "complete":
|
||||
ctx.supabase.table("marking_batches").update({"status": "complete"}).eq("id", batch_id).execute()
|
||||
|
||||
|
||||
@router.put("/marks/{mark_id}")
|
||||
async def upsert_mark(
|
||||
mark_id: str,
|
||||
@ -259,6 +289,15 @@ async def upsert_mark(
|
||||
if not submission:
|
||||
raise HTTPException(status_code=404, detail="Submission not found")
|
||||
|
||||
# Reject an award that exceeds the question's max (only when a max is actually set; 0/None means
|
||||
# "not scored yet" for AI/unmapped questions, so we can't validate those).
|
||||
question = _first(
|
||||
ctx.supabase.table("exam_questions").select("id, max_marks").eq("id", body.question_id).limit(1).execute()
|
||||
)
|
||||
max_marks = (question or {}).get("max_marks")
|
||||
if isinstance(max_marks, (int, float)) and max_marks > 0 and body.awarded_marks is not None and body.awarded_marks > max_marks:
|
||||
raise HTTPException(status_code=422, detail=f"awarded_marks {body.awarded_marks} exceeds max_marks {max_marks} for this question")
|
||||
|
||||
row = {
|
||||
"id": mark_id,
|
||||
"submission_id": body.submission_id,
|
||||
@ -285,6 +324,9 @@ async def upsert_mark(
|
||||
if submission.get("status") in ("absent", "unmatched"):
|
||||
ctx.supabase.table("student_submissions").update({"status": "marking"}).eq("id", body.submission_id).execute()
|
||||
|
||||
# Promote the submission/batch to complete once every markable question has a mark.
|
||||
_advance_completion(ctx, submission["batch_id"], body.submission_id)
|
||||
|
||||
return upserted
|
||||
|
||||
|
||||
|
||||
98
routers/exam/corpus.py
Normal file
98
routers/exam/corpus.py
Normal file
@ -0,0 +1,98 @@
|
||||
"""Exam-bank corpus coverage (/api/exam/corpus) — the state of the collected exam bank.
|
||||
|
||||
Read-only view over the seeded exam-board catalogue (eb_specifications + eb_exams): board → subject →
|
||||
specification → papers, with per-session QP/MS/ER coverage and rollup counts. Shows what the app has
|
||||
COLLECTED (question papers, mark schemes, examiner reports) so a teacher/admin can see the bank's state
|
||||
and where science coverage is complete vs thin. Catalogue data is public reference — read as-the-user.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from modules.logger_tool import initialise_logger
|
||||
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()
|
||||
|
||||
DOC_TYPES = ("QP", "MS", "ER")
|
||||
|
||||
|
||||
def _rows(result: Any) -> List[Dict[str, Any]]:
|
||||
data = getattr(result, "data", None)
|
||||
if not data:
|
||||
return []
|
||||
return data if isinstance(data, list) else [data]
|
||||
|
||||
|
||||
def _award_level(spec: Dict[str, Any]) -> str:
|
||||
"""Best-effort GCSE / AS / A-level from the spec code (AQA GCSE = 8xxx, A-level/AS = 7xxx)."""
|
||||
code = re.sub(r"\D", "", spec.get("award_code") or spec.get("spec_code") or "")
|
||||
if code.startswith("8"):
|
||||
return "GCSE"
|
||||
if code.startswith("7"):
|
||||
return "A-level"
|
||||
return spec.get("award_level") or "Other"
|
||||
|
||||
|
||||
@router.get("/corpus")
|
||||
async def corpus_coverage(ctx: ExamContext = Depends(get_exam_context)) -> Dict[str, Any]:
|
||||
specs = _rows(
|
||||
ctx.supabase.table("eb_specifications")
|
||||
.select("spec_code, exam_board_code, subject_code, award_code, first_teach").execute()
|
||||
)
|
||||
exams = _rows(
|
||||
ctx.supabase.table("eb_exams")
|
||||
.select("exam_code, spec_code, paper_code, tier, session, type_code, storage_loc").execute()
|
||||
)
|
||||
|
||||
# group exam docs → per spec → per paper (paper_code + session) → which doc types are present
|
||||
by_spec: Dict[str, Dict[str, Dict[str, Any]]] = {}
|
||||
for e in exams:
|
||||
sc = e.get("spec_code")
|
||||
if not sc:
|
||||
continue
|
||||
key = f"{e.get('paper_code') or '?'}|{e.get('session') or '?'}"
|
||||
paper = by_spec.setdefault(sc, {}).setdefault(key, {
|
||||
"paper_code": e.get("paper_code"), "session": e.get("session"),
|
||||
"tier": e.get("tier"), "docs": {}, "exam_codes": {},
|
||||
})
|
||||
dt = (e.get("type_code") or "").upper()
|
||||
if dt in DOC_TYPES:
|
||||
paper["docs"][dt] = bool(e.get("storage_loc"))
|
||||
paper["exam_codes"][dt] = e.get("exam_code")
|
||||
|
||||
totals = {"specs": 0, "papers": 0, "sessions": 0, **{d: 0 for d in DOC_TYPES}}
|
||||
boards: Dict[str, Dict[str, Any]] = {}
|
||||
for s in specs:
|
||||
sc = s["spec_code"]
|
||||
papers_map = by_spec.get(sc, {})
|
||||
if not papers_map:
|
||||
continue
|
||||
totals["specs"] += 1
|
||||
board = s.get("exam_board_code") or "?"
|
||||
level = _award_level(s)
|
||||
papers = sorted(papers_map.values(), key=lambda p: (str(p["session"]), str(p["paper_code"])))
|
||||
counts = {d: sum(1 for p in papers if p["docs"].get(d)) for d in DOC_TYPES}
|
||||
for d in DOC_TYPES:
|
||||
totals[d] += counts[d]
|
||||
totals["papers"] += len(papers)
|
||||
totals["sessions"] += len({p["session"] for p in papers})
|
||||
spec_entry = {
|
||||
"spec_code": sc, "subject": (s.get("subject_code") or "").title(), "level": level,
|
||||
"board": board, "first_teach": s.get("first_teach"),
|
||||
"n_papers": len(papers), "counts": counts, "papers": papers,
|
||||
}
|
||||
boards.setdefault(board, {"board": board, "specs": []})["specs"].append(spec_entry)
|
||||
|
||||
board_list = []
|
||||
for board in sorted(boards):
|
||||
specs_sorted = sorted(boards[board]["specs"], key=lambda x: (x["level"], x["subject"], x["spec_code"]))
|
||||
board_list.append({"board": board, "n_specs": len(specs_sorted), "specs": specs_sorted})
|
||||
|
||||
return {"totals": totals, "boards": board_list}
|
||||
@ -80,6 +80,9 @@ class ResponseAreaPayload(BaseModel):
|
||||
] = None
|
||||
# Optional Context differentiation (v1 generic; future graph/chart/data_table/diagram/code_block/passage).
|
||||
context_type: Optional[str] = None
|
||||
# Rich recognition payload (75-exam-marker-region-meta.sql): figure name/description, OMR geometry, unit…
|
||||
# Carried on canvas save so a named context figure survives a round-trip.
|
||||
meta: Optional[Dict[str, Any]] = None
|
||||
source: Literal["manual", "ai"] = "manual"
|
||||
confirmed: bool = True
|
||||
confidence: Optional[float] = Field(default=None, ge=0, le=1)
|
||||
|
||||
@ -15,6 +15,7 @@ from __future__ import annotations
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
import time
|
||||
import uuid
|
||||
@ -30,6 +31,7 @@ from modules.database.services.exam_projection import project_template, project_
|
||||
from modules.database.supabase.utils.client import SupabaseServiceRoleClient
|
||||
from modules.database.supabase.utils.storage import StorageAdmin
|
||||
from modules.upload_validation import read_pdf_upload_bytes
|
||||
from modules.services import exam_extract
|
||||
from modules.logger_tool import initialise_logger
|
||||
from routers.exam.dependencies import ExamContext, get_exam_context, lookup_exam_code
|
||||
from routers.exam.schemas import (
|
||||
@ -455,6 +457,32 @@ def _y_to_canvas(y_value: float, page_number: int, pages: List[Dict[str, float]]
|
||||
return round(g["page_top"] + (g["page_pt_h"] - (float(y_value) - g["crop_y0"])) / g["page_pt_h"] * g["rendered_h"], 2)
|
||||
|
||||
|
||||
def _frac_box_to_canvas(bounds: Optional[Dict[str, Any]], page_number: int,
|
||||
pages: List[Dict[str, float]]) -> Optional[Dict[str, float]]:
|
||||
"""Page-fraction {x,y,w,h} (0..1 per page, from the extraction service) → 780-wide stacked canvas."""
|
||||
if not bounds:
|
||||
return None
|
||||
g = _page_geom(pages, page_number)
|
||||
try:
|
||||
x, y, w, h = (float(bounds["x"]), float(bounds["y"]), float(bounds["w"]), float(bounds["h"]))
|
||||
except (KeyError, TypeError, ValueError):
|
||||
return None
|
||||
return {
|
||||
"x": round(x * g["rendered_w"], 2),
|
||||
"y": round(g["page_top"] + y * g["rendered_h"], 2),
|
||||
"w": round(w * g["rendered_w"], 2),
|
||||
"h": round(h * g["rendered_h"], 2),
|
||||
}
|
||||
|
||||
|
||||
def _frac_y_to_canvas(y_frac: Any, page_number: int, pages: List[Dict[str, float]]) -> Optional[float]:
|
||||
g = _page_geom(pages, page_number)
|
||||
try:
|
||||
return round(g["page_top"] + float(y_frac) * g["rendered_h"], 2)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _ai_id(template_id: str, *parts: Any) -> str:
|
||||
return str(uuid.uuid5(uuid.NAMESPACE_URL, "/".join(["cc-auto-map", template_id, *[str(p) for p in parts]])))
|
||||
|
||||
@ -613,7 +641,13 @@ def _refresh_ai_rows(ctx: ExamContext, template_id: str, rows: Dict[str, List[Di
|
||||
for table in ("exam_response_areas", "exam_boundaries", "exam_template_layout", "exam_questions"):
|
||||
sb.table(table).delete().eq("template_id", template_id).eq("source", "ai").eq("confirmed", False).execute()
|
||||
for table, key in (("exam_questions", "questions"), ("exam_response_areas", "response_areas"), ("exam_boundaries", "boundaries"), ("exam_template_layout", "layout")):
|
||||
payload = _dedupe_rows_by_id(rows.get(key) or [])
|
||||
# A row that survived the delete above is confirmed-AI or manual — the teacher's curated version.
|
||||
# AI ids are a deterministic uuid5 of (template_id, semantic key), so a re-run re-emits the SAME id
|
||||
# for a confirmed ghost; re-inserting it would PK-collide and fail the whole batch. Skip those ids so
|
||||
# confirmed work is preserved and the insert is safe (fixes the re-map-after-confirm collision).
|
||||
kept = sb.table(table).select("id").eq("template_id", template_id).execute()
|
||||
kept_ids = {str(r["id"]) for r in (getattr(kept, "data", None) or []) if isinstance(r, dict) and r.get("id")}
|
||||
payload = [r for r in _dedupe_rows_by_id(rows.get(key) or []) if str(r.get("id")) not in kept_ids]
|
||||
if payload:
|
||||
sb.table(table).insert(payload).execute()
|
||||
|
||||
@ -645,12 +679,153 @@ def _run_auto_map_job(job_id: str, ctx: ExamContext, template_id: str, pdf_bytes
|
||||
_set_auto_map_status(job_id, {"status": "running", "template_id": template_id})
|
||||
try:
|
||||
rows = _run_auto_map_merge(ctx, template_id, pdf_bytes, source_label)
|
||||
# Project to Neo4j like the born-digital fast path does — otherwise image-only papers (R3's
|
||||
# primary target, routed here because they need OCR) never reach the graph after auto-map.
|
||||
project_template_safe(template_id)
|
||||
_set_auto_map_status(job_id, {"status": "completed", "template_id": template_id, "counts": {k: len(v) for k, v in rows.items()}})
|
||||
except Exception as exc:
|
||||
logger.exception(f"auto-map job failed for template {template_id}: {exc}")
|
||||
_set_auto_map_status(job_id, {"status": "failed", "template_id": template_id, "error": str(exc)})
|
||||
|
||||
|
||||
_ALLOWED_RESPONSE_FORMS = {"lines", "answer-box", "working", "diagram", "tick-boxes", "table", "blanks"}
|
||||
_ALLOWED_ANSWER_TYPES = {"written", "mcq", "short", "diagram"}
|
||||
_ALLOWED_KINDS = {"response", "context", "question_number", "mark_area", "reference", "furniture"}
|
||||
_BOARD_RE = re.compile(r"^(aqa|edexcel|ocr|wjec|eduqas|ccea)-")
|
||||
|
||||
|
||||
def _extract_slug(ctx: ExamContext, template: Dict[str, Any]) -> str:
|
||||
"""A stable, board-prefixed slug for the extraction-service cache. Prefer the catalogue exam_code
|
||||
(e.g. 'AQA-8463-1H-2022JUN-QP' → 'aqa-8463-1h-2022jun-qp' → board 'aqa' for the right margins);
|
||||
fall back to a template-id slug (structure.py then defaults to AQA content-box margins)."""
|
||||
code = None
|
||||
exam_id = template.get("exam_id")
|
||||
if exam_id:
|
||||
try:
|
||||
row = _first(ctx.supabase.table("eb_exams").select("exam_code").eq("id", exam_id).limit(1).execute())
|
||||
code = (row or {}).get("exam_code")
|
||||
except Exception as exc:
|
||||
logger.info(f"extract slug: eb_exams lookup failed for {exam_id}: {exc}")
|
||||
slug = re.sub(r"[^a-z0-9._-]+", "-", (code or "").lower()).strip("-")
|
||||
if _BOARD_RE.match(slug):
|
||||
return slug
|
||||
return f"aqa-tmpl-{str(template.get('id') or '')[:12]}"
|
||||
|
||||
|
||||
def _map_service_contract_to_rows(template_id: str, contract: Dict[str, Any],
|
||||
pdf_bytes: bytes) -> Dict[str, List[Dict[str, Any]]]:
|
||||
"""Map the extraction service's page-fraction analyse contract onto the app's canvas-space ghost rows.
|
||||
|
||||
Coordinates: page-fraction → the 780-wide stacked canvas. IDs: the service's deterministic uuid5s are
|
||||
re-namespaced per template via _ai_id so two templates of the same paper don't collide and a re-map of
|
||||
the same template re-emits stable ids (so _refresh_ai_rows preserves confirmed ghosts). FK-safe: parts
|
||||
whose parent/owner question is absent are de-parented / dropped rather than crashing the insert.
|
||||
"""
|
||||
pages = _pdf_page_geometry(pdf_bytes)
|
||||
sug = contract.get("suggestions") or {}
|
||||
|
||||
def qid(uid: Any) -> str:
|
||||
return _ai_id(template_id, "svc-q", uid)
|
||||
|
||||
questions: List[Dict[str, Any]] = []
|
||||
q_ids: set = set()
|
||||
for q in sug.get("questions") or []:
|
||||
uid = q.get("uid")
|
||||
if not uid:
|
||||
continue
|
||||
rid = qid(uid)
|
||||
q_ids.add(rid)
|
||||
at = q.get("answer_type") if q.get("answer_type") in _ALLOWED_ANSWER_TYPES else None
|
||||
questions.append({
|
||||
"id": rid, "template_id": template_id,
|
||||
"parent_id": qid(q["parent_uid"]) if q.get("parent_uid") else None,
|
||||
"label": q.get("label") or "?", "order": q.get("order", len(questions)),
|
||||
"max_marks": _safe_marks(q.get("max_marks")), "answer_type": at,
|
||||
"is_container": bool(q.get("is_container")),
|
||||
"bounds": _frac_box_to_canvas(q.get("bounds"), q.get("page") or 1, pages),
|
||||
"page": q.get("page"), "source": "ai", "confirmed": False,
|
||||
"confidence": _safe_confidence(q.get("confidence")), "derivation": "extract-service",
|
||||
# analyse contract v2 (migration 78): command verb + stem prose per part
|
||||
"command_word": (q.get("command_word") or None) if not q.get("is_container") else None,
|
||||
"preamble": q.get("preamble") or None,
|
||||
})
|
||||
for q in questions: # FK safety: de-parent a dangling parent_id
|
||||
if q["parent_id"] and q["parent_id"] not in q_ids:
|
||||
q["parent_id"] = None
|
||||
|
||||
response_areas: List[Dict[str, Any]] = []
|
||||
for ra in sug.get("response_areas") or []:
|
||||
uid = ra.get("uid")
|
||||
quid = qid(ra.get("question_uid") or "")
|
||||
if not uid or quid not in q_ids: # orphan region → drop (FK safety)
|
||||
continue
|
||||
bounds = _frac_box_to_canvas(ra.get("bounds"), ra.get("page") or 1, pages)
|
||||
if not bounds:
|
||||
continue
|
||||
kind = ra.get("kind") if ra.get("kind") in _ALLOWED_KINDS else "response"
|
||||
form = ra.get("response_form") if ra.get("response_form") in _ALLOWED_RESPONSE_FORMS else None
|
||||
response_areas.append({
|
||||
"id": _ai_id(template_id, "svc-ra", uid), "template_id": template_id,
|
||||
"question_id": quid, "page": ra.get("page"), "bounds": bounds,
|
||||
"kind": kind, "response_form": form if kind == "response" else None,
|
||||
"context_type": ra.get("context_type"), "meta": ra.get("meta") or {},
|
||||
"source": "ai", "confirmed": False,
|
||||
"confidence": _safe_confidence(ra.get("confidence")), "derivation": "extract-service",
|
||||
})
|
||||
|
||||
boundaries: List[Dict[str, Any]] = []
|
||||
for i, b in enumerate(sug.get("boundaries") or []):
|
||||
quid = qid(b.get("question_uid") or "")
|
||||
if quid not in q_ids:
|
||||
continue
|
||||
page_index = b.get("page_index")
|
||||
y = _frac_y_to_canvas(b.get("y"), (page_index or 0) + 1, pages)
|
||||
if y is None:
|
||||
continue
|
||||
boundaries.append({
|
||||
"id": _ai_id(template_id, "svc-b", b.get("question_uid") or i), "template_id": template_id,
|
||||
"question_id": quid, "label": b.get("label") or "", "page_index": page_index,
|
||||
"y": y, "bounds": None, "source": "ai", "confirmed": False,
|
||||
"confidence": _safe_confidence(b.get("confidence")), "derivation": "extract-service",
|
||||
})
|
||||
|
||||
return {"questions": questions, "response_areas": response_areas, "boundaries": boundaries}
|
||||
|
||||
|
||||
def _run_service_extract_merge(ctx: ExamContext, template_id: str, pdf_bytes: bytes, slug: str) -> Dict[str, List[Dict[str, Any]]]:
|
||||
contract = exam_extract.extract_suggestions(slug, pdf_bytes)
|
||||
rows = _map_service_contract_to_rows(template_id, contract, pdf_bytes)
|
||||
_refresh_ai_rows(ctx, template_id, rows)
|
||||
meta = contract.get("meta") or {}
|
||||
# P3: record provenance + the audit cover-reconciliation gate so the setup UI can flag under-reads,
|
||||
# and store the slug so the digital-text view can resolve this paper's replica.
|
||||
updates: Dict[str, Any] = {"extraction_meta": {
|
||||
"engine": "extract-service", "slug": slug, "audit": meta.get("audit") or {},
|
||||
"counts": {k: len(v) for k, v in rows.items()},
|
||||
# question-layout signals for the setup UI: paper sections (e.g. A-level Section A/B) + EITHER/OR choices
|
||||
"sections": meta.get("sections") or [],
|
||||
"choice_groups": meta.get("choice_groups") or [],
|
||||
"marks_confidence": meta.get("marks_confidence"),
|
||||
}}
|
||||
n_pages = meta.get("n_pages") or meta.get("pages")
|
||||
if n_pages:
|
||||
updates["page_count"] = n_pages
|
||||
ctx.supabase.table("exam_templates").update(updates).eq("id", template_id).execute()
|
||||
return rows
|
||||
|
||||
|
||||
def _run_service_extract_job(job_id: str, ctx: ExamContext, template_id: str, pdf_bytes: bytes, slug: str) -> None:
|
||||
_set_auto_map_status(job_id, {"status": "running", "template_id": template_id, "engine": "extract-service", "slug": slug})
|
||||
try:
|
||||
rows = _run_service_extract_merge(ctx, template_id, pdf_bytes, slug)
|
||||
project_template_safe(template_id)
|
||||
_set_auto_map_status(job_id, {"status": "completed", "template_id": template_id,
|
||||
"engine": "extract-service", "counts": {k: len(v) for k, v in rows.items()}})
|
||||
except Exception as exc:
|
||||
logger.exception(f"extract-service job failed for template {template_id}: {exc}")
|
||||
_set_auto_map_status(job_id, {"status": "failed", "template_id": template_id, "engine": "extract-service", "error": str(exc)})
|
||||
|
||||
|
||||
# ─── templates ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@ -828,6 +1003,14 @@ async def auto_map_template(
|
||||
raise HTTPException(status_code=409, detail="Template has recorded marks; auto-map structural refresh is blocked.")
|
||||
bucket, path, pdf_bytes = _resolve_template_source(ctx, template)
|
||||
source_label = f"{bucket}/{path}"
|
||||
# Extraction-service path (P2): when EXAM_EXTRACT_URL is set, route auto-map through the spike's full
|
||||
# recognition pipeline instead of the thin first-pass. Always async — a cold paper is ~15 min.
|
||||
if exam_extract.is_enabled():
|
||||
slug = _extract_slug(ctx, template)
|
||||
job_id = str(uuid.uuid4())
|
||||
_set_auto_map_status(job_id, {"status": "queued", "template_id": template_id, "engine": "extract-service", "slug": slug})
|
||||
background_tasks.add_task(_run_service_extract_job, job_id, ctx, template_id, pdf_bytes, slug)
|
||||
return JSONResponse(status_code=202, content={"status": "accepted", "job_id": job_id, "engine": "extract-service"})
|
||||
try:
|
||||
fast_path = _pdf_has_text_layer(pdf_bytes)
|
||||
except Exception as exc:
|
||||
@ -866,6 +1049,24 @@ async def auto_map_status(
|
||||
return body
|
||||
|
||||
|
||||
@router.get("/templates/{template_id}/digital-text")
|
||||
async def template_digital_text(
|
||||
template_id: str,
|
||||
ctx: ExamContext = Depends(get_exam_context),
|
||||
) -> Dict[str, Any]:
|
||||
"""P4: the digital-replica markdown for this template's paper (stem text, parts, marks, answer-space
|
||||
placeholders, spec refs). Resolves the paper via the slug the extraction used."""
|
||||
template = _fetch_template_or_404(ctx, template_id)
|
||||
_require_source_visibility_or_404(ctx, template)
|
||||
if not exam_extract.is_enabled():
|
||||
raise HTTPException(status_code=503, detail="Extraction service not configured")
|
||||
slug = (template.get("extraction_meta") or {}).get("slug") or _extract_slug(ctx, template)
|
||||
try:
|
||||
return exam_extract.get_replica(slug)
|
||||
except exam_extract.ExtractError as exc:
|
||||
raise HTTPException(status_code=404, detail=f"No digital text yet — run auto-map first ({exc})")
|
||||
|
||||
|
||||
@router.put("/templates/{template_id}")
|
||||
async def replace_template(
|
||||
template_id: str,
|
||||
@ -946,6 +1147,7 @@ async def replace_template(
|
||||
"kind": ra.kind,
|
||||
"response_form": ra.response_form,
|
||||
"context_type": ra.context_type, # 73: optional Context differentiation
|
||||
"meta": ra.meta, # 75: rich recognition payload (name/description/OMR/…)
|
||||
"source": ra.source,
|
||||
"confirmed": ra.confirmed,
|
||||
"confidence": ra.confidence,
|
||||
|
||||
@ -1,17 +1,19 @@
|
||||
"""
|
||||
init_exam_graph.py — Initialise the cc.public.exams Neo4j knowledge graph.
|
||||
|
||||
Creates the shared, public exam database, its uniqueness constraints, and seeds the AQA exam
|
||||
board + AQA GCSE Physics (8463) specification with its 8 top-level topic SpecPoints. Idempotent
|
||||
(CREATE DATABASE IF NOT EXISTS / CREATE CONSTRAINT IF NOT EXISTS / MERGE).
|
||||
Creates the shared, public exam database, its uniqueness constraints, and seeds the AQA exam board
|
||||
+ the 6 current test specifications (GCSE & A-level Physics/Chemistry/Biology) with their top-level
|
||||
topic SpecPoints (44 in total). Idempotent (CREATE DATABASE IF NOT EXISTS / CREATE CONSTRAINT IF NOT
|
||||
EXISTS / MERGE).
|
||||
|
||||
Run inside the ccapi container:
|
||||
python3 -c "from run.initialization.init_exam_graph import init; import json; print(json.dumps(init()))"
|
||||
|
||||
NOTE: the 8 SpecPoints seeded here are the real AQA GCSE Physics *top-level* topics. The full
|
||||
sub-point breakdown (e.g. 4.1.1.1 ...) is a later data-population task (sourceable from the AQA
|
||||
spec PDF via Docling). spec_code AQA-PHYS-8463 is the standalone GCSE Physics code that matches
|
||||
"AQA Physics Paper 1H"; the eb_exams/eb_specifications seed (card S4-3) must use the same code.
|
||||
NOTE: only *top-level* topics are seeded (the granularity a teacher plans against). The full sub-point
|
||||
breakdown (e.g. 4.1.1.1 ...) is a later data-population task (sourceable from the AQA spec PDF via
|
||||
Docling). Seeding all 6 specs means a template's spec_ref finds a matching SpecPoint so
|
||||
(:Part)-[:ASSESSES]->(:SpecPoint) fires beyond GCSE Physics; spec_code (e.g. AQA-PHYS-8463) must match
|
||||
the eb_exams/eb_specifications seed (card S4-3) and the app's deriveSpecCode.
|
||||
"""
|
||||
import uuid
|
||||
from typing import Dict, Any
|
||||
@ -41,6 +43,37 @@ SPEC_POINTS = [
|
||||
("4.8", "Space physics"),
|
||||
]
|
||||
|
||||
# Full AQA catalogue for the current test specs (top-level topics; ref = topic number). Seeding all of
|
||||
# them means a template's spec_ref finds a matching SpecPoint so (:Part)-[:ASSESSES]->(:SpecPoint) fires
|
||||
# beyond AQA GCSE Physics. Sub-point granularity (e.g. 4.1.1.1) remains a later data-population task.
|
||||
SPECIFICATIONS = [
|
||||
{**SPEC, "topics": SPEC_POINTS},
|
||||
{"spec_code": "AQA-CHEM-8462", "exam_board_code": "AQA", "subject_code": "CHEM", "award_code": "GCSE",
|
||||
"title": "AQA GCSE Chemistry (8462)", "topics": [
|
||||
("4.1", "Atomic structure and the periodic table"), ("4.2", "Bonding, structure, and the properties of matter"),
|
||||
("4.3", "Quantitative chemistry"), ("4.4", "Chemical changes"), ("4.5", "Energy changes"),
|
||||
("4.6", "The rate and extent of chemical change"), ("4.7", "Organic chemistry"), ("4.8", "Chemical analysis"),
|
||||
("4.9", "Chemistry of the atmosphere"), ("4.10", "Using resources")]},
|
||||
{"spec_code": "AQA-BIOL-8461", "exam_board_code": "AQA", "subject_code": "BIOL", "award_code": "GCSE",
|
||||
"title": "AQA GCSE Biology (8461)", "topics": [
|
||||
("4.1", "Cell biology"), ("4.2", "Organisation"), ("4.3", "Infection and response"), ("4.4", "Bioenergetics"),
|
||||
("4.5", "Homeostasis and response"), ("4.6", "Inheritance, variation and evolution"), ("4.7", "Ecology")]},
|
||||
{"spec_code": "AQA-PHYS-7408", "exam_board_code": "AQA", "subject_code": "PHYS", "award_code": "A-level",
|
||||
"title": "AQA A-level Physics (7408)", "topics": [
|
||||
("3.1", "Measurements and their errors"), ("3.2", "Particles and radiation"), ("3.3", "Waves"),
|
||||
("3.4", "Mechanics and materials"), ("3.5", "Electricity"), ("3.6", "Further mechanics and thermal physics"),
|
||||
("3.7", "Fields and their consequences"), ("3.8", "Nuclear physics")]},
|
||||
{"spec_code": "AQA-CHEM-7405", "exam_board_code": "AQA", "subject_code": "CHEM", "award_code": "A-level",
|
||||
"title": "AQA A-level Chemistry (7405)", "topics": [
|
||||
("3.1", "Physical chemistry"), ("3.2", "Inorganic chemistry"), ("3.3", "Organic chemistry")]},
|
||||
{"spec_code": "AQA-BIOL-7402", "exam_board_code": "AQA", "subject_code": "BIOL", "award_code": "A-level",
|
||||
"title": "AQA A-level Biology (7402)", "topics": [
|
||||
("3.1", "Biological molecules"), ("3.2", "Cells"), ("3.3", "Organisms exchange substances with their environment"),
|
||||
("3.4", "Genetic information, variation and relationships between organisms"),
|
||||
("3.5", "Energy transfers in and between organisms"), ("3.6", "Organisms respond to changes"),
|
||||
("3.7", "Genetics, populations, evolution and ecosystems"), ("3.8", "The control of gene expression")]},
|
||||
]
|
||||
|
||||
CONSTRAINTS = [
|
||||
"CREATE CONSTRAINT exam_board_uid IF NOT EXISTS FOR (n:ExamBoard) REQUIRE n.uuid_string IS UNIQUE",
|
||||
"CREATE CONSTRAINT spec_uid IF NOT EXISTS FOR (n:Specification) REQUIRE n.uuid_string IS UNIQUE",
|
||||
@ -81,35 +114,36 @@ def init() -> Dict[str, Any]:
|
||||
s.run(c).consume()
|
||||
result["constraints"] += 1
|
||||
|
||||
# 3. board + spec
|
||||
# 3. board (once)
|
||||
board_uid = _uid("ExamBoard", BOARD["code"])
|
||||
spec_uid = _uid("Specification", SPEC["spec_code"])
|
||||
s.run(
|
||||
"MERGE (b:ExamBoard {uuid_string:$uid}) "
|
||||
"SET b.code=$code, b.name=$name, b.node_storage_path=$nsp",
|
||||
uid=board_uid, code=BOARD["code"], name=BOARD["name"],
|
||||
nsp=f"{EXAM_DB}/ExamBoard/{BOARD['code']}",
|
||||
).consume()
|
||||
|
||||
# 4. each specification + its top-level spec points (idempotent MERGE)
|
||||
for spec in SPECIFICATIONS:
|
||||
spec_uid = _uid("Specification", spec["spec_code"])
|
||||
s.run(
|
||||
"MERGE (sp:Specification {uuid_string:$uid}) "
|
||||
"SET sp.spec_code=$sc, sp.exam_board_code=$ebc, sp.subject_code=$subj, "
|
||||
" sp.award_code=$award, sp.title=$title, sp.node_storage_path=$nsp "
|
||||
"WITH sp MATCH (b:ExamBoard {code:$ebc}) MERGE (b)-[:PUBLISHES]->(sp)",
|
||||
uid=spec_uid, sc=SPEC["spec_code"], ebc=SPEC["exam_board_code"],
|
||||
subj=SPEC["subject_code"], award=SPEC["award_code"], title=SPEC["title"],
|
||||
nsp=f"{EXAM_DB}/Specification/{SPEC['spec_code']}",
|
||||
uid=spec_uid, sc=spec["spec_code"], ebc=spec["exam_board_code"],
|
||||
subj=spec["subject_code"], award=spec["award_code"], title=spec["title"],
|
||||
nsp=f"{EXAM_DB}/Specification/{spec['spec_code']}",
|
||||
).consume()
|
||||
|
||||
# 4. spec points
|
||||
for ref, desc in SPEC_POINTS:
|
||||
sp_uid = _uid("SpecPoint", SPEC["spec_code"], ref)
|
||||
for ref, desc in spec["topics"]:
|
||||
sp_uid = _uid("SpecPoint", spec["spec_code"], ref)
|
||||
s.run(
|
||||
"MERGE (p:SpecPoint {uuid_string:$uid}) "
|
||||
"SET p.ref=$ref, p.description=$desc, p.spec_code=$sc, "
|
||||
" p.exam_board_code=$ebc, p.node_storage_path=$nsp "
|
||||
"WITH p MATCH (s:Specification {spec_code:$sc}) MERGE (s)-[:HAS_SPEC_POINT]->(p)",
|
||||
uid=sp_uid, ref=ref, desc=desc, sc=SPEC["spec_code"],
|
||||
ebc=SPEC["exam_board_code"], nsp=f"{EXAM_DB}/SpecPoint/{SPEC['spec_code']}/{ref}",
|
||||
uid=sp_uid, ref=ref, desc=desc, sc=spec["spec_code"],
|
||||
ebc=spec["exam_board_code"], nsp=f"{EXAM_DB}/SpecPoint/{spec['spec_code']}/{ref}",
|
||||
).consume()
|
||||
result["spec_points"] += 1
|
||||
|
||||
|
||||
134
tests/test_exam_bank.py
Normal file
134
tests/test_exam_bank.py
Normal file
@ -0,0 +1,134 @@
|
||||
"""Tests for the question-bank + custom-paper assembly router (/api/exam/bank, /custom-papers) — mode 3.
|
||||
|
||||
Same FakeSupabase + dependency-override pattern as test_exam_templates.py; the as-user RLS itself is
|
||||
verified against the live dev DB (.94), not here.
|
||||
"""
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import routers.exam.bank as bank_mod
|
||||
from routers.exam.bank import router
|
||||
from routers.exam.dependencies import ExamContext, get_exam_context
|
||||
|
||||
|
||||
TEACHER = "00000000-0000-0000-0000-000000000001"
|
||||
INST_A = "10000000-0000-0000-0000-000000000001"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _stub_projection(monkeypatch):
|
||||
monkeypatch.setattr(bank_mod, "project_template_safe", lambda tid: None)
|
||||
|
||||
|
||||
class FakeResult:
|
||||
def __init__(self, data):
|
||||
self.data = data
|
||||
|
||||
|
||||
class FakeQuery:
|
||||
def __init__(self, store, table):
|
||||
self.store = store
|
||||
self.table = table
|
||||
self.rows = list(store.get(table, []))
|
||||
self._op = None
|
||||
self._payload = None
|
||||
|
||||
def select(self, *_a, **_k):
|
||||
self._op = "select"
|
||||
return self
|
||||
|
||||
def insert(self, payload):
|
||||
self._op = "insert"
|
||||
self._payload = payload
|
||||
return self
|
||||
|
||||
def eq(self, key, value):
|
||||
self.rows = [r for r in self.rows if r.get(key) == value]
|
||||
return self
|
||||
|
||||
def in_(self, key, values):
|
||||
values = set(values)
|
||||
self.rows = [r for r in self.rows if r.get(key) in values]
|
||||
return self
|
||||
|
||||
def execute(self):
|
||||
backing = self.store.setdefault(self.table, [])
|
||||
if self._op == "insert":
|
||||
payloads = self._payload if isinstance(self._payload, list) else [self._payload]
|
||||
inserted = []
|
||||
for p in payloads:
|
||||
row = dict(p)
|
||||
row.setdefault("id", f"gen-{self.table}-{len(backing)}")
|
||||
backing.append(row)
|
||||
inserted.append(row)
|
||||
return FakeResult(inserted)
|
||||
return FakeResult(self.rows)
|
||||
|
||||
|
||||
class FakeSupabase:
|
||||
def __init__(self, store):
|
||||
self.store = store
|
||||
|
||||
def table(self, name):
|
||||
return FakeQuery(self.store, name)
|
||||
|
||||
|
||||
def make_client(store, user_id=TEACHER, institute_ids=(INST_A,)):
|
||||
app = FastAPI()
|
||||
app.include_router(router, prefix="/api/exam")
|
||||
app.dependency_overrides[get_exam_context] = lambda: ExamContext(user_id, "tok", FakeSupabase(store), list(institute_ids))
|
||||
return TestClient(app), store
|
||||
|
||||
|
||||
def test_bank_lists_leaf_questions_with_facets_excluding_containers():
|
||||
store = {"exam_questions": [
|
||||
{"id": "c1", "template_id": "t1", "label": "Q1", "is_container": True, "spec_ref": None,
|
||||
"exam_templates": {"id": "t1", "title": "Physics 1H", "subject": "physics", "exam_code": "AQA-PHYS-8463"}},
|
||||
{"id": "q1", "template_id": "t1", "label": "01.1", "is_container": False, "max_marks": 3, "spec_ref": "4.2",
|
||||
"exam_templates": {"id": "t1", "title": "Physics 1H", "subject": "physics", "exam_code": "AQA-PHYS-8463"}},
|
||||
{"id": "q2", "template_id": "t1", "label": "01.2", "is_container": False, "max_marks": 2, "spec_ref": "4.2",
|
||||
"exam_templates": {"id": "t1", "title": "Physics 1H", "subject": "physics", "exam_code": "AQA-PHYS-8463"}},
|
||||
]}
|
||||
c, _ = make_client(store)
|
||||
body = c.get("/api/exam/bank").json()
|
||||
labels = {q["label"] for q in body["questions"]}
|
||||
assert labels == {"01.1", "01.2"} # container excluded
|
||||
assert body["facets"]["spec_ref"] == {"4.2": 2}
|
||||
assert body["questions"][0]["paper"]["exam_code"] == "AQA-PHYS-8463"
|
||||
|
||||
|
||||
def test_bank_filters_by_spec_ref():
|
||||
store = {"exam_questions": [
|
||||
{"id": "q1", "template_id": "t1", "label": "a", "is_container": False, "spec_ref": "4.2", "exam_templates": {"subject": "physics"}},
|
||||
{"id": "q2", "template_id": "t1", "label": "b", "is_container": False, "spec_ref": "4.5", "exam_templates": {"subject": "physics"}},
|
||||
]}
|
||||
c, _ = make_client(store)
|
||||
body = c.get("/api/exam/bank?spec_ref=4.2").json()
|
||||
assert [q["id"] for q in body["questions"]] == ["q1"]
|
||||
|
||||
|
||||
def test_custom_paper_copies_selected_questions_in_order():
|
||||
store = {"exam_questions": [
|
||||
{"id": "q1", "template_id": "t1", "label": "01.1", "is_container": False, "max_marks": 3, "spec_ref": "4.2", "answer_type": "short"},
|
||||
{"id": "q2", "template_id": "t2", "label": "05.1", "is_container": False, "max_marks": 6, "spec_ref": "4.5", "answer_type": "written"},
|
||||
]}
|
||||
c, store = make_client(store)
|
||||
r = c.post("/api/exam/custom-papers", json={"title": "Electricity mini-test", "subject": "physics", "question_ids": ["q2", "q1"]})
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["n_questions"] == 2
|
||||
# a new template row was created, owned + institute-scoped
|
||||
new_tpls = [t for t in store["exam_templates"] if t["id"] == body["id"]]
|
||||
assert new_tpls and new_tpls[0]["teacher_id"] == TEACHER and new_tpls[0]["institute_id"] == INST_A
|
||||
# two COPIES exist under the new template, order preserving the requested [q2, q1], with fresh ids
|
||||
copies = sorted([q for q in store["exam_questions"] if q["template_id"] == body["id"]], key=lambda q: q["order"])
|
||||
assert [q["label"] for q in copies] == ["05.1", "01.1"]
|
||||
assert all(q["id"] not in ("q1", "q2") for q in copies) # not the originals
|
||||
assert copies[0]["max_marks"] == 6 and copies[0]["spec_ref"] == "4.5"
|
||||
|
||||
|
||||
def test_custom_paper_requires_accessible_questions():
|
||||
c, _ = make_client({"exam_questions": []})
|
||||
assert c.post("/api/exam/custom-papers", json={"title": "x", "question_ids": ["nope"]}).status_code == 404
|
||||
assert c.post("/api/exam/custom-papers", json={"title": "x", "question_ids": []}).status_code == 400
|
||||
@ -249,6 +249,30 @@ def test_upsert_mark_submission_404():
|
||||
assert c.put("/api/exam/marks/mk-x", json={"submission_id": "nope", "question_id": "q1", "awarded_marks": 1}).status_code == 404
|
||||
|
||||
|
||||
def test_upsert_mark_rejects_over_max():
|
||||
c = make_client(_batch_with_cohort()) # q1 max_marks = 3
|
||||
r = c.put("/api/exam/marks/mk-over", json={"submission_id": "sub2", "question_id": "q1", "awarded_marks": 4})
|
||||
assert r.status_code == 422
|
||||
|
||||
|
||||
def test_upsert_mark_completes_submission_and_batch():
|
||||
store = base_store(
|
||||
marking_batches=[{"id": "b1", "template_id": TPL, "institute_id": INST_A, "teacher_id": TEACHER, "status": "marking"}],
|
||||
exam_questions=[
|
||||
{"id": "q0", "template_id": TPL, "label": "Q1", "max_marks": 0, "order": 0, "is_container": True},
|
||||
{"id": "q1", "template_id": TPL, "label": "01", "max_marks": 3, "order": 1, "is_container": False},
|
||||
{"id": "q2", "template_id": TPL, "label": "02", "max_marks": 5, "order": 2, "is_container": False},
|
||||
],
|
||||
student_submissions=[{"id": "sub1", "batch_id": "b1", "student_id": "s1", "status": "marking"}],
|
||||
mark_entries=[{"id": "m1", "batch_id": "b1", "submission_id": "sub1", "question_id": "q1", "awarded_marks": 2}],
|
||||
)
|
||||
c = make_client(store)
|
||||
# marking the last leaf question completes the submission (container q0 doesn't block) and the batch
|
||||
assert c.put("/api/exam/marks/m2", json={"submission_id": "sub1", "question_id": "q2", "awarded_marks": 4}).status_code == 200
|
||||
assert next(s for s in store["student_submissions"] if s["id"] == "sub1")["status"] == "complete"
|
||||
assert next(b for b in store["marking_batches"] if b["id"] == "b1")["status"] == "complete"
|
||||
|
||||
|
||||
# ─── scans (E3 guards) ───────────────────────────────────────────────────────
|
||||
|
||||
def _batch_store():
|
||||
|
||||
48
tests/test_exam_corpus.py
Normal file
48
tests/test_exam_corpus.py
Normal file
@ -0,0 +1,48 @@
|
||||
"""Test the exam-bank corpus coverage grouping (/api/exam/corpus)."""
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from routers.exam.corpus import router
|
||||
from routers.exam.dependencies import ExamContext, get_exam_context
|
||||
|
||||
TEACHER = "00000000-0000-0000-0000-000000000001"
|
||||
INST_A = "10000000-0000-0000-0000-000000000001"
|
||||
|
||||
|
||||
class FakeResult:
|
||||
def __init__(self, data): self.data = data
|
||||
|
||||
|
||||
class FakeQuery:
|
||||
def __init__(self, store, table): self.store, self.table = store, table
|
||||
def select(self, *_a, **_k): return self
|
||||
def execute(self): return FakeResult(list(self.store.get(self.table, [])))
|
||||
|
||||
|
||||
class FakeSupabase:
|
||||
def __init__(self, store): self.store = store
|
||||
def table(self, name): return FakeQuery(self.store, name)
|
||||
|
||||
|
||||
def _client(store):
|
||||
app = FastAPI(); app.include_router(router, prefix="/api/exam")
|
||||
app.dependency_overrides[get_exam_context] = lambda: ExamContext(TEACHER, "tok", FakeSupabase(store), [INST_A])
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_corpus_groups_papers_by_session_with_qp_ms_er_coverage():
|
||||
store = {
|
||||
"eb_specifications": [{"spec_code": "AQA-PHYS-8463", "exam_board_code": "AQA", "subject_code": "PHYSICS", "award_code": "8463", "first_teach": "2016"}],
|
||||
"eb_exams": [
|
||||
{"exam_code": "a", "spec_code": "AQA-PHYS-8463", "paper_code": "8463/1H", "session": "2022-Jun", "type_code": "QP", "storage_loc": "cc.examboards/a.pdf"},
|
||||
{"exam_code": "b", "spec_code": "AQA-PHYS-8463", "paper_code": "8463/1H", "session": "2022-Jun", "type_code": "MS", "storage_loc": "cc.examboards/b.pdf"},
|
||||
{"exam_code": "c", "spec_code": "AQA-PHYS-8463", "paper_code": "8463/1H", "session": "2022-Jun", "type_code": "ER", "storage_loc": None},
|
||||
{"exam_code": "d", "spec_code": "AQA-PHYS-8463", "paper_code": "8463/2H", "session": "2023-Jun", "type_code": "QP", "storage_loc": "cc.examboards/d.pdf"},
|
||||
],
|
||||
}
|
||||
body = _client(store).get("/api/exam/corpus").json()
|
||||
assert body["totals"]["specs"] == 1 and body["totals"]["papers"] == 2
|
||||
spec = body["boards"][0]["specs"][0]
|
||||
assert spec["level"] == "GCSE" and spec["subject"] == "Physics"
|
||||
assert spec["counts"] == {"QP": 2, "MS": 1, "ER": 0} # ER present but not stored → not counted
|
||||
p = next(p for p in spec["papers"] if p["paper_code"] == "8463/1H")
|
||||
assert p["docs"] == {"QP": True, "MS": True, "ER": False}
|
||||
75
tests/test_exam_extract_mapping.py
Normal file
75
tests/test_exam_extract_mapping.py
Normal file
@ -0,0 +1,75 @@
|
||||
"""Test the extraction-service contract → app ghost-row mapping (P2).
|
||||
|
||||
Mocks the PDF geometry so the mapper is tested in isolation: page-fraction → 780-canvas conversion,
|
||||
per-template uuid remap, FK-safety (orphan regions / dangling parents), and rich meta passthrough.
|
||||
"""
|
||||
import routers.exam.templates as T
|
||||
|
||||
TID = "11111111-1111-1111-1111-111111111111"
|
||||
|
||||
# two pages, each rendered 780 wide × 1000 tall; page 2 stacked below page 1
|
||||
_GEOM = [
|
||||
{"rendered_w": 780.0, "rendered_h": 1000.0, "page_top": 0.0, "page_pt_w": 595.0, "page_pt_h": 842.0, "crop_x0": 0.0, "crop_y0": 0.0},
|
||||
{"rendered_w": 780.0, "rendered_h": 1000.0, "page_top": 1000.0, "page_pt_w": 595.0, "page_pt_h": 842.0, "crop_x0": 0.0, "crop_y0": 0.0},
|
||||
]
|
||||
|
||||
_CONTRACT = {
|
||||
"coordinate_space": "page_fraction",
|
||||
"suggestions": {
|
||||
"questions": [
|
||||
{"uid": "Q1", "label": "1", "order": 0, "max_marks": 5, "is_container": True, "page": 1},
|
||||
{"uid": "Q1a", "parent_uid": "Q1", "label": "1(a)", "order": 1, "max_marks": 3,
|
||||
"answer_type": "short", "is_container": False, "page": 1},
|
||||
{"uid": "Q1b", "parent_uid": "Q1", "label": "1(b)", "order": 2, "max_marks": 2,
|
||||
"answer_type": "mcq", "is_container": False, "page": 2},
|
||||
],
|
||||
"response_areas": [
|
||||
{"uid": "RA1", "question_uid": "Q1a", "page": 1, "kind": "response", "response_form": "answer-box",
|
||||
"confidence": 0.9, "bounds": {"x": 0.1, "y": 0.2, "w": 0.5, "h": 0.05},
|
||||
"meta": {"unit": "m/s", "quantity": "v", "n_lines": 1}},
|
||||
{"uid": "RA2", "question_uid": "Q1b", "page": 2, "kind": "response", "response_form": "tick-boxes",
|
||||
"bounds": {"x": 0.2, "y": 0.3, "w": 0.4, "h": 0.2},
|
||||
"meta": {"select_n": 2, "n_options": 5, "boxes": [{"x0": 0.2, "y0": 0.3, "fill": 0.8}]}},
|
||||
{"uid": "CTX1", "question_uid": "Q1b", "page": 2, "kind": "context", "response_form": None,
|
||||
"bounds": {"x": 0.1, "y": 0.1, "w": 0.6, "h": 0.15}},
|
||||
{"uid": "ORPH", "question_uid": "GHOST", "page": 1, "kind": "response", "response_form": "lines",
|
||||
"bounds": {"x": 0.1, "y": 0.5, "w": 0.5, "h": 0.1}}, # orphan owner → must be dropped
|
||||
],
|
||||
"boundaries": [
|
||||
{"question_uid": "Q1", "label": "1", "page_index": 0, "y": 0.18, "confidence": 0.85},
|
||||
],
|
||||
},
|
||||
"meta": {"n_pages": 2},
|
||||
}
|
||||
|
||||
|
||||
def test_contract_maps_to_canvas_rows(monkeypatch):
|
||||
monkeypatch.setattr(T, "_pdf_page_geometry", lambda _b: _GEOM)
|
||||
rows = T._map_service_contract_to_rows(TID, _CONTRACT, b"%PDF-1.4")
|
||||
|
||||
q = rows["questions"]
|
||||
assert len(q) == 3
|
||||
container = next(x for x in q if x["label"] == "1")
|
||||
a = next(x for x in q if x["label"] == "1(a)")
|
||||
assert container["is_container"] is True and container["parent_id"] is None
|
||||
assert a["parent_id"] == container["id"] # parent remapped to the container's per-template id
|
||||
assert a["answer_type"] == "short" and a["max_marks"] == 3
|
||||
assert all(x["source"] == "ai" and x["confirmed"] is False for x in q)
|
||||
|
||||
ra = rows["response_areas"]
|
||||
assert len(ra) == 3 # orphan (owner GHOST) dropped
|
||||
ids = {r["id"] for r in ra}
|
||||
assert len(ids) == 3 # ids unique
|
||||
box = next(r for r in ra if r["response_form"] == "answer-box")
|
||||
# page-fraction → 780-canvas: x=0.1*780=78, y=0.2*1000=200, w=0.5*780=390, h=0.05*1000=50
|
||||
assert box["bounds"] == {"x": 78.0, "y": 200.0, "w": 390.0, "h": 50.0}
|
||||
assert box["meta"]["unit"] == "m/s" # rich meta preserved
|
||||
tick = next(r for r in ra if r["response_form"] == "tick-boxes")
|
||||
# page 2 y stacked: 0.3*1000 + page_top(1000) = 1300
|
||||
assert tick["bounds"]["y"] == 1300.0 and tick["meta"]["select_n"] == 2
|
||||
ctx = next(r for r in ra if r["kind"] == "context")
|
||||
assert ctx["response_form"] is None # context carries no answer form
|
||||
assert all(r["question_id"] in {x["id"] for x in q} for r in ra) # FK-safe
|
||||
|
||||
b = rows["boundaries"]
|
||||
assert len(b) == 1 and b[0]["y"] == 180.0 and b[0]["question_id"] == container["id"]
|
||||
@ -689,6 +689,27 @@ def test_auto_map_preserves_manual_and_confirmed_rows_on_rerun(monkeypatch):
|
||||
assert "old-ai" not in ids
|
||||
|
||||
|
||||
def test_auto_map_rerun_after_confirm_does_not_pk_collide(monkeypatch):
|
||||
# Regression: a confirmed ghost keeps its DETERMINISTIC uuid5 id, which auto-map re-emits on a
|
||||
# re-run — the old code re-inserted that id and PK-collided. Use the real generated id (not an
|
||||
# arbitrary one, unlike the test above) so the collision path is actually exercised.
|
||||
store = _template_with_source()
|
||||
store.update({"exam_questions": [], "exam_response_areas": [], "exam_boundaries": [], "exam_template_layout": []})
|
||||
client, store = make_client(store=store)
|
||||
_patch_auto_map(monkeypatch, store, fast=True)
|
||||
assert client.post("/api/exam/templates/t1/auto-map").status_code == 200
|
||||
ghost_id = next(q["id"] for q in store["exam_questions"] if q["source"] == "ai")
|
||||
# teacher confirms that ghost (row kept, deterministic id unchanged)
|
||||
for q in store["exam_questions"]:
|
||||
if q["id"] == ghost_id:
|
||||
q["confirmed"] = True
|
||||
# re-run auto-map: must not re-insert the same id, and must preserve the teacher's confirmation
|
||||
assert client.post("/api/exam/templates/t1/auto-map").status_code == 200
|
||||
same = [r for r in store["exam_questions"] if r["id"] == ghost_id]
|
||||
assert len(same) == 1, "confirmed ghost must not be duplicated (PK collision) on re-run"
|
||||
assert same[0]["confirmed"] is True, "teacher's confirmation must survive a re-run"
|
||||
|
||||
|
||||
def test_auto_map_non_owner_is_403_before_download(monkeypatch):
|
||||
store = _template_with_source(owner=OTHER_TEACHER)
|
||||
client, store = make_client(user_id=TEACHER, institute_ids=(INST_A,), store=store)
|
||||
@ -733,4 +754,16 @@ def test_auto_map_ocr_returns_job_id_and_status_completes(monkeypatch):
|
||||
body = status.json()
|
||||
assert body["status"] == "completed"
|
||||
assert body["counts"]["questions"] >= 2
|
||||
|
||||
|
||||
def test_auto_map_ocr_path_projects_to_neo4j(monkeypatch, _stub_projection):
|
||||
# Image-only papers route through the async OCR job; regression: that path must project to Neo4j
|
||||
# like the born-digital fast path, or the graph is never built for the primary target.
|
||||
store = _template_with_source()
|
||||
client, store = make_client(store=store)
|
||||
_patch_auto_map(monkeypatch, store, fast=False)
|
||||
resp = client.post("/api/exam/templates/t1/auto-map")
|
||||
assert resp.status_code == 202
|
||||
# the BackgroundTask runs after the response under TestClient
|
||||
assert "t1" in _stub_projection
|
||||
assert body["template"]["layout"]
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user