A pathway or an algorithm had no slide of its own and came out as bullets.
"flow" is a slide of two to eight steps, each a short phrase with an
optional note; the exporter lays it out — across for up to four, down for
more — as rounded boxes joined by arrows in the shape vocabulary the
renderer already draws, so the model is never asked for coordinates and
Python gains one small builder. Word gets the steps as a numbered list.
The theme sample shows one, so every template demonstrates it.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
A PowerPoint or Word file cannot be shown in a browser and a phone has
nowhere to open one. Preview renders the resource the way its download
would be built, turns it into one PNG per page (Gotenberg to PDF, pdftoppm
to pages), and shows the pages in an overlay that scrolls and pinch-zooms
like anything else. Rendered once per version — updated_at and theme are in
the key — and served from disk afterwards. The theme picker's sample deck
has the same Preview beside its download.
Every presentation now shows the theme picker in the library, since
markdown slides take a theme too.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
[1] early in an answer and then "[2][1]" later showed "2 1". A run of
adjacent chips is now sorted by what it displays — a core rule after inline
parsing — so it reads "1 2". Text between two clusters keeps them apart,
every chip still points at its own source, and the numbering itself is
unchanged: only the order within a run moves.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
The account boundary reloads a tab when the shared session changes under
it — the guard against one account's tab showing another account's data.
A second tab signing in as the same person is a new session, not a new
account; it is adopted, and the reload is kept for a different owner or
none. "Your account session changed. Reload to continue safely" now means
what it says.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
nextcloud.url (or NEXTCLOUD_URL) names the site's Nextcloud. With it set,
the address field is hidden and the page reads "Sign in with Nextcloud",
with the app-password route underneath for whoever needs it; both routes
use the default when no address is given. Without it, nothing changes.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
Topic is one line and Instructions say how; neither is the place for a list
of twelve topics or a case to build around. Details is: optional,
collapsed by default, up to 12,000 characters, quoted into the prompt as the
author's own account of what the resource must cover — material, not
commands, in both the deck and the markdown shapes.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
Signing in is a code from the PedsHub sign-in page, not a password; the
citation chips are numbered by first appearance; voices come from every
model the administrator added; the Learning Hub section — a feature that no
longer exists — is replaced by My Resources: decks, themes with sample
downloads, Modify, and the download formats. The two-factor and
change-password entries go with the passwords they described.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
Both PedsHub apps now take admin and moderator from the same two Authentik
groups. oidc.admin_groups and oidc.moderator_groups name them; unset means
local roles stand. Applied at every sign-in so removal at the SSO demotes
here, and never applied to the last admin — a group edit must not be able
to lock everyone out of the panel.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
The app has no pages and is not getting any. Another site can still send
someone straight to a tab by naming it in the hash: it is read once at
load, stored where sign-in looks for the last tab, and dropped from the
URL, so it survives the round trip through the SSO and does not stick to
the address bar. #resources, #deck and #decks all mean My Resources.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
Three generations in a row fell back to plain slides for the same reason,
visible once a failed reply was kept whole: the model wrote "rows":[[…]]}
and then ,"notes":"…"} — the slide closed before its notes, which is not
JSON. On parse failure the one premature brace is removed, bounded to the
schema's slide keys; a brace that legitimately closes an inner object is
left alone. Both test generations now come back as designed decks.
Modify used to hand the model's new deck back without its theme; the look
is the author's, so it is carried over, and the column agrees.
Failed deck replies are kept under data/logs/deck-failures for the next
time "the reply was not a deck" needs reading rather than guessing.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
"Change template" answered "no slide layout" for 28 of the 41 presentations
in production: the theme lived only inside the deck JSON, and a presentation
whose deck reply failed twice and fell back to markdown slides had nowhere
to keep one. The theme is a column now, written at generation and by the
picker, and the markdown slide builder carries it to the same renderer field
a designed deck uses. A deck's own theme field is kept in step. Articles are
the only thing refused — they have no slides.
A deck reply that fails to parse is logged with its first 240 characters, so
the next "the reply was not a deck" can be read rather than guessed at.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
The same snippets sent DeepSeek into the same 26,000-character reasoning
spiral three times running, each ending in an empty reply at the token
ceiling. A retry now rotates the snippets. Production sets
clinical_assistant.prompt_model to gpt-4.1-mini, a model that does not
think about a list of questions; the setting is now documented with why.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
Two batches in five came back as a 200 with an empty message — nothing in
the gateway log, nothing in ours. callLiteLLM now says when that happens
(finish reason, completion tokens, whether the tokens went into reasoning),
and the starter-question build tries such a batch once more instead of
writing the category off.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
Cases are three to four times the length of headings, and 30 of them did
not fit the 2,600-token ceiling: the JSON was cut mid-list, failed to parse,
and whole categories came back with nothing kept. The ceiling is 7,000,
batches are 20, and a reply that is still cut off yields every question that
finished rather than none. Each batch logs what it offered.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
app.get(['/', '/index.html', '/assistant']) reached the OpenAPI generator as
one route whose path was the array, joined with commas; the e2e reachability
check then probed "/,/index.html,/assistant" and found a 404. Each path is
now its own route, and routes outside /api/ are left out: the document
describes what a client calls, and a client does not call index.html.
The starter-question pool logs when a build starts, what each category kept,
and how long it took, so a build that produces nothing can be traced.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
The pool read like a chapter index — "What red flags in a child's headache
history warrant investigation?", "Which clinical scores are useful for
asthma?". The prompt now asks for what a clinician types with a patient in
front of them: a one-line vignette with an age and at least one number,
then the one decision to make now. The filter enforces it: a number is
required, textbook openers are refused, and the length cap makes room for a
case.
A pool carries the prompt version it was built with. One from an older
version is served as before and rebuilt in the background, so a prompt
change reaches the screen without anyone pressing Regenerate.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
Two apps, two logos after all: the stethoscope stays here, the hospital
stays on the quiz. What the last change got right stays too — the login
card without a heading over a single button, the icon as an image in the
card and the menu — and the name now matches the SSO tile.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
The scribe wore a stethoscope and called itself PedAI while the quiz app
was PedsHub; one product, two identities. Both now use the PedsHub icon.
The login card shows the icon, the name and the tagline and loses the
"Sign In" heading that sat above a single button; the menu bar shows the
icon at 20px beside the name, since the full wordmark did not fit there.
Favicons and the PWA icons are rendered from the same SVG.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
Sign-in is email → code at sso.pedshub.com, and new accounts come from an
invitation link minted there, so the app's own code emails and invite codes
recorded a path nobody can take. Gone: the login-code routes and their rate
limiters, the invite admin API and card, the invite field on the register
form, the "email me a code / use my password" choice on the sign-in screen
(an email now leads straight to the password), both utility modules, and
the invite-only setting. A migration drops login_codes and
registration_invites.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
Numbers only, one line per streamed question, so "is the chat slow?" can be
answered from the log instead of from a feeling. Nothing about the request
is logged beyond the counts.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
One line of changing text is now a list the reader can follow: Analyzing
the question → Searching the clinical library → Found 7 sources → Writing
the answer, plus any step the server reports (looking at an image, drawing
one, completing a cut-off reply). Steps are added when the stream says work
began and ticked when it says it ended, so the list is a record of the real
work, not an animation on a timer; nothing delays the answer.
The one hand-off the server cannot signal — retrieval runs before the stream
opens — is done by the stylesheet, not a JS timer, so the autosave debounce
keeps its clock to itself.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
The export styles pinned the last column of every table to one unbreakable
line (width:1%; white-space:nowrap) — right for a numbers column, wrong for a
two-column clinical table whose second column is the whole finding: the
table grew to the width of its longest sentence and iOS then inflated the
text in those cells, so the "Finding" column read in a bigger font than the
"Feature" column. The pin goes, and the sheet opts out of text inflation.
With citations switched off the answer is still grounded in the retrieved
sources; only the markers are removed. The stripper now also removes the
escaped \[1, 2\] form and [src] placeholders, which used to survive as text.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
The tail — the block still arriving — was shown as raw text until its blank
line came, which is the flash of asterisks, pipes and brackets the reader
saw on every paragraph. It is now parsed each frame like the finished part
(Open WebUI never shows the flash because it re-renders the whole message
per token; ours re-renders one block). Two exceptions stay text: a block
inside an unclosed code fence, so a half-written diagram is not handed to
its renderer every frame, and a table header with no body row yet.
JS, CSS and component HTML were cached for an hour, and the assistant's ES
module imports carry no version query, so every open browser kept the old
citation renderer for an hour after the deploy that replaced it. They now
revalidate on every load (no-cache with the ETag), which is a 304.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
Discover lists speech models with the voices each accepts and a + Add that
puts the model on tts.roster. The Roster card lists every model with a voice
picker, Test, Make default and Remove. Test on any row (or a discovered model
not yet added) fills the test panel's voice list with that model's voices, so
Orpheus and Kokoro can be heard one voice at a time before either is chosen.
The default is a pair — PUT /config/tts/default sets tts.model and tts.voice
together and refuses a voice the model does not accept, naming the ones it
does. The generic setter no longer takes tts.model/tts.voice one at a time,
which is how a Kokoro voice got paired with Orpheus. A default that leaves
the roster stops being the default.
Users pick from the voices of every roster model, grouped by model in
Settings; the stored value is "model|voice" so read-aloud sends the voice to
the model that accepts it. A bare voice saved before there was a roster is
read as a voice of the default model. chooseTTS is the one place the pair is
decided, shared by read-aloud, the admin test and the settings options.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
The old renderer rewrote the text: it found "[n]" with regexes, renumbered
them, and swapped the result back in — which broke inside `arr[2][1]`, inside
HTML attributes, and whenever two turns disagreed about what "[3]" meant. It
also had a fallback markdown renderer of its own for when the rewrite
produced something markdown-it would not parse.
Now "[n]" is an inline rule registered on the same markdown-it instance that
renders everything else. The parser decides what is prose and what is code, a
link, or a URL, so the rule never sees "[1]" inside a code span, and it steps
aside for "[1](url)". Math is two more rules on the same parser instead of a
regex pre-pass, so "$" inside a URL is no longer math.
Identity vs display: the stored "[n]" and each card's id are the source's
identity (sourceNumber) and are never rewritten. The number a reader sees is
the order of first appearance, computed at render time from the token stream
(orderSourcesByCitation), so "one, then seven" cannot happen and a saved chat
re-opens pointing at the same cards it was saved with. Stored messages and
sources are untouched; export and the modal resolve by identity.
Translated HTML gets the same links through a TreeWalker over text nodes
(linkCitationsInHtml) rather than a regex over markup.
Deleted: renderCitationLinks, normalizeAdjacentCitationClusters, the
fallback renderer (fallbackMarkdown/renderMixedList/renderFallbackTable),
renderLatexText, CITATION_SCAN. Tests that asserted rewritten text now assert
token output; harnesses that render for real are given a parser.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
"These settings don't work" — picking a model and testing with a voice
returned 500 every time. The gateway said why, once asked directly:
voice must be one of the following voices: [autumn diana hannah austin daniel troy]
The screen was listing twelve Orpheus voices and six Kokoro ones in a
single flat list with no indication of which model would accept which,
because LITELLM_TTS_VOICES — written for one model — was treated as a
list that applied to all of them, and the Orpheus lists were pushed in
beside it. Choosing Orpheus and testing it with a Kokoro voice is not a
configuration; it is an error, and it was the default outcome.
LiteLLM cannot supply this. /model/info reports mode audio_speech for
all four models and carries no voice field for any of them. So the
mapping lives here, keyed by family so the gateway alias and the
upstream id resolve to one list, and every list was taken from the
provider rather than from documentation:
Groq Orpheus English autumn diana hannah austin daniel troy (stated by Groq)
Groq Orpheus Arabic abdullah fahad sultan lulwa noura aisha (stated by Groq)
Fish s2.1-pro alloy (alloy returns audio; the rest 400)
Kokoro sherpa/kokoro:* from LITELLM_TTS_VOICES (the gateway's own list)
The environment still wins for the model it was written for, so the
local gateway's voices can change without a code change — but it
answers for that model only. A model with no list at all is offered
nothing rather than another model's voices, and a voice known to belong
to a different family is refused.
There were two copies of this knowledge before: getLiteLLMTTSVoicesForModel
branched by family and fell through to the env list for any model it did
not recognise — which is how Fish came to be offered six Kokoro voices.
One table now.
Also in this commit: citation renumbering skips fenced code, inline code
and math, so arr[2][1] in a code block is never rewritten. Renumbering at
render time was tried and reverted — it also has to skip HTML attributes,
and every such region is another regex branch. It stays at the answer
boundary, and the saved-chat boundary is next.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
Revoked codes had a Revoke button and nothing else — the row stayed on
the list for good. The reasoning was that revoke stops a live code and
leaves the row behind as a record, which is right about revoke and wrong
about what comes after: a revoked code is already dead. It cannot be
redeemed by anyone, so keeping it only fills the list.
The rule that matters is unchanged, and it is the only one that ever
mattered: a code that could still be redeemed is never deletable.
Deleting one 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. Revoke is what stops
such a code; deleting it afterwards is the second half of the same
thought, and it was missing.
Also removed a note that had been wrong since codes became copyable:
"The code is shown once, here. Only its hash is stored, so it cannot be
read again afterwards." Both sentences stopped being true when the
cipher was added so an invitation could be handed to somebody later than
the moment it was made.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
Borrowed from the quiz app's AI Mode, where validating citations and
ordering them fall out of the same pass: it collects the sources an
answer actually used into an insertion-ordered map, so the list comes
back in first-citation order for free.
Ours listed sources in retrieval order — an order the reader never sees
and has no way to follow. An answer whose first citation was [7] opened
a list that began at [1], so matching a marker to a source meant
hunting. Reference lists in published writing are numbered by first
appearance for exactly this reason.
Cited sources now come first, renumbered by first appearance, and the
markers in the text are rewritten to match. Anything retrieved and not
cited keeps its place after them, labelled "not cited" — the panel is
also a view of what the search returned, which is worth keeping, but it
should not sit among the numbers the answer used.
The marker itself now shows its number instead of the word "src". Every
citation read identically, so the only way to tell one from another was
to hover it — which made the numbered list beneath useless to match
against. The export has shown numbers since the day "src" was
introduced, with no recorded reason for the difference.
Renumbering happens once the whole answer is known, never while
streaming: the order is the order of first citation, so a citation that
has not arrived yet cannot take its place, and numbers would shuffle
under the reader mid-sentence. The text is rewritten in a single pass —
number by number would turn 2 into 1 and then that 1 into whatever 1
maps to.
An invented citation reserves no position and is left exactly as it was.
It is still not turned into a link, and citation_audit still records it;
what matters here is that it cannot push a real source down the list.
Accuracy was already held: a marker with no matching source never
becomes a link. This changes what a reader can do with the ones that are
real.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
You could change an image model, scroll past it, and never learn there
was a button. The Availability card runs to about 1,300 pixels: the
image models sit above the slide reviewer, which sits above the Save, so
the fields and the thing that saves them were never on screen together.
The save row is now sticky. It pins to the bottom of the window while
you are anywhere inside its card and settles back into place at the end.
That class is already on four cards, so Clinical Assistant, Search
Sources, SMTP and OIDC get it too.
Two things were stopping sticky from working, and both were invisible:
.card sets overflow:hidden to clip its rounded header, and overflow
hidden on an ancestor silently disables position:sticky in every
descendant. clip does the same clipping without making the card a scroll
container.
Then #admin-tab sets overflow:auto as part of the tab shell — which
makes it the nearest scrolling ancestor for everything inside it while
it never actually scrolls, because the document does. bottom:0 resolved
against a box 7,700px tall, which is to say it did not stick at all.
Admin now opts out of that overflow the way #assistant-tab already does;
nothing in there needs the clipping, since every wide thing already
scrolls in a container of its own.
Checked in a browser rather than reasoned about: with the image fields
on screen the bar's bottom edge is at 820px in an 820px viewport.
Audited the other cards while here. Registration, Users and Discover &
test have no Save because they have nothing to save — they are actions
and search boxes. Roster's is called "Set default".
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
"The model is chosen per request, where the deck is generated" described
something that happens nowhere. There is no per-request picker, and
asking where it was is what exposed the real bug.
My Resources read clinical_assistant.image_model while the admin screen
saved my_resources.image_model. That setting was stored, returned by the
API and rendered into the form — and never used by anything. Somebody
noticed the field did nothing and disabled it rather than finding out
why, which left a control that could not be changed and a note
explaining a mechanism that does not exist. My note repeating it was
wrong too.
The generator now reads its own setting and falls back to the
Assistant's, so an install that only ever set one model keeps working
untouched, and the field is enabled again with "leave blank to use the
Clinical Assistant's" — which is now true rather than a rationalisation.
Saving also says what it saved. "Saved 8:31:59 PM. Decks will be
reviewed by ..." answered a different question from the one an admin
actually has, which is whether the model they just picked is the one
that will draw. It now names each workflow's model and fallbacks back,
and spells out the blank case rather than leaving a gap.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
Two things, both about not making someone redo work.
One key per provider. There was a single websearch.api_key shared by all
of them, so trying a different provider meant pasting a new key over the
working one and pasting the old one back to return — and the keys are not
interchangeable, so a wrong pairing fails as an authentication error that
reads like a dead provider. Each now has its own slot. The old shared key
is still read as a fallback: whatever was configured before this is the
right key for whichever provider was selected at the time.
The sign-in code email went out raw, while every other message this app
sends goes through emailWrapper — so the one mail a person receives while
locked out was the one that looked least like it came from us. It now
uses the same wrapper, and the body is built around the thing the reader
actually needs: the code, alone, large, monospaced so a 0 cannot be read
as an O, in a box of its own.
It also names the address it signs into. A code arriving at a shared
mailbox, or to someone with two accounts, is otherwise a number with no
indication of what it opens — and that line is the one thing that lets a
person notice a sign-in they did not start. The address is escaped; it is
the only part of that mail that did not come from us.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
PubMed read "Disabled" after every refresh while the database said true,
and the Test button reported the truth — so the card was showing its
defaults, not its settings.
The browser said why, once asked:
[Admin] Settings load failed:
ReferenceError: loadWebSearch is not defined
This file is a series of bare { } blocks, and a function declared in one
is not in scope in another. loadWebSearch lives in the block at 322 and
was called from the settings loader in the block at 42. Worse than not
running: the call sat inside that loader's .then(), so it threw and took
the rest of the handler with it.
It now loads itself on tabChanged, which is what every other block in
this file already does.
There is a comment at the top of loadAdmin saying loadOidcConfig cannot
be called from there for exactly this reason. Someone met this hazard
before; the web search call was added later without seeing it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
Two unrelated small things in the admin.
Exa searches by meaning rather than keywords, which suits a clinical
question asked as a question — the shape the assistant actually sends.
It is also the only provider that returns the page text in the same
call, and the snippet is the part the model reads, so asking for it
there saves a second fetch per result. type is 'auto' rather than
'neural': pinning neural makes it worse at the keyword-shaped queries
the other four handle well.
One adapter, one dropdown entry, no other change — the route already
validated against webSearch.PROVIDERS, so the backend accepted it the
moment the list grew. A test now walks both lists in both directions: a
provider the server takes but the admin never offers is unreachable, and
one the admin offers but the server rejects is a setting that cannot be
saved.
The speech and transcription buttons said "Set". Directly beneath them
is a Roster card headed "What has been added", and Set is the one thing
that does not add anything — there is a single voice and a single model,
and choosing one replaces the last. They now say "Make default", and the
note says plainly that nothing joins a list.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
Measured against Open WebUI on the same question, sampling every 250ms:
it never showed a raw pipe. A real <table> appeared with 2 rows at
t+3.25s and grew to 4, then 6, as they arrived.
The blank-line rule alone cannot do that, and a table is the case it
serves worst: a markdown table contains no blank line, so the whole of
it stayed in the tail as plain text until the line *after* it landed,
then snapped into place. That is the markdown flash, on exactly the
content where it shows most.
So a table is now rendered while it is still arriving: once there is a
header, the |---| rule and one body row, the tail is rendered as
markdown rather than held as text, and every complete row that follows
joins it. A half-typed row is left out and appears a frame later, which
is what makes the table grow a row at a time.
Re-rendered each frame rather than appended, unlike a settled block: the
rows arriving next carry no header of their own, so they cannot be
parsed as a separate chunk. The tail is small, so the cost is small.
The status was the other half of that trace: Open WebUI keeps a skeleton
beside the streaming content until the answer is done. Ours removed the
status the instant the first token landed — the moment it becomes most
useful, because the answer is arriving *and* the assistant is still
working, drawing a figure or completing a cut-off reply. It now sits
above the partial answer, shimmering, until the final render replaces
the bubble.
And the wait itself says what is happening. Retrieval finishes before
the stream opens — deliberately, so a bad request still returns an error
rather than a stream — which makes that first line the only thing a
reader has during the slowest part. "Retrieving and synthesizing
references" described the software; "Searching the clinical library"
describes the work.
Mutation-tested: dropping the table rule, rendering the incomplete
trailing row, showing a table with no body row, or failing to restore
the status each frame all fail a test.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
"Not all of it was saved: Workflow not found" on every press of Save
availability. The card sent image settings for three workflows, and the
server has only had two since Learning Hub was removed — the DB
constraint allows clinical_assistant and my_resources and nothing else.
One rejection failed the whole save, so the two settings that were valid
looked unsaved as well.
The frontend was the only place that still believed in it. Also gone:
the learning_hub.image_behavior prompt, its Learning prompts section in
the admin — which held that one prompt and nothing else — and the
theme's card tints, which never applied.
While there: My Resources had a Model dropdown labelled "set per
request", permanently disabled and permanently empty. A control that can
never do anything reads as broken rather than as not applicable, so it
is now a sentence saying where the model is actually chosen. Its
fallbacks stay — those apply to whichever model the request picked.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
Picking a theme on a saved resource did nothing. The picker saved the
choice, the route wrote deck.theme, and the renderer knew how to apply
it — the theme was lost in between.
attachFigures, the step that puts drawn figures back onto slides,
rebuilds the deck as a fresh object:
var out = { title, subtitle, date, slides };
A fresh object keeps only the fields it names, and theme was not one.
So every export rendered in the default palette, whatever the picker
said. Rendering the same deck under clinical-blue, teaching-amber and
high-contrast produced three byte-identical files; it now produces
three different ones, and they look different.
Silent, because nothing downstream could tell the difference between a
deck with no theme and a deck whose theme had been dropped — both mean
"use the default", which is also the right behaviour for an unknown id.
Known and not fixed here: the two tinted cards on a compare slide are
hardcoded blue and amber rather than taken from the theme, so those
stay the same colour under every palette. The headings, accent, rules
and bullets do change. That is a gap in the theme definition, not in
this path.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
docs/api-reference.md was hand-written, and by the time anyone checked
it was documenting twenty-three endpoints that answer 404 while missing
others that exist. That is what hand-written reference material does: it
is correct on the day it is written and silently wrong afterwards. A
second hand-written document, in YAML this time, would rot the same way.
So paths, methods and mount points are read from the Express router
stack at request time. They cannot disagree with the app, because they
are the app: 186 paths, 215 operations, and — checked — no /learning
endpoints, which is what the prose version went on claiming for weeks
after that feature was deleted.
What introspection cannot know is what an endpoint is *for*. That half
lives in src/utils/openapiRoutes.js, keyed by "METHOD /path", and it is
the half that rots, so it is the half that is enforced: a Playwright
spec fetches the live document and fails when the number of operations
without a summary rises above 199 — the debt as measured today. A
ratchet, not a target. Adding an endpoint pushes the count over and
fails the build; describing one lowers the number. The failure lists the
operations by name, so it says what to write.
Whether an operation is public is stated per route rather than inferred
from middleware. Guessing wrong there is worse in both directions:
calling a public endpoint protected hides a hole, and the reverse
invites a bug report.
The contract spec lives in e2e rather than the unit suite because it
needs the whole app mounted, and requiring server.js from node:test
pulls in the database pool and hangs the run — that has happened here
before.
Also: e2e now runs in CI on dev, gated by a shell check inside the step
rather than a job-level "if", which this Forgejo dispatches anyway and
then kills with "Early termination".
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
Three specs were asserting screens that no longer exist. They passed for
as long as they did only because the e2e stack shared production's
database and its configuration; against a clean one they failed honestly.
Signing in is a stepped flow now — email, then a choice between a
password and an emailed code — so #login-password is in the DOM but
hidden until that choice is made. The spec asserted it visible on the
landing screen. Replaced with one test for the landing step and a new
one that walks the transition, which nothing covered before.
The register link is hidden only when registration is disabled. This
install has it enabled and invite-gated, so the link shows and the
invite field is required; the spec asserted display:none.
Connecting Nextcloud by signing in to Nextcloud is now the offered path,
with the username and app-password fields folded behind "Use an app
password instead". The spec asserted all three visible at once; it now
checks the primary path and then opens the fallback.
learning-tab.spec.js is deleted and `learning` is out of the smoke tab
list — that feature was removed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
The e2e stack shared production's Postgres — same server, same database,
same table. Seeded robots sat in `users` beside real clinicians, and
anything a test wrote, or a migration under test changed, landed on real
data. Nothing about "run the tests" should be able to reach an account
belonging to a person.
Now it has a Postgres and a Redis of its own, both on tmpfs: created
empty on every run, held in RAM, gone on teardown. scripts/e2e.sh is one
command that recreates the stack, seeds it, runs the browser and leaves
the app up at 127.0.0.1:3553 so it can be clicked around in, with the
report served at :3554.
Two bugs fell out of it immediately, both of which only a database that
did not already exist could have found:
The schema could not be built from nothing. The entrypoint migrated
before the app created its baseline tables, so the first migration
failed on saved_encounters not existing. It never showed because every
database this has ever run against already had the baseline. Then, one
layer down, 1777800000000_generated-images creates a table with a
foreign key to learning_content — which the baseline stopped creating
when Learning Hub was removed. Restoring into a brand-new database could
not have booted. The entrypoint now stands aside when the database is
empty and lets the app do it in the order it already gets right, and the
foreign key is only created where its target is. All 20 migrations
replay from empty, producing the same 23 tables production has.
Configuration lives in the database, so a throwaway one starts at
defaults — 14 settings against production's 49. That is why every model
picker was empty: models.custom did not exist. The tests were right and
the environment was incomplete, so the seed now states what the suite
depends on, with fictional model ids: a test should not pass because of
a setting somebody changed on the live system last week.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
"Your password still works as usual" is not reassurance for everyone who
gets this mail. An account created through an invite has no password at
all, and one signing in with a code may never use theirs — for them the
sentence raises a question rather than settling one.
What the paragraph is for is saying that ignoring the mail is safe, and
it says that without the second sentence.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
Three things, one subject: making CI say the truth about this repo.
## The red on every run was ours, not the runners'
Every docker-build run came back success, success, failure — the same
shape for weeks. The failing job was `deploy`, and it was failing to
*not run*:
if: ${{ github.event.inputs.deploy == 'true' }}
On a push there is no github.event.inputs at all. This Forgejo does not
treat that as false and skip; it dispatches the job, the runner cannot
resolve it, and the task ends in "Early termination". The runners were
never at fault, and nothing about them needed changing.
The `'runs-on' key not defined` line is a red herring: the `build` job
prints it too and succeeds. It names the job's *needs* target, not the
job, and the old android-apk workflow used `needs:` happily for months.
Deploy is now its own workflow with only workflow_dispatch — no
condition to evaluate, so nothing can be dispatched by mistake. No job
in either file now carries a job-level `if`. The one conditional left is
a *step* (push to registry), and step conditions are evaluated by the
runner once the job is already running, which is why that one has always
worked.
## dev and main
docker-build now runs on `dev` as well. Both branches prove the same two
things — tests pass, image builds — and only `main` publishes the image,
so nothing on `dev` can be mistaken for something deployable. Deploying
stays a person pressing a button after looking at the change.
CONTRIBUTING.md documents the flow.
## Android
Removed: the mobile/ Capacitor project, docs/mobile-build.md, and the
Android bits of scripts/release.sh. All of it is in git history — 4613a278
is the last commit that had it — for when it is rebuilt.
src/utils/platform.js stays. isMobileClient only decides token lifetime,
it is twelve lines, and it is the contract a future app would come back
to; deleting it would be a change to auth for no gain.
.github/workflows/ went too — all five. There is no GitHub remote on
this repository, so none of them has ever run, and two of them wrote
into mobile/ paths that no longer exist.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
The feature was removed; the docs still described it as live. Worst of
it was api-reference.md, which documented nine /api/learning endpoints
and fourteen /api/admin/learning CMS endpoints — routes that answer 404
— plus POST /api/user/webdav-path, whose column was dropped by
migration. Anyone reading them was reading fiction.
Checked against the running system rather than assumed: no learning
table exists, users.webdav_learning_path is gone, generated_image_links
is gone, and no route mounts /api/learning or /api/admin/learning.
Two things that look like Learning Hub and are not, so they stay:
- learningRetrieval.js is live — My Resources uses it. Its settings keep
the learning.* names because renaming them would orphan whatever an
administrator has already set. retrieval-tuning.md now says so instead
of listing the rows under two different feature names.
- the moderator role is still assignable. It gated the CMS and now
grants nothing; authentication.md says that rather than implying
powers it does not have. moderatorMiddleware has no callers left,
which is worth removing on its own.
auth-admin-learning.md is now auth-admin.md.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
Requested: no more Android building, focus on the Docker app build and
the web app.
android-apk.yml also ran on every push to every branch, so every commit
started an APK build beside the one workflow anyone was watching. Worse,
its runner — ped-ai-android-runner — advertises the same forgejo-local
label as the general runner, so it was also picking up the Docker build
job and dying instantly with "'runs-on' key not defined". That is why
docker-build showed a failure next to a success on the same commit.
Removed: .forgejo/workflows/android-apk.yml, and the two GitHub APK
workflows, which never ran at all — there is no GitHub remote on this
repo, only forgejo.
The Capacitor project under mobile/ is untouched and still builds by
hand; docs/mobile-build.md now says plainly that nothing builds it for
you.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
A picture of one slide answers a narrower question than the one a theme
picker is asked. What a person wants to know is what a deck will look
like in this theme — all of it, at the size it will be shown, with the
fonts substituted the way they will be. One rendered slide showed one
layout, in content someone then read instead of looking at.
So: a sample deck per theme. Every layout the renderer can draw — title,
bullets with a sub-point, both two-column forms, table, callout, figure,
full-slide figure, section divider, a custom slide with shapes, an arrow
and a chart, and a references slide — with filler text throughout.
Download it, open it, see the theme.
The text is deliberately meaningless. Clinical content in a specimen
invites you to read it, and then you are judging the teaching rather
than the type; that is what the old croup slide got wrong.
No engine. The previous preview needed a Gotenberg round trip and a
pdftoppm to produce a PNG, cached on disk because of what it cost, and
could fail in ways a missing picture cannot explain. python-pptx builds
the file in ~330ms and PowerPoint draws it. The link is a plain anchor,
so it is there whether or not anything on the server is well.
The figure placeholder rides the same path a generated figure does —
attachFigures downgrades a figure slide with no picture to bullets and
an image slide to a section, so without it the sample would silently
stop showing those two layouts.
Verified: all five themes build, 11 slides, the chart is a real chart
and the placeholder embeds as real media.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
The box was hidden until an image loaded and hidden again on error, so a
preview that failed and a preview that did not exist looked identical —
an empty gap under the dropdown. That is what it looked like from the
outside, and it cost a diagnosis: the endpoint renders a correct 934x525
PNG in 0.66s when called exactly as the browser calls it, cookie and
all, and nothing on the page could have told anyone that.
The first view of a theme is a pptx render, a Gotenberg round trip and a
pdftoppm — a second or so before anything appears, and then cached on
disk. A second of silence reads as a feature that is not there, so it
now says it is drawing, and on failure says the look itself is
unaffected, which is true: the preview is a picture of the export, not
the thing that produces it.
img.src is assigned after both handlers. A cached preview can complete
before a handler attached below the assignment exists, which would leave
the box stuck on "Drawing...".
describeTheme already ends by calling it, so the call sitting beside it
fired a second identical request on every load.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU