diff --git a/backend/scripts/seed_illustrations.py b/backend/scripts/seed_illustrations.py new file mode 100644 index 0000000..448f3cd --- /dev/null +++ b/backend/scripts/seed_illustrations.py @@ -0,0 +1,222 @@ +"""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()) diff --git a/frontend/src/components/RichText.css b/frontend/src/components/RichText.css new file mode 100644 index 0000000..5954e75 --- /dev/null +++ b/frontend/src/components/RichText.css @@ -0,0 +1,32 @@ +/* Rendered educator prose, wherever it appears. */ + +.rich-text { line-height: 1.6; overflow-wrap: anywhere; } +.rich-text > :first-child { margin-top: 0; } +.rich-text > :last-child { margin-bottom: 0; } +.rich-text p { margin: 0 0 0.7em; } +.rich-text ul, .rich-text ol { margin: 0 0 0.7em; padding-left: 1.4em; } +.rich-text li { margin-bottom: 0.25em; } +.rich-text code { + font-size: 0.9em; padding: 1px 5px; border-radius: 4px; + background: var(--bg); border: 1px solid var(--border); +} +.rich-text pre { overflow-x: auto; padding: 12px; border-radius: 8px; background: var(--bg); border: 1px solid var(--border); } +.rich-text pre code { border: 0; padding: 0; background: none; } +.rich-text blockquote { + margin: 0 0 0.7em; padding-left: 12px; + border-left: 3px solid var(--border); color: var(--text-muted); +} +.rich-text img { max-width: 100%; border-radius: 8px; } + +/* A lab panel is the common case: many short rows, numbers that must line up. */ +.rich-table-wrap { overflow-x: auto; margin: 0 0 0.8em; } +.rich-text table { border-collapse: collapse; width: 100%; font-size: 0.92em; } +.rich-text th, .rich-text td { + border: 1px solid var(--border); padding: 6px 10px; text-align: left; vertical-align: top; +} +.rich-text th { background: var(--bg); font-weight: 650; } +.rich-text td:nth-child(n+2) { font-variant-numeric: tabular-nums; white-space: nowrap; } + +/* KaTeX paints its own colours; these keep it on the page's. */ +.rich-text .katex { font-size: 1.02em; color: inherit; } +.rich-text .katex-display { overflow-x: auto; overflow-y: hidden; padding: 2px 0; } diff --git a/frontend/src/components/RichText.jsx b/frontend/src/components/RichText.jsx new file mode 100644 index 0000000..cae2c25 --- /dev/null +++ b/frontend/src/components/RichText.jsx @@ -0,0 +1,84 @@ +import ReactMarkdown from 'react-markdown' +import remarkGfm from 'remark-gfm' +import remarkMath from 'remark-math' +import rehypeKatex from 'rehype-katex' +import 'katex/dist/katex.min.css' +import ArticleLink from './ArticleLink' +import rehypeHighlightOffsets from '../utils/highlightOffsets' +import { markdownImageUrl } from '../utils/uploads' +import './RichText.css' + +/** + * The one place educator-written text becomes rendered content. + * + * Everything the platform stores as prose — question stems and options, + * explanations, articles, lesson text — is Markdown, and until now only + * articles rendered it. A lab panel written as a table showed up in the quiz + * player as a row of literal pipes, which is how this component came about. + * + * Markdown only: raw HTML is escaped rather than executed, so a stem can never + * inject markup into the page around it. Maths is written as `$…$` or `$$…$$` + * and rendered by KaTeX, because a serum osmolality formula set as plain text + * is a different sentence from the one the author wrote. + */ + +// [[febrile-seizures]] and [[Febrile seizures|febrile-seizures]] are how an +// educator writes a cross-reference without needing an article's numeric id. +const WIKI_LINK = /\[\[(?:(\d+)\|([^\]]+)|([^\]|]+?)(?:\|([a-z0-9-]+))?)\]\]/g + +const expandWikiLinks = (text) => (text || '').replace( + WIKI_LINK, + (_m, id, idLabel, label, slug) => (id + ? `[${idLabel.trim()}](/articles/${id})` + : `[${label.trim()}](/articles/${(slug || label).trim().toLowerCase()})`), +) + +const internalArticle = (href) => { + const match = /^\/articles\/(?:s\/)?([a-z0-9-]+|\d+)\/?$/.exec(href || '') + return match ? match[1] : null +} + +export default function RichText({ + value, + attemptId, + className = '', + linkArticles = false, + // Passing a textId turns on the highlight layer: text keeps the source + // offsets that highlights and the read-aloud cursor are stored against. + textId = null, + highlights = [], + speechRange = null, + onRemoveHighlight = null, +}) { + const rehypePlugins = [rehypeKatex] + if (textId) rehypePlugins.unshift([rehypeHighlightOffsets, { textId, highlights, speechRange }]) + + return ( +
{ + const span = event.target?.closest?.('[data-highlighted="true"]') + if (!span) return + event.preventDefault() + onRemoveHighlight(span.dataset.manualHighlightId, Number(span.dataset.start)) + } : undefined}> + , + a: ({ node, href, children, ...props }) => { + const target = linkArticles && internalArticle(href) + if (target) return {children} + return {children} + }, + // A wide table is the reason this exists; it scrolls inside its own + // box rather than pushing the page sideways. + table: ({ node, ...props }) => ( +
+ ), + }}> + {linkArticles ? expandWikiLinks(value) : (value || '')} + + + ) +} diff --git a/frontend/src/components/RichText.test.jsx b/frontend/src/components/RichText.test.jsx new file mode 100644 index 0000000..9f6bbfe --- /dev/null +++ b/frontend/src/components/RichText.test.jsx @@ -0,0 +1,118 @@ +import { describe, expect, it, vi } from 'vitest' +import { render, screen, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { MemoryRouter } from 'react-router-dom' +import RichText from './RichText' + +vi.mock('../api/client', () => ({ default: { get: vi.fn(() => Promise.resolve({ data: {} })) } })) +vi.mock('../utils/uploads', () => ({ markdownImageUrl: (src) => `/uploads/${src}` })) + +const mount = (props) => render() + +const LAB_TABLE = `Initial studies: + +| Analyte | Value | SI | +| --- | --- | --- | +| Sodium | 139 mEq/L | 139 mmol/L | +| Potassium | 2.8 mEq/L | 2.8 mmol/L |` + +describe('rendered prose', () => { + it('renders a lab panel as a table, which is the reason this exists', () => { + mount({ value: LAB_TABLE }) + const table = screen.getByRole('table') + expect(within(table).getByRole('columnheader', { name: 'Analyte' })).toBeInTheDocument() + expect(within(table).getByRole('cell', { name: '2.8 mEq/L' })).toBeInTheDocument() + // A wide table scrolls in its own box rather than pushing the page sideways. + expect(table.closest('.rich-table-wrap')).toBeTruthy() + expect(screen.queryByText(/\|/)).not.toBeInTheDocument() + }) + + it('renders maths rather than printing the source', () => { + const { container } = mount({ value: 'Osmolality is $2 \\times Na$ roughly.' }) + expect(container.querySelector('.katex')).toBeTruthy() + // What a reader sees is the typeset half; the TeX survives only in the + // MathML annotation, which is there for screen readers and is not visible. + expect(container.querySelector('.katex-html').textContent).toContain('×') + expect(container.querySelector('.katex-html').textContent).not.toContain('times') + }) + + it('escapes raw HTML instead of executing it', () => { + const { container } = mount({ value: 'A stem' }) + expect(container.querySelector('img')).toBeNull() + expect(screen.getByText(/A stem/)).toBeInTheDocument() + }) + + it('sends an external link away in a new tab, safely', () => { + mount({ value: '[AAP](https://aap.org)' }) + const link = screen.getByRole('link', { name: 'AAP' }) + expect(link).toHaveAttribute('target', '_blank') + expect(link).toHaveAttribute('rel', expect.stringContaining('noopener')) + }) + + it('leaves article cross-references alone unless asked to link them', () => { + mount({ value: 'See [[febrile-seizures]] for more.' }) + expect(screen.getByText(/\[\[febrile-seizures\]\]/)).toBeInTheDocument() + }) + + it('turns a cross-reference into a preview link when asked', () => { + mount({ value: 'See [[7|Febrile seizures]] for more.', linkArticles: true }) + expect(screen.getByRole('link', { name: 'Febrile seizures' })) + .toHaveAttribute('href', '/articles/s/7') + }) +}) + +describe('highlights across rendered markdown', () => { + const stem = 'A 9-year-old boy is brought to the office.' + + it('keeps the source offsets a highlight is stored against', () => { + const { container } = mount({ + value: stem, textId: 'q1::question', + highlights: [{ start: 2, end: 12 }], + }) + const active = container.querySelector('.manual-highlight-active') + expect(active).toHaveTextContent('9-year-old') + expect(active.dataset.start).toBe('2') + expect(active.dataset.end).toBe('12') + expect(active.dataset.manualHighlightId).toBe('q1::question') + }) + + it('marks the read-aloud cursor separately from a highlight', () => { + const { container } = mount({ + value: stem, textId: 'q1::question', + highlights: [{ start: 0, end: 1 }], + speechRange: { start: 2, end: 12 }, + }) + expect(container.querySelector('.speech-highlight-active')).toHaveTextContent('9-year-old') + expect(container.querySelector('.manual-highlight-active')).toHaveTextContent('A') + }) + + it('offsets survive a stem that also contains a table', () => { + const { container } = mount({ + value: LAB_TABLE, textId: 'q2::question', + highlights: [{ start: 0, end: 8 }], + }) + expect(screen.getByRole('table')).toBeInTheDocument() + const active = container.querySelector('.manual-highlight-active') + expect(active).toHaveTextContent('Initial') + expect(active.dataset.start).toBe('0') + }) + + it('removes a highlight on right-click, and ignores plain text', async () => { + const onRemoveHighlight = vi.fn() + const { container } = mount({ + value: stem, textId: 'q1::question', + highlights: [{ start: 2, end: 12 }], onRemoveHighlight, + }) + await userEvent.pointer({ keys: '[MouseRight]', target: container.querySelector('.manual-highlight-active') }) + expect(onRemoveHighlight).toHaveBeenCalledWith('q1::question', 2) + + onRemoveHighlight.mockClear() + await userEvent.pointer({ keys: '[MouseRight]', target: container.querySelector('.manual-highlight-segment:not(.manual-highlight-active)') }) + expect(onRemoveHighlight).not.toHaveBeenCalled() + }) + + it('adds no highlight machinery when nothing is being highlighted', () => { + const { container } = mount({ value: stem }) + expect(container.querySelector('[data-manual-highlight-id]')).toBeNull() + }) +}) diff --git a/frontend/src/pages/QuestionBankPage.jsx b/frontend/src/pages/QuestionBankPage.jsx index 9734095..8b6d41e 100644 --- a/frontend/src/pages/QuestionBankPage.jsx +++ b/frontend/src/pages/QuestionBankPage.jsx @@ -1,5 +1,6 @@ import { useState, useEffect, useRef, lazy, Suspense } from 'react' import { useLocation, useNavigate, Link } from 'react-router-dom' +import RichText from '../components/RichText' import { useAuth } from '../context/AuthContext' import api from '../api/client' import Dialog from '../components/Dialog' @@ -58,7 +59,11 @@ function QuestionStudyModal({ question, onClose, isFavorited, onToggleFavorite, -
+ {/* Markdown, not injected HTML: a stem is educator prose, and the + same renderer the quiz player uses keeps a lab table a table. */} +
+ +
{question.image_path && (
Question illustration Number.isFinite(r.start) && Number.isFinite(r.end) && r.end > r.start) - .sort((a, b) => a.start - b.start || a.end - b.end) - const merged = [] - sorted.forEach(range => { - const last = merged[merged.length - 1] - if (!last || range.start > last.end) { - merged.push({ start: range.start, end: range.end }) - } else { - last.end = Math.max(last.end, range.end) - } - }) - return merged -} function removeTextRange(ranges, removeRange) { const next = [] @@ -83,44 +70,6 @@ function getManualHighlightSelection(selection = window.getSelection?.()) { return { id: start.id, ...ordered } } -function ManualHighlightText({ text, textId, highlights = [], speechRange = null, onRemoveHighlight = null }) { - const ranges = mergeTextRanges(highlights) - const boundaries = new Set([0, (text || '').length]) - ranges.forEach(range => { boundaries.add(range.start); boundaries.add(range.end) }) - if (speechRange) { boundaries.add(speechRange.start); boundaries.add(speechRange.end) } - const points = [...boundaries] - .filter(point => point >= 0 && point <= (text || '').length) - .sort((a, b) => a - b) - - return points.slice(0, -1).map((start, i) => { - const end = points[i + 1] - if (end <= start) return null - const manuallyHighlighted = ranges.some(range => start >= range.start && end <= range.end) - const speechHighlighted = speechRange && start >= speechRange.start && end <= speechRange.end - const className = [ - 'manual-highlight-segment', - manuallyHighlighted ? 'manual-highlight-active' : '', - speechHighlighted ? 'speech-highlight-active' : '', - ].filter(Boolean).join(' ') - return ( - { - event.preventDefault() - onRemoveHighlight?.(textId, start) - } : undefined} - title={manuallyHighlighted ? 'Right-click to remove this highlight' : undefined} - > - {text.slice(start, end)} - - ) - }) -} - function splitSpeechChunks(text, maxWords) { const words = (text || '').trim().split(/\s+/).filter(Boolean) if (!words.length) return [] @@ -1157,16 +1106,17 @@ const timerStarted = timeLeft !== null {current.question_type === 'mcq' ? 'Multiple choice' : current.question_type === 'true_false' ? 'True / False' : 'Fill in the blank'}
-
-

- -

+ {/* A stem carrying a lab table cannot live inside a heading — + the table would be invalid markup there — so the heading is the + labelled region and the prose sits inside it. */} +
+
) @@ -1313,7 +1266,7 @@ const timerStarted = timeLeft !== null {statsError &&

{statsError}

} {(current.explanation || current.explanation_image_path) && (
- {current.explanation && <>Explanation: {current.explanation}} + {current.explanation && <>Explanation: } {current.explanation_image_path && (