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:
parent
ff1aee6fad
commit
88912f83c7
4 changed files with 251 additions and 10 deletions
182
backend/scripts/convert_tags_to_categories.py
Normal file
182
backend/scripts/convert_tags_to_categories.py
Normal 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())
|
||||
48
backend/scripts/rollback_tag_category_conversion.py
Normal file
48
backend/scripts/rollback_tag_category_conversion.py
Normal 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())
|
||||
|
|
@ -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); }
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue