Commit graph

776 commits

Author SHA1 Message Date
Daniel
ff2b2bc9d3 chore: Kitten and Supertonic retired; Kokoro is the local voice
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 53s
Forgejo Docker Build / Root app tests (push) Successful in 48s
Forgejo Android APK / Build signed APK (push) Successful in 2m12s
Forgejo Docker Build / Build Docker image (push) Successful in 10s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
Both were removed from the gateway in favour of Kokoro, so the app's curated
voice lists for them went too. Kokoro's own check no longer has to exclude
them — it names its own voices and the list is open, so it accepts anything
that is not another family's voice.

Voices stay curated per family rather than discovered. Models are discovered
from /model/info; voices are not, because no TTS provider exposes its voice list
consistently, and a list guessed from a model id is how a picker ends up
offering a voice the provider rejects.

Tests that pinned the retired voices now pin the families that remain.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-12 16:45:05 +02:00
Daniel
fce05a2749 fix: the active library view is unmistakable, and a leading question cannot mislead the vision model
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 49s
Forgejo Docker Build / Root app tests (push) Successful in 57s
Forgejo Android APK / Build signed APK (push) Successful in 1m58s
Forgejo Docker Build / Build Docker image (push) Successful in 18s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
Documents and Images were two buttons of the same weight, which reads as two
buttons rather than as a current view and an alternative. The active one now
carries the surface, the weight and a hairline — keyed off aria-selected, so the
visible highlight and what a screen reader announces cannot disagree.

The look_at_image question is written by a model that has not seen the image,
from what the user said, so it can presume something that is not there —
"describe this rash" about a photograph of a drug chart. Answering the
presumption would send it back as fact. The seeing model is now told the
question may assume something absent, and to say so first and describe what is
actually there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-12 16:27:21 +02:00
Daniel
1f06a19007 feat: a text-only model can ask a model that can see; and the image regex is gone
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 50s
Forgejo Docker Build / Root app tests (push) Successful in 48s
Forgejo Android APK / Build signed APK (push) Successful in 2m8s
Forgejo Docker Build / Build Docker image (push) Successful in 18s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
**The regex is gone.** The route ran a pattern over the user's message and
enqueued an image from the answer text when the model had not called the tool.
It was a compatibility path for models without tool calling and it did more harm
than good: it decided in English only, it could not see the conversation, and
"image summary" fell through it while reading as an obvious image request to the
model itself — which was measured, not assumed. A second and worse
decision-maker sitting behind the first. Whether a message deserves a picture is
now the model's call, made from the tool description, which is the only place it
ever belonged.

**Lending eyes.** The same shape, for a different capability. When someone
attaches a photograph and the chat model cannot accept image input, the
attachment was either refused by the provider or silently dropped — an answer
about a picture nobody had looked at, which is worse than a refusal.

The chat model is now offered look_at_image beside the image tool and decides
when to use it. The attachment goes to clinical_assistant.vision_model, whose
description comes back as a tool result, and the chat model answers in its own
voice with its own sources. Only the seeing is delegated; the clinical reasoning
stays with the model an administrator chose. The seeing model is told to report
and not to diagnose, because it has a picture and no context and an opinion from
it would carry weight it has not earned.

Delegation triggers only on an explicit supports_vision: false from the gateway.
An unknown is left alone — most of a roster reports nothing, and treating
silence as blindness would route good models through a detour. The capability
lookup moved to its own module, is cached for five minutes because it runs on
exactly the requests that are already slowest, and is never inferred from the
model id. liteLLMBaseUrl moved from the admin route to litellm.js, where the
other gateway helpers live.

The new setting is guarded like the slide reviewer: a model the gateway calls
text-only cannot be saved as the one that looks at images.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-12 15:43:36 +02:00
Daniel
03621752e8 feat: image fallback chains for every workflow, and a library worth looking at
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
**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
Daniel
259b4858be feat: an image library — every picture this account has made
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 47s
Forgejo Docker Build / Root app tests (push) Successful in 46s
Forgejo Android APK / Build signed APK (push) Successful in 2m6s
Forgejo Docker Build / Build Docker image (push) Successful in 19s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
A figure outlives the deck it was drawn for: the deck gets replaced, the diagram
is still good. Until now a generated image could only be seen in the resource it
was made for, and there was no way to find one again or to delete it.

Library → Images is a grid of every finished image the account owns, across all
three workflows, newest first. GET /api/generated-images is scoped by owner_id
in the statement rather than filtered after, returns only finished jobs — an
unfinished one is a broken frame in a gallery — and pages by keyset, because a
gallery that grows while you scroll repeats or skips rows under OFFSET.

Most of this already existed. Thumbnails were already rendered at 256 and 640 by
sharp and already served by ?w=, with their own checksum so the client's
tamper check passes on a derived copy; hydrateImage already handles auth, the
account boundary and caching. The tiles ask for the 256px preview, so thirty of
them cost a few kB each rather than thirty full-size downloads, and the prompt
is decrypted for the caption because it is the only human-readable label an
image has.

Deleting needed new work. The storage interface had no remove at all, so a
delete that dropped the row would have left the object and both previews in the
bucket — paid for, and still readable by anything with credentials. Storage now
removes all three keys, and the bytes go before the row: a row pointing at a
missing object is a broken image in a gallery, while an object without its row
is only wasted space, and unreachable storage refuses the delete outright rather
than reporting a success that left the picture behind.

THUMB_WIDTHS now has one definition, in generatedImageStorage. Two copies drift,
and the drift that matters is a width that gets written and never deleted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-12 14:36:30 +02:00
Daniel
83206e907c fix: a second deck reused the first deck's pictures
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 48s
Forgejo Docker Build / Root app tests (push) Successful in 51s
Forgejo Android APK / Build signed APK (push) Successful in 2m16s
Forgejo Docker Build / Build Docker image (push) Successful in 20s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
The image job key was the request-body hash plus the figure's position — slide
index for a deck, reply index for a document. Two generations from the same form
produce the same body hash, so figure 4 of the second generation collided with
figure 4 of the first on the unique (owner, workflow, idempotency_key). The
constraint handed back the existing job, and the new deck displayed the old
deck's artwork. The decks are not even the same length, so the reused picture
could land on a slide about something else entirely.

Keyed on what is being drawn now: the body hash stays, so submitting the
identical request twice still dedupes rather than billing twice, and a hash of
the prompt (plus layout and shape) is what makes two different pictures two
different jobs. Same fix in deckBuild and resourceImages.

Also split fileLog out of logger. logger requires the database at module load,
so importing it to record a diagnostic pulls in a connection pool — wrong on its
own terms, and it hung the whole test suite when imageTool started logging its
refusals: a unit test that never touches a database inherited an open pool
handle and never exited. logger.file now delegates to fileLog, so there is still
one implementation of where a line goes and how it is redacted.

With that in place, every image-tool refusal is recorded durably. There are five
of them, they want five different fixes, and until now none of them left any
trace once the container was replaced.

Verified against a mutation: restoring the index-based key fails two of the four
collision tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-12 08:26:09 +02:00
Daniel
94f320f140 fix: an empty answer from the model was the one failure never caught
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 48s
Forgejo Docker Build / Root app tests (push) Successful in 57s
Forgejo Android APK / Build signed APK (push) Successful in 2m8s
Forgejo Docker Build / Build Docker image (push) Successful in 12s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
The Clinical Assistant answered "image summary" with a zero-length string, saved
that as the turn, and drew it as an empty bubble. No error was raised, nothing
was logged, and the empty turn stayed in the conversation history where it
degrades every answer after it. Confirmed by decrypting the saved chat: the
final assistant turn is content length 0.

The cause is one line. finalizeAssistantAnswer only regenerates an answer that
shouldRegenerateTruncatedAnswer flags, and that function opens with
`if (!answer) return false` — there is no dangling conjunction to detect in a
zero-length string, so empty was classified as "not truncated" and returned as a
result. Every other failure mode had a path; this one had none.

An empty answer is now asked for once more — a model returning nothing is
usually transient — and if it comes back empty again it raises 502
`empty_answer`, which both callers already turn into a visible error. Whitespace
counts as empty, and so does an answer that strips to nothing. Without a callAI
to retry with it still raises rather than returning empty.

Verified against a mutation: removing the new branch fails five of the six
tests.

This is separate from the image question in the same request. No image job was
created, because "image summary" does not match the text fallback pattern —
which requires a verb (create/generate/draw/…) before the noun — and the model
did not call the tool. That is left alone for now: the request is genuinely
ambiguous, and guessing at it is how an assistant starts making pictures nobody
asked for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-12 07:41:04 +02:00
Daniel
4dcd0b4fc7 docs: the generation comment still said "one pass, on generation only"
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 47s
Forgejo Docker Build / Root app tests (push) Successful in 48s
Forgejo Android APK / Build signed APK (push) Successful in 1m52s
Forgejo Docker Build / Build Docker image (push) Successful in 10s
Forgejo Docker Build / Deploy to the host (push) Failing after 2s
Modifying now reviews its own result, so that line described the rule as it was
a commit ago. Also says what the generation pass can actually see: the deck is
rendered there without its figures, because they were enqueued a few lines
earlier and are still being drawn, so it judges the space a figure will occupy
rather than the artwork that lands in it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-12 07:29:01 +02:00
Daniel
e244ee5240 feat: modifying a deck can see it
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 1m0s
Forgejo Docker Build / Root app tests (push) Successful in 47s
Forgejo Android APK / Build signed APK (push) Successful in 2m13s
Forgejo Docker Build / Build Docker image (push) Successful in 24s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
The model editing a deck could not see it, which made most of what people
actually ask for unanswerable: "that slide is too crowded", "the diagram is in
the wrong place", "this one looks empty" are facts about the rendered page, not
about the JSON.

When a vision model is configured, modifying now renders the current deck —
with its figures, unlike the review pass, which runs while they are still being
drawn — and hands the model one image per slide alongside the JSON. Same
pipeline as review, reused rather than reimplemented: pptx, Gotenberg, PDF,
pdftoppm, capped at MAX_SLIDES.

The vision model then does the editing, which is a second and separately
measured benefit. On a real 20-slide deck, ds-deepseek-v4-flash returned the
deck unchanged for "make it better" — the echo reported yesterday — while
openrouter-gemini-3.8-flash applied it. So the stronger model fixes the echo
even without sight. A model the author picks explicitly still wins over both.

The result is rendered and reviewed again. Generation-only was the old rule, on
the reasoning that refining is a text edit; it is not. The edit is made against
how the deck looked before it, so a slide that gains two bullets only overflows
once it is rendered again. The reviewer may reposition but is held to the same
words, so a verification pass cannot quietly undo what was just asked for.

Whether an instruction achieved anything is judged on the model's edit, before
the reviewer runs, or a reviewer nudging a slide into two columns would mask an
instruction that did nothing.

Sight is an upgrade, never a dependency: no vision model, Gotenberg down, a
render that fails — each falls through to editing blind, and a test covers each
of those paths. Verified against two mutations: keeping the text model when
images are attached, and dropping the verification pass, each fail exactly one
test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-12 06:55:16 +02:00
Daniel
973f9d554f feat: a text-only model cannot be set as the slide reviewer
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 49s
Forgejo Docker Build / Root app tests (push) Successful in 48s
Forgejo Android APK / Build signed APK (push) Successful in 2m6s
Forgejo Docker Build / Build Docker image (push) Successful in 14s
Forgejo Docker Build / Deploy to the host (push) Failing after 2s
The reviewer is shown rendered images of the deck, so a text-only model there
fails on every generation — at request time, long after the one moment an
administrator could have picked differently. Nothing checked it.

Saving my_resources.review_model now asks the gateway what it reports for that
model and refuses only an explicit supports_vision === false.

Three answers, not two. Most of this roster carries no supports_vision at all
(every openrouter-* entry here), and refusing unknowns would block the reviewer
this deployment already runs on. An unreachable gateway is not evidence about a
model either, so it never blocks the save. Empty means review is off and skips
the lookup entirely.

The capability is read from the gateway, never inferred from the model id.

Verified against a mutation: relaxing `canSee === false` to `canSee !== true`
fails the two tests that say unknown must stay allowed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-12 06:42:44 +02:00
Daniel
491a2b0811 fix: the My Resources diagnostics survive a deploy
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 47s
Forgejo Docker Build / Root app tests (push) Successful in 52s
Forgejo Android APK / Build signed APK (push) Successful in 2m5s
Forgejo Docker Build / Build Docker image (push) Successful in 17s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
logRefine writes the one line that answers "did that modification change
anything" — path, before and after size, CHANGED=yes/no, figures, model,
instruction. It went to console, so it lived in the container's stdout and was
destroyed the next time the container was recreated.

That cost a diagnosis today: a modification came back unchanged, the user asked
why, and the evidence had already been deleted by a deploy. The deck-fallback
warnings and the deck-vocabulary gaps had the same problem, and those exist
specifically to be read later — the vocabulary gaps are meant to show which
shapes to build next, which is a question about weeks, not about one container.

All of them now go through logger, which writes the dated file in the
scribe-logs volume and ships to Loki when it is configured, and carries the
event as structured data rather than only as a formatted string.

console.error is left alone: those are failures, and logger.error already
echoes to the console.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-12 06:09:34 +02:00
Daniel
c9b6d6b3c9 docs: deploy.sh is the deploy path, and it was documented nowhere
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 47s
Forgejo Docker Build / Root app tests (push) Successful in 48s
Forgejo Android APK / Build signed APK (push) Successful in 2m22s
Forgejo Docker Build / Build Docker image (push) Successful in 11s
Forgejo Docker Build / Deploy to the host (push) Failing after 2s
scripts/deploy.sh landed on 2026-09-11 as "a deploy you can repeat, and prove
afterwards". It moves the PED_AI_IMAGE pin, waits for health, reads /api/build
to find out which revision is actually serving, and rolls back when that is not
the revision asked for. Its own comment says it: "Healthy is not the same as
running what was asked for."

No doc mentioned it. Both deployment.md and DEVELOPMENT.md instead showed
`build-image.sh` then `docker compose up -d --no-build` then `curl /api/health`
— a sequence that moves no pin and proves no revision. Following it deployed
nothing four times in one session, eventually reverting the app by 31 commits
and removing a feature, which was then reported as a bug.

Both docs now lead with deploy.sh and say why `up` by hand is not a deploy.

Also replaced yesterday's build-image.sh warning, which told the operator to
`sed` the pin themselves. That reimplemented, badly, one of the three things
deploy.sh already does — and it was written while auditing these very files
without noticing the script was there. It now prints the deploy.sh line to run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-12 06:04:13 +02:00
Daniel
739286b53b fix: warn when the built image is not the one a deploy will start
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 47s
Forgejo Docker Build / Root app tests (push) Successful in 46s
Forgejo Android APK / Build signed APK (push) Successful in 2m12s
Forgejo Docker Build / Build Docker image (push) Successful in 17s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
`docker compose up` starts whatever PED_AI_IMAGE in .env names, and
build-image.sh does not move that pin. A pin left from an earlier deploy
therefore starts the older image while every signal reports success: the build
completes, `up` says the container started, and /api/health returns {ok:true}
from the wrong revision.

This is not hypothetical. The pin here had been sitting on a revision from two
hours before the Modify card was added, so a rebuild-and-restart rolled My
Resources back 31 commits and removed the feature. The missing card was then
reported as a new bug, and three deploys in this session had in fact deployed
nothing.

build-image.sh now compares the pin to the revision it just built and, when they
differ, prints the pin, says that `up` will start it instead, and gives the
command to move it. It does not correct the pin: naming a revision is also how a
deliberate rollback is done, so this is said rather than silently overridden.

Both deployment docs now check /api/build against `git rev-parse HEAD` after
starting, because /api/health passing only proves a container is up, not that it
is the one you built.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-12 05:58:37 +02:00
Daniel
07d1c541a0 fix: a deck the model fumbles once is asked for again, not abandoned
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 50s
Forgejo Docker Build / Root app tests (push) Successful in 48s
Forgejo Android APK / Build signed APK (push) Successful in 2m2s
Forgejo Docker Build / Build Docker image (push) Successful in 23s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
A presentation is generated as a typed deck — the model chooses each slide's
layout — and that deck is what scripts/render_pptx.py renders. When the reply
did not parse as a deck, the route fell straight back to asking for markdown,
and slideSpec.build() then inferred slides from it. Both paths go through
python-pptx, but the fallback's layout is guessed from heading and list
structure rather than chosen, so everything lands as title-and-bullets.

Measured on the stored library: since decks landed, 7 of 8 generations produced
one and 1 did not. Models are stochastic, so one unlucky reply was costing the
whole layout. It now asks a second time with the same prompt before giving up.

The fallback was also invisible. It warned to the console, where the person who
would simply have generated again could not see it, so they kept the plainer
deck without knowing a better one was one click away. The response now carries
deckFallback and the UI says it came out as plain slides, and why.

Fixed the reason heuristic while adding tests for it: truncation was claimed for
any reply not ending in "}", which is every prose refusal. It is now only
claimed for a reply that began as JSON and stopped.

The four generate tests run the handler. Verified against a mutation: removing
the retry fails tests 6 and 7 and nothing else.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-12 05:34:14 +02:00
Daniel
e69eb9a9f7 fix: modifying a presentation failed whenever illustration was ticked
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 53s
Forgejo Docker Build / Root app tests (push) Successful in 54s
Forgejo Android APK / Build signed APK (push) Successful in 1m56s
Forgejo Docker Build / Build Docker image (push) Successful in 22s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
existingDeck was declared below the branch that reads it, so `var` hoisting made
it undefined there. With illustration on, a deck modification therefore appended
the *markdown* instruction — "Returning the markdown is still required; a tool
call is not a substitute for it" — to a prompt whose body asked for deck JSON.
The model was told to produce two different artifacts in one reply, the reply
parsed as neither, and deckBuild.parse returned null, so the handler answered
502 "That change could not be applied."

Moved the declaration above its first reader. Same class of fault as the
savedFigureIds one, in the same file.

Two things made it hard to see, both fixed:

- The library row read created_at, so a modification that did apply left the
  visible timestamp on the generation time. That timestamp is what led to
  "modification doesn't work" — it was the only signal available, and it was
  reading the wrong column. Rows now show the modified time when there is one.
- A model can also return the document back unchanged. That was logged
  server-side and answered "Applied. Download it to see the result", which sent
  people to download an identical file. The response now carries `unchanged` and
  the UI says so, keeping the instruction in the box so it can be reworded.

Also surfaced has_deck on the library list: 28 of 38 stored presentations have
no deck and go through the weaker flat-markdown path, and nothing in the UI
distinguished them. They now read "plain text, no slide layout".

test/my-resources-refine.test.js runs the handler rather than reading it, since
all three faults were invisible to source reading. Verified against a mutation:
putting the declaration back where it was fails test 1 and nothing else.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-12 05:05:39 +02:00
Daniel
cfb8aab77d fix: /api/health/detailed reported a text-to-speech provider that does not exist
The endpoint answered from environment variables of its own rather than from the
speech code, so ELEVENLABS_API_KEY being set made it report tts: 'elevenlabs'.
There is no ElevenLabs path in ttsProvider.js — getTTSProvider() only ever
returns 'litellm' or 'none' — so the one endpoint an operator checks to find out
what speech is doing was reporting a provider this app cannot use. Likewise
whisper: OPENAI_API_KEY, which stopped describing STT when transcription moved
behind the gateway.

Both now ask getSTTProvider() and getTTSProvider(), the same functions the routes
ask, so the answer cannot drift from behaviour again.

Removed the two other ElevenLabs leftovers: the FAQ told users their notes were
read aloud by "Google, OpenAI, or ElevenLabs", and a TTS test used 'elevenlabs'
as its sample value for the passthrough of TTS_PROVIDER, which implied the
provider was supported.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-12 05:05:39 +02:00
Daniel
67e416c6d9 docs: merge the duplicate pairs and correct them against the running app
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 47s
Forgejo Docker Build / Root app tests (push) Successful in 48s
Forgejo Android APK / Build signed APK (push) Successful in 2m38s
Forgejo Docker Build / Build Docker image (push) Successful in 12s
Forgejo Docker Build / Deploy to the host (push) Failing after 2s
Three pairs of docs described the same thing twice, and the copies had drifted
apart. Merged each into one file, keeping the unique content from both:

- ARCHITECTURE.md -> architecture.md (its operational map: ownership, request
  flow, runtime boundaries, source of truth, deployment shape)
- DEVELOPMENT.md -> developer-guide.md (change workflow, Clinical Assistant
  high-risk areas, frontend rendering rules, deployment checks)
- transcription-options.md -> speech.md (the clinic setup table, and the list
  of browser-Whisper paths that must stay removed)

Then audited what remained against the code and the live database rather than
against the previous docs. Corrected:

- Google Vertex was still documented as a provider across nine files. The SDK
  is gone; AI_PROVIDER=vertex now logs an advisory and falls back to
  OpenRouter, and Gemini is reached through LiteLLM. Fixed the provider
  selection order to match src/utils/ai.js, which starts from LITELLM_API_BASE.
- promptSafe was documented on 8 routes; it is on 13.
- Node 20 -> 24, "24 vanilla JS modules" -> no fixed count, and
  transcribe.js/tts.js -> sttProvider.js/ttsProvider.js, which is what exists.
- STT/TTS are LiteLLM-only; README listed direct Google, AWS Transcribe and
  ElevenLabs paths that are not in the runtime.
- Learning Hub PPTX export was documented as pptxgenjs, which is not a
  dependency. It is pandoc against a reference deck.
- POST /api/admin/milestones/seed does not exist; it is /bulk-import.
- NEXTCLOUD_URL and NTFY_TOPIC are not read anywhere. Nextcloud is per-user in
  the users table, and the ntfy topic is derived as pedscribe-{userId}.
- A prose paragraph sat inside the Clinical Assistant settings table, so half
  the rows rendered as text.

Filled the gaps the audit exposed:

- database.md was missing 12 of 29 tables, including user_resources,
  personal_notes, login_codes, registration_invites and generated_image_jobs.
- developer-guide.md was missing 11 routers and 10 frontend modules.
- api-reference.md detailed 121 of 244 endpoints and said so, but whole
  features were absent. Added an endpoint index covering Clinical Assistant,
  My Resources, Notes, Diagrams, ED Encounters, invites and sign-in codes.
- configuration.md was missing METRICS_TOKEN, REDIS_URL, API_RATE_LIMIT_MAX,
  the LITELLM_* model variables, the DB_* ones maintenance.js reads, and the
  per-purpose S3 resolution scheme.
- clinical-assistant.md documented 2 of its 17 environment variables.
- features-explained.md had no entry for My Resources or Clinical Assistant.

Renamed the three remaining SHOUTING filenames to kebab-case, which is what the
docs viewer's prettyName() was working around, and rewrote README's index,
which listed architecture.md twice and omitted nine files.

Noted but not changed: the Turnstile site key is hardcoded in index.html rather
than read from TURNSTILE_SITE_KEY, and /api/health/detailed can report
tts: 'elevenlabs' though no ElevenLabs path exists.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-12 04:57:35 +02:00
Daniel
5614a436be fix: a deck asked for a figure and never got one — three causes, one symptom
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 52s
Forgejo Docker Build / Root app tests (push) Successful in 52s
Forgejo Android APK / Build signed APK (push) Successful in 2m15s
Forgejo Docker Build / Build Docker image (push) Successful in 10s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
"Include a diagram" produced decks with no picture. Three separate faults, each
hiding the next, found by generating the same deck after fixing each one.

First, deck generation ran on the default 4000-token budget. A deck's JSON is
several times the size of the prose it holds, so a long deck came back truncated,
failed to parse, and fell back to markdown — which has no way to request a
figure, so the model described one instead and the slide rendered a literal
"![Placeholder: Flow diagram ...]" as its first bullet. Deck generation now gets
room, and the fallback says how the reply failed: empty, cut short at N
characters, or not a deck.

Second, the figure request sat inside the layout vocabulary, one line among
forty, and the model passed over it. It goes last now, after the author's own
instructions — the same placement lesson the image tool taught earlier.

Third, and the one that actually mattered: image_prompt is only read on the
figure and image types, so an image_prompt on a bullets slide was dropped in
silence. The instruction said "add image_prompt to N slides" without saying
which types carry one. It now names them, and a misplaced request is honoured
rather than discarded — a slide with words becomes a figure, one without becomes
a full-slide image.

Image markup is also stripped wherever text enters a slide, on both paths: a
described figure is not a figure, and a bullet of raw markdown is worse than no
bullet.

Verified end to end after: the same request produced a deck with one figure, the
job completed, and the exported pptx carries one embedded image across 21 slides.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-12 04:28:30 +02:00
Daniel
2c3fbbcf37 fix: decks were falling back to markdown, so no figure could ever be requested
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 47s
Forgejo Docker Build / Root app tests (push) Successful in 46s
Forgejo Android APK / Build signed APK (push) Successful in 2m3s
Forgejo Docker Build / Build Docker image (push) Successful in 22s
Forgejo Docker Build / Deploy to the host (push) Failing after 1s
A deck's JSON is several times the size of the prose it holds, and generation
used the default 4000-token budget — raised for refine and for slide review, but
never here. A sixteen-slide deck ran past it, came back truncated, failed to
parse, and fell back to markdown. Markdown has no way to ask for a figure, so
the model described one instead and the slide rendered a literal
"![Placeholder: Flow diagram — "Neonate with rash" → ...]" as its first bullet,
above the steps it was meant to illustrate. That is why no generated deck was
arriving with an image.

Deck generation now gets room for a deck. The fallback also says how the reply
failed — empty, cut short at N characters, or simply not a deck — because those
want different fixes and "not usable" covered all three.

Image markup is stripped wherever text enters a slide, on both the deck and
markdown paths, since a described figure is not a figure and a bullet of raw
markdown is worse than no bullet. The model is also told plainly: if a figure is
wanted say so with image_prompt, and if that is not on offer, write the slide
without one rather than describing the picture you would have drawn.

Separately, the Documentation list showed ARCHITECTURE, CLINICAL_ASSISTANT,
DEVELOPMENT, MODULE_CONVENTIONS and SCALING shouting in caps with underscores
intact: the label builder replaced hyphens but not underscores, and uppercased
the first letter of each word rather than normalising the case, so a
SHOUTING_FILENAME stayed shouting. It now reads "Clinical Assistant", keeps
acronyms as acronyms (AI, API, OpenID, LiteLLM) and leaves joining words lower.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-12 04:21:20 +02:00
Daniel
9b2cb339a1 test(e2e): drive My Resources through the browser, and assert what it sends
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 47s
Forgejo Docker Build / Root app tests (push) Successful in 48s
Forgejo Android APK / Build signed APK (push) Successful in 1m59s
Forgejo Docker Build / Build Docker image (push) Successful in 9s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
The unit tests for this feature read source files and assert patterns. They
prove the code says the right thing, not that the screen does it, and nothing
exercised the browser at all — so a mismatch between what the form sends and
what the route reads passed all of them.

Three real bugs shipped through that gap in one session: a modification that
updated the markdown but not the deck, generation that failed whenever the slide
reviewer was off, and a figure generated for a slide that never referenced it.
Every one was found by driving the running server by hand.

So these assert the request bodies, not only the rendering: that Generate sends
topic, kind, slideCount, refinement, model and all four options as the strings
the route compares against; that unticking the library sends 'false' rather than
omitting the field, which the route would read as on; and that Modify posts to
the right resource with every source option. Plus the screen's own behaviour —
availability gating on both cards, the illustration hint switching on and
staying off once overruled, the bounded searchable library, the two different
empty states, an article never being offered as slides, a local refusal that
spends no round trip, and a refused modification surfacing its reason. Fourteen
tests, both viewports.

The API is stubbed. This is the contract between the screen and the route, and
stubbing keeps it fast, free and deterministic.

Proven to catch regressions rather than merely pass: renaming useCorpus in the
form failed two tests, breaking the availability gating failed one, and
truncating the modify picker failed another.

Two flakes of my own were fixed rather than retried. openTab slept 400ms for the
library and picker instead of waiting for them, which made Modify report
"nothing to modify yet" under load. And the console-error guard failed on
net::ERR_ABORTED and net::ERR_NETWORK_CHANGED — a request in flight when the
context closes, and the host network reconfiguring under a browser that runs on
it. Both are the harness, not the page: anything genuinely failing carries a
status code and is still caught. Five consecutive clean full runs after.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-12 01:51:15 +02:00
Daniel
3ec65a91f6 fix: a figure asked for while modifying a deck now belongs to a slide
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 46s
Forgejo Docker Build / Root app tests (push) Successful in 47s
Forgejo Android APK / Build signed APK (push) Successful in 1m56s
Forgejo Docker Build / Build Docker image (push) Successful in 8s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
Ticking "Add illustrations" on Modify offered the image tool regardless of what
was being edited. The tool returns job ids and has no way to place them, which is
fine for markdown — there is nowhere to put a figure in markdown anyway — and
wrong for a deck, where figures are placed by a slide declaring them.

So modifying a deck with illustrations on generated a figure, paid for it,
recorded it against the resource, and referenced it from nothing. Measured: one
figure recorded, zero referenced by a slide, and absent from the export.

Deck mode now asks the revised deck to declare its figures and draws them with
the same drawFigures() generation uses, so each one belongs to the slide that
wanted it. Slides that already have a figure keep it. The tool path stays for
markdown resources, where it is the only option.

Verified: the same modification now records one figure, one slide references it,
and the exported deck embeds one image.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-12 01:24:40 +02:00
Daniel
012346528c fix: generation stopped working whenever the slide reviewer was switched off
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 51s
Forgejo Docker Build / Root app tests (push) Successful in 1m0s
Forgejo Android APK / Build signed APK (push) Successful in 2m35s
Forgejo Docker Build / Build Docker image (push) Successful in 17s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
savedFigureIds was declared inside the review branch, so with no reviewer
configured — the default, and what everyone is running — it was undefined by the
time the INSERT stringified it. JSON.stringify(undefined) is not a string, the
column is NOT NULL, and every generation failed with "Generation failed". `var`
is function-scoped, so nothing complained until the database did.

This is the second bug of exactly this shape in this file, so the test asserts
position rather than presence: the value must be declared before both the review
and the insert read it.

Found by the logging added in the same change, which is the other half of this
commit. Every modification now says what it did:

  [my-resources] refine id=29 path=deck outcome=applied 13→14 slides changed=yes
  [my-resources] refine id=37 path=markdown outcome=applied 2635→3018 chars changed=yes

CHANGED=no is warn-level and deliberately shouty, because that is the failure
worth catching: the response says success either way, the row updates, and the
download is identical — which is exactly how the deck bug went unnoticed. A
refusal logs its reason. ped_ai_resource_refine_total{path,outcome} counts the
same thing over time, so "did that modification do anything" is answerable
without watching logs live.

Verified across every path rather than the one that was broken: a deck
presentation modified and exported to both pptx and docx carries the change; a
legacy presentation with no stored deck still takes the markdown path and
carries it; an article generates, modifies and exports; and a presentation
generates with the reviewer off.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-12 00:57:30 +02:00
Daniel
f66daf0c02 fix: modifying a presentation changes the presentation, not just its markdown
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 49s
Forgejo Docker Build / Root app tests (push) Successful in 52s
Forgejo Android APK / Build signed APK (push) Successful in 2m0s
Forgejo Docker Build / Build Docker image (push) Successful in 11s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
Export renders a presentation from its stored deck. Refine edited the markdown
beside it and never touched the deck — so a modification reported success,
updated the title and the library row, and produced a byte-identical download.
Nothing said otherwise. It looked like the model had ignored the instruction.

Measured before the fix, with a marker that was definitely not in the deck:
refine succeeded, the stored markdown gained the new slide, the stored deck did
not, and the exported pptx did not. After: the export gains the slide and the
marker, twelve slides where there were eleven.

A presentation with a stored deck is now edited as a deck — the deck goes to the
model, a revised deck comes back, and the markdown is serialised from it, which
is the same direction generation runs in. Layouts, custom slides and image_job
values survive a modification instead of being flattened away.

A reply that is not a usable deck is refused rather than saved as markdown:
saving it would drop every layout the deck held while looking like it worked,
which is the failure this commit exists to remove. Articles have no deck and keep
the markdown path unchanged.

The reply restates the whole resource, so the token budget is raised to match —
the old default was already close to truncating a long deck's markdown.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-11 23:55:27 +02:00
Daniel
4f8e686907 feat: record what a deck wanted and could not have
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 47s
Forgejo Docker Build / Root app tests (push) Successful in 47s
Forgejo Android APK / Build signed APK (push) Successful in 1m58s
Forgejo Docker Build / Build Docker image (push) Successful in 13s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
The shape vocabulary is deliberately small, which leaves the question of what to
add next. Rather than guess, it now records demand.

Two signals, because a model asks both ways. It can say so outright —
{"kind":"unsupported","need":"a SmartArt cycle of four stages"}, which draws
nothing and is told about in the same file that validates it — or it can reach
for a kind, chart type or slide type that does not exist, which is the more
common way of asking and just as much of a signal.

Both produce a log line naming what was wanted and the topic it came up on, and
increment ped_ai_deck_vocabulary_gap_total{wanted}, so it can be counted over
time in Grafana rather than noticed once and forgotten. Deduplicated per
generation and capped at twelve: a model that asks for a hundred things it cannot
have should not write a hundred log lines. It can never fail a generation — it is
a note to whoever decides what to build next.

This is also the answer to whether to run model-authored code in a sandbox
instead. The log will say whether the gap is real. Some of it is not closeable by
any sandbox, being python-pptx's own ceiling — no SmartArt, no animations or
transitions, limited chart types — and a sandbox would only let a model write
code against the same library and hit the same wall. Documented in
docs/my-resources.md, which the in-app Docs tab serves directly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-11 22:51:15 +02:00
Daniel
af2e09c1de feat: a slide can be drawn from primitives when the named layouts have no word for it
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 1m7s
Forgejo Docker Build / Root app tests (push) Successful in 47s
Forgejo Android APK / Build signed APK (push) Successful in 2m16s
Forgejo Docker Build / Build Docker image (push) Successful in 16s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
The nine layouts are a fixed vocabulary and a good default, but "lay the three
severity levels out left to right with arrows between them" had no expression in
them at all. A "custom" slide now carries a list of shapes: positioned text,
eight autoshape families, lines, images, tables, and native PowerPoint charts —
column, bar, line, pie, doughnut.

Coordinates are percentages of the slide rather than EMU, because a model
reasons about "the left half" and not about 12192000. 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 holding clinical data and secrets.

Validation lives beside the text that teaches the vocabulary, in one file, so
what the model is told about is exactly what is accepted. Kinds are an
allowlist, colours must be six hex digits, coordinates are clamped inside the
slide — a shape at x=95 w=30 is cut to the edge rather than drawn half off it —
counts are capped, a pie is held to one series, and anything that cannot be
understood is dropped. A custom slide that loses every shape becomes a plain one
rather than a heading over an empty frame, and one bad shape is caught in the
renderer so it cannot cost the slide it sits on.

A figure on a custom slide is requested through an image shape, drawn by the
same path as any other, and attached by job id. Word renders a custom slide as
its words in reading order with its tables and figures — lossy, and better than
dropping the slide.

Verified live end to end: 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 the rendered
slide was looked at. A column chart beside its commentary renders with real axes
and gridlines.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-11 22:12:26 +02:00
Daniel
f7e0277552 docs: accounts in the README, and the sign-in code path is verified end to end
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 47s
Forgejo Docker Build / Root app tests (push) Successful in 47s
Forgejo Android APK / Build signed APK (push) Successful in 2m4s
Forgejo Docker Build / Build Docker image (push) Successful in 11s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
The README's feature list never mentioned accounts at all. It now says how
somebody signs in — password or an emailed code, side by side — that passwords
are argon2id with bcrypt rows rehashed on next sign-in, and that registration can
be open, closed or invite-only with codes that are revocable while live and
deletable only once spent.

The browser-STT note now says why the Web Speech gate exists rather than only
that it exists: Chrome and Edge send that audio to Google, so it is off unless a
user turns it on.

And the one thing that could not be demonstrated before now can. With SMTP
configured, a real send returns true, and driving the sign-in screen in a browser
— request a code, type it — returns 200 from /login-code/verify and enters the
app. docs/authentication.md records that.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-11 22:02:25 +02:00
Daniel
154b896d5b feat: Word is built by python-docx from the same typed source as the deck
Some checks failed
Forgejo Docker Build / Build Docker image (push) Blocked by required conditions
Forgejo Docker Build / Deploy to the host (push) Blocked by required conditions
Forgejo Android APK / Root app tests (push) Successful in 58s
Forgejo Docker Build / Root app tests (push) Successful in 49s
Forgejo Android APK / Build signed APK (push) Has been cancelled
Pandoc reads markdown, so every Word export had to flatten the resource to
markdown first — and a deck flattened to markdown stops being one. A comparison
became two headings and two lists, a callout became bold text, and a figure
became nothing at all, because markdown has nowhere to put it.

src/utils/docSpec.js reduces either source to the same blocks: a stored deck
where there is one, the markdown where there is not. scripts/render_docx.py
draws them. A comparison comes out as a labelled two-column table, a callout as
a shaded box, a table as a real table, a figure embedded at its own aspect ratio
with its caption, and speaker notes as muted indented text.

The deck wins over the markdown beside it, because that markdown is a
serialisation of the deck and reading it instead would be reading a lossy copy of
what is right there.

Word now carries the figures too. The export route skipped fetching them for
docx, which was correct when pandoc could not place them and wrong the moment
this could.

Pandoc stays installed and stays the fallback: a plainer document beats a failed
download. Both renderers now share one spawn helper.

Verified end to end: a deck with two figures exported as a six-page Word document
with both images embedded (537KB, two files in word/media), rendered to PDF and
looked at — the comparison is a labelled table, the figure sits at its true
aspect ratio, and the notes read as notes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-11 21:58:53 +02:00
Daniel
9d307fd442 feat: a vision model looks at the rendered deck and fixes the layout
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 50s
Forgejo Docker Build / Root app tests (push) Successful in 1m2s
Forgejo Android APK / Build signed APK (push) Successful in 2m6s
Forgejo Docker Build / Build Docker image (push) Successful in 37s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
The model that writes a deck never sees it. It cannot tell that slide four
overflowed, that a nine-item list would read better in two columns, or that two
labelled groups want to be a comparison — those are facts about the rendered
page, not about the text. So each generated deck is now rendered to PDF through
Gotenberg, rasterised to one image per slide with pdftoppm, and shown to a
vision model.

Off unless an administrator names a reviewer, in its own admin card because it
is the one setting that spends money on every generation without a user having
asked for anything. One pass, on generation only: a second pass costs as much as
the first and fixes far less, and refining is a text edit.

It returns a patch, not a deck. Asking for the corrected deck back put the reply
in proportion to the deck rather than to the number of problems, and a
fourteen-slide deck came back cut off mid-object at every output budget the
provider would honour — measured twice before changing shape.

The patch is better for a second reason. The reviewer names a slide and an
action — two columns, one column, split after bullet N, compare with these two
labels — and the server moves the text it already has. The words never pass
through the model, so a review cannot reword, drop or invent a single bullet.
That is a stronger guarantee than instructing it not to and checking afterwards.

The check runs anyway, because a bug in applyChanges would be as bad as a model
rewriting the words and worse for being trusted: 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 is
replaced when it does not continue anything.

Nothing here can fail a generation — no reviewer, an unreachable one, an
unparseable reply, a deck too long to look at, or a patch that applies to
nothing each return the deck that was written.

Verified end to end against a deck with a deliberately overloaded slide: three
slides rendered and sent, one change returned, ten bullets split into five and
five under "Stepwise Management … (continued)", text intact. Left switched off;
enable it under Admin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-11 21:38:18 +02:00
Daniel
f3c3f47d99 fix: voice mode reads the answer that just arrived, and reads what the page shows
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 47s
Forgejo Docker Build / Root app tests (push) Successful in 49s
Forgejo Android APK / Build signed APK (push) Successful in 2m5s
Forgejo Docker Build / Build Docker image (push) Successful in 10s
Forgejo Docker Build / Deploy to the host (push) Failing after 2s
Two bugs, one cause each.

It read the previous answer. setBusy(false) is what announces
assistant-answer-done, and it ran before lastAnswer was assigned — so every
listener was handed the answer before last. It now fires after the answer exists
both in that variable and on the page. A test asserts the order, because the
order is the whole bug.

And it read the markdown. The better answer than unpicking the markup is not to
have any: the rendered bubble is already the answer with its headings, emphasis
and tables resolved, so voice mode reads that. It cannot drift from what the
reader is looking at, and it needs no rules about what "##" sounds like. Read
from a clone, with the parts that are not the answer removed — the action
buttons, the sources list, the follow-up suggestions, code blocks and tables —
so the page itself is untouched. A bubble still thinking is never read.

speakableText() stays as the fallback for when the bubble cannot be found, since
raw markdown read aloud is worse than silence.

Separately: e2e/seed.js hashed with bcrypt directly, so seeded accounts did not
exercise the argon2id path production writes. It uses the app's own hasher now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-11 21:10:50 +02:00
Daniel
0e17f553fc fix: hanging up the voice call stops the voice, and the answer is read not its markup
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 1m0s
Forgejo Docker Build / Root app tests (push) Successful in 47s
Forgejo Android APK / Build signed APK (push) Successful in 2m3s
Forgejo Docker Build / Build Docker image (push) Successful in 20s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
Ending the call stopped listening and aborted the request but never stopped
playback, and the <audio> element was a local variable with no handle kept — so
nothing could stop it and the answer talked on until the page was reloaded.
conversationMode now holds whatever is speaking, whichever of the two players it
is, and hanging up silences it before anything else. Starting a new turn does
the same, because speaking over the previous answer is how an assistant talks
past you, and a reply that arrives after the overlay has closed no longer starts
talking into a closed call.

The other half: the raw answer went straight to the speaker. It is markdown, so
a browser voice reads "#" and "**" aloud or stumbles over them. speakableText()
now reduces it to what a person would read out — headings and bullets become
sentences, links keep their words, emphasis and code fences are dropped, tables
are dropped entirely because a table read aloud is noise, and a removed citation
marker leaves no gap before the punctuation it preceded.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-11 20:52:35 +02:00
Daniel
fa2e7523d6 docs: My Resources, sign-in codes, invitations, and what the image carries
Some checks failed
Forgejo Docker Build / Build Docker image (push) Blocked by required conditions
Forgejo Docker Build / Deploy to the host (push) Blocked by required conditions
Forgejo Android APK / Root app tests (push) Successful in 50s
Forgejo Docker Build / Root app tests (push) Successful in 48s
Forgejo Android APK / Build signed APK (push) Has been cancelled
Nothing documented My Resources, the slide renderer, PubMed or web search, and
the authentication doc predated both sign-in codes and registration invitations.

docs/my-resources.md is new and covers the feature end to end: what a resource
is, where its material comes from, why both searches run in the route rather
than as tools the model never called, why keyword engines get the topic while
retrieval gets the instruction too, how a presentation is designed as a deck
rather than written as markdown, the separate multi-image path, and what the
export pipeline is made of.

docs/authentication.md gains sign-in codes — storage, lifetime, reuse,
supersession, guessing, and that two-factor still applies — and registration
invitations, including the exact condition that decides when a code may be
deleted and why it is written to match the status the list displays. Both new
rate limits are in the table, with a note that Express matches app.use paths on
segment boundaries, so a new sign-in endpoint needs its own limiter or it has
none at all.

docs/deployment.md now says what the runtime image carries and why — pandoc for
Word, python3 with apk-installed lxml and pillow for the slide renderer,
python-pptx pinned, and that PDF conversion is not in the image at all but goes
to Gotenberg, so Word and PowerPoint still work when it is down.

docs/configuration.md picks up LOGIN_RATE_LIMIT_MAX, LOGIN_CODE_RATE_LIMIT_MAX
and GOTENBERG_URL, none of which were listed. README gains a My Resources
section and indexes the two new docs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-11 20:49:10 +02:00
Daniel
b98ffc61c7 fix: expired invitations can be cleared too, revoked ones still cannot
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 47s
Forgejo Docker Build / Root app tests (push) Successful in 51s
Forgejo Android APK / Build signed APK (push) Successful in 2m2s
Forgejo Docker Build / Build Docker image (push) Successful in 12s
Forgejo Docker Build / Deploy to the host (push) Failing after 2s
An expired code is as dead as a used one and just as accounted for, so it is now
deletable. The rule the code enforces is the one that matters: a code that could
still be redeemed is never deleted, because that takes it off the list without
taking it out of anybody's inbox — the holder keeps something that looks valid,
it quietly stops working, and nothing is left to say who had it.

One condition, shared by the single delete and the bulk clear:

  (used_at IS NOT NULL OR (revoked_at IS NULL AND expires_at <= NOW()))

Written that way rather than as "used OR past its date" because the second form
also catches a revoked code whose date has since passed — a row the list still
labels revoked and offers no delete on, so the button and the query would have
disagreed about the same row.

Revoked codes keep their rows. Revoking records a decision somebody took, and a
handful of them is not the clutter a pile of expired codes is.

Verified against the live database across every state: active refused, used
deleted, expired deleted, revoked refused, and revoked-with-a-past-date refused
rather than slipping through as expired.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-11 20:41:18 +02:00
Daniel
7b084c7edf fix: an invitation can only be deleted once it has been used
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 49s
Forgejo Docker Build / Root app tests (push) Successful in 46s
Forgejo Android APK / Build signed APK (push) Successful in 2m2s
Forgejo Docker Build / Build Docker image (push) Successful in 17s
Forgejo Docker Build / Deploy to the host (push) Failing after 1s
The delete button was offered on every invitation regardless of state, and the
query behind it deleted any row it was given. Deleting an unused code takes it
off the list without taking it out of anybody's inbox: the person still holds
something that looks like a valid invitation, it silently stops working, and
there is no longer a record of who it went to or why. Revoke is what stops a
live code — it leaves the row behind, marked.

So the delete is now for spent codes only, in three places rather than one: the
query carries AND used_at IS NOT NULL, the route answers 409 with the reason
instead of pretending the row is missing, and the button is rendered only on a
used row.

A "Clear N used" control alongside, since the complaint was clutter and clearing
them one at a time is not much of an answer. Same rule — nothing unused or
revoked is touched — and it confirms first, because it is still a delete.

The bulk route is declared before /invites/:id, or Express reads "used" as an id.

Verified against the live database: deleting an unused invitation is refused and
the row survives, deleting a used one works, the bulk clear removes only used
ones, and the unused probe row was still there afterwards.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-11 20:30:06 +02:00
Daniel
22683f3584 feat: sign in with a code emailed to you, offered beside the password
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 46s
Forgejo Docker Build / Root app tests (push) Successful in 56s
Forgejo Android APK / Build signed APK (push) Successful in 2m6s
Forgejo Docker Build / Build Docker image (push) Successful in 15s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
The sign-in screen asks for an email first, then offers both ways in together:
a six-digit code sent to that address, or the password. Beside rather than
instead — a code depends on mail being delivered and a password does not, so
neither may be the only route. "Use a different email" goes back a step, and
creating an account stays where it was.

What keeps it from being a second, weaker front door:

- Only a bcrypt hash is stored, so a code read out of the database is not a
  working credential.
- Ten minutes, single use, marked used before the session is issued so a replay
  cannot race it, and requesting a new one deletes the old.
- Five wrong guesses burn it. Six digits is a million possibilities, which is
  plenty against a person and nothing against a script with unlimited tries.
- Requesting a code answers identically whether or not the address exists, and
  every verify failure returns one message. A sign-in screen that says "no such
  account" is a way of finding out who has one.
- Two-factor still applies: a code proves you can read the mailbox, which is one
  factor, and an account that asked for a second still wants it.
- Its own rate limits, tighter for requesting than for attempting, because
  requesting sends mail to someone else's address. These had to be separate
  limiters: Express matches app.use paths on segment boundaries, so
  /api/auth/login does not cover /api/auth/login-code — checked against a real
  router rather than assumed.

Two bugs found while building it, both mine:

authFetch keeps an allowlist of endpoints callable with no verified owner and
rejects everything else before it is sent. The new endpoints were not on it, so
the request never left the browser and surfaced as "Connection error".

reveal() hid elements by appending 'hidden' to className and showed them with a
non-global replace, so hiding twice left two copies and showing stripped one.
The "use a different email" link never reappeared. It uses classList now, which
is idempotent.

Verified against the running server: correct code signs in, the same code again
is refused, a superseded code is refused, five wrong guesses burn it, an expired
one is refused, and the stored value is a hash. In the browser: requesting a
code advances the screen, a wrong code is refused without losing the screen, and
the password route still signs in.

Not yet demonstrated: a correct code typed into the browser. The harness keeps
racing the one-live-code rule — the page's own request supersedes whatever code
the test holds, and with SMTP off the delivered one cannot be read. The same
request reaches the server on the wrong-code path, and the endpoint itself is
verified, but that last step is untested end to end.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-11 20:12:03 +02:00
Daniel
087f717f55 feat: the model designs the deck instead of writing markdown for a parser to guess at
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 59s
Forgejo Docker Build / Root app tests (push) Successful in 53s
Forgejo Android APK / Build signed APK (push) Successful in 2m2s
Forgejo Docker Build / Build Docker image (push) Successful in 8s
Forgejo Docker Build / Deploy to the host (push) Failing after 2s
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" — my parser inferred a layout from
the shape of a list, and inferring is what made every deck look the same.

A presentation is now described as a deck: the model returns JSON naming a
layout per slide and the prompt it wants each figure drawn from. Four layouts
were added to the renderer for it — two tinted labelled columns for a
comparison, a callout card for a red flag or a dose, a figure beside its
bullets, and a full-slide figure. Articles stay markdown, which is what prose
wants.

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 throw away exactly the layout choices this
was built to capture. A resource made before this, or an article forced into
slides, still renders by inferring from its markdown.

Nothing here can cost more than the thing that went wrong. A reply that is not a
deck falls back to asking for markdown rather than saving the model's apology; a
malformed slide degrades to bullets rather than throwing; a comparison with one
column is not a comparison; a figure that cannot be queued leaves a slide of
text rather than an empty frame; and JSON wrapped in fences or a covering
sentence is read rather than refused.

Verified live on "croup versus epiglottitis": the model chose section, bullets,
table, compare, figure, callout and image layouts across thirteen slides, and
the exported deck was rendered to PDF, rasterised and looked at — the comparison
renders as two tinted cards, the red flag as a callout, and the figure sits
beside its bullets.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-11 19:49:55 +02:00
Daniel
73ce4049d4 feat: decks are built with python-pptx instead of pandoc, and carry their figures
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 48s
Forgejo Docker Build / Root app tests (push) Successful in 59s
Forgejo Android APK / Build signed APK (push) Successful in 2m6s
Forgejo Docker Build / Build Docker image (push) Successful in 25s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
Pandoc's pptx writer was the ceiling on how good a generated deck could be, and
the model on top made no difference to it. It maps markdown onto a handful of
reference layouts with no per-slide layout, no positioning and no control over
how large an image is drawn, which is why every deck came out as bullets on a
template — and why autofit had to be injected into its emitted OOXML by hand
afterwards, because LibreOffice ignores the autofit pandoc leaves off.

scripts/render_pptx.py draws the deck and src/utils/slideSpec.js decides what
each slide is. Markdown stays the stored artifact, so "change slide 4" is still
a text edit and Word export is untouched — pandoc still writes docx, where its
output is good.

What that buys, all of it visible in a rendered deck rather than argued for:

- 16:9, not pandoc's 4:3.
- A pipe table becomes a real table with a header band and banded rows, not
  eight lines of text with pipes in them.
- A list longer than seven items becomes two columns instead of a wall of text.
- Text is measured and sized to fit before the file is written, so nothing
  depends on a renderer honouring autofit.
- Wrapped lines hang under the text instead of running back to the margin,
  which is the clearest single tell that a deck was generated.
- An image is drawn at its own aspect ratio, centred, with a caption.

Figures now reach the deck at all, which they never did. They were queued and
shown on the page, but nothing recorded that they belonged to the resource, so
an export could not include them: user_resources.image_ids holds them, a
modification adds to that list rather than replacing it, and export fetches the
finished ones to a scratch directory. They are spread through the deck rather
than appended, because ending on three unexplained pictures is worse than
showing each near its material, and a References slide stays last.

If the renderer fails for any reason, pandoc still produces a deck — a plainer
deck beats a failed download.

Verified end to end: a seven-slide request with three figures exported as a
13-page deck; the slides were rendered to PDF, rasterised and looked at. All
three formats still download.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-11 19:34:26 +02:00
Daniel
1ad72b134b feat: a resource can have several illustrations, on its own path
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 54s
Forgejo Docker Build / Root app tests (push) Successful in 48s
Forgejo Android APK / Build signed APK (push) Successful in 2m22s
Forgejo Docker Build / Build Docker image (push) Successful in 12s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
My Resources used imageTool.dispatch, which permits exactly one image per
request — "Only one image tool invocation is permitted per request". That is the
right rule for a chat reply and the wrong one for a twelve-slide deck where the
author asked for three figures. Rather than relax a limit the clinical assistant
and the Learning Hub also depend on, this adds a separate dispatcher for this
feature. Same queue, same storage, same my_resources workflow, same asset
endpoint — only the number of figures differs, bounded at six because each one
is a paid request.

"Use 3 diagrams" in the instructions is read as the number it is, and the
illustration option now says several are possible rather than promising one.

Three things had to be got right, each found by measuring rather than assuming:

The illustration guidance has to be the last thing in the prompt. Placed before
the output rules it lost — with the tool offered and the paragraph present, the
model returned 3297 characters of markdown and zero tool calls, while the same
tool and wording in a shorter prompt produced three calls.

Even last, it loses to a prompt carrying thirty library excerpts:
deterministically, with the library off "use 3 diagrams" made three calls and
with the library on it made none and wrote a longer deck instead. So when the
author names a number the call is required rather than merely offered. With no
number named the choice stays the model's.

And a model that has just made three tool calls tends to sign off instead of
writing: "I'll create the presentation and the three teaching diagrams." was
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 not a Pandoc
document whatever its length, and if the continuation is no better than the
first attempt, whichever actually reads like a resource is kept.

Verified end to end with the library on: generate produced three figures and an
eight-slide deck; modify added two more figures and a ninth slide. The figures
were fetched and looked at — labelled airway anatomy, and a croup/epiglottitis/
bacterial tracheitis comparison.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-11 19:19:22 +02:00
Daniel
055a86bbb1 feat: My Resources says what it is, offers its sources in one place, and Modify gets them too
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 55s
Forgejo Docker Build / Root app tests (push) Successful in 47s
Forgejo Android APK / Build signed APK (push) Successful in 1m59s
Forgejo Docker Build / Build Docker image (push) Successful in 18s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
The screen had been trimmed to controls with no explanation of what it was for;
"Private to you" said who could see it, not what it did. It now opens with a
sentence that says what you get and how you get it out, and the header reads
"Only you can see these".

The four choices — clinical library, PubMed, web, illustration — are one "Draw
on" group instead of four separate rows, with the library ticked by default.
Each option hides itself when an administrator has not enabled it, so nothing
appears that a person could tick and then be refused.

Modify offers exactly the same choices. It had none, so "add what the 2024 trial
showed" was answered from the model's memory rather than by looking anything up.
Generate and Modify now go through one gatherSources(), so they cannot drift
into offering different things or searching them differently.

Writing "include a diagram of the airway" in the instructions now switches the
illustration option on and says why, rather than the request being dropped in
silence. Switching it off by hand sticks — the hint then reminds instead of
fighting — and when no image model is configured it says so rather than
pretending. Both the generate and modify boxes behave this way.

Two things found by testing this rather than assuming it.

PubMed ANDs every mapped term, so one unrecognised word takes the query to zero.
"febrile seizures" returns six results and "febrile seizures in under-fives"
returns none; "the anatomy of croup: subglottic narrowing and the steeple sign"
returned none until it was narrowed to "anatomy croup", which returns six. A
query that finds nothing is now retried against progressively shorter versions
of itself, longest first, and the response says which query actually worked so
the screen cannot report one that found nothing. Those extra calls tripped
NCBI's three-a-second limit and produced a 429, so retries are spaced and the
first attempt waits for nothing. Separately, the searches run on the topic while
the library retrieval also gets the instruction: retrieval is semantic and
benefits from the context, but a keyword engine handed a whole sentence returns
nothing.

And when a search was asked for and came back empty, the prompt now 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 looks exactly like a real one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-11 18:54:17 +02:00
Daniel
05dcd1146d test(e2e): repair the harness, taking the browser suite from 96 failures to 11
Three separate reasons tests were failing, none of them a defect in the app.

The calculators. e2e-harness.html loaded calculators.js and drugs-loader.js with
`defer` after they were split into ES modules; index.html was updated at the
time and this page was not. A module parsed as a classic script throws "Cannot
use import statement outside a module" before a line runs, so no click handler
was ever attached: the pills rendered from static HTML and did nothing. Only the
first calculator appeared to pass, because it carries `active` in the markup and
needs no click. That was 52 failures.

Settings and FAQ. Both moved from the tab rail into the account-card menu; the
helper still clicked button.tab-btn[data-tab=…] and timed out. Ten more.

The AI mocks, which had stopped intercepting for two independent reasons and so
were calling the real model on every run — spending credits and comparing
genuine output against strings like "MOCK HPI from dictation". A '**/api/x' glob
matches no URL on Playwright 1.50, and page.route fails silently when nothing
matches; measured against a real URL, that glob and '*/**/api/x' both matched
zero times where a regex matched. Fixing that alone was not enough: the app
registers a service worker that answers every /api/ request with its own
fetch(), and a request made inside a service worker never reaches page.route.
Blocking registration in the config puts them back in the page. The mocked
dictation test now finishes in 1.6s rather than 7.5s, which is what a real model
call costs.

Whole suite: 204 passed / 96 failed in 15.8 minutes, now 289 passed / 11 failed
in 6.8. The remaining eleven are spread across nine specs with no shared cause
and are not touched here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-11 18:54:03 +02:00
Daniel
5577ec301c refactor: cut the explanatory copy from the sign-in screen and My Resources
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 55s
Forgejo Docker Build / Root app tests (push) Successful in 55s
Forgejo Android APK / Build signed APK (push) Successful in 2m3s
Forgejo Docker Build / Build Docker image (push) Successful in 19s
Forgejo Docker Build / Deploy to the host (push) Failing after 2s
The HIPAA notice is gone from the auth screen, along with the CSS rule that had
no other user. The My Resources intro paragraph and the helper paragraphs under
each checkbox are gone too; where a paragraph carried something the reader
actually needed, it moved onto the control itself — the illustration checkbox
now says what it does and where to steer it.

Two sentences were kept rather than deleted, both about data leaving the
building: that a web search query leaves this network and must not carry
anything identifying, and that a PubMed query goes to NCBI. Trimmed to one line
each. Say the word and they go too, but a clinical tool should not quietly send
a query off-network with nothing on screen about it.

The three tests that pinned the removed sentences now assert the behaviour
instead: that the header still claims privacy, and that the illustration
checkbox is genuinely unchecked in the markup rather than merely described as
off by default.

Verified as rendered: neither the sign-in nor the sign-up screen mentions HIPAA,
the invite-code field is visible, and the generator card is down to its controls
plus the NCBI line.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-11 17:57:07 +02:00
Daniel
8cca5a4796 feat: instructions can ask for the illustration; library scrolls and searches; Modify
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 50s
Forgejo Docker Build / Root app tests (push) Successful in 48s
Forgejo Android APK / Build signed APK (push) Successful in 2m4s
Forgejo Docker Build / Build Docker image (push) Successful in 12s
Forgejo Docker Build / Deploy to the host (push) Failing after 2s
Three things to My Resources.

Illustration was entirely the model's call: someone who wanted a figure of
something particular had no way to say so, because the instructions steered the
prose and nothing else. The illustration guidance now tells the model to follow
the author's instructions when they ask for a figure or name what it should
show, and to treat that as the decision already made. Verified live: "include a
diagram showing the age distribution and the simple-versus-complex distinction"
produced exactly that, both halves in one figure.

Exactly one image per generation is a real limit, not a wording choice — the
shared imageTool dispatcher rejects more than one tool call per request, and it
is used by the assistant and Learning Hub too. So the prompt says to draw the
single most useful one if several are asked for, and the screen says the same.

The library was an unbounded list that pushed everything below it off the page.
It is now a 360px scrolling box with a search over title and topic, filtered
locally because the rows are already in hand. "Nothing yet" and "nothing
matches" are different messages, because telling someone whose search missed
that they have never generated anything is wrong. Measured in a real render:
360px visible of 642px of content, and searching narrows 10 rows to 3.

Modify is new UI over the refine endpoint, which existed with no way to reach
it. Pick a resource, say what to change, and it is rewritten in place keeping
its id, its downloads and its References section. The picker is built from the
same library array, so it cannot drift, and a selection survives the refresh
that follows a generation. Verified live: "add a Key Takeaways slide before
References" inserted exactly that and left the other four slides alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-11 17:49:27 +02:00
Daniel
79c329ceda test(e2e): seed an admin account, and fix the sign-in that broke the browser suite
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 55s
Forgejo Docker Build / Root app tests (push) Successful in 48s
Forgejo Android APK / Build signed APK (push) Successful in 2m0s
Forgejo Docker Build / Build Docker image (push) Successful in 16s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
Adds the admin fixture the Search Sources screen needed, and repairs the reason
no browser-driving e2e test could log in at all.

The sign-in failure first. The suite drove the app over http on a container
hostname, which is not a secure context, so the browser provides no
crypto.randomUUID. AccountBoundary calls it to mint a session generation on
every sign-in; the call threw, the boot handler's catch swallowed it, and every
test landed on the login screen holding a perfectly valid session. Measured:
isSecureContext false and randomUUID undefined on
http://pediatric-ai-scribe-e2e:3000, both true on http://127.0.0.1:3553, where
boundary.enter() returns true and the app enters.

Chrome's --unsafely-treat-insecure-origin-as-secure was tried first and does not
work: Playwright rejects the --user-data-dir it must be paired with, and the
flag alone leaves isSecureContext false. Loopback needs no flags, so the runner
now uses the host network and the published port.

The seed is new. The e2e user was a registration someone did by hand once that
the shared Postgres happened to keep — enough to log in and no more. There was
no admin account, so nothing under /api/admin could be tested through a real
request, which is how the Search Sources card came to be verified by reading its
markup. e2e/seed.js creates both accounts and reconciles an existing one, so a
leftover with the wrong role cannot fail the suite for a reason unrelated to the
code. It resets passwords and grants admin, so it refuses any address outside
@ped-ai.test. The runner seeds before it tests.

The new spec covers what markup-reading could not: that an ordinary account is
refused the settings and never offered the Admin menu item, that no API key
comes back readable, that the Test button reports each source separately, and
that every control the save handler reads exists in a real render. Each account
gets its own browser context, because AccountBoundary allows one owner per
document and freezing the page on a second is the behaviour, not a bug.

10/10 pass on both projects. Two unit tests pin the loopback requirement and the
seed's domain guard so neither can be undone quietly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-11 17:24:32 +02:00
Daniel
1270899dcb feat: PubMed search for My Resources, and an image tool that actually fires
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 46s
Forgejo Docker Build / Root app tests (push) Successful in 47s
Forgejo Android APK / Build signed APK (push) Successful in 1m56s
Forgejo Docker Build / Build Docker image (push) Successful in 18s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
PubMed joins web search as an optional source for a generated resource: a
literature search on the topic, with abstracts, cited by PMID in References.
Off by default, admin-enabled, with its own optional API key (NCBI raises the
rate limit from 3/sec to 10/sec; it works without one).

Neither search is a tool any more, and that is the point. Offering them as
function calls meant the model decided whether to search, and with a prompt
ending "Output ONLY Pandoc markdown" it decided not to — every time, with and
without corpus grounding, no matter how the tool description was worded.
Calling callAI with the tool directly produced a correct pubmed_search 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, so both searches now run up
front and their results go into the prompt as findings, exactly the way corpus
excerpts do. Ticking the box now means the search happened.

Verified live against deepseek-v4-flash: 30 corpus excerpts and 6 PubMed
results, and a References slide carrying both the library sources and four real
PMIDs (29562151, 38506440, 35721052, 28814254).

Three fixes to illustration, which had never once fired:

- The dispatch call had been lost in a refactor. The tool was still offered, the
  model still called it, and the call was dropped, so no job was ever enqueued.
- imageContext was passed as a bare topic string where dispatch expects
  { request, history }, which made the bound request undefined.
- The prompt never mentioned the tool existed while explicitly demanding only
  markdown — the same suppression that killed the searches. It now says an
  illustration is available and that calling it is not a violation of that rule.

my_resources is its own image workflow rather than a reuse of learning_hub,
because generated_image_links only accepts learning_hub assets, and that is
exactly the barrier that keeps a private illustration out of published content.
The illustration renders in the panel, rather than a toast pointing at an image
history this feature does not have.

Verified end to end: job queued, rendered, and the asset served to its owner as
a correctly labelled subglottic-anatomy teaching diagram.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-11 16:47:08 +02:00
Daniel
60556aae28 config: the clinical assistant answers from 12 excerpts
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 47s
Forgejo Docker Build / Root app tests (push) Successful in 48s
Forgejo Android APK / Build signed APK (push) Successful in 1m59s
Forgejo Docker Build / Build Docker image (push) Successful in 18s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
It asked for 8 while the reranker capped everything at 12, so 8 is what it ever
actually received — and its answers were judged good on that. Now that the cap
defers to the caller, 12 is the number worth asking for: it is what the corpus
has been tuned against, and the reranker still decides which 12.

Verified live: the assistant returns 12 sources.

Also records PubMed as a search source of its own rather than a provider option
under web search. It returns structured records — title, journal, year, PMID,
abstract — so a reference can be exact instead of reconstructed from a page
title, and a model should be able to reach for "the literature" distinctly from
"the web".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-11 16:13:55 +02:00
Daniel
571a013d29 feat: optional web search, admin-enabled and off by default
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 47s
Forgejo Docker Build / Root app tests (push) Successful in 47s
Forgejo Android APK / Build signed APK (push) Successful in 2m9s
Forgejo Docker Build / Build Docker image (push) Successful in 11s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
The one feature here that sends text outside the building, so the defaults are
the careful ones: disabled unless an administrator turns it on, opt-in per
generation even then, and the option is hidden entirely rather than shown as
something a user can tick and be refused.

Only the search query leaves. Library excerpts, the generated resource and
anything about the user never do. Both screens say so plainly, because a topic
typed while drafting clinical material can carry clinical detail and the
provider keeps its own logs.

Four providers behind one shape, so swapping changes nothing downstream: Tavily,
Serper over Google, Brave, and SearXNG — the only one where the query does not
reach a commercial third party at all, which is why it is worth supporting even
though it needs somewhere to run.

The tool description says when NOT to search, because a model handed a search
tool will reach for it constantly: not for settled clinical knowledge, which is
what the indexed library is for, and one search per resource. That last one is
enforced in the route with toolChoice: 'none' on the continuation rather than
trusted to the model.

A failed search never fails a generation — same contract as corpus retrieval.
The resource is written without it and the response says what was searched for
and what came back, so a query that left the network is visible rather than
silent.

The API key is masked on read and preserved when the field is left blank, the
handling the OIDC client secret already gets, so changing provider cannot
silently wipe a working key.

Verified on the running instance: with nothing configured, webSearchAvailable is
false, and a request asking for it anyway is ignored rather than honoured.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-11 15:47:06 +02:00
Daniel
7eca509b02 fix: slides shrink to fit, and an article is never offered as slides
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 49s
Forgejo Docker Build / Root app tests (push) Successful in 47s
Forgejo Android APK / Build signed APK (push) Successful in 2m0s
Forgejo Docker Build / Build Docker image (push) Successful in 15s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
A generated deck was losing content. pandoc writes a bare <a:bodyPr/> on every
shape, which leaves the body with no autofit even though the slide master has
one, so a slide with too much on it is cut off mid-sentence and the rest is not
rendered at all. Reproduced and counted: eight bullets went in, three came out,
the third ending mid-word.

Every generated deck now carries <a:normAutofit/> on its body placeholders. No
fontScale, deliberately — the renderer works out the reduction, so a slide that
already fits is untouched, where a fixed scale would shrink all of them. The
same eight bullets now fit with nothing in the bottom 6% of the slide.

This is a floor, not a licence to overcrowd. The prompt still asks for one idea
per slide; this stops a long one becoming unreadable.

Also: an article is no longer offered as PowerPoint. A deck of paragraphs is not
a presentation. Word and PDF suit either kind, and the route refuses the
combination rather than relying on the button being absent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-11 15:34:16 +02:00
Daniel
efa84ac0e6 docs: one knob per feature, and record how many excerpts each gets
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 47s
Forgejo Docker Build / Root app tests (push) Successful in 47s
Forgejo Android APK / Build signed APK (push) Successful in 2m3s
Forgejo Docker Build / Build Docker image (push) Successful in 10s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
Answering a fair question: needing two settings in two repositories to agree
before one number changes is not a design anyone chose. It is two codebases each
assuming it owned the decision, and the symptom was a caller asking for 30
excerpts and silently receiving 12.

rerank_results computes min(reranker_top_k or limit, limit), so RERANKER_TOP_K=0
reads as "however many the caller asked for". The app setting is now the only
knob. Verified: Learning asks 30 and receives 30, the assistant asks 8 and
receives 8.

Zero costs nothing extra — the reranker is billed on documents sent, which is
candidate_limit and unchanged; top_n only decides how many come back. A real
number there is now what it should always have been: an optional hard ceiling
for when someone deliberately wants one, not an invisible default.

docs/retrieval-tuning.md covers the per-feature budgets, why the assistant's are
so much smaller than Learning's, that My Resources deliberately shares the
Learning budget, how to read what actually happened from the MCP logs and the
grounding field, and why raising these is not free.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-11 15:28:01 +02:00
Daniel
fac8757ce8 feat: My Resources has a menu, a library and three downloads
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 56s
Forgejo Docker Build / Root app tests (push) Successful in 48s
Forgejo Android APK / Build signed APK (push) Successful in 2m13s
Forgejo Docker Build / Build Docker image (push) Successful in 14s
Forgejo Docker Build / Deploy to the host (push) Failing after 1s
The pathway existed but was reachable only by API. It now has a tab of its own
next to the Learning Hub — related, not the same thing, and sitting together is
how someone discovers the difference — visible to every signed-in user with no
role gate in the markup.

Generate a deck or an article, see everything you have made, download each as
PowerPoint, Word or PDF, delete what you no longer want. The screen says
"Private to you" and "Nobody else sees these", because the distinction from
published Learning content is the thing a person needs to understand before
typing a patient's condition into it.

Downloads are fetched rather than linked: an <a href> cannot carry the
Authorization header. The blob is saved under the filename the server chose and
the object URL is revoked afterwards. Resource titles come from a model, so rows
are built as elements and a title is only ever assigned to textContent.

The e2e stack now joins danvics_convert too. It could previously reach only
Postgres and Redis, so a PDF download failed there in a way production would
not — which did at least prove the degradation path works: with Gotenberg
unreachable the response is "PDF conversion is unavailable right now. PowerPoint
and Word still work", and the other two formats download unaffected.

Verified in a browser as an ordinary user: the tab appears and opens, the form
swaps slide count for word count when the format changes, the library lists
their own work, and pptx, docx and pdf all download with sensible filenames
(36360, 13285 and 68310 bytes).

Also documents retrieval sizing in docs/retrieval-tuning.md — the per-feature
budgets, and RERANKER_TOP_K, which caps all of them and had until now appeared
in no configuration file at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-11 15:00:19 +02:00
Daniel
a0d81789ff feat: My Resources — anyone can generate teaching material, privately
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 49s
Forgejo Docker Build / Root app tests (push) Successful in 47s
Forgejo Android APK / Build signed APK (push) Successful in 2m2s
Forgejo Docker Build / Build Docker image (push) Successful in 9s
Forgejo Docker Build / Deploy to the host (push) Failing after 2s
Learning is moderator-owned: content published into categories that everyone
sees, behind router.use(moderatorMiddleware). That is right for institutional
material and wrong as the only way in — an ordinary user could not generate
anything at all.

So this is a separate pathway rather than a loosening of that one. Learning is
untouched; the moderator gate stays exactly where it was. A signed-in user can
generate a deck or an article for their own use, keep it, refine it and export
it, and nobody else ever sees it.

Private by construction. Every statement filters on the owner and there is no
route that returns another person's work, which a test asserts statement by
statement rather than trusting. The foreign key cascades, so deleting an account
takes its drafts with it. There is no category, no publish state and no sharing:
adding sharing later should be a deliberate feature, not something that leaks
out of a forgotten WHERE clause.

Markdown is the artifact. Every format is rendered from it on demand — pptx and
docx by pandoc, both carrying the house reference deck, and PDF by Gotenberg,
whose LibreOffice preserves a deck's layout in a way rendering from markdown
would not. That is what makes "add a slide on when to admit" a text edit rather
than a binary patch.

Gotenberg was published on the host but on a network of its own, so reaching it
from a container went out and back through the host gateway. It now joins
danvics_convert, owned by danvics-net like the others. PDF is the one export
allowed to fail: if that service is down, the deck and the document still
download and the error says which.

Verified end to end as a plain user: the moderator route still refuses with 403,
generation returned a deck grounded on 12 corpus excerpts, the library lists
only their own, pptx/docx/pdf all downloaded valid, "add a slide on when to
admit" put the slide in the right place and left References last, and an
unauthenticated request gets 401 while someone else's id gets 404.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-11 14:50:54 +02:00
Daniel
99a72734fb feat: the Learning screen can ask for grounding, and says what it got
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 48s
Forgejo Docker Build / Root app tests (push) Successful in 48s
Forgejo Android APK / Build signed APK (push) Successful in 2m20s
Forgejo Docker Build / Build Docker image (push) Successful in 19s
Forgejo Docker Build / Deploy to the host (push) Failing after 2s
The route accepted useCorpus and reported grounding, but nothing in the admin
screen sent the flag or showed the result — so the feature existed and was
unreachable.

Opt-out in the UI rather than opt-in. For clinical teaching the library is
nearly always the right source, so someone who never notices the checkbox
should get the grounded version. The help text explains when to turn it OFF,
which is the non-obvious case: a topic the library does not cover is better
written without grounding than padded with the nearest unrelated excerpts.

Afterwards it says what happened — "Written from 12 library excerpts", or "Not
grounded — nothing indexed matched. Written from the model alone." Ungrounded
material presented as grounded is the failure worth preventing here, so the
wording never implies the library was used when it was not.

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