diff --git a/backend/scripts/convert_tags_to_categories.py b/backend/scripts/convert_tags_to_categories.py new file mode 100644 index 0000000..5f9fb12 --- /dev/null +++ b/backend/scripts/convert_tags_to_categories.py @@ -0,0 +1,182 @@ +"""Convert existing question tags into a clean system → disease category hierarchy. + +Subject tags map to a small set of canonical systems (synonyms normalized); +disease tags become children of the question's primary system only. +Keyword tags stay untouched. Previous assignments are preserved as extra links +and a backup is written to /backups/tag_category_conversion_backup.json. +""" +import json +import sys +from collections import defaultdict +from pathlib import Path + +from sqlalchemy import text + +from app.database import SessionLocal +from app.models.question import Question +from app.models.question_category import QuestionCategory, QuestionCategoryLink +from app.models.user import User + +SYSTEMS = { + "Adolescent Medicine": ["adolescent medicine", "contraception", "sexual health", "reproductive health", + "reproductive medicine", "gynecology", "pediatric gynecology", + "obstetrics and gynecology", "obstetrics/gynecology", "obstetrics"], + "Allergy/Immunology": ["allergy", "allergy and immunology", "allergy/immunology", "immunology", + "pediatric allergy and immunology"], + "Cardiology": ["cardiology", "pediatric cardiology", "cardiac surgery", "cardiothoracic surgery", + "heart failure", "vascular disease", "vascular medicine", "vascular surgery"], + "Critical Care": ["critical care", "critical care medicine", "intensive care", "intensive care medicine", + "pediatric critical care", "resuscitation", "pediatric resuscitation", "trauma", + "trauma medicine", "trauma surgery", "pediatric trauma", "pediatric trauma surgery", + "burn medicine", "burn surgery"], + "Dermatology": ["dermatology", "pediatric dermatology"], + "Developmental & Behavioral": ["developmental medicine", "developmental pediatrics", "developmental psychology", + "child development", "child behavior", "behavioral health", "behavioral medicine", + "behavioral pediatrics", "child psychiatry", "child psychology", "neurodevelopmental", + "neurodevelopmental disorders", "neurodevelopmental medicine", "learning disorders"], + "Emergency Medicine": ["emergency medicine", "pediatric emergency medicine"], + "Endocrinology": ["endocrinology", "pediatric endocrinology", "diabetes management", "growth disorders", + "growth medicine", "growth and development", "obesity medicine"], + "Gastroenterology & Nutrition": ["gastroenterology", "pediatric gastroenterology", "hepatology", + "pediatric hepatology", "nutrition", "pediatric nutrition", + "inflammatory bowel disease", "lactation medicine"], + "Genetics & Metabolism": ["genetics", "dysmorphology", "metabolic disorders", "pediatric metabolic disorders"], + "Hematology-Oncology": ["hematology", "pediatric hematology", "hematology-oncology", "hematology/oncology", + "pediatric hematology-oncology", "oncology", "pediatric oncology", "radiation oncology", + "transfusion medicine", "transplantation", "survivorship"], + "Infectious Disease": ["infectious disease", "pediatric infectious disease", "parasitology", "virology", + "immunization", "travel medicine"], + "Neonatology": ["neonatology", "perinatology", "newborn screening", "maternal-fetal medicine", + "prenatal care", "prenatal medicine"], + "Nephrology & Urology": ["nephrology", "pediatric nephrology", "urology", "pediatric urology", + "fluid and electrolytes"], + "Neurology": ["neurology", "pediatric neurology", "neurosurgery", "pediatric neurosurgery", "epilepsy", + "headache medicine", "movement disorders", "sleep medicine", "neuropsychology"], + "Ophthalmology": ["ophthalmology"], + "Orthopedics & Sports": ["orthopedic surgery", "orthopedics", "pediatric orthopedics", "sports medicine", + "spine surgery", "physical medicine", "physical medicine & rehabilitation", + "rehabilitation medicine"], + "Otolaryngology": ["otolaryngology", "pediatric otolaryngology", "audiology", + "speech-language pathology", "head and neck surgery"], + "Psychiatry & Psychology": ["psychiatry", "pediatric psychiatry", "psychology", "psychopharmacology", + "substance abuse", "substance abuse medicine", "substance use disorder", + "substance use disorders", "addiction medicine", "mental health"], + "Pulmonology": ["pulmonology", "pediatric pulmonology", "respiratory medicine"], + "Rheumatology": ["rheumatology", "pediatric rheumatology"], + "Surgery": ["surgery", "general surgery", "pediatric surgery", "plastic surgery", "breast surgery", + "bariatric surgery"], + "Toxicology": ["toxicology"], + "Primary Care & Prevention": ["primary care", "preventive medicine", "family medicine", "hospital medicine", + "internal medicine", "care coordination", "injury prevention", "environmental health", + "public health", "international health", "social medicine", "health equity", + "patient safety", "child abuse", "child abuse & neglect", "child abuse and neglect", + "child abuse medicine", "child welfare", "adoption medicine", "forensic medicine", + "pain management", "palliative care"], + "Professional Topics": ["ethics", "medical ethics", "research ethics", "medical education", + "graduate medical education", "clinical research", "research methods", + "clinical trial design", "epidemiology", "clinical epidemiology", "biostatistics", + "evidence-based medicine", "quality improvement", "healthcare administration", + "hospital administration", "clinical operations", "medical history", "medical law", + "medical-legal", "terminology", "clinical skills", "physical examination", + "pharmacology", "pharmacy", "dentistry", "pediatric dentistry", "anesthesia", + "anesthesiology", "occupational health", "occupational medicine", "special education", + "multidisciplinary care", "safety", "clinical pathology"], + "General Pediatrics": ["pediatrics"], +} + + +def canonical_system(tag): + name = tag.strip().casefold() + for system, aliases in SYSTEMS.items(): + if name in [alias.casefold() for alias in aliases]: + return system + return None + + +def main(): + db = SessionLocal() + try: + questions = db.query(Question.id, Question.question_category_id).all() + question_ids = [row[0] for row in questions] + old_primary = {row[0]: row[1] for row in questions} + old_links = defaultdict(list) + for qid, cid in db.query(QuestionCategoryLink.question_id, QuestionCategoryLink.category_id).all(): + old_links[qid].append(cid) + backup = { + "old_primary": {str(k): v for k, v in old_primary.items()}, + "old_links": {str(k): v for k, v in old_links.items()}, + } + target = Path("/backups/tag_category_conversion_backup.json") + target.write_text(json.dumps(backup, indent=2)) + print(f"Backup saved: {target}") + + subject_rows = db.execute(text(""" + SELECT tl.question_id, t.name FROM question_tag_links tl + JOIN question_tags t ON t.id = tl.tag_id WHERE t.type = 'subject' + """)).all() + disease_rows = db.execute(text(""" + SELECT tl.question_id, t.name FROM question_tag_links tl + JOIN question_tags t ON t.id = tl.tag_id WHERE t.type = 'disease' + """)).all() + subjects: dict[int, list[str]] = defaultdict(list) + diseases: dict[int, list[str]] = defaultdict(list) + for qid, name in subject_rows: + subjects[qid].append(name) + for qid, name in disease_rows: + diseases[qid].append(name) + + moderator = db.query(User.id).filter(User.role.in_(["admin", "moderator"])).order_by(User.id).first() + moderator = moderator[0] if moderator else None + + def get_category(name, parent_id=None): + query = db.query(QuestionCategory).filter(QuestionCategory.name == name) + query = query.filter(QuestionCategory.parent_id == parent_id) if parent_id is not None else query.filter( + QuestionCategory.parent_id.is_(None)) + category = query.first() + if not category: + taken = db.query(QuestionCategory.id).filter(QuestionCategory.name == name).first() + if taken: + parent = db.get(QuestionCategory, parent_id) if parent_id is not None else None + name = f"{name} ({parent.name})" if parent else f"{name} (system)" + category = QuestionCategory(name=name, parent_id=parent_id, user_id=moderator, + description="Generated from existing tags") + db.add(category) + db.flush() + return category + + system_by_name = {name: get_category(name).id for name in SYSTEMS} + print(f"Canonical systems: {len(SYSTEMS)}") + + changed, links_added, skipped = 0, 0, 0 + for qid in question_ids: + specific = sorted({canonical_system(tag) for tag in subjects.get(qid, []) + if canonical_system(tag) and canonical_system(tag) != "General Pediatrics"}) + if specific: + system_names = specific + elif subjects.get(qid) or diseases.get(qid): + system_names = ["General Pediatrics"] + else: + skipped += 1 + continue + # Systems first: diseases become children in a later pass once the taxonomy settles. + primary = system_by_name[system_names[0]] + extras = {system_by_name[name] for name in system_names} + extras.discard(primary) + if old_primary.get(qid) is not None: + extras.add(old_primary[qid]) + if old_primary.get(qid) != primary: + db.query(Question).filter(Question.id == qid).update({"question_category_id": primary}) + changed += 1 + existing = {cid for cid in db.query(QuestionCategoryLink.category_id).filter_by(question_id=qid).all()} + for cid in sorted(extras - existing): + db.add(QuestionCategoryLink(question_id=qid, category_id=cid)) + links_added += 1 + db.commit() + print(f"Reassigned primary for {changed} questions; added {links_added} extra links; skipped {skipped}; " + f"{db.query(QuestionCategory).count()} categories total.") + finally: + db.close() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/backend/scripts/rollback_tag_category_conversion.py b/backend/scripts/rollback_tag_category_conversion.py new file mode 100644 index 0000000..cc7ac58 --- /dev/null +++ b/backend/scripts/rollback_tag_category_conversion.py @@ -0,0 +1,48 @@ +"""Roll back the tag→category conversion using the saved backup JSON.""" +import json +import sys +from pathlib import Path + +from app.database import SessionLocal +from app.models.question import Question +from app.models.question_category import QuestionCategory, QuestionCategoryLink + + +def main(): + backup = json.loads(Path("/backups/tag_category_conversion_backup.json").read_text()) + db = SessionLocal() + try: + old_primary = {int(k): v for k, v in backup["old_primary"].items()} + old_links = {int(k): v for k, v in backup["old_links"].items()} + restored = 0 + for qid, primary in old_primary.items(): + db.query(Question).filter(Question.id == qid).update({"question_category_id": primary}) + restored += 1 + db.query(QuestionCategoryLink).delete(synchronize_session=False) + links_added = 0 + for qid, links in old_links.items(): + for cid in links: + db.add(QuestionCategoryLink(question_id=qid, category_id=cid)) + links_added += 1 + # Remove generated categories bottom-up (self-referential parent FK). + deleted = 0 + while True: + parent_ids = {row[0] for row in db.query(QuestionCategory.parent_id).filter( + QuestionCategory.parent_id.isnot(None)).all()} + candidates = db.query(QuestionCategory).filter( + QuestionCategory.description == "Generated from existing tags", + ~QuestionCategory.id.in_(parent_ids) if parent_ids else True, + ).all() + if not candidates: + break + for category in candidates: + db.delete(category) + deleted += 1 + db.commit() + print(f"Restored primary for {restored} questions, {links_added} links; deleted {deleted} generated categories.") + finally: + db.close() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/frontend/src/pages/QuestionBankPage.css b/frontend/src/pages/QuestionBankPage.css index 54d6416..778db25 100644 --- a/frontend/src/pages/QuestionBankPage.css +++ b/frontend/src/pages/QuestionBankPage.css @@ -2,3 +2,6 @@ .category-chip { display: inline-flex; align-items: center; gap: 6px; font-size: .78rem; padding: 5px 10px; border: 1px solid var(--border); border-radius: 999px; cursor: pointer; background: var(--input-bg); color: var(--text); } .category-chip.checked { background: var(--primary-soft, #dcebfa); border-color: var(--primary); color: var(--primary); font-weight: 600; } .category-chip input { width: auto; accent-color: var(--primary); } +.category-editor-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.45); z-index: 1100; display: flex; align-items: center; justify-content: center; padding: 16px; } +.category-editor-card { background: var(--card-bg); border-radius: 12px; padding: 18px; width: 100%; max-width: 420px; box-shadow: 0 20px 60px rgba(0,0,0,0.3); } +.category-editor-card select { width: 100%; padding: 7px 12px; border: 1px solid var(--border); border-radius: 8px; font-size: .875rem; background: var(--input-bg); color: var(--text); } diff --git a/frontend/src/pages/QuestionBankPage.jsx b/frontend/src/pages/QuestionBankPage.jsx index 19eb0a8..4649b41 100644 --- a/frontend/src/pages/QuestionBankPage.jsx +++ b/frontend/src/pages/QuestionBankPage.jsx @@ -929,16 +929,24 @@ export default function QuestionBankPage() { {showCatForm && ( -