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