pdf-quiz-generator/backend/scripts/settle_topic_questions.py
Daniel b06560c515 fix: the ear to ENT, gynecology to its own branch, and Surgery back into medicine
Three decisions the owner made about where content lives.

Otitis media was split across two roots — the acute form and mastoiditis under
Infectious Disease, effusion and the recurrent form under Otolaryngology — so
somebody looking for "the ear" found half of it twice. All of it is in ENT now,
with the three forms as children of Otitis Media rather than four siblings
saying nearly the same thing. The questions keep a link back to Infectious
Disease; they are still infections.

Surgery was a root with 34 questions, no children and no ABP domain, so that
content was invisible to the blueprint entirely. Nothing about those questions
is "surgery" in the sense the outline uses: they are bowel obstruction in a
neonate, a burn, a torsion, a swallowed coin. Each is now filed under the topic
that names its condition — read off the stem and the keyed answer one at a
time, because no rule derives that and a rule that guessed would be worse than
the root they are leaving — and every one keeps Surgery as a subject tag, which
is the axis a discipline belongs on.

Gynecology is its own branch, mapped to domain 20 as the outline has it at
20.B. It was living under Adolescent Care, so Genitourinary drew from a pool
missing a third of what it should hold. The sexually transmitted infections
beside it stay where they are: those are 3.B, and the outline means them there.

Four topics were made for questions that had nowhere honest to go — Burns,
Foreign Body Ingestion, Procedural Sedation, Chest Wall Deformity.

569 topics, 28 roots, 2,924 questions, none orphaned, none without an organ
system, and no two ABP domains sharing a category.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 08:06:59 +02:00

238 lines
11 KiB
Python

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