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
101 lines
4.6 KiB
Python
101 lines
4.6 KiB
Python
from datetime import datetime
|
|
|
|
from sqlalchemy import JSON, Column, DateTime, ForeignKey, Integer, Numeric, String, UniqueConstraint
|
|
|
|
from app.database import Base
|
|
|
|
|
|
class Exam(Base):
|
|
"""A study objective — the top of the hierarchy, above systems and disciplines.
|
|
|
|
A question can sit under more than one exam (paediatric cardiology counts for
|
|
both a paediatrics board and a step exam), so membership is a link table
|
|
rather than a column on the question.
|
|
"""
|
|
|
|
__tablename__ = "exams"
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
slug = Column(String(80), unique=True, nullable=False, index=True)
|
|
name = Column(String(160), nullable=False)
|
|
sort_order = Column(Integer, default=100)
|
|
is_active = Column(Integer, default=1) # 0 hides it from the switcher
|
|
# Objectives are picked from families — USMLE, COMLEX, boards — because a
|
|
# flat list of every exam is not a choice anyone can make.
|
|
family = Column(String(80), nullable=True)
|
|
description = Column(String(300), nullable=True)
|
|
# Which article views this objective shows. Someone revising a basic-science
|
|
# step has no use for bedside dosing, and a view they can open but must never
|
|
# act on is worse than one they were never offered. Null means all of them.
|
|
article_views = Column(JSON, nullable=True)
|
|
created_at = Column(DateTime, default=datetime.utcnow)
|
|
|
|
|
|
class QuestionExamLink(Base):
|
|
__tablename__ = "question_exam_links"
|
|
__table_args__ = (UniqueConstraint("question_id", "exam_id", name="uq_question_exam"),)
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
question_id = Column(Integer, ForeignKey("questions.id", ondelete="CASCADE"), nullable=False, index=True)
|
|
exam_id = Column(Integer, ForeignKey("exams.id", ondelete="CASCADE"), nullable=False, index=True)
|
|
|
|
|
|
class ArticleExamLink(Base):
|
|
"""Which reading belongs to which objective.
|
|
|
|
An article reached an exam only by inference through its category, which
|
|
cannot express the case that matters: the same article belongs to a basic
|
|
science step and to a clinical one, showing different views in each.
|
|
`Exam.article_views` decides what is shown once you are there; this decides
|
|
whether it is in the group at all.
|
|
"""
|
|
|
|
__tablename__ = "article_exam_links"
|
|
__table_args__ = (UniqueConstraint("article_id", "exam_id", name="uq_article_exam"),)
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
article_id = Column(Integer, ForeignKey("articles.id", ondelete="CASCADE"), nullable=False, index=True)
|
|
exam_id = Column(Integer, ForeignKey("exams.id", ondelete="CASCADE"), nullable=False, index=True)
|
|
|
|
|
|
class ExamBlueprint(Base):
|
|
"""One line of an examining board's published content outline.
|
|
|
|
A real exam is not a uniform draw from a bank: the ABP publishes that 12%
|
|
of a general paediatrics paper is preventive care and 2% is rheumatology.
|
|
Without that, a forty-question block is forty coin flips and tells a
|
|
learner nothing about how they would do on the day.
|
|
|
|
`code` is the board's own numbering ("1", "4.A") so a domain can be matched
|
|
back to the published outline, and `weight` is a percentage of the whole
|
|
paper — set on domains, left null on the subdomains beneath them.
|
|
"""
|
|
|
|
__tablename__ = "exam_blueprints"
|
|
__table_args__ = (UniqueConstraint("exam_id", "code", name="uq_exam_blueprint_code"),)
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
exam_id = Column(Integer, ForeignKey("exams.id", ondelete="CASCADE"), nullable=False, index=True)
|
|
parent_id = Column(Integer, ForeignKey("exam_blueprints.id", ondelete="CASCADE"), nullable=True, index=True)
|
|
code = Column(String(16), nullable=False)
|
|
title = Column(String(240), nullable=False)
|
|
#: Percent of the paper. Null on a subdomain, which inherits its domain's share.
|
|
weight = Column(Numeric(5, 2), nullable=True)
|
|
sort_order = Column(Integer, default=0)
|
|
created_at = Column(DateTime, default=datetime.utcnow)
|
|
|
|
|
|
class BlueprintCategoryLink(Base):
|
|
"""Which of our categories feed one blueprint line.
|
|
|
|
The taxonomy is not reshaped to match a board's outline — it is arranged
|
|
for studying, and theirs is arranged for examining. This maps one onto the
|
|
other, so both can be right.
|
|
"""
|
|
|
|
__tablename__ = "blueprint_category_links"
|
|
__table_args__ = (UniqueConstraint("blueprint_id", "category_id", name="uq_blueprint_category"),)
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
blueprint_id = Column(Integer, ForeignKey("exam_blueprints.id", ondelete="CASCADE"), nullable=False, index=True)
|
|
category_id = Column(Integer, ForeignKey("question_categories.id", ondelete="CASCADE"), nullable=False, index=True)
|