diff --git a/backend/alembic/versions/b0c1d2e3f4a5_exam_scoped_tags.py b/backend/alembic/versions/b0c1d2e3f4a5_exam_scoped_tags.py new file mode 100644 index 0000000..f46c307 --- /dev/null +++ b/backend/alembic/versions/b0c1d2e3f4a5_exam_scoped_tags.py @@ -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") diff --git a/backend/app/routers/tags.py b/backend/app/routers/tags.py index 3a422ea..2f748c2 100644 --- a/backend/app/routers/tags.py +++ b/backend/app/routers/tags.py @@ -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 diff --git a/backend/scripts/bind_exam_tags.py b/backend/scripts/bind_exam_tags.py new file mode 100644 index 0000000..db3a9f6 --- /dev/null +++ b/backend/scripts/bind_exam_tags.py @@ -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()) diff --git a/backend/tests/test_exam_scoped_tags.py b/backend/tests/test_exam_scoped_tags.py new file mode 100644 index 0000000..d1cfa2f --- /dev/null +++ b/backend/tests/test_exam_scoped_tags.py @@ -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() diff --git a/docs/TODO.md b/docs/TODO.md index 809a70a..40930ce 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -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. ---