pdf-quiz-generator/backend/scripts/seed_illustrations.py
Daniel 3980ecb7f8 feat: one Markdown renderer for the whole site, with LaTeX and highlights intact
Every piece of educator prose the platform stores is Markdown, and until now
only articles rendered it. A lab panel written as a table reached the quiz player
as a row of literal pipes, which is why the table conversion had to be held back.

`RichText` is now the single renderer: GFM tables, `$…$` maths through KaTeX,
images resolved through the uploads helper, external links opened safely, and
raw HTML escaped rather than executed — a stem can never inject markup into the
page around it. The question bank's `dangerouslySetInnerHTML` is gone with it.

Highlights were the hard part
Manual highlights and the read-aloud cursor are stored as character offsets into
the raw stem, and rendering Markdown destroys the one-to-one map a plain string
gave us. A rehype plugin puts it back: each text node in the output carries the
source offsets it was parsed from, so a highlight saved before this change still
lands exactly where it was drawn, and the selection arithmetic that reads
`data-start` needs no change at all.

Inside an inline-formatted run the rendered text is shorter than its source by
the marker characters, so an offset picked mid-run can be out by a few. Splitting
per text node bounds that to one node and keeps every node boundary exact —
stated in the code, because it is a real limit rather than an oversight.

With that in place the lab tables are applied: 79 stems, 82 panels. Question 3333
now reads as two tables with `3.5 × 10⁹/L` instead of `3.5 x 109/L`, and the
`inEq/L` and `mrnol/L` scanning damage repaired. Each change was snapshotted
first, so it is reversible from the question editor.

Six schematic illustrations
Drawn from scratch as SVG in `scripts/seed_illustrations.py` — bilirubin risk
zones, airway narrowing by level, dehydration bands, the fluid pathway, the
target sign, growth velocity. Each is captioned, tagged and searchable in the
image bank, and each says on its face that it is schematic and not a clinical
reference. They exist so the media library, picker and article figures can be
exercised against real files, and because an article with no figure looks
unfinished even when its prose is not.

234 frontend tests green, 11 of them new on the renderer.

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

222 lines
11 KiB
Python

"""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"""<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {W} {H}" width="{W}" height="{H}" role="img">
<rect width="{W}" height="{H}" fill="#ffffff"/>
<text x="24" y="34" font-family="system-ui,sans-serif" font-size="17" font-weight="700" fill="{INK}">{title}</text>
{body}
<text x="24" y="{H - 14}" font-family="system-ui,sans-serif" font-size="11" fill="{MUTED}">Schematic — not to scale. Teaching diagram, not a clinical reference.</text>
</svg>"""
def _label(x, y, s, size=12, fill=MUTED, weight="400", anchor="start"):
return (f'<text x="{x}" y="{y}" font-family="system-ui,sans-serif" font-size="{size}" '
f'font-weight="{weight}" fill="{fill}" text-anchor="{anchor}">{s}</text>')
def _axes(x0, y0, x1, y1, xlabel, ylabel):
return f"""
<line x1="{x0}" y1="{y1}" x2="{x1}" y2="{y1}" stroke="{INK}" stroke-width="1.5"/>
<line x1="{x0}" y1="{y0}" x2="{x0}" y2="{y1}" stroke="{INK}" stroke-width="1.5"/>
{_label((x0 + x1) / 2, y1 + 30, xlabel, 12, MUTED, "600", "middle")}
<text x="{x0 - 34}" y="{(y0 + y1) / 2}" font-family="system-ui,sans-serif" font-size="12"
font-weight="600" fill="{MUTED}" text-anchor="middle"
transform="rotate(-90 {x0 - 34} {(y0 + y1) / 2})">{ylabel}</text>"""
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'<path d="M70 {y_start + 120} Q 300 {y_start + 30} 590 {y_start}" '
f'fill="none" stroke="{colour}" stroke-width="2.5"/>')
body += _label(596, y_start + 4, name, 11, colour, "600")
for hour, x in [(24, 200), (48, 330), (72, 460), (96, 585)]:
body += f'<line x1="{x}" y1="316" x2="{x}" y2="324" stroke="{INK}"/>' + \
_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'<path d="M{x - 40} 90 L{x - 40} 300 M{x + 40} 90 L{x + 40} 300" stroke="{LINE}" stroke-width="2"/>'
body += (f'<path d="M{x - 40} 90 L{x - 40 + narrow} 175 L{x - 40 + narrow} 205 L{x - 40} 300 '
f'L{x + 40} 300 L{x + 40 - narrow} 205 L{x + 40 - narrow} 175 L{x + 40} 90 Z" '
f'fill="{colour}" fill-opacity="0.14" stroke="{colour}" stroke-width="2"/>')
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'<rect x="70" y="{y}" width="{160 + index * 130}" height="56" rx="10" fill="{colour}" fill-opacity="0.12" stroke="{colour}" stroke-width="1.6"/>'
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'<rect x="120" y="{y}" width="400" height="40" rx="8" fill="{colour}" fill-opacity="0.10" stroke="{colour}" stroke-width="1.6"/>'
body += _label(320, y + 25, label, 13, colour, "600", "middle")
if index < len(steps) - 1:
body += f'<path d="M320 {y + 40} L320 {y + 56}" stroke="{LINE}" stroke-width="2" marker-end="url(#a)"/>'
body = ('<defs><marker id="a" markerWidth="8" markerHeight="8" refX="4" refY="4" orient="auto">'
f'<path d="M0 0 L8 4 L0 8 z" fill="{LINE}"/></marker></defs>') + 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'<circle cx="240" cy="215" r="{radius}" fill="{colour}" fill-opacity="{opacity}" '
f'stroke="{colour}" stroke-width="1.8"/>')
for index, (label, radius) in enumerate([("outer wall", 110), ("intussuscipiens", 78),
("intussusceptum", 46), ("mesenteric fat", 18)]):
y = 130 + index * 46
body += f'<line x1="{240 + radius}" y1="215" x2="420" y2="{y}" stroke="{LINE}" stroke-width="1.2"/>'
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 += ('<path d="M70 90 Q 130 250 200 285 T 380 292 Q 450 292 480 180 Q 510 120 590 300" '
f'fill="none" stroke="{ACCENT}" stroke-width="2.6"/>')
for x, label in [(110, "infancy"), (300, "childhood"), (490, "puberty")]:
body += _label(x, 344, label, 11, MUTED, "600", "middle")
body += f'<line x1="{x}" y1="316" x2="{x}" y2="324" stroke="{INK}"/>'
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())