diff --git a/backend/scripts/settle_topic_questions.py b/backend/scripts/settle_topic_questions.py new file mode 100644 index 0000000..cbe21e6 --- /dev/null +++ b/backend/scripts/settle_topic_questions.py @@ -0,0 +1,238 @@ +"""Three decisions the owner made about where things live. + +**The ear goes to ENT.** Otitis media was split across two roots — the acute +form and mastoiditis under Infectious Disease, effusion and the recurrent form +under Otolaryngology — so a learner looking for "the ear" found half of it +twice. All of it moves to Otolaryngology, and the three forms become children +of Otitis Media rather than four siblings saying nearly the same thing. The +questions keep a link back to Infectious Disease, because they are infections +and somebody browsing that way should still find them. + +**Surgery stops being a root.** It held 34 questions, no children, and mapped +to no ABP domain — so a twelfth of a paper's worth of content was invisible to +the blueprint. Nothing about these questions is "surgery" in the sense the +outline uses: they are bowel obstruction in a neonate, a burn, a torsion, a +swallowed coin. Each goes to the topic that names its condition, and every one +of them keeps `Surgery` as a subject tag, which is the axis a discipline +belongs on. + +**Gynecology becomes its own branch.** ABP puts it at 20.B, under Genitourinary +System, but it was living under Adolescent Care — so domain 20 drew from a pool +that was missing a third of what the outline says is in it. Sexually +transmitted infections stay where they are: those are 3.B, sex and sexuality, +and the outline means them there. + +Idempotent, and a dry run by default: + + docker compose exec backend python -m scripts.settle_topic_questions + docker compose exec backend python -m scripts.settle_topic_questions --apply +""" +import sys + +from sqlalchemy import text as sa_text + +from app.database import SessionLocal + +#: Topics to reparent, keeping the old parent as a cross-file link on every +#: question so the branch they came from still finds them. +#: (topic name, new parent name) +REHOME = [ + ("Otitis Media", "Otolaryngology"), + ("Acute Otitis Media", "Otitis Media"), + ("Otitis Media With Effusion", "Otitis Media"), + ("Recurrent Otitis Media", "Otitis Media"), + ("Mastoiditis", "Otolaryngology"), + # ABP 20.B. Menstrual and vulvovaginal complaints are gynecology wherever + # the patient happens to be an adolescent; the STIs beside them are not. + ("Menorrhagia", "Gynecology"), + ("Abnormal Uterine Bleeding", "Gynecology"), + ("Dysmenorrhea", "Gynecology"), + ("Amenorrhea", "Gynecology"), + ("Vulvovaginitis", "Gynecology"), + ("Vaginal Infection", "Gynecology"), + ("Bacterial Vaginosis", "Gynecology"), +] + +#: Topics to create if absent: (name, parent or None for a root, system). +NEW = [ + ("Gynecology", None, "Reproductive System"), + ("Burns", "Emergency Medicine", "Skin & Subcutaneous Tissue"), + ("Foreign Body Ingestion", "Gastroenterology & Nutrition", "Gastrointestinal System"), + ("Procedural Sedation", "Emergency Medicine", "Multisystem Processes & Disorders"), + ("Chest Wall Deformity", "Orthopedics & Sports", "Musculoskeletal System"), +] + +#: Every question filed under the old `Surgery` root, and the topic that names +#: what it is actually about. Read off the stem and the keyed answer, one at a +#: time — there is no rule that derives these, and a rule that guessed would be +#: worse than the root they are leaving. +SURGERY = { + 163: "Bilious Vomiting", # bile-stained emesis, contrast enema + 217: "Congenital Heart Disease", # interrupted aortic arch, PGE1 + 255: "Reflux & Peptic Disease", # counselling before a fundoplication + 794: "Laceration", # lip laceration across the vermillion + 905: "Duodenal Atresia", # double bubble, trisomy 21 + 912: "Bilious Vomiting", # neonatal small left colon + 1147: "Congenital Anomalies", # multiple anomalies, spine radiograph + 1173: "Pediatric Urology", # analgesia for circumcision + 1204: "Bilious Vomiting", # malrotation on an upper GI series + 1422: "Burns", # hypocalcaemia after a 50% burn + 1509: "Urinary Tract Infection", # catheter-associated, post-operative + 1597: "Surgical Abdomen", # perforated appendicitis + 1770: "Bilious Vomiting", # small left colon, no meconium + 1917: "Congenital Anomalies", # gastroschisis and its risk factors + 2015: "Congenital Urinary Tract", # posterior urethral valves + 2020: "Chronic Lung Disease", # pulmonary hypertension after BPD + 2132: "Procedural Sedation", # propofol and fentanyl for a reduction + 2199: "Surgical Abdomen", # incarcerated inguinal hernia + 2355: "Liver Disease", # biliary atresia after a Kasai + 2358: "Duodenal Atresia", + 2397: "Malabsorption", # short bowel, line sepsis + 2475: "Congenital Urinary Tract", # antenatal hydronephrosis + 2592: "Tracheoesophageal Fistula", # choking with feeds, echocardiography + 2616: "Acute Abdomen", # analgesia before the diagnosis + 2621: "Congenital Heart Disease", # heterotaxy + 2647: "Chest Wall Deformity", # pectus excavatum + 2651: "Genital & Scrotal", # testicular torsion + 2728: "Congenital Anomalies", # gastroschisis at delivery + 2743: "Otolaryngology", # choanal atresia + 2746: "Chromosomal Disorders", # trisomy 13 + 2852: "Burns", # nutrition after a scald + 2913: "Constipation & Motility", # Hirschsprung disease + 2917: "Foreign Body Ingestion", # swallowed coin + 2988: "Genital & Scrotal", # post-circumcision inclusion cyst +} + +#: The discipline every one of them keeps, on the axis a discipline belongs on. +SURGERY_TAG = "Surgery" + +#: The root that is emptied and removed. +RETIRED_ROOT = "Surgery" + +#: Domain 20 draws from gynecology too, which is what the outline says at 20.B. +BLUEPRINT_ADD = {"20": ["Gynecology"]} + + +def one(db, sql, **args): + return db.execute(sa_text(sql), args).first() + + +def cat(db, name): + return one(db, "SELECT id FROM question_categories WHERE name = :n", n=name) + + +def main(apply: bool) -> int: + db = SessionLocal() + try: + owner = one(db, "SELECT id FROM users ORDER BY id LIMIT 1") + if not owner: + print("! no users, so nothing can own a category") + return 1 + + print("create") + for name, parent, system in NEW: + exists = cat(db, name) + print(f" {name:<26} under {parent or 'a root of its own':<28}" + f"{' (already there)' if exists else ''}") + if exists: + continue + p = cat(db, parent) if parent else None + s = one(db, "SELECT id FROM question_tags WHERE name = :n AND type = 'system'", n=system) + db.execute(sa_text( + "INSERT INTO question_categories (name, parent_id, user_id, system_id, created_at)" + " VALUES (:n, :p, :u, :s, NOW())"), + {"n": name, "p": p[0] if p else None, "u": owner[0], "s": s[0] if s else None}) + + print("\nrehome") + for name, parent in REHOME: + row = cat(db, name) + target = cat(db, parent) + if not row or not target: + print(f" ! {name} → {parent}: one of them does not exist") + continue + old = one(db, "SELECT parent_id FROM question_categories WHERE id = :i", i=row[0]) + if old and old[0] == target[0]: + print(f" {name:<28} → {parent} (already)") + continue + print(f" {name:<28} → {parent}") + # The branch it is leaving keeps a link to every question on it, so + # browsing the old way still finds them. + if old and old[0]: + db.execute(sa_text( + "INSERT INTO question_category_links (question_id, category_id)" + " SELECT id, :old FROM questions WHERE question_category_id = :c" + " ON CONFLICT DO NOTHING"), {"old": old[0], "c": row[0]}) + db.execute(sa_text("UPDATE question_categories SET parent_id = :p WHERE id = :i"), + {"p": target[0], "i": row[0]}) + + print(f"\n{RETIRED_ROOT} → the topic each question is about") + root = cat(db, RETIRED_ROOT) + tag = one(db, "SELECT id FROM question_tags WHERE name = :n AND type = 'subject'", n=SURGERY_TAG) + if not root: + print(" (already retired)") + else: + missing = [n for n in set(SURGERY.values()) if not cat(db, n)] + if missing: + print(f" ! no such topic: {', '.join(sorted(missing))}") + return 1 + held = {r[0] for r in db.execute(sa_text( + "SELECT id FROM questions WHERE question_category_id = :c"), {"c": root[0]})} + unlisted = held - set(SURGERY) + if unlisted: + print(f" ! {len(unlisted)} question(s) here are not in the list: " + f"{sorted(unlisted)}") + return 1 + for qid, name in sorted(SURGERY.items()): + if qid not in held: + continue + print(f" q#{qid:<6} → {name}") + target = cat(db, name) + db.execute(sa_text( + "UPDATE questions SET question_category_id = :t WHERE id = :q"), + {"t": target[0], "q": qid}) + db.execute(sa_text( + "INSERT INTO question_category_links (question_id, category_id)" + " VALUES (:q, :t) ON CONFLICT DO NOTHING"), {"q": qid, "t": target[0]}) + # The discipline, on the discipline axis. + if tag: + db.execute(sa_text( + "INSERT INTO question_tag_links (question_id, tag_id)" + " VALUES (:q, :t) ON CONFLICT DO NOTHING"), {"q": qid, "t": tag[0]}) + db.execute(sa_text( + "DELETE FROM question_category_links WHERE category_id = :c"), {"c": root[0]}) + db.execute(sa_text( + "DELETE FROM question_categories WHERE id = :c"), {"c": root[0]}) + print(f" removed the {RETIRED_ROOT} root") + + print("\nblueprint") + for code, names in BLUEPRINT_ADD.items(): + line = one(db, "SELECT id, title FROM exam_blueprints WHERE exam_id = 1 AND code = :c", c=code) + if not line: + print(f" ! no domain {code}") + continue + for name in names: + target = cat(db, name) + if not target: + continue + already = one(db, "SELECT 1 FROM blueprint_category_links" + " WHERE blueprint_id = :b AND category_id = :c", + b=line[0], c=target[0]) + print(f" {code:>2} {line[1][:40]:<42} + {name}{' (already)' if already else ''}") + if not already: + db.execute(sa_text( + "INSERT INTO blueprint_category_links (blueprint_id, category_id)" + " VALUES (:b, :c) ON CONFLICT DO NOTHING"), {"b": line[0], "c": target[0]}) + + if not apply: + db.rollback() + print("\ndry run. Pass --apply to write.") + return 0 + db.commit() + print("\ndone.") + return 0 + finally: + db.close() + + +if __name__ == "__main__": + sys.exit(main("--apply" in sys.argv))