feat: system category hierarchy from tags, category editor overlay

Subject tags map to 26 canonical systems via a reversible, backed-up conversion script; questions keep old categories as extra links. Category add/edit now opens a proper dialog instead of stacking above the chips. 95 frontend tests pass.
This commit is contained in:
Daniel 2026-09-08 23:50:25 +02:00
parent ff1aee6fad
commit 88912f83c7
4 changed files with 251 additions and 10 deletions

View file

@ -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())

View file

@ -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())

View file

@ -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); }

View file

@ -929,16 +929,24 @@ export default function QuestionBankPage() {
</div>
{showCatForm && (
<div style={{ marginTop: 12, display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<input aria-label="Category name" type="text" maxLength={200} value={newCatName} onChange={e => setNewCatName(e.target.value)} placeholder="New category name..."
onKeyDown={e => e.key === 'Enter' && addCategory()}
style={{ flex: 1, padding: '7px 12px', border: '1px solid var(--border)', borderRadius: 8, fontSize: '0.875rem', background: 'var(--input-bg)', color: 'var(--text)' }} />
<select aria-label="Parent category" value={catParent} onChange={e => setCatParent(e.target.value)}>
<option value="">No parent (root)</option>
{categories.filter(c => !editingCategory || !(c.breadcrumbs || [{ id: c.id }]).some(b => b.id === editingCategory.id)).map(c => <option key={c.id} value={c.id}>{(c.breadcrumbs || []).map(b => b.name).join(' ') || c.name}</option>)}
</select>
<button className="btn btn-primary btn-sm" onClick={addCategory}>{editingCategory ? 'Save category' : 'Add'}</button>
<button className="btn btn-secondary btn-sm" onClick={() => { setShowCatForm(false); setNewCatName(''); setCatParent(''); setEditingCategory(null) }}>Cancel</button>
<div className="category-editor-overlay" onClick={e => e.target === e.currentTarget && (setShowCatForm(false), setEditingCategory(null))}>
<div className="category-editor-card" role="dialog" aria-label={editingCategory ? `Edit category ${editingCategory.name}` : 'New category'}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 10 }}>
<strong>{editingCategory ? `Edit category: ${editingCategory.name}` : 'New category'}</strong>
<button style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)' }} onClick={() => { setShowCatForm(false); setNewCatName(''); setCatParent(''); setEditingCategory(null) }}></button>
</div>
<input aria-label="Category name" type="text" maxLength={200} value={newCatName} onChange={e => setNewCatName(e.target.value)} placeholder="Category name..."
onKeyDown={e => e.key === 'Enter' && addCategory()}
style={{ width: '100%', padding: '7px 12px', border: '1px solid var(--border)', borderRadius: 8, fontSize: '0.875rem', background: 'var(--input-bg)', color: 'var(--text)', marginBottom: 8 }} />
<select aria-label="Parent category" value={catParent} onChange={e => setCatParent(e.target.value)}>
<option value="">No parent (root)</option>
{categories.filter(c => !editingCategory || !(c.breadcrumbs || [{ id: c.id }]).some(b => b.id === editingCategory.id)).map(c => <option key={c.id} value={c.id}>{(c.breadcrumbs || []).map(b => b.name).join(' ') || c.name}</option>)}
</select>
<div style={{ display: 'flex', gap: 8, marginTop: 10, justifyContent: 'flex-end' }}>
<button className="btn btn-primary btn-sm" onClick={addCategory}>{editingCategory ? 'Save category' : 'Add'}</button>
<button className="btn btn-secondary btn-sm" onClick={() => { setShowCatForm(false); setNewCatName(''); setCatParent(''); setEditingCategory(null) }}>Cancel</button>
</div>
</div>
</div>
)}
</div>