feat: exam-scoped disciplines and exam-scoped facet counts
A discipline may now belong to one exam. `question_tags.exam_id` NULL keeps a tag shared — Cardiology means the same thing whichever exam you sit — while a set exam_id scopes it. Boards Pediatrics and a future Step 1 Pediatrics are therefore separate rows over genuinely different bodies of content, not one label stretched across both. Uniqueness moves from (name, type) to (name, type, exam) to allow it (migration b0c1d2e3f4a5). `scripts/bind_exam_tags.py` binds Pediatrics to Pediatrics Boards and tags the 884 questions in that exam that were missing it — the whole bank is paediatrics, so it now reads 2,948. Facet counts are computed within the learner's active exam, and a tag scoped to a different exam is left out: an unscoped list offered disciplines that could not match anything they were studying. With no exam chosen, everything is offered as before. Tests: 4 new backend (same name once per exam, unscoped list offers all, choosing an exam scopes counts and hides other exams' tags, switching exam switches which Pediatrics is offered). Full suite green: 123 backend. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PpfzbZ1QTLMeVYxM2kyq8m
This commit is contained in:
parent
817cff569d
commit
b7947b1d53
5 changed files with 238 additions and 6 deletions
37
backend/alembic/versions/b0c1d2e3f4a5_exam_scoped_tags.py
Normal file
37
backend/alembic/versions/b0c1d2e3f4a5_exam_scoped_tags.py
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
"""Let a tag belong to one exam, so same-named disciplines stay distinct.
|
||||
|
||||
NULL exam_id means the tag is shared across exams — the right default for
|
||||
Cardiology or Neonatology, which mean the same thing everywhere. A set exam_id
|
||||
scopes it, so "Pediatrics" for a paediatrics board and "Pediatrics" for a step
|
||||
exam are separate vocabulary rather than one label over two different bodies
|
||||
of content.
|
||||
|
||||
Uniqueness moves from (name, type) to (name, type, exam) to allow that.
|
||||
|
||||
Revision ID: b0c1d2e3f4a5
|
||||
Revises: a9b0c1d2e3f4
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
revision = "b0c1d2e3f4a5"
|
||||
down_revision = "a9b0c1d2e3f4"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.execute("ALTER TABLE question_tags ADD COLUMN IF NOT EXISTS exam_id INTEGER REFERENCES exams(id) ON DELETE CASCADE")
|
||||
op.execute("CREATE INDEX IF NOT EXISTS ix_question_tags_exam ON question_tags(exam_id)")
|
||||
op.execute("DROP INDEX IF EXISTS uq_tag_name_type")
|
||||
# COALESCE keeps the constraint meaningful for shared tags, where exam_id is NULL.
|
||||
op.execute("""
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_tag_name_type_exam
|
||||
ON question_tags (lower(name), type, COALESCE(exam_id, 0))
|
||||
""")
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.execute("DROP INDEX IF EXISTS uq_tag_name_type_exam")
|
||||
op.execute("CREATE UNIQUE INDEX IF NOT EXISTS uq_tag_name_type ON question_tags (lower(name), type)")
|
||||
op.execute("DROP INDEX IF EXISTS ix_question_tags_exam")
|
||||
op.execute("ALTER TABLE question_tags DROP COLUMN IF EXISTS exam_id")
|
||||
|
|
@ -19,18 +19,26 @@ log = logging.getLogger(__name__)
|
|||
@router.get("")
|
||||
def get_tags(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||
"""Return all tags grouped by type, with question counts."""
|
||||
# Counts are computed within the learner's active exam, and tags scoped to a
|
||||
# different exam are left out — an unscoped list would offer disciplines that
|
||||
# cannot match anything they are studying.
|
||||
exam_id = getattr(current_user, "active_exam_id", None)
|
||||
rows = db.execute(text("""
|
||||
SELECT t.id, t.name, t.type, COUNT(tl.question_id) AS count
|
||||
SELECT t.id, t.name, t.type, COUNT(DISTINCT tl.question_id) AS count
|
||||
FROM question_tags t
|
||||
LEFT JOIN question_tag_links tl ON tl.tag_id = t.id
|
||||
LEFT JOIN question_exam_links el
|
||||
ON el.question_id = tl.question_id AND (:exam_id IS NULL OR el.exam_id = :exam_id)
|
||||
WHERE (t.exam_id IS NULL OR :exam_id IS NULL OR t.exam_id = :exam_id)
|
||||
AND (:exam_id IS NULL OR tl.question_id IS NULL OR el.question_id IS NOT NULL)
|
||||
GROUP BY t.id, t.name, t.type
|
||||
ORDER BY count DESC, t.name
|
||||
""")).fetchall()
|
||||
"""), {"exam_id": exam_id}).fetchall()
|
||||
|
||||
result = {"subjects": [], "diseases": [], "keywords": []}
|
||||
for r in rows:
|
||||
key = {"subject": "subjects", "disease": "diseases", "keyword": "keywords"}.get(r.type)
|
||||
if key:
|
||||
if key and (r.count > 0 or not exam_id):
|
||||
result[key].append({"id": r.id, "name": r.name, "count": r.count})
|
||||
return result
|
||||
|
||||
|
|
|
|||
69
backend/scripts/bind_exam_tags.py
Normal file
69
backend/scripts/bind_exam_tags.py
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
"""Bind exam-specific disciplines to their exam, and tag the bank accordingly.
|
||||
|
||||
"Pediatrics" means something different under a paediatrics board than under a
|
||||
step exam, so it is scoped to its exam rather than shared. Everything else stays
|
||||
global: Cardiology means the same thing whichever exam you are sitting.
|
||||
|
||||
The whole bank is currently paediatrics, so every question in the Pediatrics
|
||||
Boards exam gets the discipline.
|
||||
|
||||
docker compose exec backend python -m scripts.bind_exam_tags
|
||||
docker compose exec backend python -m scripts.bind_exam_tags --apply
|
||||
"""
|
||||
import sys
|
||||
|
||||
from sqlalchemy import text as sa_text
|
||||
|
||||
from app.database import SessionLocal
|
||||
|
||||
# Disciplines whose meaning depends on the exam, and the exam they belong to.
|
||||
EXAM_SCOPED = {"Pediatrics": "pediatrics-boards"}
|
||||
|
||||
|
||||
def main():
|
||||
apply_changes = "--apply" in sys.argv
|
||||
db = SessionLocal()
|
||||
try:
|
||||
for tag_name, exam_slug in EXAM_SCOPED.items():
|
||||
exam = db.execute(sa_text("SELECT id, name FROM exams WHERE slug = :s"),
|
||||
{"s": exam_slug}).first()
|
||||
if not exam:
|
||||
print(f" exam {exam_slug!r} not found; skipping {tag_name!r}")
|
||||
continue
|
||||
tag = db.execute(sa_text(
|
||||
"SELECT id, exam_id FROM question_tags WHERE type='subject' AND lower(name)=lower(:n) AND exam_id IS NULL"
|
||||
), {"n": tag_name}).first()
|
||||
if not tag:
|
||||
print(f" {tag_name!r} is already scoped or absent")
|
||||
continue
|
||||
|
||||
missing = db.execute(sa_text("""
|
||||
SELECT COUNT(*) FROM questions q
|
||||
JOIN question_exam_links el ON el.question_id = q.id AND el.exam_id = :e
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM question_tag_links l WHERE l.question_id = q.id AND l.tag_id = :t)
|
||||
"""), {"e": exam.id, "t": tag.id}).scalar()
|
||||
|
||||
print(f" bind {tag_name!r} -> {exam.name}")
|
||||
print(f" questions in the exam still missing the tag: {missing}")
|
||||
|
||||
if apply_changes:
|
||||
db.execute(sa_text("UPDATE question_tags SET exam_id = :e WHERE id = :t"),
|
||||
{"e": exam.id, "t": tag.id})
|
||||
# The whole bank is paediatrics, so every question in the exam carries it.
|
||||
db.execute(sa_text("""
|
||||
INSERT INTO question_tag_links (question_id, tag_id)
|
||||
SELECT q.id, :t FROM questions q
|
||||
JOIN question_exam_links el ON el.question_id = q.id AND el.exam_id = :e
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM question_tag_links l WHERE l.question_id = q.id AND l.tag_id = :t)
|
||||
"""), {"e": exam.id, "t": tag.id})
|
||||
if apply_changes:
|
||||
db.commit()
|
||||
print("APPLIED" if apply_changes else "DRY RUN — re-run with --apply to write")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
115
backend/tests/test_exam_scoped_tags.py
Normal file
115
backend/tests/test_exam_scoped_tags.py
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
"""Tags may be shared across exams or scoped to one, and facets reflect the choice.
|
||||
|
||||
Run: DATABASE_URL=sqlite:///:memory: PYTHONPATH=backend python -m unittest discover -s backend/tests
|
||||
"""
|
||||
import os
|
||||
os.environ["DATABASE_URL"] = "sqlite:///:memory:"
|
||||
|
||||
import unittest
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine, text
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.database import Base, get_db
|
||||
from app.models.course import Course # noqa — Quiz.course_id FK needs the table in metadata.
|
||||
from app.models.exam import Exam, QuestionExamLink
|
||||
from app.models.question import Question
|
||||
from app.models.user import User
|
||||
from app.routers import tags
|
||||
from app.utils.auth import get_current_user
|
||||
|
||||
# question_tags is created by raw DDL in the app, so the test builds it directly.
|
||||
TAG_DDL = """
|
||||
CREATE TABLE question_tags (
|
||||
id INTEGER PRIMARY KEY, name VARCHAR(200) NOT NULL,
|
||||
type VARCHAR(50) NOT NULL, exam_id INTEGER, created_at TIMESTAMP
|
||||
);
|
||||
CREATE TABLE question_tag_links (
|
||||
id INTEGER PRIMARY KEY, question_id INTEGER NOT NULL, tag_id INTEGER NOT NULL
|
||||
);
|
||||
"""
|
||||
|
||||
|
||||
class ExamScopedTagTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
|
||||
Base.metadata.create_all(self.engine)
|
||||
self.db = Session(self.engine)
|
||||
for statement in TAG_DDL.strip().split(";"):
|
||||
if statement.strip():
|
||||
self.db.execute(text(statement))
|
||||
|
||||
self.user = User(id=1, name="Learner", email="l@example.test", hashed_password="unused")
|
||||
self.db.add(self.user)
|
||||
self.db.add_all([Exam(id=1, slug="pediatrics-boards", name="Pediatrics Boards"),
|
||||
Exam(id=2, slug="usmle-step-2-ck", name="USMLE Step 2 CK")])
|
||||
self.db.flush()
|
||||
for qid, exam_id in ((1, 1), (2, 1), (3, 2)):
|
||||
self.db.add(Question(id=qid, user_id=1, is_shared=1, question_text=f"Q{qid}",
|
||||
question_type="mcq", options=["a", "b"], correct_answer="a"))
|
||||
self.db.flush()
|
||||
self.db.add(QuestionExamLink(question_id=qid, exam_id=exam_id))
|
||||
# Cardiology is shared; each Pediatrics is scoped to its own exam.
|
||||
self.db.execute(text("""
|
||||
INSERT INTO question_tags (id, name, type, exam_id) VALUES
|
||||
(10, 'Cardiology', 'subject', NULL),
|
||||
(11, 'Pediatrics', 'subject', 1),
|
||||
(12, 'Pediatrics', 'subject', 2),
|
||||
(13, 'Obstetrics', 'subject', NULL)
|
||||
"""))
|
||||
self.db.execute(text("""
|
||||
INSERT INTO question_tag_links (question_id, tag_id) VALUES
|
||||
(1, 10), (1, 11), (2, 11), (3, 12), (3, 10)
|
||||
"""))
|
||||
self.db.commit()
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(tags.router, prefix="/tags")
|
||||
app.dependency_overrides[get_db] = lambda: self.db
|
||||
app.dependency_overrides[get_current_user] = lambda: self.user
|
||||
self.client = TestClient(app)
|
||||
|
||||
def tearDown(self):
|
||||
self.client.close()
|
||||
self.db.close()
|
||||
self.engine.dispose()
|
||||
|
||||
def subjects(self):
|
||||
return {t["name"]: t["count"] for t in self.client.get("/tags").json()["subjects"]}
|
||||
|
||||
def test_the_same_name_can_exist_once_per_exam(self):
|
||||
rows = self.db.execute(text(
|
||||
"SELECT exam_id FROM question_tags WHERE name='Pediatrics' ORDER BY exam_id")).fetchall()
|
||||
self.assertEqual([r[0] for r in rows], [1, 2])
|
||||
|
||||
def test_without_an_exam_every_tag_is_offered(self):
|
||||
rows = self.client.get("/tags").json()["subjects"]
|
||||
# Both Pediatrics rows appear, since neither exam is in scope.
|
||||
self.assertEqual(sorted((t["id"], t["count"]) for t in rows),
|
||||
[(10, 2), (11, 2), (12, 1), (13, 0)])
|
||||
|
||||
def test_choosing_an_exam_scopes_counts_and_hides_the_other_exams_tags(self):
|
||||
self.user.active_exam_id = 1
|
||||
self.db.commit()
|
||||
subjects = self.subjects()
|
||||
# Cardiology is shared but only question 1 is in this exam.
|
||||
self.assertEqual(subjects["Cardiology"], 1)
|
||||
self.assertEqual(subjects["Pediatrics"], 2)
|
||||
# Obstetrics matches nothing here, and Step 2's Pediatrics is out of scope.
|
||||
self.assertNotIn("Obstetrics", subjects)
|
||||
self.assertEqual(len([t for t in self.client.get("/tags").json()["subjects"]
|
||||
if t["name"] == "Pediatrics"]), 1)
|
||||
|
||||
def test_switching_exam_switches_which_pediatrics_is_offered(self):
|
||||
self.user.active_exam_id = 2
|
||||
self.db.commit()
|
||||
subjects = self.subjects()
|
||||
self.assertEqual(subjects["Pediatrics"], 1) # question 3 only
|
||||
self.assertEqual(subjects["Cardiology"], 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -73,9 +73,12 @@ Updated 2026-09-10.
|
|||
|
||||
- [ ] **Systems need subsystems** — the current tree came from the old subject
|
||||
tags and is flat where it should nest. Disciplines are fine.
|
||||
- [ ] **Drop "Pediatrics" as a discipline** — it duplicates the exam. Exams are
|
||||
the top level now (Pediatrics Boards, USMLE Step 2 CK), so a discipline
|
||||
called Pediatrics is redundant.
|
||||
- [x] **Exam-scoped disciplines** — done 2026-09-10. A tag may be shared
|
||||
(`exam_id` NULL, e.g. Cardiology) or scoped to one exam. Boards Pediatrics
|
||||
and a future Step 1 Pediatrics are separate rows over different content,
|
||||
rather than one label spanning both. Pediatrics is bound to Pediatrics
|
||||
Boards and now covers all 2,948 questions. Facet counts are computed
|
||||
within the active exam and empty facets are hidden.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue