pdf-quiz-generator/backend/scripts/assign_category_systems.py
Daniel 13d5f50c58 feat: an organ system is a fact about a topic, not about a keyword
A question reached a system through a symptom keyword it happened to
mention — question → keyword → parent system — and only 726 of 4,281
keywords had ever been given a parent. The Systems tab saw 1,492 of 2,924
questions while Disciplines saw all of them.

The system now sits on the category: question_categories.system_id. Every
question has a category, so every question reaches a system. 2,919 of
2,924, and all sixteen buckets have real content.

It stays a third way of asking rather than the discipline tree relabelled
because a topic's system is assigned separately from where it sits in the
tree. scripts/assign_category_systems takes the discipline as a default
and lets the topic's own name overrule it, which is exactly the case that
makes the axis worth having: conjunctivitis is filed under Infectious
Disease and is an eye, osteomyelitis is filed there and is a bone. 110 of
660 topics were decided that way.

Two regex traps caught in the dry run and fixed before applying:
"adRENAL" matched the kidney rule, and "Abnormal Uterine Bleeding" matched
the bleeding rule. Both now have a specific rule above the general one.

I first tried to fix this by parenting the orphan keywords to systems,
deriving each keyword's system from the questions carrying it. The dry run
showed why that was the wrong shape: it reached only 534 of 3,555 orphans,
and inherited every coarse edge of the discipline map — conjunctivitis came
out as Multisystem because conjunctivitis questions are filed under
Infectious Disease. That script is left in place, unapplied, as the record
of a measurement worth keeping.

No ForeignKey on system_id in the model: question_tags is a raw-SQL table
with no ORM class, and declaring one leaves every metadata build unable to
resolve it. The constraint is real in Postgres.

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

185 lines
8.8 KiB
Python

"""Say which organ system each topic belongs to.
Two passes, and the order matters.
First the discipline it sits under: a topic beneath Cardiology is
cardiovascular unless something says otherwise. That is right for most of the
tree and wrong for exactly the cases that make an organ-system axis worth
having — conjunctivitis is filed under Infectious Disease and is an eye,
osteomyelitis is filed under Infectious Disease and is a bone.
So the second pass reads the topic's own name. A name that names an organ wins
over the shelf it was put on, which is the whole reason this axis is not just
the discipline tree relabelled.
Disciplines that span every system — General Pediatrics, Primary Care, Surgery
— assign nothing from the first pass; only a name that says an organ does.
Idempotent, and a dry run by default:
docker compose exec backend python -m scripts.assign_category_systems
docker compose exec backend python -m scripts.assign_category_systems --apply
docker compose exec backend python -m scripts.assign_category_systems --apply --force
"""
import re
import sys
from collections import Counter
from sqlalchemy import text as sa_text
from app.database import SessionLocal
ROOT_TO_SYSTEM = {
"Adolescent Medicine": "Human Development",
"Allergy/Immunology": "Immune System",
"Cardiology": "Cardiovascular System",
"Child Abuse and Neglect": "Social Sciences & Ethics",
"Critical Care": "Multisystem Processes & Disorders",
"Dermatology": "Skin & Subcutaneous Tissue",
"Developmental & Behavioral": "Human Development",
"Emergency Medicine": "Multisystem Processes & Disorders",
"Endocrinology": "Endocrine System",
"Gastroenterology & Nutrition": "Gastrointestinal System",
"Genetics & Metabolism": "Multisystem Processes & Disorders",
"Hematology-Oncology": "Blood & Lymphoreticular System",
"Infectious Disease": "Multisystem Processes & Disorders",
"Neonatology": "Human Development",
"Nephrology & Urology": "Renal & Urinary System",
"Neurology": "Nervous System & Special Senses",
"Ophthalmology": "Nervous System & Special Senses",
"Orthopedics & Sports": "Musculoskeletal System",
"Otolaryngology": "Nervous System & Special Senses",
"Professional Topics": "Social Sciences & Ethics",
"Psychiatry & Psychology": "Behavioral Health",
"Psychosocial Issues": "Behavioral Health",
"Pulmonology": "Respiratory System",
"Rheumatology": "Musculoskeletal System",
"Toxicology": "Multisystem Processes & Disorders",
}
#: A name that names an organ beats the shelf the topic was put on. Ordered:
#: the first match wins, so the specific sits above the general.
BY_NAME: list[tuple[str, str]] = [
(r"conjunctiv|uveitis|retin|cornea|glaucoma|strabism|amblyop|orbital|ocular|"
r"\beye|vision|optic|periorbital|chalazi|blephar|nystagmus", "Nervous System & Special Senses"),
(r"otitis|hearing|deaf|tympan|cochlea|mastoid|\bear\b|vestibul|labyrinth",
"Nervous System & Special Senses"),
(r"meningitis|encephal|seizure|epilep|migraine|headache|neuropath|myelin|"
r"cerebr|hydrocephal|neural tube|spina bifida|ataxia|palsy|concussion|coma",
"Nervous System & Special Senses"),
(r"osteomyel|arthritis|fracture|scoliosis|myosit|muscular dystroph|rickets|"
r"limp|\bbone|joint|septic arthritis|slipped capital|legg-calv", "Musculoskeletal System"),
(r"cellulit|impetigo|eczema|dermatitis|urticaria|psoria|acne|scabies|tinea|"
r"exanthem|rash|abscess|burn|molluscum|wart|alopecia|nevus|hemangioma",
"Skin & Subcutaneous Tissue"),
(r"pneumon|bronchiol|asthma|croup|epiglott|pertussis|cystic fibrosis|apnea|"
r"stridor|wheez|tuberculosis|pleural|respiratory|airway|sinusitis|pharyngitis|"
r"tonsill|rhinitis|\blung", "Respiratory System"),
(r"gastroenter|diarrhea|constipat|hepatitis|jaundice|intussuscept|pylor|"
r"appendicit|celiac|reflux|colitis|\bliver|biliary|pancreat|volvulus|"
r"hirschsprung|encopresis|\bstool", "Gastrointestinal System"),
(r"nephr|urinary|pyelonephritis|glomerul|proteinuria|hematuria|enuresis|"
r"\bkidney|bladder|vesicoureter|hydronephro|\brenal\b", "Renal & Urinary System"),
(r"endocardit|myocardit|kawasaki|congenital heart|arrhythm|hypertension|"
r"murmur|cardiomyopath|rheumatic fever|\bheart", "Cardiovascular System"),
(r"anemia|sickle|thalass|leukemi|lymphom|neutropen|thrombocytopen|hemophil|"
r"coagul|purpura|splenomegal|transfusion|bone marrow|epistaxis",
"Blood & Lymphoreticular System"),
(r"diabet|thyroid|adrenal|growth hormone|puberty|precocious|short stature|"
r"obesity|hypoglycem|congenital adrenal|calcium|rickets", "Endocrine System"),
(r"immunodefic|hiv\b|allerg|anaphyla|urticaria|vaccine|immuniz|scid|"
r"complement deficiency", "Immune System"),
(r"testic|scrotal|ovarian|menstrual|amenorrh|vaginal|pregnan|contracept|"
r"sexually transmitted|gonorrh|chlamydia|breast", "Reproductive System"),
(r"depress|anxiety|adhd|autism|suicide|eating disorder|substance|behaviou?r|"
r"psychos|bullying|school refusal", "Behavioral Health"),
(r"development|milestone|growth chart|newborn|premature|breastfeed|"
r"adolescen|puberty", "Human Development"),
(r"ethic|consent|confidential|disparit|advocacy|statistic|epidemiolog|"
r"screening test|sensitivity|specificity", "Social Sciences & Ethics"),
]
#: Read before BY_NAME. These are the names that a general rule below would
#: otherwise catch by accident — "adrenal" is not a kidney and "abnormal
#: uterine bleeding" is not a clotting disorder.
FIRST: list[tuple[str, str]] = [
(r"uterine|menorrh|menstrual|dysmenorrh", "Reproductive System"),
(r"adrenal|pituitar|parathyroid", "Endocrine System"),
]
COMPILED = [(re.compile(pattern, re.I), system) for pattern, system in FIRST + BY_NAME]
def by_name(name: str) -> str | None:
for pattern, system in COMPILED:
if pattern.search(name):
return system
return None
def main(apply: bool, force: bool) -> int:
db = SessionLocal()
try:
systems = {r.name: r.id for r in db.execute(sa_text(
"SELECT id, name FROM question_tags WHERE type = 'system' AND parent_id IS NULL")).fetchall()}
unknown = sorted({s for s in ROOT_TO_SYSTEM.values()} | {s for _, s in BY_NAME})
missing = [s for s in unknown if s not in systems]
if missing:
print(f"! these systems do not exist as tags: {', '.join(missing)}")
return 1
rows = db.execute(sa_text(
"SELECT id, name, parent_id, system_id FROM question_categories")).fetchall()
parents = {r.id: r.parent_id for r in rows}
names = {r.id: r.name for r in rows}
def root_of(cid):
walk, guard = cid, 0
while parents.get(walk) is not None and guard < 20:
walk = parents[walk]
guard += 1
return names.get(walk, "")
planned: dict[int, tuple[str, str]] = {} # id -> (system, why)
for row in rows:
if row.system_id and not force:
continue
named = by_name(row.name)
if named:
planned[row.id] = (named, "name")
continue
inherited = ROOT_TO_SYSTEM.get(root_of(row.id))
if inherited:
planned[row.id] = (inherited, "discipline")
already = sum(1 for r in rows if r.system_id)
print(f"categories {len(rows)}")
print(f" already assigned {already}")
print(f" from the topic's name {sum(1 for s, why in planned.values() if why == 'name')}")
print(f" from its discipline {sum(1 for s, why in planned.values() if why == 'discipline')}")
print(f" left unassigned {len(rows) - already - len(planned) if not force else len(rows) - len(planned)}")
for system, n in Counter(s for s, _ in planned.values()).most_common():
print(f" {system:<38} {n}")
overrides = [(names[cid], s) for cid, (s, why) in planned.items()
if why == "name" and ROOT_TO_SYSTEM.get(root_of(cid)) not in (None, s)]
print(f"\n {len(overrides)} topics where the name beat the shelf. A sample:")
for name, system in sorted(overrides)[:14]:
print(f" {name:<40}{system}")
if not apply:
print("\ndry run. Pass --apply to write.")
return 0
for cid, (system, _) in planned.items():
db.execute(sa_text("UPDATE question_categories SET system_id = :s WHERE id = :id"),
{"s": systems[system], "id": cid})
db.commit()
print(f"\nassigned {len(planned)} topics.")
return 0
finally:
db.close()
if __name__ == "__main__":
sys.exit(main("--apply" in sys.argv, "--force" in sys.argv))