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() { Exams Pediatrics 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 => (