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
222 lines
11 KiB
Python
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())
|