pdf-quiz-generator/backend/scripts/seed_abp_blueprint.py
Daniel 5c4823d9a4 feat: exams are a group you can build, with the board's own blueprint
Four gaps, one change.

Articles could not belong to an exam at all — an article reached one only
by inference through its category, which cannot say that the same article
belongs to a basic-science step and a clinical one showing different
views in each. article_exam_links says whether it is in the group;
Exam.article_views already decided what is shown once you are there.

POST /exams/ wrote name, slug, sort order and active, and silently
dropped family, description and article views, so a new objective landed
in "Other" showing everything whatever was asked for. It writes what it
is given now, and PATCH can change it afterwards.

Membership was one link row at a time, which nobody would do for three
thousand questions. POST /exams/{id}/assign takes whole topics with
everything beneath them — questions and articles both — and is
idempotent, so widening a selection and running it again adds only what
is new.

And the point of all of it: a real paper is not a uniform draw. The ABP
publishes that 12% of a general paediatrics exam is preventive care and
2% is rheumatology; forty questions drawn evenly is forty coin flips.
exam_blueprints holds a board's published outline — its own numbering,
its headings, its weights — and blueprint_category_links maps it onto
our taxonomy rather than bending the tree to fit, because their outline
is arranged for examining and ours for studying.

The sampler uses largest-remainder, so twenty-four percentages still come
to forty questions, and a domain that cannot supply its share gives the
shortfall back to be spread over those that can — the paper keeps its
length and loses only accuracy, and the working is returned so the
shortfall is visible rather than silent.

Seeded from the ABP General Pediatrics Content Outline (Oct 2024):
structure and published weights only, no exam material. 120 lines, 22 of
24 domains mapped; Psychosocial Issues and Child Abuse and Neglect have
no category of ours and are reported rather than hidden.

Creating an objective is now an administrator's rather than a
moderator's: it appears in everyone's picker and scopes the whole bank,
which is site configuration, and it sits with the other site switches a
moderator cannot reach.

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

143 lines
5.5 KiB
Python

"""Seed the ABP General Pediatrics content outline as a blueprint.
Source: the American Board of Pediatrics, "General Pediatrics Content Outline",
effective 15 October 2024 (minor revisions December 2024). Only the structure
is taken — domain numbering, headings and the published exam weights, which are
the board's statement of what a paper contains. No exam material is reproduced.
The mapping below is the part that is ours. The board's outline is arranged for
examining and our taxonomy is arranged for studying, and neither should be bent
to fit the other, so this says which of our categories feed each domain. It is
a first pass by name; an administrator edits it from the exam screen, and
anything left unmapped is reported rather than hidden.
docker compose cp backend/scripts/seed_abp_blueprint.py backend:/app/seed.py
docker compose exec backend python /app/seed.py --exam pediatrics-boards [--apply]
"""
import argparse
import json
import os
import sys
from pathlib import Path
sys.path.insert(0, "/app")
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.models.exam import BlueprintCategoryLink, Exam, ExamBlueprint
from app.models.question_category import QuestionCategory
OUTLINE = Path(__file__).with_name("abp-2024-outline.json")
#: ABP domain code -> the root categories that feed it, by name. Everything
#: beneath a named category counts, so only the roots are listed.
MAPPING: dict[str, list[str]] = {
"1": ["Primary Care & Prevention"],
"2": ["Neonatology"],
"3": ["Adolescent Medicine"],
"4": ["Infectious Disease", "Sepsis"],
"5": ["Psychiatry & Psychology", "Developmental & Behavioral"],
# Poverty, family structure, school, grief. No root of ours holds these;
# left for an administrator rather than forced into a poor fit.
"6": [],
"7": ["Emergency Medicine", "Critical Care", "Toxicology"],
# Nor this one. Reported, not hidden.
"8": [],
"9": ["Orthopedics & Sports"],
"10": ["Ophthalmology", "Otolaryngology"],
"11": ["Cardiology"],
"12": ["Pulmonology"],
"13": ["Gastroenterology & Nutrition"],
"14": ["Neurology"],
"15": ["Dermatology"],
"16": ["Hematology-Oncology"],
"17": ["Allergy/Immunology"],
"18": ["Endocrinology"],
"19": ["Nephrology & Urology"],
# The board splits genitourinary from nephrology; we do not, so both point
# at the same root and the weights add up to the pair's real share.
"20": ["Nephrology & Urology", "Surgery"],
"21": ["Genetics & Metabolism"],
"22": ["Rheumatology"],
"23": ["Professional Topics"],
"24": ["Professional Topics"],
}
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--exam", default="pediatrics-boards")
parser.add_argument("--apply", action="store_true", help="write; otherwise report only")
args = parser.parse_args()
engine = create_engine(os.environ["DATABASE_URL"])
db = sessionmaker(bind=engine)()
exam = db.query(Exam).filter(Exam.slug == args.exam).first()
if not exam:
print(f"No exam with slug {args.exam!r}")
return 1
outline = json.loads(OUTLINE.read_text())
roots = {c.name: c.id for c in db.query(QuestionCategory).filter(
QuestionCategory.parent_id.is_(None)).all()}
unknown = sorted({name for names in MAPPING.values() for name in names} - set(roots))
if unknown:
print("Mapping names no such root category:", ", ".join(unknown))
return 1
existing = {row.code: row for row in db.query(ExamBlueprint).filter(
ExamBlueprint.exam_id == exam.id).all()}
written = mapped = 0
unmapped = []
for order, domain in enumerate(outline):
code = domain["code"]
line = existing.get(code)
if line is None:
line = ExamBlueprint(exam_id=exam.id, code=code, title=domain["title"],
weight=domain["weight"], sort_order=order)
if args.apply:
db.add(line)
db.flush()
written += 1
else:
line.title, line.weight, line.sort_order = domain["title"], domain["weight"], order
for sub_order, sub in enumerate(domain["subdomains"]):
if sub["code"] in existing:
continue
if args.apply:
db.add(ExamBlueprint(exam_id=exam.id, parent_id=line.id, code=sub["code"],
title=sub["title"], weight=None, sort_order=sub_order))
written += 1
names = MAPPING.get(code, [])
if not names:
unmapped.append(f"{code}. {domain['title']} ({domain['weight']}%)")
continue
if args.apply and line.id:
have = {row.category_id for row in db.query(BlueprintCategoryLink).filter(
BlueprintCategoryLink.blueprint_id == line.id).all()}
for name in names:
if roots[name] not in have:
db.add(BlueprintCategoryLink(blueprint_id=line.id, category_id=roots[name]))
mapped += 1
if args.apply:
db.commit()
print(f"{'Wrote' if args.apply else 'Would write'} {written} blueprint lines "
f"for {exam.name}; {mapped} of {len(outline)} domains mapped to categories.")
if unmapped:
print("\nNo category maps to these — an administrator should choose, or they")
print("will contribute nothing to a weighted paper:")
for row in unmapped:
print(" ", row)
return 0
if __name__ == "__main__":
raise SystemExit(main())