"""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))