Compare commits

...

22 Commits

Author SHA1 Message Date
e48dd73fdf exam: capture sections + choice_groups + marks_confidence in extraction_meta (WS-2 R3)
Some checks failed
api-ci-deploy / test-build-deploy (push) Has been cancelled
Persist the paper's question-layout signals (A-level Section A/B, EITHER/OR choice
groups, marks confidence) onto exam_templates.extraction_meta so the setup UI can
show them. Data was carried by the contract but dropped at persistence.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GruxHXxfdp4kZCgAMVFgvV
2026-07-04 13:13:45 +00:00
547836e04b exam: persist exam_response_areas.meta on canvas replace-save (WS-2 item 4)
Some checks failed
api-ci-deploy / test-build-deploy (push) Has been cancelled
The full-replace save dropped the rich region meta (figure name/description, OMR
geometry). Add meta to ResponseAreaPayload + the replace insert so a named context
figure survives a round-trip. Pairs with the app carrying name/description through.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GruxHXxfdp4kZCgAMVFgvV
2026-07-04 12:13:13 +00:00
df128508a3 exam: persist command_word + preamble from analyse contract v2 (WS-2)
_map_service_contract_to_rows now carries the two v2 fields onto exam_questions
ghost rows (command_word on leaf parts, preamble jsonb). Requires supabase
migration 78; applied to dev .94.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GruxHXxfdp4kZCgAMVFgvV
2026-07-04 11:56:40 +00:00
81bf44c6cc Merge P3+P4 app wiring
Some checks failed
api-ci-deploy / test-build-deploy (push) Has been cancelled
2026-07-03 02:09:17 +00:00
544d858f62 P3+P4 app: persist audit gate (extraction_meta) + digital-text endpoint
_run_service_extract_merge now records extraction_meta {engine, slug, audit
(cover-total reconciliation), counts} on the template (migration 76) — a durable
trust signal the setup UI shows. New GET /templates/{id}/digital-text proxies the
service's /api/replica for the paper (resolved via extraction_meta.slug), returning
the digital-replica markdown. exam_extract.get_replica client added.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 02:09:17 +00:00
7d1876b799 Merge P2 fix: extraction-service wiring
Some checks failed
api-ci-deploy / test-build-deploy (push) Has been cancelled
2026-07-02 23:45:29 +00:00
c79a161119 P2 fix: re-apply auto-map extraction-service wiring + compose env
The prior P2 merge lost the templates.py + compose changes (a git reset --hard in
the commit sequence discarded the tracked-file edits; only the two new files survived).
This re-applies: exam_extract import + _frac_box/_frac_y canvas adapters +
_extract_slug + _map_service_contract_to_rows + _run_service_extract_merge/_job +
the auto_map_template routing, and the EXAM_EXTRACT_URL compose env.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 23:45:29 +00:00
08af7c8ca1 Merge P2: auto-map via extraction service
Some checks failed
api-ci-deploy / test-build-deploy (push) Has been cancelled
2026-07-02 23:41:37 +00:00
98210f2cff P2: route exam auto-map through the extraction service (full recognition)
When EXAM_EXTRACT_URL is set, POST /templates/{id}/auto-map now runs the spike's
full recognition pipeline (via the P1 service) instead of the thin first-pass, as
an async job. New modules/services/exam_extract.py (HTTP client: POST paper, poll,
return the analyse contract). templates.py: _map_service_contract_to_rows adapts the
page-fraction analyse contract onto the app's 780-wide stacked canvas (fraction ×
rendered dims + page_top), re-namespaces the service's uuid5s per template (stable
re-map, no cross-template collision), and is FK-safe (drops orphan regions, de-parents
dangling parents). Rich meta (OMR boxes, select_n, unit/quantity, n_options) persists
to the new exam_response_areas.meta column (migration 75). Typed response_form + mcq
answer_type + kind:context reference links map natively (schema already allows them).

The thin path is preserved as fallback when EXAM_EXTRACT_URL is unset. Unit test
covers the mapping (coord conversion, id remap, FK-safety, meta passthrough).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 23:41:37 +00:00
d3d0639f44 Merge exam-bank corpus coverage endpoint
Some checks failed
api-ci-deploy / test-build-deploy (push) Has been cancelled
2026-07-02 22:33:07 +00:00
52801a5d34 Exam bank: corpus coverage endpoint (state of the collected exam bank)
GET /api/exam/corpus — read-only view over the seeded eb_specifications + eb_exams
catalogue: board → subject → spec → papers grouped by paper_code+session, each
showing which of QP/MS/ER exist AND are stored, with per-spec + global rollups
(specs, papers, sessions, QP/MS/ER counts). Surfaces the collected exam bank so
the app can display its state — e.g. AQA GCSE Physics 8463: 21 QP / 12 MS / 17 ER.

Validated against the live catalogue (60 specs, 1178 docs, 535 papers) + a unit
test (grouping, QP/MS/ER presence, stored-vs-catalogued). As-user (catalogue is
public reference data).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 22:33:07 +00:00
6a6d12875d Merge mode-3 question bank + custom-paper endpoints
Some checks failed
api-ci-deploy / test-build-deploy (push) Has been cancelled
2026-07-02 22:05:39 +00:00
df827b990c Mode-3: question bank + custom-paper assembly endpoints
GET /api/exam/bank — leaf questions across the caller's institute templates
(RLS-scoped) with source-paper context + spec_ref/subject filters and facets;
the spec-planning surface for build-your-own.

POST /api/exam/custom-papers {title, subject?, question_ids[]} — copy-on-assemble:
create a new exam_template owned by the caller and insert COPIES of the selected
leaf questions (new ids, requested order, carrying marks/answer_type/spec_ref/
bounds + their response areas), then project to Neo4j. A custom paper is a normal
template, so it flows through setup/marking/results/projection unchanged. The
shared-question decouple is Phase-2 (design: ~/cc/ideas/2026-07-02-mode3-...).

Tests (4, pass in-container): bank lists leaves + facets (excludes containers),
spec_ref filter, custom-paper copies in order with fresh ids, 400/404 guards.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 22:05:39 +00:00
4f66fb83ce Merge fx-3-image-only-projection
Some checks failed
api-ci-deploy / test-build-deploy (push) Has been cancelled
2026-07-02 21:48:08 +00:00
a0f77333b9 Merge fx-2-born-digital-marks 2026-07-02 21:48:08 +00:00
e4b0477b77 Merge fx-1-auto-map-pk 2026-07-02 21:48:08 +00:00
e95b148e0f Merge FX-7: marking completion status + max_marks validation 2026-07-02 21:48:01 +00:00
b0b59077bc Merge FX-6: seed SpecPoint catalogue for all 6 test specs 2026-07-02 21:48:01 +00:00
CC Worker
931e254b93 FX-7: marking completion status + max_marks validation
batches.py upsert_mark previously never advanced a submission or batch to
'complete', and never validated an award against the question's max.

- Reject (422) an awarded_marks that exceeds the question's max_marks — only when
  a max is actually set (0/None = not-yet-scored AI/unmapped question, unvalidatable).
- _advance_completion: a submission with a mark for every markable (leaf) question
  → 'complete'; a batch whose every non-absent submission is complete → 'complete'.
  Container questions and absent students are excluded; the helper only promotes,
  never regresses, so it is safe on every upsert.

Adds test_upsert_mark_rejects_over_max and test_upsert_mark_completes_submission_and_batch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 21:35:24 +00:00
CC Worker
cce46305c9 FX-2: surface born-digital per-part marks from auto-map
The board grammar already parses per-part marks ([N marks] / "Total for Question
N is M marks"), but they were dropped building the first-pass template and the
row mapper hardcoded max_marks=0 — so every born-digital paper came back with
zero marks for a teacher to re-key by hand.

Thread the parsed mark through: bands.py derive_bands now carries each part's
`marks`, template.build passes it into part_bands, and _map_first_pass_to_rows
reads it via a defensive _safe_marks (non-negative int; unknown/None -> 0, so
image-only OCR — which has no marks yet — is unchanged). Containers still roll up
from parts. Adds test_auto_map_surfaces_born_digital_part_marks.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 21:30:28 +00:00
CC Worker
47e45f59e8 FX-6: seed SpecPoint catalogue for all 6 test specs (ASSESSES beyond AQA physics)
Only AQA GCSE Physics 8463 (4.1-4.8) was seeded, so any other board/spec — or
any spec_ref that didn't match those 8 — projected zero (:Part)-[:ASSESSES]->
(:SpecPoint) edges, silently. Seed the full top-level topic catalogue for the 6
current test specs (GCSE + A-level Physics/Chemistry/Biology, 44 SpecPoints) and
loop the Specification/SpecPoint MERGE over all of them (board created once).

Idempotent; deterministic uuid5 keys unchanged. Sub-point granularity remains a
later data task. Run: python3 -c "from run.initialization.init_exam_graph import
init; import json; print(json.dumps(init()))" in the ccapi container.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 21:25:23 +00:00
CC Worker
1671518ca8 FX-1: auto-map re-run must not PK-collide with confirmed ghosts
_refresh_ai_rows deleted only unconfirmed AI rows, then bulk-inserted freshly
generated rows keyed by a deterministic uuid5 of (template_id, semantic key).
A confirmed ghost keeps that id, so a re-run re-emitted it → primary-key
conflict that failed the whole insert batch (reachable: auto-map is blocked only
when marks exist, not when ghosts are confirmed).

Fix: after the delete, read the ids that survived (confirmed-AI + manual) and
skip re-inserting any freshly generated row whose id matches — preserving the
teacher's curated row and making the insert PK-safe.

Adds test_auto_map_rerun_after_confirm_does_not_pk_collide, which confirms a
ghost at its REAL generated id (the prior test used an arbitrary id that never
collided) and asserts the row survives exactly once with confirmed=True.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 21:02:10 +00:00
16 changed files with 983 additions and 31 deletions

View File

@ -67,11 +67,13 @@ def derive_bands(result, doc=None, rapid_glob=None):
topnum = _topnumber_boxes(docs)
# gather parts with geometry, grouped by page
by_page = defaultdict(list) # page -> [(q, label, t, b)]
part_marks = {} # (question, part label) -> parsed marks (born-digital grammar)
for q in result.get("questions", []):
for p in q["parts"]:
bb, pg = p.get("bbox"), p.get("page")
if bb and pg:
by_page[pg].append((q["question"], p["label"], bb["t"], bb["b"]))
part_marks[(q["question"], p["label"])] = p.get("marks")
# global first page each question appears on (to mark the true start vs continuation pages)
q_first_page = {}
@ -104,7 +106,8 @@ def derive_bands(result, doc=None, rapid_glob=None):
for (q, lab), st, en, _ in _ends(part_items):
qen = main_band.get(q, (st, 0))[1] # don't run past the question end
part.append({"label": lab, "question": q,
"y_start": round(st, 1), "y_end": round(max(en, qen), 1)})
"y_start": round(st, 1), "y_end": round(max(en, qen), 1),
"marks": part_marks.get((q, lab))})
pages[pg] = {"main": main, "part": part}
return {"board": result.get("board"), "paper_code": result.get("paper_code"),

View File

@ -159,6 +159,7 @@ def build(structured, bands, furniture, pdf=None, page_roles=None):
"y_start": p["y_start"], "y_end": p["y_end"],
"label_box": part_bbox.get(p["label"]), # anchor, not the part extent
"box": synthesize_part_box(p, xband),
"marks": p.get("marks"), # parsed per-part marks (born-digital)
"source": "auto", "confirmed": False,
})
pr = page_roles.get(pgs) or page_roles.get(pg) or {}

View File

@ -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:

View 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)

View File

@ -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
View 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)}

View File

@ -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
View 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}

View File

@ -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)

View File

@ -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]])))
@ -465,6 +493,17 @@ def _safe_confidence(value: Any = None) -> float:
return 0.75
def _safe_marks(value: Any = None) -> int:
"""Parsed per-part marks → a non-negative int; unknown/None → 0 (image-only OCR has no marks yet)."""
if isinstance(value, bool):
return 0
if isinstance(value, (int, float)):
return max(0, int(value))
if isinstance(value, str) and value.strip().isdigit():
return int(value.strip())
return 0
def _margin_values(first_pass: Dict[str, Any], page_number: int) -> Dict[str, Optional[float]]:
vals: Dict[str, Optional[float]] = {"left": None, "right": None, "top": None, "bottom": None}
for m in first_pass.get("margins") or []:
@ -541,7 +580,7 @@ def _map_first_pass_to_rows(template_id: str, first_pass: Dict[str, Any], pdf_by
top = max(float(y1), float(y2)); bottom = min(float(y1), float(y2))
bounds = _box_to_canvas({"l": margins["left"], "r": margins["right"], "t": top, "b": bottom, "coord_origin": "BOTTOMLEFT"}, page_number, pages_geom)
bounds = bounds or _box_to_canvas(band.get("label_box"), page_number, pages_geom)
questions.append({"id": pid, "template_id": template_id, "parent_id": parent_id, "label": label, "order": len(questions), "max_marks": 0, "is_container": False, "bounds": bounds, "page": page_number, "source": "ai", "confirmed": False, "confidence": _safe_confidence(band.get("confidence")), "derivation": "docling-part-band-x-margins"})
questions.append({"id": pid, "template_id": template_id, "parent_id": parent_id, "label": label, "order": len(questions), "max_marks": _safe_marks(band.get("marks")), "is_container": False, "bounds": bounds, "page": page_number, "source": "ai", "confirmed": False, "confidence": _safe_confidence(band.get("confidence")), "derivation": "docling-part-band-x-margins"})
default_qid = questions[0]["id"] if questions else _ai_id(template_id, "question", "auto")
for page_key in sorted(pages_obj, key=lambda k: int(k)):
@ -602,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()
@ -643,6 +688,144 @@ def _run_auto_map_job(job_id: str, ctx: ExamContext, template_id: str, pdf_bytes
_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 ───────────────────────────────────────────────────────────────
@ -820,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:
@ -858,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,
@ -938,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,

View File

@ -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,37 +114,38 @@ 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()
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']}",
).consume()
# 4. spec points
for ref, desc in SPEC_POINTS:
sp_uid = _uid("SpecPoint", SPEC["spec_code"], ref)
# 4. each specification + its top-level spec points (idempotent MERGE)
for spec in SPECIFICATIONS:
spec_uid = _uid("Specification", spec["spec_code"])
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}",
"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']}",
).consume()
result["spec_points"] += 1
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}",
).consume()
result["spec_points"] += 1
counts = s.run(
"MATCH (b:ExamBoard) WITH count(b) AS boards "

134
tests/test_exam_bank.py Normal file
View 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

View File

@ -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
View 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}

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

View File

@ -642,6 +642,21 @@ def test_auto_map_fast_path_merges_ai_rows_and_returns_detail(monkeypatch):
assert store["exam_boundaries"] and store["exam_boundaries"][0]["derivation"] == "docling-main-band"
def test_auto_map_surfaces_born_digital_part_marks(monkeypatch):
# Regression: the born-digital grammar parses per-part marks, but the row mapper hardcoded
# max_marks=0. A part band carrying `marks` must flow through to the question row's max_marks.
store = _template_with_source()
store.update({"exam_questions": [], "exam_response_areas": [], "exam_boundaries": [], "exam_template_layout": []})
client, store = make_client(store=store)
fp = _first_pass_template()
fp["pages"]["1"]["part_bands"][0]["marks"] = 4
_patch_auto_map(monkeypatch, store, fast=True)
monkeypatch.setattr(templates_mod, "auto_map", lambda *_a, **_k: fp) # override with the marked part band
assert client.post("/api/exam/templates/t1/auto-map").status_code == 200
part = next(q for q in store["exam_questions"] if q.get("label") == "01.1")
assert part["max_marks"] == 4
def test_auto_map_deduplicates_repeated_response_area_ids(monkeypatch):
store = _template_with_source()
client, store = make_client(store=store)
@ -674,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)