pediatric-ai-scribe-v3/docs/my-resources.md
Daniel 03621752e8
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 55s
Forgejo Docker Build / Root app tests (push) Successful in 45s
Forgejo Android APK / Build signed APK (push) Successful in 2m5s
Forgejo Docker Build / Build Docker image (push) Successful in 19s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
feat: image fallback chains for every workflow, and a library worth looking at
**Fallbacks.** One image model meant a refusal, a rate limit or a model the
gateway had since dropped ended as a missing picture. Every workflow now tries
its model, then each fallback in order, stopping at the first that produces an
image. Primary plus two, capped: each hop is a paid request, and a chain long
enough to need a cap is long enough to surprise someone.

My Resources previously had no fallback at all — only the Clinical Assistant
did, and only one. That is backwards: a missing figure is most visible in a
deck, where it leaves a hole in a slide.

The retry rule is now a classifier that says *why*, rather than a boolean.
Transient faults, a 404 for a model the gateway does not have, and a content
refusal all move to the next model — a refusal because policy is a vendor
decision, not a fact about the request. 401/403 stop immediately (one gateway,
one set of credentials, the next model fails identically), as do 413 and any
other 4xx, which are malformed everywhere. Refusals are recognised from the
message: no provider sends a machine-readable reason and the status varies.

Each hop re-leases the job, so a chain cannot outlive its claim and let a second
worker repeat the same paid work, and the row records the model actually being
paid for so a picture made by the third model is not attributed to the first.

The old singular `fallback_image_model` is still read, so an existing
configuration keeps working without anyone re-entering it.

**Library.** Documents/Images tabs in My Resources, with a real grid: fixed
aspect tiles so the rows line up whatever shape the pictures are, a source badge
on the picture, two-line prompt, hover lift, shimmer skeletons while thumbnails
land, and a lightbox that closes on Escape or the backdrop and restores focus.
Actions are hidden on hover only behind `@media (hover:hover)` — hiding delete
behind :hover would put it out of reach on touch and keyboard.

Downloads go through privateImageBlob rather than a bare `<a download href>`: a
mobile client's session is a bearer token an anchor cannot send, and these
assets are served no-store on purpose.

The gallery lives in My Resources only. Assistant images appear in it, which was
the point; the assistant page does not grow a gallery of its own, and a test
asserts no assistant module lists the endpoint.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-12 14:53:04 +02:00

20 KiB
Raw Blame History

My Resources

Teaching material a signed-in user generates for themselves — a deck for tomorrow's session, a handout, a summary — kept privately and exported as PowerPoint, Word or PDF.

Deliberately separate from the Learning Hub. That is moderator-owned content published into categories for everyone; this needs no role beyond being signed in, and nothing here is shared. Every statement filters on user_id, and there is no route that returns another person's work. Sharing, if it is ever wanted, should be a deliberate feature rather than something that leaks out of a forgotten WHERE clause.

What a resource is

Column
markdown the readable artifact — what Word renders and what a text edit edits
deck for a presentation, the slide structure the model designed (see below)
image_ids the illustration jobs belonging to this resource, in the order they were made
topic, grounded_count what it was asked for and how many library excerpts it was written from

MAX_PER_USER caps how many a person may keep.

Sources

One function, gatherSources(), answers "what is this written from" for both generating and modifying, so the two cannot drift into offering different things or searching them differently.

  • The clinical library — on by default. Semantic retrieval over the indexed corpus. Budgets are in retrieval-tuning.md.
  • PubMed — admin-enabled, optional API key. Returns structured records so a reference carries a PMID somebody can look up.
  • The web — admin-enabled, provider-configurable (Tavily, Serper, Brave, SearXNG).
  • Illustrations — see below.

Each option hides itself when an administrator has not enabled it, so nothing appears that a person could tick and then be refused.

Nothing here may fail a generation. A retrieval or search that comes back empty is reported as a reason and the resource is written from what is available.

Searching is the route's job, not the model's

Both searches run up front on the topic, and their results go into the prompt as findings. They are not offered as tools.

They were, once. Tested live against a question explicitly about recent trials, the model never called them — with or without corpus grounding, and no matter how the tool description was worded, because the prompt ends "Output ONLY Pandoc markdown" and a model told to output only markdown does not emit a tool call. Calling callAI with the tool directly produced a correct call, so the plumbing was never the problem. The search only ever needed the topic, and the route knows the topic before it calls the model.

Keyword searches get the topic, retrieval gets more

PubMed ANDs every mapped term, so one unrecognised word takes a query to zero: "febrile seizures" returns six results and "febrile seizures in under-fives" returns none. A query that finds nothing is retried against progressively shorter versions of itself, longest first, and the response says which query actually worked. Those retries are spaced — three esearch calls back to back trips NCBI's three-a-second limit without an API key.

When modifying, the library search gets the topic plus the instruction — retrieval is semantic and benefits from context — while PubMed and the web get the topic alone.

Empty searches must not invite invention

When a search was asked for and came back empty, the prompt says not to invent a citation, a PMID or a URL to fill the gap. Without that the model supplies them from memory, and a fabricated PMID is indistinguishable from a real one.

Presentations are designed, not written

A presentation is described as a deck: the model returns JSON naming a layout per slide and the prompt for each figure it wants. Articles stay markdown, which is what prose wants.

Markdown could express about five of the things the renderer can draw, so the model had no way to say "put this figure beside these three bullets" or "make this a comparison with two labelled columns" — the parser inferred a layout from the shape of a list, and inferring is what made every deck look the same.

Layouts: title, section, bullets, two, compare, table, callout, figure, image. See src/utils/deckSchema.js for what each accepts.

When the named layouts are not enough

Those nine are a fixed vocabulary, so "lay the three severity levels out left to right with arrows between them" had no expression at all. A custom slide carries a list of shapes instead:

{"type":"custom","heading":"Severity at a glance","shapes":[
  {"kind":"rect","x":6,"y":30,"w":26,"h":18,"fill":"DCFCE7","line":"16A34A",
   "runs":[{"text":"MILD","bold":true,"align":"center"}]},
  {"kind":"arrow","x":33,"y":37,"w":8,"h":5,"fill":"94A3B8"},
  {"kind":"chart","chart":"column","x":6,"y":28,"w":56,"h":60,
   "categories":["<6m","6-12m"],"series":[{"name":"Cases","values":[4,22]}]}
]}

Kinds: text, rect, roundRect, ellipse, arrow, arrowDown, chevron, diamond, hexagon, line, image, table, chart (column, bar, line, pie, doughnut — native PowerPoint charts, not pictures of charts).

Coordinates are percentages of the slide, 0100, so a model can reason about position without knowing anything about EMU. Shapes draw in array order, so a later one sits on top.

The model never emits Python. It names shapes and the renderer draws them. Running model-authored code to lay out a slide would be an enormous amount of trust to buy a feature, on a server that holds clinical data.

Everything is validated in src/utils/slideShapes.js, which lives beside the text that describes the vocabulary to the model so the two cannot drift: kinds are an allowlist, colours must be six hex digits, coordinates are clamped inside the slide, counts are capped, and a shape that cannot be understood is dropped. A custom slide that loses every shape falls back to being a plain one rather than a heading over an empty frame, and one bad shape never costs the slide it is on.

Verified live: asked to "lay the three severity levels out left to right as coloured boxes with arrows between them", the model produced [rect arrow rect arrow rect], chose green/amber/red itself, and it rendered as asked.

When it wants something that is not there

The vocabulary is deliberately small, so it needs a way to find out what it is missing. The model is told to say so:

{"kind":"unsupported","need":"a SmartArt cycle of four stages"}

Nothing is drawn for that entry. It is recorded, along with the other signal — reaching for a kind, chart type or slide type that does not exist, which is how a model asks by trying. Both produce a log line:

[deck-vocabulary] wanted "smartart" (used as a shape kind) while generating: croup severity

and increment ped_ai_deck_vocabulary_gap_total{wanted="smartart"}, so it can be counted over time rather than noticed once. Capped per generation, deduplicated, and it can never fail anything — it is a note to whoever decides what to build next.

That is the answer to "should this run model-authored code in a sandbox instead". Maybe, one day, and the log says whether the gap is real. Today the model never emits Python: running model-authored code to lay out a slide would be an enormous amount of trust to buy a feature, on a server holding clinical data and secrets, and it would need its own network-isolated container with dropped capabilities, a read-only filesystem and hard resource limits before it was even safe to try.

Some of the delta is not closeable by any amount of sandboxing, because it is python-pptx's own ceiling rather than this vocabulary's: no SmartArt, no animations or slide transitions, and a limited set of chart types. Those are library limits. A sandbox would let a model write code against the same library and hit the same wall.

Markdown is still produced, serialised from the deck, so Word export and text editing keep working and the stored artifact stays readable by a person. The deck is stored alongside it because that serialisation is lossy by design: round-tripping through markdown would discard the layout choices.

Nothing costs more than the thing that went wrong — a reply that is not a deck falls back to asking for markdown; a malformed slide degrades to bullets; a comparison with one column is not a comparison; JSON wrapped in fences or a covering sentence is read rather than refused.

Illustrations

resourceImages.js, not the shared imageTool.dispatch — that one permits exactly one image per request, which is right for a chat reply and wrong for a twelve-slide deck, and the clinical assistant and Learning Hub depend on that rule. Same queue, same storage, same my_resources workflow, same asset endpoint; only the number differs, bounded at MAX_IMAGES because each figure is a paid request.

"Use 3 diagrams" in the instructions is read as the number it is. Writing "include a diagram of the airway" switches the illustration option on and says why, rather than the request being dropped in silence; switching it off by hand sticks.

my_resources is its own image workflow rather than a reuse of learning_hub because generated_image_links only accepts learning_hub assets — that is the barrier keeping a private illustration out of published content.

Getting a model to illustrate at all

Two things had to be right, both measured:

  1. The illustration guidance is the last thing in the prompt. Placed before the output rules it lost: the model returned 3297 characters of markdown and zero tool calls, while the same tool and wording in a shorter prompt produced three calls.
  2. Even last, it loses to a prompt carrying thirty library excerpts — deterministically: library off → three calls, library on → none. So when the author names a number the call is required rather than offered. With no number named the choice stays the model's.

A model that has just made three tool calls also tends to sign off instead of writing — "I'll create the presentation and the three teaching diagrams." was once returned as the resource, 61 characters, because only a completely empty body counted as missing. A body with no title block and no heading is now treated as missing whatever its length.

Export

Format Built by
pptx scripts/render_pptx.py (python-pptx) from the stored deck
docx scripts/render_docx.py (python-docx) from the same typed source
pdf Gotenberg (LibreOffice), from whichever office file above

Both office formats come from src/utils/docSpec.js / slideSpec.js rather than from markdown. Pandoc reads markdown, so a deck had to be flattened first — and a flattened deck stops being one: a comparison became two headings and two lists, a callout became bold text, and a figure became nothing at all. From the typed source a comparison is a labelled two-column table, a callout is a shaded box, and a figure is embedded at its own aspect ratio with its caption. An article, which has no deck, is parsed from its markdown into the same blocks.

Pandoc is still installed and is still the fallback for Word.

Pandoc's pptx writer was the ceiling on how good a deck could be, and the model on top made no difference to it: a handful of reference layouts, no per-slide layout, no positioning, no control over how large an image is drawn. It also leaves a bare <a:bodyPr/> on every shape, so slides overflowed until autofit was injected into its emitted OOXML by hand.

The renderer sizes text to fit before writing the file rather than trusting autofit — LibreOffice ignores <a:normAutofit/> when converting to PDF, which is how slides were being cut off mid-sentence. Wrapped bullet lines hang under the text. Images are drawn at their own aspect ratio.

If the renderer fails for any reason, pandoc still produces a deck: a plainer deck beats a failed download. The log line is deck renderer failed, falling back to pandoc.

Figures are fetched to a scratch directory for the renderer and removed afterwards. One that cannot be fetched is left out rather than failing a download that works without it.

Runtime dependency: the image carries python3, py3-lxml, py3-pillow (apk — both are C extensions with no Alpine wheels), plus python-pptx 1.0.2 and python-docx 1.1.2 from pip, and poppler-utils for slide review. Roughly 58MB of Python. Both pip packages are pinned: unpinned, a rebuild from the same commit could produce different documents.

The image library

Library → Images is every picture the account has generated, across all three features — My Resources, the Clinical Assistant and the Learning Hub — newest first. A figure outlives the deck it was drawn for: the deck gets replaced, the diagram is still good.

GET /api/generated-images returns only finished jobs, scoped by owner_id in the statement rather than filtered afterwards. Paging is keyset (created_at < cursor), not OFFSET, because a gallery that grows while you scroll repeats or skips a row under OFFSET. The prompt is the only human-readable label an image has — there is no filename and no title — so it is decrypted for the caption; a prompt that cannot be decrypted costs the caption, never the picture.

Tiles request the stored 256px preview through data-image-thumb, so thirty tiles cost a few kB each rather than thirty full-size downloads. Every fetch goes through hydrateImage, never a bare src: assets are served no-store and a bare src would not carry the session on a mobile client.

Image model fallbacks

Every workflow tries its configured model first, then each fallback in order, stopping at the first that produces an image. Primary plus two, capped — each hop is a paid request. Set in Admin → Image models.

A fallback is only tried where another model has a real chance:

Failure Next model? Why
Timeout, 429, 5xx, network fault yes The provider said "not now", not "not ever"
404 — the gateway does not have that model yes A configuration mistake the next model rescues
A content refusal yes Policy is a vendor decision, not a fact about the request
401 / 403 no One gateway, one set of credentials; the next model fails identically
413 — too large no It is too large everywhere
Any other 4xx no Malformed is malformed everywhere
Cancelled, or shutting down no Never start more paid work

A refusal is recognised from the message, because no provider sends a machine-readable reason and the status varies — 400 from some, 422 from others.

Each hop re-leases the job, so a chain cannot outlive its claim and let a second worker repeat the same paid work; if the lease has gone the attempt stops there rather than paying again. The row records the model actually being paid for, so a picture made by the third model is not attributed to the first, and every hop is logged with the reason it moved on.

This used to be the Clinical Assistant alone, with one fallback. My Resources had none at all — which is where a missing picture is most visible, because it leaves a hole in a slide.

Deleting

DELETE /api/generated-images/:id removes the bytes before the row, and refuses the whole operation if storage is unreachable. The other order would leave a row pointing at a key that is gone — an image listed in the gallery that renders broken — whereas failing between the two leaves a complete, working image and an error worth retrying.

Both derived previews go with the original; they live under their own prefix in the same bucket, and missing them would leave paid-for bytes behind that are still readable. THUMB_WIDTHS is defined once, in generatedImageStorage.js, because a width that is written but never deleted is exactly what two copies of that list produces.

A resource that embedded the figure keeps working: a deck stores the job id and renders without the figure when it has gone.

Slide review

Off unless an administrator names a model, in Admin → Slide review.

The model that writes a deck never sees it, so overflow, a figure on the wrong slide and a nine-item list that wants two columns are invisible to it. With a reviewer configured, each generated deck is rendered to PDF through Gotenberg, rasterised to one PNG per slide with pdftoppm, and shown to a vision model.

One pass per change — on generation, and again on the result of a modification. Modifying was excluded at first on the reasoning that refining is a text edit. It is not: an edit is made against how the deck looked before it, so a slide that gains two bullets only overflows once it is rendered again, which is exactly what the reviewer exists to catch.

Modifying can see the deck too

When a vision model is configured, modifying renders the current deck — with its figures — and hands the model one image per slide alongside the JSON. Most of what people ask for while modifying is about the rendered page: "that slide is crowded", "the diagram is in the wrong place", "this one looks empty". None of it is answerable from the JSON.

The vision model then does the editing, which is a second benefit measured before this was built: on a real 20-slide deck, ds-deepseek-v4-flash returned the deck unchanged for "make it better" and openrouter-gemini-3.8-flash did not. A model the author picks explicitly still wins over both.

Sight is an upgrade, never a dependency. No vision model configured, Gotenberg down, a render that fails — each falls through to editing the JSON blind, which is what this did before it could see at all, and none of them may cost someone their modification.

The reviewer must be able to see. Saving my_resources.review_model asks the gateway what it reports for that model and refuses one whose supports_vision is explicitly false — otherwise the mistake surfaces as a failed request on every generation, long after the moment an administrator could have chosen differently. A model the gateway says nothing about is allowed: most of a roster carries no supports_vision at all, and silence is not proof of blindness. An unreachable gateway is not evidence either, and never blocks the save.

It returns a patch, not a deck

{"changes":[
  {"slide":2,"action":"two"},
  {"slide":4,"action":"split","after":3,"heading":"Management (continued)"},
  {"slide":6,"action":"compare","at":3,"labels":["MILD","SEVERE"]}
]}

Asking for the corrected deck back put the reply in proportion to the deck rather than to the number of problems — a fourteen-slide deck came back cut off mid-object every time, at any output budget the provider would honour.

The patch is better for a second reason. The reviewer names a slide and an action; the server moves the text it already has. The words never pass through the model at all, so a review cannot reword, drop or invent a single bullet — which is a stronger guarantee than instructing it not to and checking afterwards. The check still runs: body text must come out the same multiset, figures the same set, and a heading may only be reused or extended. A continuation heading is the reviewer's one piece of text, and it is replaced with "<original> (continued)" if it does not continue anything.

Nothing here can fail a generation. No reviewer, an unreachable one, an unparseable reply, a deck longer than MAX_SLIDES, or a patch that applies to nothing — each returns the deck that was written.

Cost

One image per slide on every presentation generated. Pick a cheap capable vision model rather than the best one available; openrouter-gemini-3.8-flash is a reasonable default. Measured on a three-slide deck: three images in, one change out.

Modify

POST /api/my-resources/:id/refine rewrites a resource in place, keeping its id, its downloads and its References section. It offers the same four sources as generating — it had none, so "add what the 2024 trial showed" was answered from the model's memory rather than by looking anything up.

The previous version is replaced, not versioned.