pdf-quiz-generator/backend/app/services/exam_blueprint.py
Daniel 0fa5598aad feat: study plans built to the board's content outline
Two shapes, because a plan is asked to do two different things. Papers
are rehearsal: each block is drawn to the ABP's published weights, so
sitting one says something about how you would do on the day. Domains
are study: the board's twenty-four content areas in its own order and
carrying its own titles, each given the share of the plan the board
gives it on the exam.

Both were written, then run against the real bank, which found two bugs
a unit test on a clean fixture would not have. Domains 19 and 20 —
nephrology and genitourinary — both map to our "Nephrology & Urology",
so a question sat in two pools and was dealt twice; the deal now keeps a
record of what has gone. And chunking every question a domain has into
blocks of forty gave preventive care six blocks and the plan a hundred
and sixty, which is not a plan: blocks are shared out by weight, with at
least one per domain so nothing the board examines is left out.

Built on the live bank alongside what was already there: Boards: Full
Papers (12 × 40) and Boards: By Content Domain (27 blocks, 1069
questions). Nothing existing was touched.

Psychosocial Issues and Child Abuse and Neglect — 6% of the paper
between them — had no category of ours at all, so they could contribute
nothing. Both now exist, with sub-topics named from the board's own
subdomains, and all 24 domains map to categories.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-11 21:40:17 +02:00

301 lines
12 KiB
Python

"""An exam's published shape, and drawing a paper that matches it.
A real paper is not a uniform draw from a bank. The ABP publishes that 12% of a
general paediatrics exam is preventive care and 2% is rheumatology; a block
drawn at random is forty coin flips, and tells a learner nothing about how they
would do on the day.
The blueprint is a mapping, not a reshaping. Our taxonomy is arranged for
studying and a board's outline is arranged for examining, and both are right —
so a blueprint line names its categories rather than the tree being bent to fit.
"""
from decimal import Decimal
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.models.exam import BlueprintCategoryLink, ExamBlueprint, QuestionExamLink
from app.models.question import Question
from app.models.question_category import QuestionCategory, QuestionCategoryLink
def _descendants(db: Session, category_ids: list[int]) -> set[int]:
"""Every category at or below the given ones."""
found = set(category_ids)
frontier = list(category_ids)
while frontier:
rows = db.query(QuestionCategory.id).filter(QuestionCategory.parent_id.in_(frontier)).all()
nxt = [row[0] for row in rows if row[0] not in found]
found.update(nxt)
frontier = nxt
return found
def domains(db: Session, exam_id: int) -> list[ExamBlueprint]:
"""The weighted lines — the domains, not the subdomains beneath them."""
return (db.query(ExamBlueprint)
.filter(ExamBlueprint.exam_id == exam_id, ExamBlueprint.parent_id.is_(None))
.order_by(ExamBlueprint.sort_order, ExamBlueprint.id).all())
def categories_for(db: Session, blueprint_id: int) -> set[int]:
direct = [row[0] for row in db.query(BlueprintCategoryLink.category_id)
.filter(BlueprintCategoryLink.blueprint_id == blueprint_id).all()]
return _descendants(db, direct) if direct else set()
def question_ids_for(db: Session, exam_id: int, category_ids: set[int],
predicate=None) -> list[int]:
"""Serveable questions of this exam that fall in these categories."""
if not category_ids:
return []
in_exam = select(QuestionExamLink.question_id).where(QuestionExamLink.exam_id == exam_id)
linked = select(QuestionCategoryLink.question_id).where(
QuestionCategoryLink.category_id.in_(category_ids))
query = db.query(Question.id).filter(
Question.id.in_(in_exam),
Question.question_category_id.in_(category_ids) | Question.id.in_(linked),
)
if predicate is not None:
query = query.filter(predicate)
return [row[0] for row in query.all()]
def allocate(weights: dict[int, Decimal], total: int, available: dict[int, int]) -> dict[int, int]:
"""How many questions each domain should contribute.
Largest-remainder rather than rounding each share independently: rounding
twenty-four percentages to whole questions loses or gains several, and a
forty-question block that returns thirty-six is a bug the learner sees.
A domain that cannot supply its share gives the shortfall back, and it is
redistributed over the domains that can — so a thin corner of the bank
shrinks the paper's accuracy rather than its length.
"""
if total <= 0 or not weights:
return {}
share = sum(weights.values())
if share <= 0:
return {}
exact = {key: (Decimal(total) * weight / share) for key, weight in weights.items()}
plan = {key: int(value) for key, value in exact.items()}
# Largest remainder takes the seats whole numbers left over.
remainder = total - sum(plan.values())
for key, _ in sorted(exact.items(), key=lambda kv: kv[1] - int(kv[1]), reverse=True):
if remainder <= 0:
break
plan[key] += 1
remainder -= 1
# Give back what cannot be supplied, then re-offer it to whoever has room.
spare = 0
for key in list(plan):
cap = available.get(key, 0)
if plan[key] > cap:
spare += plan[key] - cap
plan[key] = cap
while spare > 0:
room = [key for key in plan if available.get(key, 0) > plan[key]]
if not room:
break
# In weight order, so the overflow lands where the exam is heaviest.
room.sort(key=lambda key: weights[key], reverse=True)
for key in room:
if spare <= 0:
break
plan[key] += 1
spare -= 1
return {key: count for key, count in plan.items() if count > 0}
def sample(db: Session, exam_id: int, total: int, predicate=None,
rng=None) -> tuple[list[int], list[dict]]:
"""A paper shaped like the real one, and the working that produced it.
Returns the question ids and a per-domain account — what each domain was
owed and what it could give — so the shortfall is visible rather than the
block quietly being the wrong shape.
"""
import random
rng = rng or random
lines = domains(db, exam_id)
if not lines:
return [], []
pools: dict[int, list[int]] = {}
weights: dict[int, Decimal] = {}
for line in lines:
if line.weight is None:
continue
weights[line.id] = Decimal(line.weight)
pools[line.id] = question_ids_for(db, exam_id, categories_for(db, line.id), predicate)
plan = allocate(weights, total, {key: len(ids) for key, ids in pools.items()})
chosen: list[int] = []
report = []
by_id = {line.id: line for line in lines}
for line_id, weight in sorted(weights.items(), key=lambda kv: kv[1], reverse=True):
want = plan.get(line_id, 0)
pool = pools.get(line_id, [])
picked = rng.sample(pool, min(want, len(pool))) if pool and want else []
chosen.extend(picked)
line = by_id[line_id]
report.append({
"code": line.code, "title": line.title, "weight": float(weight),
"asked_for": want, "given": len(picked), "pool": len(pool),
})
rng.shuffle(chosen)
return chosen, report
def coverage(db: Session, exam_id: int) -> list[dict]:
"""What each domain is owed and what the bank can actually supply.
The number worth knowing before building anything: a domain weighted at 12%
with nine questions behind it will never carry a paper, and an educator
should be told that rather than discovering it in a thin block.
"""
rows = []
for line in domains(db, exam_id):
cats = categories_for(db, line.id)
rows.append({
"id": line.id,
"code": line.code,
"title": line.title,
"weight": float(line.weight) if line.weight is not None else None,
"category_ids": sorted(cats),
"questions": len(question_ids_for(db, exam_id, cats)),
})
return rows
def total_weight(db: Session, exam_id: int) -> float:
value = db.query(func.coalesce(func.sum(ExamBlueprint.weight), 0)).filter(
ExamBlueprint.exam_id == exam_id, ExamBlueprint.parent_id.is_(None)).scalar()
return float(value or 0)
def sample_papers(db: Session, exam_id: int, size: int, count: int,
predicate=None, rng=None) -> list[tuple[list[int], list[dict]]]:
"""Several papers from one bank, no question sitting in two of them.
A plan of twelve blocks that each drew independently would ask the same
preventive-care question four times, because that is the domain with the
largest share and the smallest pool relative to it. Drawing once and
dealing out fixes that — but only with a record of what has already gone,
because two domains can map to the same categories. The board splits
genitourinary from nephrology and we do not, so a question sits in both
pools and would otherwise be dealt twice.
"""
import random
rng = rng or random
lines = [line for line in domains(db, exam_id) if line.weight is not None]
if not lines or size <= 0 or count <= 0:
return []
pools: dict[int, list[int]] = {}
weights: dict[int, Decimal] = {}
for line in lines:
ids = question_ids_for(db, exam_id, categories_for(db, line.id), predicate)
rng.shuffle(ids)
pools[line.id] = ids
weights[line.id] = Decimal(line.weight)
by_id = {line.id: line for line in lines}
dealt: set[int] = set()
def take(line_id: int, want: int) -> list[int]:
out = []
pool = pools[line_id]
while pool and len(out) < want:
candidate = pool.pop()
if candidate not in dealt:
dealt.add(candidate)
out.append(candidate)
return out
papers = []
for _ in range(count):
# Recounted each round: what a domain can still supply shrinks as it is
# dealt, and a plan should shorten gracefully rather than repeat.
room = {key: sum(1 for q in ids if q not in dealt) for key, ids in pools.items()}
plan = allocate(weights, size, room)
if not plan:
break
chosen: list[int] = []
report = []
for line_id, want in plan.items():
taken = take(line_id, want)
chosen.extend(taken)
line = by_id[line_id]
report.append({"code": line.code, "title": line.title,
"weight": float(weights[line_id]),
"asked_for": want, "given": len(taken)})
if not chosen:
break
rng.shuffle(chosen)
report.sort(key=lambda row: row["weight"], reverse=True)
papers.append((chosen, report))
return papers
def domain_blocks(db: Session, exam_id: int, size: int, blocks: int,
predicate=None, rng=None) -> list[tuple[str, str, list[int]]]:
"""One or more blocks per domain, sized by the weight the board gives it.
The other shape. A paper is for rehearsal; this is for working through a
subject, which is what a plan is usually for — so the blocks carry the
board's own titles in its own order.
Sized by weight rather than by what happens to be in the bank: chunking
every question into blocks of forty gave preventive care six blocks and
the plan a hundred and sixty, which is not a plan. Every domain gets at
least one block, so nothing the board examines is left out of a plan built
from its outline.
"""
import random
rng = rng or random
lines = [line for line in domains(db, exam_id) if line.weight is not None]
if not lines or size <= 0 or blocks <= 0:
return []
pools: dict[int, list[int]] = {}
weights: dict[int, Decimal] = {}
for line in lines:
ids = question_ids_for(db, exam_id, categories_for(db, line.id), predicate)
if not ids:
continue
rng.shuffle(ids)
pools[line.id] = ids
weights[line.id] = Decimal(line.weight)
if not pools:
return []
# How many blocks each domain is owed, then at least one each.
share = allocate(weights, blocks, {key: len(ids) for key, ids in pools.items()})
for key in pools:
share.setdefault(key, 0)
share[key] = max(share[key], 1)
by_id = {line.id: line for line in lines}
dealt: set[int] = set()
out = []
for line in lines:
if line.id not in pools:
continue
want = share[line.id]
pool = [q for q in pools[line.id] if q not in dealt]
parts = min(want, max(1, -(-len(pool) // size)))
for index in range(parts):
chunk = [q for q in pool[index * size:(index + 1) * size] if q not in dealt]
if not chunk:
continue
dealt.update(chunk)
suffix = f" ({index + 1} of {parts})" if parts > 1 else ""
out.append((line.code, f"{by_id[line.id].title}{suffix}", chunk))
return out