diff --git a/backend/alembic/versions/e3f4a5b6c7d8_tag_hierarchy.py b/backend/alembic/versions/e3f4a5b6c7d8_tag_hierarchy.py
new file mode 100644
index 0000000..9b20afa
--- /dev/null
+++ b/backend/alembic/versions/e3f4a5b6c7d8_tag_hierarchy.py
@@ -0,0 +1,61 @@
+"""Let tags nest, and add organ systems as their own type.
+
+AMBOSS keeps Systems flat (organ systems) and nests Disciplines and Symptoms
+("Head and neck > Eye > Blindness"). Our tag table was flat for all three, so
+symptoms could not be grouped by body region and there was no organ-system axis
+at all — what the UI called "Systems" was really the discipline tree.
+
+Revision ID: e3f4a5b6c7d8
+Revises: d2e3f4a5b6c7
+"""
+from alembic import op
+
+revision = "e3f4a5b6c7d8"
+down_revision = "d2e3f4a5b6c7"
+branch_labels = None
+depends_on = None
+
+# The organ systems, plus the non-anatomical buckets an exam bank needs.
+SYSTEMS = [
+ "Cardiovascular System",
+ "Respiratory System",
+ "Gastrointestinal System",
+ "Nervous System & Special Senses",
+ "Endocrine System",
+ "Musculoskeletal System",
+ "Skin & Subcutaneous Tissue",
+ "Blood & Lymphoreticular System",
+ "Immune System",
+ "Renal & Urinary System",
+ "Reproductive System",
+ "Behavioral Health",
+ "Human Development",
+ "Multisystem Processes & Disorders",
+ "Biostatistics & Epidemiology",
+ "Social Sciences & Ethics",
+]
+
+
+def upgrade():
+ op.execute("""
+ ALTER TABLE question_tags ADD COLUMN IF NOT EXISTS parent_id INTEGER
+ REFERENCES question_tags(id) ON DELETE SET NULL
+ """)
+ op.execute("CREATE INDEX IF NOT EXISTS ix_question_tags_parent ON question_tags(parent_id)")
+ op.execute("ALTER TABLE question_tags ADD COLUMN IF NOT EXISTS sort_order INTEGER NOT NULL DEFAULT 100")
+
+ for index, name in enumerate(SYSTEMS):
+ op.execute(f"""
+ INSERT INTO question_tags (name, type, sort_order)
+ SELECT '{name.replace("'", "''")}', 'system', {index * 10}
+ WHERE NOT EXISTS (
+ SELECT 1 FROM question_tags WHERE type = 'system' AND lower(name) = lower('{name.replace("'", "''")}')
+ )
+ """)
+
+
+def downgrade():
+ op.execute("DELETE FROM question_tags WHERE type = 'system'")
+ op.execute("ALTER TABLE question_tags DROP COLUMN IF EXISTS sort_order")
+ op.execute("DROP INDEX IF EXISTS ix_question_tags_parent")
+ op.execute("ALTER TABLE question_tags DROP COLUMN IF EXISTS parent_id")
diff --git a/backend/app/routers/tags.py b/backend/app/routers/tags.py
index 2f748c2..dca4ca0 100644
--- a/backend/app/routers/tags.py
+++ b/backend/app/routers/tags.py
@@ -4,7 +4,10 @@ Tables are created via raw SQL in setup_pgvector() to avoid race conditions with
import logging
import uuid
+from typing import Literal
+
from fastapi import APIRouter, Depends, HTTPException
+from pydantic import BaseModel, Field
from sqlalchemy import text
from sqlalchemy.orm import Session
@@ -24,22 +27,32 @@ def get_tags(db: Session = Depends(get_db), current_user: User = Depends(get_cur
# 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(DISTINCT tl.question_id) AS count
+ SELECT t.id, t.name, t.type, t.parent_id, t.sort_order,
+ 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
+ GROUP BY t.id, t.name, t.type, t.parent_id, t.sort_order
+ ORDER BY t.sort_order, count DESC, t.name
"""), {"exam_id": exam_id}).fetchall()
- result = {"subjects": [], "diseases": [], "keywords": []}
+ result = {"subjects": [], "diseases": [], "keywords": [], "systems": []}
+ mapping = {"subject": "subjects", "disease": "diseases",
+ "keyword": "keywords", "system": "systems"}
+ # A parent with no direct questions still has to appear, or its children
+ # become unreachable in a tree picker.
+ has_children = {r.parent_id for r in rows if r.parent_id}
for r in rows:
- key = {"subject": "subjects", "disease": "diseases", "keyword": "keywords"}.get(r.type)
- if key and (r.count > 0 or not exam_id):
- result[key].append({"id": r.id, "name": r.name, "count": r.count})
+ key = mapping.get(r.type)
+ if not key:
+ continue
+ if exam_id and r.count == 0 and r.id not in has_children:
+ continue
+ result[key].append({"id": r.id, "name": r.name, "count": r.count,
+ "parent_id": r.parent_id})
return result
@@ -73,3 +86,139 @@ def get_classification_status(
error = r.get(f"classify:error:{job_id}")
return {"job_id": job_id, "status": status, "steps": steps, "error": error}
+
+class TagWrite(BaseModel):
+ name: str = Field(min_length=1, max_length=200)
+ type: Literal["subject", "disease", "keyword", "system"]
+ parent_id: int | None = None
+ sort_order: int = 100
+
+
+def _descendants(db: Session, tag_id: int) -> set[int]:
+ """Every tag beneath this one, so a cycle can be refused."""
+ children: dict[int, list[int]] = {}
+ for tid, parent in db.execute(text(
+ "SELECT id, parent_id FROM question_tags WHERE parent_id IS NOT NULL")).fetchall():
+ children.setdefault(parent, []).append(tid)
+ seen, stack = set(), [tag_id]
+ while stack:
+ for child in children.get(stack.pop(), ()):
+ if child not in seen:
+ seen.add(child)
+ stack.append(child)
+ return seen
+
+
+def _validate_parent(db: Session, tag_id: int | None, parent_id: int | None, tag_type: str) -> None:
+ if parent_id is None:
+ return
+ parent = db.execute(text("SELECT id, type FROM question_tags WHERE id = :i"),
+ {"i": parent_id}).first()
+ if not parent:
+ raise HTTPException(404, "Parent tag not found")
+ # Same kind nests (Surgery > Hand surgery), and anything may sit under a
+ # system, which is how symptoms are grouped by where they present.
+ if parent.type != tag_type and parent.type != "system":
+ raise HTTPException(400, "A tag can only sit under one of the same kind, or under a system")
+ if tag_id is not None and (parent_id == tag_id or parent_id in _descendants(db, tag_id)):
+ raise HTTPException(400, "A tag cannot be its own parent or a descendant's child")
+
+
+@router.post("/", status_code=201)
+def create_tag(data: TagWrite, db: Session = Depends(get_db),
+ current_user: User = Depends(require_moderator)):
+ """Add a top-level entry or a child. New tags appear in every picker at once."""
+ _validate_parent(db, None, data.parent_id, data.type)
+ clash = db.execute(text("""
+ SELECT id FROM question_tags
+ WHERE type = :t AND lower(name) = lower(:n)
+ AND COALESCE(parent_id, 0) = COALESCE(:p, 0)
+ """), {"t": data.type, "n": data.name.strip(), "p": data.parent_id}).first()
+ if clash:
+ raise HTTPException(409, "That name already exists in the same place")
+ tag_id = db.execute(text("""
+ INSERT INTO question_tags (name, type, parent_id, sort_order)
+ VALUES (:n, :t, :p, :s) RETURNING id
+ """), {"n": data.name.strip(), "t": data.type,
+ "p": data.parent_id, "s": data.sort_order}).scalar()
+ db.commit()
+ return {"id": tag_id, "name": data.name.strip(), "type": data.type,
+ "parent_id": data.parent_id, "count": 0}
+
+
+class TagUpdate(BaseModel):
+ name: str | None = Field(default=None, min_length=1, max_length=200)
+ parent_id: int | None = None
+ sort_order: int | None = None
+
+
+@router.patch("/{tag_id}")
+def update_tag(tag_id: int, data: TagUpdate, db: Session = Depends(get_db),
+ current_user: User = Depends(require_moderator)):
+ """Rename, reorder, or move a tag under a different parent."""
+ tag = db.execute(text("SELECT id, type FROM question_tags WHERE id = :i"),
+ {"i": tag_id}).first()
+ if not tag:
+ raise HTTPException(404, "Tag not found")
+ values = data.model_dump(exclude_unset=True)
+ if "parent_id" in values:
+ _validate_parent(db, tag_id, values["parent_id"], tag.type)
+ db.execute(text("UPDATE question_tags SET parent_id = :p WHERE id = :i"),
+ {"p": values["parent_id"], "i": tag_id})
+ if values.get("name"):
+ db.execute(text("UPDATE question_tags SET name = :n WHERE id = :i"),
+ {"n": values["name"].strip(), "i": tag_id})
+ if values.get("sort_order") is not None:
+ db.execute(text("UPDATE question_tags SET sort_order = :s WHERE id = :i"),
+ {"s": values["sort_order"], "i": tag_id})
+ db.commit()
+ return {"id": tag_id}
+
+
+@router.delete("/{tag_id}", status_code=204)
+def delete_tag(tag_id: int, move_to: int | None = None, db: Session = Depends(get_db),
+ current_user: User = Depends(require_moderator)):
+ """Delete a tag, optionally moving its questions and children onto another."""
+ tag = db.execute(text("SELECT id, parent_id FROM question_tags WHERE id = :i"),
+ {"i": tag_id}).first()
+ if not tag:
+ raise HTTPException(404, "Tag not found")
+ if move_to is not None:
+ if move_to == tag_id or not db.execute(text(
+ "SELECT id FROM question_tags WHERE id = :i"), {"i": move_to}).first():
+ raise HTTPException(400, "Invalid destination tag")
+ # Skip links that would duplicate an existing pair.
+ db.execute(text("""
+ UPDATE question_tag_links SET tag_id = :d
+ WHERE tag_id = :s AND question_id NOT IN (
+ SELECT question_id FROM question_tag_links WHERE tag_id = :d)
+ """), {"d": move_to, "s": tag_id})
+ # Children rise to this tag's parent rather than being orphaned.
+ db.execute(text("UPDATE question_tags SET parent_id = :p WHERE parent_id = :i"),
+ {"p": tag.parent_id, "i": tag_id})
+ db.execute(text("DELETE FROM question_tag_links WHERE tag_id = :i"), {"i": tag_id})
+ db.execute(text("DELETE FROM question_tags WHERE id = :i"), {"i": tag_id})
+ db.commit()
+
+
+class TagQuestions(BaseModel):
+ question_ids: list[int] = Field(min_length=1, max_length=500)
+
+
+@router.post("/{tag_id}/questions")
+def add_questions_to_tag(tag_id: int, data: TagQuestions, db: Session = Depends(get_db),
+ current_user: User = Depends(require_moderator)):
+ """Attach questions to a tag; they show in every picker using it immediately."""
+ if not db.execute(text("SELECT id FROM question_tags WHERE id = :i"), {"i": tag_id}).first():
+ raise HTTPException(404, "Tag not found")
+ added = 0
+ for question_id in dict.fromkeys(data.question_ids):
+ result = db.execute(text("""
+ INSERT INTO question_tag_links (question_id, tag_id)
+ SELECT :q, :t WHERE EXISTS (SELECT 1 FROM questions WHERE id = :q)
+ AND NOT EXISTS (
+ SELECT 1 FROM question_tag_links WHERE question_id = :q AND tag_id = :t)
+ """), {"q": question_id, "t": tag_id})
+ added += result.rowcount or 0
+ db.commit()
+ return {"tag_id": tag_id, "added": added}
diff --git a/backend/scripts/backfill_section_index.py b/backend/scripts/backfill_section_index.py
new file mode 100644
index 0000000..3439bf6
--- /dev/null
+++ b/backend/scripts/backfill_section_index.py
@@ -0,0 +1,37 @@
+"""Project existing article sections into the section index.
+
+`_rebuild_section_index` runs when an article is saved, so articles written
+before that existed have no rows — the index was empty and section-level
+retrieval had nothing to search.
+
+ docker compose exec backend python -m scripts.backfill_section_index
+ docker compose exec backend python -m scripts.backfill_section_index --apply
+"""
+import sys
+
+from app.database import SessionLocal
+from app.models.article import Article
+from app.routers.articles import _rebuild_section_index
+
+
+def main():
+ apply_changes = "--apply" in sys.argv
+ db = SessionLocal()
+ try:
+ articles = db.query(Article).all()
+ sections = sum(len(a.sections or []) for a in articles)
+ print(f" articles : {len(articles)}")
+ print(f" sections : {sections}")
+ if apply_changes:
+ for article in articles:
+ _rebuild_section_index(db, article)
+ db.commit()
+ print(" indexed and embedded.")
+ else:
+ print("\n Re-run with --apply to build the index.")
+ finally:
+ db.close()
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/backend/scripts/nest_symptoms_by_system.py b/backend/scripts/nest_symptoms_by_system.py
new file mode 100644
index 0000000..8b5e617
--- /dev/null
+++ b/backend/scripts/nest_symptoms_by_system.py
@@ -0,0 +1,125 @@
+"""Group symptom tags under the organ system they present in.
+
+Symptoms were a flat list of ~4,300 keywords, so the picker was an alphabetical
+wall. AMBOSS groups them by body region — "Head and neck > Eye > Blindness" — and
+that is what makes the list navigable.
+
+Only the symptoms that match a system's pattern are moved. Everything else stays
+top-level rather than being forced into an approximate bucket, and nothing is
+renamed, merged or deleted: this only sets a parent.
+
+ docker compose exec backend python -m scripts.nest_symptoms_by_system
+ docker compose exec backend python -m scripts.nest_symptoms_by_system --apply
+"""
+import re
+import sys
+from collections import defaultdict
+
+from sqlalchemy import text as sa_text
+
+from app.database import SessionLocal
+
+# Only worth a branch if several symptoms land in it.
+MIN_CHILDREN = 3
+
+# Organ system -> what a symptom in it looks like. Ordered: the first match wins,
+# so put the specific patterns above the general ones.
+SYSTEM_PATTERNS = [
+ ("Nervous System & Special Senses",
+ r"seizure|headache|migraine|ataxia|paralysis|palsy|neuropath|hypotoni|hypertoni|"
+ r"tremor|dizz|vertigo|syncope|coma|conscious|meningism|blind|\bvision\b|visual|"
+ r"strabismus|nystagmus|deaf|hearing|tinnitus|aphasia|dysarthr|weakness|numbness"),
+ ("Cardiovascular System",
+ r"murmur|palpitation|chest pain|cyanosis|hypertens|hypotens|tachycardi|bradycardi|"
+ r"edema|oedema|shock|syncope|pulse|perfusion"),
+ ("Respiratory System",
+ r"cough|wheez|stridor|dyspnea|dyspnoea|tachypnea|apnea|apnoea|respiratory distress|"
+ r"hemoptysis|haemoptysis|hypox|retraction|grunting"),
+ ("Gastrointestinal System",
+ r"vomit|diarrhea|diarrhoea|constipation|abdominal pain|abdominal distention|"
+ r"abdominal distension|jaundice|hematemesis|melena|dysphagia|reflux|colic|"
+ r"hepatomegal|splenomegal|ascites|encopresis"),
+ ("Renal & Urinary System",
+ r"hematuria|haematuria|proteinuria|dysuria|oliguria|polyuria|enuresis|"
+ r"urinary frequency|urinary retention|flank pain"),
+ ("Musculoskeletal System",
+ r"limp|joint pain|arthralgia|arthritis|back pain|scoliosis|fracture|"
+ r"limb pain|gait|swelling of the joint|muscle pain|myalgia"),
+ ("Skin & Subcutaneous Tissue",
+ r"rash|pruritus|itch|urticaria|petechia|purpura|blister|vesicle|pustule|"
+ r"alopecia|hair loss|pallor|erythema|lesion"),
+ ("Blood & Lymphoreticular System",
+ r"anemia|anaemia|bleeding|bruis|lymphadenopath|pallor|petechia|thrombocytopen"),
+ ("Endocrine System",
+ r"polydipsia|polyuria|weight loss|weight gain|obesity|short stature|tall stature|"
+ r"precocious|delayed puberty|goiter|goitre|hypoglycem|hyperglycem"),
+ ("Behavioral Health",
+ r"anxiety|depress|irritab|hyperactiv|inattent|aggress|self.harm|suicid|"
+ r"insomnia|sleep disturbance|tantrum|behaviou?r"),
+ ("Immune System",
+ r"allerg|anaphylax|recurrent infection|immunodefic|angioedema"),
+ ("Human Development",
+ r"developmental delay|failure to thrive|growth failure|milestone|regression|"
+ r"feeding difficult|poor feeding"),
+ ("Multisystem Processes & Disorders",
+ r"\bfever\b|fatigue|lethargy|malaise|sepsis|weight loss|dehydration|"
+ r"failure to thrive|night sweats"),
+]
+
+
+def main():
+ apply_changes = "--apply" in sys.argv
+ db = SessionLocal()
+ try:
+ systems = {row[1].lower(): row[0] for row in db.execute(sa_text(
+ "SELECT id, name FROM question_tags WHERE type = 'system'")).fetchall()}
+ if not systems:
+ print(" No 'system' tags yet; run the migration first.")
+ return 1
+
+ symptoms = db.execute(sa_text("""
+ SELECT t.id, t.name, COUNT(l.question_id) AS uses
+ FROM question_tags t
+ LEFT JOIN question_tag_links l ON l.tag_id = t.id
+ WHERE t.type = 'keyword' AND t.parent_id IS NULL
+ GROUP BY t.id, t.name
+ ORDER BY uses DESC, t.name
+ """)).fetchall()
+ print(f" ungrouped symptoms: {len(symptoms)}")
+
+ compiled = [(name, re.compile(pattern, re.I)) for name, pattern in SYSTEM_PATTERNS]
+ planned: dict[str, list[tuple[int, str, int]]] = defaultdict(list)
+ for tag_id, name, uses in symptoms:
+ for system_name, matcher in compiled:
+ if system_name.lower() in systems and matcher.search(name):
+ planned[system_name].append((tag_id, name, uses))
+ break
+
+ # A branch holding one or two entries is noise, not navigation.
+ planned = {k: v for k, v in planned.items() if len(v) >= MIN_CHILDREN}
+
+ if apply_changes:
+ for system_name, members in planned.items():
+ parent = systems[system_name.lower()]
+ for tag_id, _name, _uses in members:
+ db.execute(sa_text("UPDATE question_tags SET parent_id = :p WHERE id = :i"),
+ {"p": parent, "i": tag_id})
+ db.commit()
+
+ moved = sum(len(v) for v in planned.values())
+ print("APPLIED" if apply_changes else "DRY RUN")
+ print(f" grouped under a system : {moved}")
+ print(f" left top-level : {len(symptoms) - moved}\n")
+ for system_name in sorted(planned, key=lambda k: -len(planned[k])):
+ members = planned[system_name]
+ shown = ", ".join(name for _i, name, _u in members[:4])
+ more = f" … +{len(members) - 4}" if len(members) > 4 else ""
+ print(f" {system_name} ({len(members)}): {shown}{more}")
+ if not apply_changes:
+ print("\n Re-run with --apply to group them.")
+ 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
index d1cfa2f..2e63d2a 100644
--- a/backend/tests/test_exam_scoped_tags.py
+++ b/backend/tests/test_exam_scoped_tags.py
@@ -25,7 +25,8 @@ from app.utils.auth import get_current_user
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
+ type VARCHAR(50) NOT NULL, exam_id INTEGER, parent_id INTEGER,
+ sort_order INTEGER NOT NULL DEFAULT 100, created_at TIMESTAMP
);
CREATE TABLE question_tag_links (
id INTEGER PRIMARY KEY, question_id INTEGER NOT NULL, tag_id INTEGER NOT NULL
diff --git a/backend/tests/test_media_library.py b/backend/tests/test_media_library.py
index d2274b8..7bfad23 100644
--- a/backend/tests/test_media_library.py
+++ b/backend/tests/test_media_library.py
@@ -23,7 +23,7 @@ from app.routers import media
from app.utils.auth import get_current_user
TAG_DDL = """
-CREATE TABLE question_tags (id INTEGER PRIMARY KEY, name VARCHAR(200), type VARCHAR(50), exam_id INTEGER, created_at TIMESTAMP);
+CREATE TABLE question_tags (id INTEGER PRIMARY KEY, name VARCHAR(200), type VARCHAR(50), exam_id INTEGER, parent_id INTEGER, sort_order INTEGER DEFAULT 100, created_at TIMESTAMP);
"""
diff --git a/backend/tests/test_tag_hierarchy.py b/backend/tests/test_tag_hierarchy.py
new file mode 100644
index 0000000..30cf6a9
--- /dev/null
+++ b/backend/tests/test_tag_hierarchy.py
@@ -0,0 +1,147 @@
+"""Tags nest, and a moderator can grow the taxonomy without a migration.
+
+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.media import MediaAsset # noqa — health report walks every embeddable table.
+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
+
+DDL = """
+CREATE TABLE question_tags (
+ id INTEGER PRIMARY KEY, name VARCHAR(200) NOT NULL, type VARCHAR(50) NOT NULL,
+ exam_id INTEGER, parent_id INTEGER, sort_order INTEGER NOT NULL DEFAULT 100,
+ created_at TIMESTAMP
+);
+CREATE TABLE question_tag_links (id INTEGER PRIMARY KEY, question_id INTEGER, tag_id INTEGER);
+"""
+
+
+class TagHierarchyTests(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 DDL.strip().split(";"):
+ if statement.strip():
+ self.db.execute(text(statement))
+
+ self.mod = User(id=1, name="Mod", email="mod@example.test", hashed_password="unused", role="moderator")
+ self.learner = User(id=2, name="Learner", email="l@example.test", hashed_password="unused")
+ self.db.add_all([self.mod, self.learner])
+ for qid in (1, 2, 3):
+ 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.execute(text("""
+ INSERT INTO question_tags (id, name, type, parent_id, sort_order) VALUES
+ (10, 'Nervous System & Special Senses', 'system', NULL, 0),
+ (11, 'Seizure', 'keyword', 10, 100),
+ (12, 'Headache', 'keyword', 10, 100),
+ (13, 'Cardiology', 'subject', NULL, 100)
+ """))
+ self.db.execute(text("INSERT INTO question_tag_links (question_id, tag_id) VALUES (1, 11), (2, 11)"))
+ self.db.commit()
+
+ self.user = self.mod
+ 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 test_the_tree_is_returned_with_parents(self):
+ body = self.client.get("/tags").json()
+ systems = {t["name"]: t for t in body["systems"]}
+ self.assertIn("Nervous System & Special Senses", systems)
+ keywords = {t["name"]: t["parent_id"] for t in body["keywords"]}
+ # Symptoms hang off the system they present in.
+ self.assertEqual(keywords["Seizure"], 10)
+ self.assertEqual(keywords["Headache"], 10)
+
+ def test_a_moderator_can_add_a_top_level_entry_and_a_child(self):
+ top = self.client.post("/tags/", json={"name": "Respiratory System", "type": "system"})
+ self.assertEqual(top.status_code, 201, top.text)
+ parent_id = top.json()["id"]
+
+ child = self.client.post("/tags/", json={"name": "Wheeze", "type": "keyword",
+ "parent_id": parent_id})
+ self.assertEqual(child.status_code, 201, child.text)
+ # It shows up immediately, which is what every picker reads.
+ body = self.client.get("/tags").json()
+ self.assertIn("Wheeze", [t["name"] for t in body["keywords"]])
+
+ def test_a_child_must_match_its_parents_kind(self):
+ response = self.client.post("/tags/", json={"name": "Nonsense", "type": "keyword",
+ "parent_id": 13})
+ self.assertEqual(response.status_code, 400)
+
+ def test_duplicate_names_in_the_same_place_are_refused(self):
+ self.assertEqual(self.client.post("/tags/", json={
+ "name": "Seizure", "type": "keyword", "parent_id": 10}).status_code, 409)
+ # The same name under a different parent is fine.
+ self.assertEqual(self.client.post("/tags/", json={
+ "name": "Seizure", "type": "keyword"}).status_code, 201)
+
+ def test_a_tag_cannot_become_its_own_descendants_child(self):
+ self.assertEqual(self.client.patch("/tags/10", json={"parent_id": 11}).status_code, 400)
+ self.assertEqual(self.client.patch("/tags/10", json={"parent_id": 10}).status_code, 400)
+
+ def test_renaming_and_reparenting(self):
+ self.assertEqual(self.client.patch("/tags/12", json={"name": "Head pain"}).status_code, 200)
+ self.assertEqual(self.db.execute(text(
+ "SELECT name FROM question_tags WHERE id = 12")).scalar(), "Head pain")
+ self.client.patch("/tags/12", json={"parent_id": None})
+ self.assertIsNone(self.db.execute(text(
+ "SELECT parent_id FROM question_tags WHERE id = 12")).scalar())
+
+ def test_questions_attached_to_a_tag_appear_in_its_count(self):
+ response = self.client.post("/tags/12/questions", json={"question_ids": [1, 2, 3]})
+ self.assertEqual(response.json()["added"], 3)
+ counts = {t["name"]: t["count"] for t in self.client.get("/tags").json()["keywords"]}
+ self.assertEqual(counts["Headache"], 3)
+ # Adding the same ones again is a no-op rather than a duplicate.
+ self.assertEqual(self.client.post("/tags/12/questions",
+ json={"question_ids": [1, 2]}).json()["added"], 0)
+
+ def test_deleting_reparents_children_rather_than_orphaning_them(self):
+ self.client.delete("/tags/10")
+ # Seizure and Headache were under it; they rise to top level, not nowhere.
+ rows = self.db.execute(text(
+ "SELECT parent_id FROM question_tags WHERE id IN (11, 12)")).fetchall()
+ self.assertEqual([r[0] for r in rows], [None, None])
+
+ def test_deleting_can_move_its_questions_onto_another_tag(self):
+ self.client.delete("/tags/11", params={"move_to": 12})
+ counts = {t["name"]: t["count"] for t in self.client.get("/tags").json()["keywords"]}
+ self.assertEqual(counts["Headache"], 2)
+
+ def test_editing_the_taxonomy_is_moderator_only(self):
+ self.user = self.learner
+ self.assertEqual(self.client.post("/tags/", json={"name": "X", "type": "keyword"}).status_code, 403)
+ self.assertEqual(self.client.patch("/tags/11", json={"name": "X"}).status_code, 403)
+ self.assertEqual(self.client.delete("/tags/11").status_code, 403)
+ self.assertEqual(self.client.post("/tags/11/questions",
+ json={"question_ids": [1]}).status_code, 403)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/frontend/src/pages/CustomQuizPage.jsx b/frontend/src/pages/CustomQuizPage.jsx
index 4de0b27..6d43f72 100644
--- a/frontend/src/pages/CustomQuizPage.jsx
+++ b/frontend/src/pages/CustomQuizPage.jsx
@@ -130,9 +130,11 @@ export default function CustomQuizPage() {
const nameById = useMemo(() => Object.fromEntries(categories.map(c => [c.id, c.name])), [categories])
const subjectTags = tags.subjects || []
const keywordTags = tags.keywords || []
- const tagName = (id) => [...subjectTags, ...keywordTags].find(t => t.id === id)?.name
+ const tagName = (id) => [...subjectTags, ...keywordTags, ...(tags.systems || [])].find(t => t.id === id)?.name
+ const systemTags = tags.systems || []
const systems = summarise(categoryIds.map(id => nameById[id]).filter(Boolean))
+ const organSystems = summarise(tagIds.map(tagName).filter(n => n && systemTags.some(t => t.name === n)))
const disciplines = summarise(tagIds.map(tagName).filter(n => n && subjectTags.some(t => t.name === n)))
const symptoms = summarise(tagIds.map(tagName).filter(n => n && keywordTags.some(t => t.name === n)))
const articleSummary = summarise(articleIds.map(id => articles.find(a => a.id === id)?.title).filter(Boolean))
@@ -260,7 +262,8 @@ export default function CustomQuizPage() {
ExamsPediatrics Boards
- setOpenFacet('systems')} />
+ setOpenFacet('organ-systems')} />
+ setOpenFacet('systems')} />
setOpenFacet('disciplines')} />
setOpenFacet('symptoms')} />
setOpenFacet('articles')} />
@@ -372,7 +375,24 @@ export default function CustomQuizPage() {
{error &&
{error}
}
{/* ── Facet pickers ────────────────────────────────────────── */}
- setOpenFacet(null)}
+ setOpenFacet(null)}
+ onReset={() => setTagIds(ids => ids.filter(id => !systemTags.some(t => t.id === id)))}
+ helper="Body systems. Flat by nature — there are only so many.">
+ {query => {
+ const shown = systemTags.filter(t => !query || t.name.toLowerCase().includes(query))
+ if (!shown.length) return
Nothing matches that search.
+ return shown.map(tag => (
+
+ ))
+ }}
+
+
+ setOpenFacet(null)}
onReset={() => setCategoryIds([])}
helper="Open a system to see what is under it. Choosing one includes everything beneath.">
{query => (
diff --git a/frontend/src/pages/CustomQuizPage.test.jsx b/frontend/src/pages/CustomQuizPage.test.jsx
index d2a2b1c..e8ce071 100644
--- a/frontend/src/pages/CustomQuizPage.test.jsx
+++ b/frontend/src/pages/CustomQuizPage.test.jsx
@@ -42,7 +42,7 @@ describe('CustomQuizPage', () => {
expect(screen.queryByLabelText(/Time limit/)).not.toBeInTheDocument()
// Systems and Status are chosen inside their facet pickers, not inline.
- await userEvent.click(screen.getByRole('button', { name: /^Systems/ }))
+ await userEvent.click(screen.getByRole('button', { name: /^Topics/ }))
await userEvent.click(await screen.findByRole('checkbox', { name: /Pediatrics/ }))
await userEvent.click(screen.getByRole('checkbox', { name: /Neonatal/ }))
await userEvent.click(screen.getByRole('button', { name: 'Done' }))
@@ -103,32 +103,32 @@ describe('CustomQuizPage', () => {
it('summarises each facet as All, a name, or a name with +N', async () => {
renderBuilder()
- const systems = await screen.findByRole('button', { name: /^Systems/ })
+ const systems = await screen.findByRole('button', { name: /^Topics/ })
expect(systems).toHaveTextContent('All')
await userEvent.click(systems)
await userEvent.click(await screen.findByRole('checkbox', { name: /Pediatrics/ }))
await userEvent.click(screen.getByRole('button', { name: 'Done' }))
- expect(screen.getByRole('button', { name: /^Systems/ })).toHaveTextContent('Pediatrics')
+ expect(screen.getByRole('button', { name: /^Topics/ })).toHaveTextContent('Pediatrics')
expect(screen.queryByText('+1')).not.toBeInTheDocument()
- await userEvent.click(screen.getByRole('button', { name: /^Systems/ }))
+ await userEvent.click(screen.getByRole('button', { name: /^Topics/ }))
await userEvent.click(await screen.findByRole('checkbox', { name: /Neonatal/ }))
await userEvent.click(screen.getByRole('button', { name: 'Done' }))
- expect(screen.getByRole('button', { name: /^Systems/ })).toHaveTextContent('+1')
+ expect(screen.getByRole('button', { name: /^Topics/ })).toHaveTextContent('+1')
})
it('closes a picker without losing the selection and resets it on demand', async () => {
renderBuilder()
- await userEvent.click(await screen.findByRole('button', { name: /^Systems/ }))
+ await userEvent.click(await screen.findByRole('button', { name: /^Topics/ }))
await userEvent.click(await screen.findByRole('checkbox', { name: /Pediatrics/ }))
- await userEvent.click(screen.getByRole('button', { name: 'Close Systems' }))
- expect(screen.getByRole('button', { name: /^Systems/ })).toHaveTextContent('Pediatrics')
+ await userEvent.click(screen.getByRole('button', { name: 'Close Topics' }))
+ expect(screen.getByRole('button', { name: /^Topics/ })).toHaveTextContent('Pediatrics')
- await userEvent.click(screen.getByRole('button', { name: /^Systems/ }))
+ await userEvent.click(screen.getByRole('button', { name: /^Topics/ }))
await userEvent.click(screen.getByRole('button', { name: 'Reset' }))
await userEvent.click(screen.getByRole('button', { name: 'Done' }))
- expect(screen.getByRole('button', { name: /^Systems/ })).toHaveTextContent('All')
+ expect(screen.getByRole('button', { name: /^Topics/ })).toHaveTextContent('All')
})
it('searches across facets and toggles a hit in place', async () => {
@@ -153,11 +153,11 @@ describe('CustomQuizPage', () => {
it('resets every topic facet at once', async () => {
renderBuilder()
- await userEvent.click(await screen.findByRole('button', { name: /^Systems/ }))
+ await userEvent.click(await screen.findByRole('button', { name: /^Topics/ }))
await userEvent.click(await screen.findByRole('checkbox', { name: /Pediatrics/ }))
await userEvent.click(screen.getByRole('button', { name: 'Done' }))
await userEvent.click(screen.getByRole('button', { name: 'Reset all topics' }))
- expect(screen.getByRole('button', { name: /^Systems/ })).toHaveTextContent('All')
+ expect(screen.getByRole('button', { name: /^Topics/ })).toHaveTextContent('All')
})
it('keeps the count in the criteria card and mode plus Start in a sticky bar', async () => {
diff --git a/frontend/src/pages/QuestionBankPage.jsx b/frontend/src/pages/QuestionBankPage.jsx
index 2709599..2d4f186 100644
--- a/frontend/src/pages/QuestionBankPage.jsx
+++ b/frontend/src/pages/QuestionBankPage.jsx
@@ -366,7 +366,9 @@ export default function QuestionBankPage() {
const nameOf = (list, id) => list.find(t => t.id === id)?.name
const selectedIn = (list) => selectedTagIds.map(id => nameOf(list, id)).filter(Boolean)
+ const systemTags = tags.systems || []
const systemsFacet = summarise(filterCatIds.map(id => categories.find(c => c.id === id)?.name).filter(Boolean))
+ const organSystemsFacet = summarise(selectedIn(systemTags))
const disciplinesFacet = summarise(selectedIn(subjectTags))
const diseasesFacet = summarise(selectedIn(diseaseTags))
const symptomsFacet = summarise(selectedIn(keywordTags))
@@ -728,7 +730,8 @@ export default function QuestionBankPage() {
onOpen={() => setOpenFacet('status')} />
setOpenFacet('difficulty')} />
- setOpenFacet('systems')} />
+ setOpenFacet('organ-systems')} />
+ setOpenFacet('systems')} />
setOpenFacet('disciplines')} />
setOpenFacet('diseases')} />
setOpenFacet('symptoms')} />
@@ -907,7 +910,13 @@ export default function QuestionBankPage() {
))}
- setOpenFacet(null)}
+ setOpenFacet(null)}
+ onReset={() => setSelectedTagIds(ids => ids.filter(id => !systemTags.some(t => t.id === id)))}
+ helper="Body systems. Flat by nature — there are only so many.">
+ {query => tagChecklist(systemTags, query)}
+
+
+ setOpenFacet(null)}
onReset={() => setFilterCatIds([])}
helper="Open a system to see what is under it. Choosing one includes everything beneath.">
{query => (