"""Draw a set of schematic teaching illustrations and put them in the image bank. These are *diagrams*, not clinical references: drawn from scratch as SVG, labelled as schematic, and deliberately carrying no numbers that anyone should dose or diagnose from. They exist so that the media library, the image picker, article figures and question attachment can be exercised against real files rather than against placeholders — and because an article with no figure looks unfinished even when the prose is complete. Every drawing is generated here in code, so nothing is copied from anywhere. docker compose exec backend python -m scripts.seed_illustrations docker compose exec backend python -m scripts.seed_illustrations --apply """ import sys import textwrap from sqlalchemy import text as sa_text from app.database import SessionLocal from app.models.media import MediaAsset, MediaLibrary, MediaTagLink from app.services import embedding_service, storage_service W, H = 640, 400 INK = "#0f172a" MUTED = "#64748b" LINE = "#cbd5e1" ACCENT = "#2563eb" WARN = "#dc2626" GOOD = "#059669" def _frame(title: str, body: str) -> str: """Every figure shares one frame, so a set of them looks like a set.""" return f""" {title} {body} Schematic — not to scale. Teaching diagram, not a clinical reference. """ def _label(x, y, s, size=12, fill=MUTED, weight="400", anchor="start"): return (f'{s}') def _axes(x0, y0, x1, y1, xlabel, ylabel): return f""" {_label((x0 + x1) / 2, y1 + 30, xlabel, 12, MUTED, "600", "middle")} {ylabel}""" def bilirubin_zones() -> str: body = _axes(70, 60, 590, 320, "Age (hours)", "Serum bilirubin") for index, (offset, colour, name) in enumerate( [(0, WARN, "High risk"), (44, "#f59e0b", "High-intermediate"), (88, "#eab308", "Low-intermediate"), (132, GOOD, "Low risk")]): y_start = 120 + offset body += (f'') body += _label(596, y_start + 4, name, 11, colour, "600") for hour, x in [(24, 200), (48, 330), (72, 460), (96, 585)]: body += f'' + \ _label(x, 340, str(hour), 11, MUTED, "400", "middle") return _frame("Bilirubin risk zones by age", body) def airway_obstruction() -> str: body = "" for index, (x, label, narrow, colour) in enumerate( [(120, "Normal", 0, GOOD), (320, "Subglottic narrowing", 20, ACCENT), (520, "Supraglottic swelling", 26, WARN)]): body += f'' body += (f'') body += _label(x, 330, label, 12, colour, "600", "middle") body += _label(x, 78, "airway lumen", 10, MUTED, "400", "middle") body += _label(24, 62, "Where the narrowing sits changes the sound and the urgency.", 12, MUTED) return _frame("Upper airway narrowing: level and lumen", body) def dehydration_scale() -> str: body = _label(24, 62, "Signs accumulate as deficit grows; each band adds to the one before it.", 12, MUTED) bands = [("Minimal", GOOD, ["alert", "moist mucosa", "normal pulse"]), ("Mild to moderate", "#f59e0b", ["restless", "dry mucosa", "reduced urine"]), ("Severe", WARN, ["lethargic", "sunken eyes", "weak pulse", "prolonged refill"])] for index, (name, colour, signs) in enumerate(bands): y = 90 + index * 80 body += f'' body += _label(86, y + 24, name, 13, colour, "700") body += _label(86, y + 43, " · ".join(signs), 11, MUTED) return _frame("Dehydration: severity bands", body) def fluid_pathway() -> str: steps = [("Assess perfusion", ACCENT), ("Shock?", WARN), ("Bolus, reassess", WARN), ("Maintenance + deficit", GOOD), ("Reassess hourly", ACCENT)] body = "" for index, (label, colour) in enumerate(steps): y = 80 + index * 56 body += f'' body += _label(320, y + 25, label, 13, colour, "600", "middle") if index < len(steps) - 1: body += f'' body = ('' f'') + body return _frame("Fluid resuscitation: order of decisions", body) def target_sign() -> str: body = _label(24, 62, "Bowel within bowel: concentric rings on the transverse view.", 12, MUTED) for radius, colour, opacity in [(110, ACCENT, 0.10), (78, ACCENT, 0.16), (46, ACCENT, 0.24), (18, WARN, 0.30)]: body += (f'') for index, (label, radius) in enumerate([("outer wall", 110), ("intussuscipiens", 78), ("intussusceptum", 46), ("mesenteric fat", 18)]): y = 130 + index * 46 body += f'' body += _label(428, y + 4, label, 11, MUTED, "600") return _frame("Target sign: concentric bowel layers", body) def growth_velocity() -> str: body = _axes(70, 60, 590, 320, "Age (years)", "Growth velocity") body += ('') for x, label in [(110, "infancy"), (300, "childhood"), (490, "puberty")]: body += _label(x, 344, label, 11, MUTED, "600", "middle") body += f'' body += _label(24, 62, "Three phases, each driven by something different.", 12, MUTED) return _frame("Growth velocity across childhood", body) FIGURES = [ ("bilirubin-risk-zones", "Bilirubin risk zones by age", bilirubin_zones, "Schematic of serum bilirubin risk bands plotted against age in hours.", ["neonatal jaundice", "hyperbilirubinemia", "newborn"]), ("airway-narrowing-levels", "Upper airway narrowing by level", airway_obstruction, "Schematic comparing a normal airway lumen with subglottic and supraglottic narrowing.", ["stridor", "croup", "epiglottitis", "airway"]), ("dehydration-severity-bands", "Dehydration severity bands", dehydration_scale, "Schematic of clinical signs grouped by dehydration severity.", ["dehydration", "gastroenteritis", "fluid"]), ("fluid-resuscitation-pathway", "Fluid resuscitation pathway", fluid_pathway, "Schematic order of decisions in paediatric fluid resuscitation.", ["shock", "fluid", "resuscitation"]), ("target-sign-intussusception", "Target sign: concentric bowel layers", target_sign, "Schematic cross-section showing bowel within bowel as concentric rings.", ["intussusception", "ultrasound", "abdominal pain"]), ("growth-velocity-phases", "Growth velocity across childhood", growth_velocity, "Schematic growth velocity curve showing infancy, childhood and pubertal phases.", ["growth", "puberty", "development"]), ] def main(): apply_changes = "--apply" in sys.argv db = SessionLocal() try: library = db.query(MediaLibrary).order_by(MediaLibrary.id).first() if library is None: print(" No image library exists; create one first.") return 1 made = skipped = 0 for slug, title, draw, caption, tags in FIGURES: key = f"media/illustrations/{slug}.svg" if db.query(MediaAsset.id).filter(MediaAsset.path == key).first(): print(f" exists {slug}") skipped += 1 continue svg = draw().encode() print(f" draw {slug} ({len(svg)} bytes)") made += 1 if not apply_changes: continue storage_service.save(key, svg, "image/svg+xml") asset = MediaAsset( path=key, title=title, caption=caption, alt_text=caption, kind="image", library_id=library.id, storage="s3" if storage_service.using_s3() else "local", byte_size=len(svg), ) db.add(asset) db.flush() for name in tags: row = db.execute(sa_text( "SELECT id FROM question_tags WHERE lower(name) = lower(:n) ORDER BY id LIMIT 1" ), {"n": name}).first() tag_id = row[0] if row else db.execute(sa_text( "INSERT INTO question_tags (name, type) VALUES (:n, 'keyword') RETURNING id" ), {"n": name}).scalar() db.add(MediaTagLink(media_id=asset.id, tag_id=tag_id)) db.commit() try: if embedding_service.embed_record(asset, "media"): db.commit() except Exception: db.rollback() print(f"\n drawn: {made} already there: {skipped}") if not apply_changes: print(" Re-run with --apply to store them in the image bank.") else: print(textwrap.dedent(""" In the bank now, searchable by what they show. Attach one to an article with a Markdown image, or to a question from the picker. """).strip()) finally: db.close() return 0 if __name__ == "__main__": sys.exit(main())