pdf-quiz-generator/backend/scripts/nest_symptoms_by_system.py
Daniel d509d3db52 feat: real organ systems, symptoms grouped by system, and admin taxonomy CRUD
Systems were never systems
The 27 top-level rows were disciplines and care settings — Cardiology,
Emergency Medicine, Neonatology, and a stray condition (Sepsis) — not organ
systems. Cardiology is a discipline; Cardiovascular System is a system. So the
facet was mislabelled, and there was no organ-system axis at all.

Both fixes, as asked:
  * that tree is now the "Topics" facet, which is what it always was;
  * "Systems" is a new flat axis of 16 organ systems, matching how AMBOSS keeps
    Systems flat while nesting Disciplines and Symptoms.

Tags can nest (migration e3f4a5b6c7d8)
`question_tags` gains parent_id and sort_order. A tag may sit under one of the
same kind (Surgery > Hand surgery) or under a system, which is how symptoms are
grouped by where they present. 726 symptoms are now filed under the system they
appear in; the remaining 3,536 stay top-level rather than being forced into an
approximate bucket. A false positive the dry run caught: "vision" was matching
"Health Supervision" — the same trap as erythema/erythematosus earlier, fixed
with a word boundary.

Admin can grow the taxonomy without a migration
POST /tags creates a top-level entry or a child; PATCH renames, reorders and
reparents, refusing a cycle; DELETE reparents children to the deleted tag's
parent rather than orphaning them, and can move its questions elsewhere;
POST /tags/{id}/questions attaches questions. Everything appears in every picker
immediately, because they all read the same endpoint.

Article sections were indexed but empty — `_rebuild_section_index` only runs on
save, so articles written before it existed had no rows. Backfilled: 10 articles,
28 sections, now embedded and searchable. Section-scoped question links already
worked (7 of 34 links name a section).

Tests: 10 new backend covering the tree shape, adding top-level and child
entries, kind rules, duplicate refusal, cycle refusal, rename/reparent, question
attachment, delete-reparents-children, delete-with-move, and the moderator gate.
141 backend, 136 frontend green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017acfNLsJpnkvH3sCZSjMJM
2026-09-10 10:44:39 +02:00

125 lines
5.6 KiB
Python

"""Group symptom tags under the organ system they present in.
Symptoms were a flat list of ~4,300 keywords, so the picker was an alphabetical
wall. AMBOSS groups them by body region — "Head and neck > Eye > Blindness" — and
that is what makes the list navigable.
Only the symptoms that match a system's pattern are moved. Everything else stays
top-level rather than being forced into an approximate bucket, and nothing is
renamed, merged or deleted: this only sets a parent.
docker compose exec backend python -m scripts.nest_symptoms_by_system
docker compose exec backend python -m scripts.nest_symptoms_by_system --apply
"""
import re
import sys
from collections import defaultdict
from sqlalchemy import text as sa_text
from app.database import SessionLocal
# Only worth a branch if several symptoms land in it.
MIN_CHILDREN = 3
# Organ system -> what a symptom in it looks like. Ordered: the first match wins,
# so put the specific patterns above the general ones.
SYSTEM_PATTERNS = [
("Nervous System & Special Senses",
r"seizure|headache|migraine|ataxia|paralysis|palsy|neuropath|hypotoni|hypertoni|"
r"tremor|dizz|vertigo|syncope|coma|conscious|meningism|blind|\bvision\b|visual|"
r"strabismus|nystagmus|deaf|hearing|tinnitus|aphasia|dysarthr|weakness|numbness"),
("Cardiovascular System",
r"murmur|palpitation|chest pain|cyanosis|hypertens|hypotens|tachycardi|bradycardi|"
r"edema|oedema|shock|syncope|pulse|perfusion"),
("Respiratory System",
r"cough|wheez|stridor|dyspnea|dyspnoea|tachypnea|apnea|apnoea|respiratory distress|"
r"hemoptysis|haemoptysis|hypox|retraction|grunting"),
("Gastrointestinal System",
r"vomit|diarrhea|diarrhoea|constipation|abdominal pain|abdominal distention|"
r"abdominal distension|jaundice|hematemesis|melena|dysphagia|reflux|colic|"
r"hepatomegal|splenomegal|ascites|encopresis"),
("Renal & Urinary System",
r"hematuria|haematuria|proteinuria|dysuria|oliguria|polyuria|enuresis|"
r"urinary frequency|urinary retention|flank pain"),
("Musculoskeletal System",
r"limp|joint pain|arthralgia|arthritis|back pain|scoliosis|fracture|"
r"limb pain|gait|swelling of the joint|muscle pain|myalgia"),
("Skin & Subcutaneous Tissue",
r"rash|pruritus|itch|urticaria|petechia|purpura|blister|vesicle|pustule|"
r"alopecia|hair loss|pallor|erythema|lesion"),
("Blood & Lymphoreticular System",
r"anemia|anaemia|bleeding|bruis|lymphadenopath|pallor|petechia|thrombocytopen"),
("Endocrine System",
r"polydipsia|polyuria|weight loss|weight gain|obesity|short stature|tall stature|"
r"precocious|delayed puberty|goiter|goitre|hypoglycem|hyperglycem"),
("Behavioral Health",
r"anxiety|depress|irritab|hyperactiv|inattent|aggress|self.harm|suicid|"
r"insomnia|sleep disturbance|tantrum|behaviou?r"),
("Immune System",
r"allerg|anaphylax|recurrent infection|immunodefic|angioedema"),
("Human Development",
r"developmental delay|failure to thrive|growth failure|milestone|regression|"
r"feeding difficult|poor feeding"),
("Multisystem Processes & Disorders",
r"\bfever\b|fatigue|lethargy|malaise|sepsis|weight loss|dehydration|"
r"failure to thrive|night sweats"),
]
def main():
apply_changes = "--apply" in sys.argv
db = SessionLocal()
try:
systems = {row[1].lower(): row[0] for row in db.execute(sa_text(
"SELECT id, name FROM question_tags WHERE type = 'system'")).fetchall()}
if not systems:
print(" No 'system' tags yet; run the migration first.")
return 1
symptoms = db.execute(sa_text("""
SELECT t.id, t.name, COUNT(l.question_id) AS uses
FROM question_tags t
LEFT JOIN question_tag_links l ON l.tag_id = t.id
WHERE t.type = 'keyword' AND t.parent_id IS NULL
GROUP BY t.id, t.name
ORDER BY uses DESC, t.name
""")).fetchall()
print(f" ungrouped symptoms: {len(symptoms)}")
compiled = [(name, re.compile(pattern, re.I)) for name, pattern in SYSTEM_PATTERNS]
planned: dict[str, list[tuple[int, str, int]]] = defaultdict(list)
for tag_id, name, uses in symptoms:
for system_name, matcher in compiled:
if system_name.lower() in systems and matcher.search(name):
planned[system_name].append((tag_id, name, uses))
break
# A branch holding one or two entries is noise, not navigation.
planned = {k: v for k, v in planned.items() if len(v) >= MIN_CHILDREN}
if apply_changes:
for system_name, members in planned.items():
parent = systems[system_name.lower()]
for tag_id, _name, _uses in members:
db.execute(sa_text("UPDATE question_tags SET parent_id = :p WHERE id = :i"),
{"p": parent, "i": tag_id})
db.commit()
moved = sum(len(v) for v in planned.values())
print("APPLIED" if apply_changes else "DRY RUN")
print(f" grouped under a system : {moved}")
print(f" left top-level : {len(symptoms) - moved}\n")
for system_name in sorted(planned, key=lambda k: -len(planned[k])):
members = planned[system_name]
shown = ", ".join(name for _i, name, _u in members[:4])
more = f" … +{len(members) - 4}" if len(members) > 4 else ""
print(f" {system_name} ({len(members)}): {shown}{more}")
if not apply_changes:
print("\n Re-run with --apply to group them.")
finally:
db.close()
if __name__ == "__main__":
sys.exit(main())