Commit graph

488 commits

Author SHA1 Message Date
Daniel
9df2230479 refactor: rebuild the admin settings page around what each card does
The page had grown by accretion: model discovery scattered across five
cards with a search box each, one Save writing eight keys from the
bottom of a card that also held a second Save for something else, and a
banner apologising that a button "applies only the settings above it".

Now it reads in groups — Accounts, Models, Assistant & prompts, Site —
and every card is a <details> that folds, so Save & Close means
something. The rule is that each card saves exactly what it shows,
which is what removed the need for the banner.

Models is one workflow in three steps. Discover & test has a single
search box and a kind switch (chat / image / speech / transcription /
embedding); the five discovery calls are unchanged, the switch only
decides which one answers. Roster is what has been added, including the
image roster, which had no visible list before. Availability is the
chat model, the two allowed lists, the per-workflow image settings and
the slide reviewer, under one Save.

Splitting the eight-key save follows from that rule: Save & Close writes
the five retrieval and citation keys; Save availability writes the chat
model, both allowed lists, the reviewer and the three image-settings
PUTs. No route, request shape or setting key changed.

Switching kind clears the results first — a row button would otherwise
add an image model to the chat roster.

The kind switch dispatches its event through document.defaultView's
CustomEvent. jsdom refuses one built from another realm, and the
existing announceModelsChanged() has exactly that bug: its event is
built from the Node global, dispatchEvent refuses it, and a try/catch
swallows the error — so models-changed propagation has only ever been
source-grepped, never actually tested. Left alone here to keep this
change to one subject.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-12 21:42:27 +02:00
Daniel
c68e3a6219 fix: "Sign in with Nextcloud" opens the sign-in page
The button did nothing. It claimed a blank tab during the click and
pointed it at the login URL once the request returned — the standard way
around a popup blocker, and it cannot work in this app: helmet sends
Cross-Origin-Opener-Policy: same-origin, which severs the handle to that
tab the moment it goes cross-origin. Assigning its location was a no-op.
A blank tab opened, nothing else happened.

The handle was never needed. window.open with 'noopener' asks for none,
and a click's user activation outlives the fetch, so the browser does
not treat it as a popup. The status line now also carries the sign-in
URL as an ordinary link, so there is a way through whatever any
particular browser decides about opening windows.

Verified the server side against the real Nextcloud first: the flow
starts, both returned URLs pass the SSRF guard and the same-host check.
The fault was entirely in the browser.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-12 21:36:51 +02:00
Daniel
89e71b3b86 fix: stream the assistant's answer a block at a time
Markdown only means anything once a block is finished. Half a table is a
row of pipes; half a fence is a stray ```. Re-parsing the whole partial
answer every 180ms therefore flickered between a broken parse and the
real thing, and the previous answer to that was to abandon markdown
past 3500 characters or eight pipe rows and dump raw text into a <pre>
that had no CSS at all — inheriting the browser's black monospace
default. That is the dark block people saw while a table streamed.

Now the text is split at the last finished block — a blank line outside
a code fence — and everything before it is rendered once and *appended*.
Only the unfinished tail is plain text, styled as prose. Settled content
is never re-parsed and never rebuilt, so a diagram or chart that has
already drawn is not thrown away by the next token.

Two blank lines are not boundaries: the gap inside a loose list, which
would render one list as two each restarting at 1, and one inside
indented code. Telling the first from the perfectly good boundary
between a sentence and the list it introduces takes looking at both
sides of the gap, not just ahead.

renderEmbeddedBlocks now marks what it has drawn. mermaid.render is
async and replaces the element; a second Chart on one canvas throws.
Marked before the await, not after, or two frames race the same element.

Streaming is a preview: 'done' still renders the whole answer from
scratch, so anything transient here is settled by the final pass. That
is what makes appending safe.

Mutation-tested: removing fence tracking, the list guard, the backward
half of that guard, the append, the drawn marker, or the state reset in
fillMessageBubble each fail a test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-12 21:36:51 +02:00
Daniel
191b3b433b wip: invite codes stay copyable 2026-09-12 21:00:22 +02:00
Daniel
46112e1221 feat: connect Nextcloud by signing in to Nextcloud
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 46s
Forgejo Docker Build / Root app tests (push) Successful in 59s
Forgejo Android APK / Build signed APK (push) Successful in 2m7s
Forgejo Docker Build / Build Docker image (push) Successful in 10s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
Asking someone to find Settings → Security → Create new app password is a poor
first run, and it is the step people give up on. Nextcloud has its own answer:
Login Flow v2. The person enters their server address, signs in on Nextcloud the
way they normally do — SSO, 2FA, a password manager — and Nextcloud generates an
app password for this app. We never see their real password.

Pasting an app password still works, behind "Use an app password instead". It is
the fallback, not the front door.

The security of this is all in what is trusted. The remote server chooses both
the login URL and the poll endpoint, so both are SSRF-checked and both must be
on the host the person actually typed — an endpoint pointing elsewhere would
make this a request-forgery gadget aimed at whatever it named. The server
Nextcloud reports at the end is re-checked before it is stored. The poll token
is a credential, so polling happens server-side and the browser holds only an
opaque handle bound to its own account.

Flows live in memory with a 20 minute life, matching Nextcloud's own expiry: a
login lasts minutes, and a restart mid-flow is a retry rather than a loss.
Starting a second flow replaces the first, which is what clicking again means.

The tab is opened from the click itself, before the request — opening it after
an await is what a popup blocker stops.

Removed with Learning Hub: the WebDAV browse path. Its field, its route and its
column are gone, since nothing browses Nextcloud any more. nextcloud_folder is a
different column and still in use.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-12 20:55:00 +02:00
Daniel
24c8d71b7e feat: send a resource to your own Nextcloud, as the rendered file
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 52s
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 11s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
A .pptx landing in someone's own storage is worth having; a markdown blob is
not, and it is not what they would have downloaded. So this renders through
exactly the path the download uses — stored deck, its figures, the chosen
theme — and PUTs the bytes. The file never travels through the browser.

Offered only when a Nextcloud is connected: an action that always fails is
worse than one that is not offered. An article offers Word, a deck PowerPoint,
and asking for slides from an article is refused with the reason.

Putting a file in Nextcloud now lives in src/utils/nextcloudFiles.js. Two
callers want it and neither should grow its own copy of the WebDAV dance — make
the dated folder a segment at a time, PUT, migrate a legacy plaintext token —
because it reaches into storage that is not ours and a second slightly
different copy is how the two drift. It also replaces a route importing another
route.

Also: a model that leaves the roster now leaves every list that names it.
clinical_assistant.allowed_models and the image roster are advisory copies of
the roster, and a stale id there was invisible until someone asked a clinical
question and the request failed at the gateway. Removing or disabling a model
prunes it; clearing the roster clears them. Re-enabling deliberately does not
re-allow it — that is a separate decision.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-12 20:34:58 +02:00
Daniel
025290d64a feat: retire Learning Hub
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 47s
Forgejo Docker Build / Root app tests (push) Successful in 45s
Forgejo Android APK / Build signed APK (push) Successful in 2m1s
Forgejo Docker Build / Build Docker image (push) Successful in 9s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
My Resources generates better slides than Learning Hub ever did — a typed deck
the model fills in, rendered by python-pptx with fit-to-slide text, figures, a
vision review and themes, against Learning Hub's markdown-through-pandoc — and
the articles and quizzes now live in the quiz app. Keeping a second, weaker
generator and a whole CMS beside it was not earning its maintenance.

Removed: three routers, the Learning Hub and Content Manager tabs, their
components and frontend modules, the five database tables, the WebDAV browser,
the content embedding column and its vector index.

Content was exported first — every article as markdown plus a full SQL dump of
all five tables — to ops-backups/learning-hub-export-*. That export is the
restore path; the migration's down() can recreate the shape but never the rows,
and says so.

Two things this simplifies rather than merely deletes:

generated_image_links existed only to record which published content an image
appeared in, and it was the sole reason a generated image could be read by
someone who did not make it. Images are now owner-only — the visibility rule is
one WHERE clause instead of a join across two tables and a published flag.

embeddings.js keeps the model discovery the admin panel uses and loses
searchSimilar and generateContentEmbedding, which queried a table that no longer
exists.

Kept deliberately: Nextcloud connect, disconnect and export, which are how a
generated note reaches a real filesystem and have nothing to do with Learning
Hub; learningRetrieval, which despite its name is the clinical corpus search My
Resources depends on; and the pandoc reference deck, still the fallback when the
python renderer fails, moved from assets/learning to assets/deck now that the
old name misleads.

Tests: four Learning-Hub-only files removed, and the individual cases inside
shared files that asserted its behaviour. Where a test used a Learning endpoint
only as a convenient example — the account-boundary token test, the policy
matrix — it now uses one that still exists, so the property it proves is
unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-12 20:14:20 +02:00
Daniel
59226f2109 feat: deck themes — a palette you pick, previewed by the renderer itself
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 46s
Forgejo Docker Build / Root app tests (push) Successful in 46s
Forgejo Android APK / Build signed APK (push) Successful in 2m13s
Forgejo Docker Build / Build Docker image (push) Successful in 9s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
A deck had exactly one look. The slide vocabulary is structural — bullets,
compare, table, callout, figure — and none of it carries a colour, so "make it
yellow" had nowhere to land but the image prompts, and produced yellow figures
on a blue deck.

A theme is a palette and a typeface in assets/deck-themes.json. render_pptx.py
rebinds INK, MUTED, ACCENT, RULE and PAPER from it in one place, so every slide
builder follows without a line changing in any of them — five themes restyle ten
slide types for free. An unusable theme leaves the default standing, because a
deck in the wrong colours beats a deck that will not render.

The theme rides on the deck, which is already the renderer's spec, so nothing
has to thread it through. It is validated against the same catalogue the
renderer reads: an id the renderer would ignore is never stored, so a deck
cannot claim a look it does not have.

PUT /my-resources/:id/theme re-skins a stored deck — a column write, no model
call, nothing that can reword a slide — and the next download is in the new
colours. Offered in the library only on rows that have a deck; flat markdown has
no palette.

Previews are rendered by the renderer, one representative compare slide per
theme, cached because each costs a pptx render, a Gotenberg round trip and a
rasterise. Drawn rather than mocked up: a hand-made swatch drifts the moment a
palette or a layout changes, and a preview that is not true is worse than none.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-12 19:40:19 +02:00
Daniel
bf4f895f2c fix: article uploads are 10 MB, type-checked both ways, and sniffed
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 1m55s
Forgejo Docker Build / Build Docker image (push) Successful in 9s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
The ceiling was 100 MB per file with ten files allowed at once, and every file
is held whole in memory to be parsed — so the old limit let a single request ask
for a gigabyte of heap. A source article that size is not a thing anyone
uploads here. Now 10 MB, defined once and used by both the multer limit and the
post-upload check.

The filter accepted `allowed mime OR allowed extension`, so naming a file .pdf
was enough on its own, whatever it declared — and the extension is chosen by
whoever uploads. Both are required now.

Neither of those sees any bytes: multer filters on the headers, before the file
has arrived. verifySources() runs once the buffer exists and refuses a file
whose contents are not what its type claims, using the same helper as documents,
S3 uploads and assistant attachments. It runs before extraction, because an
extractor handed a malformed file is where the damage would happen.

The CMS screen said 100 MB and listed four of the ten accepted formats; it now
says what the server actually does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-12 19:25:41 +02:00
Daniel
bd8e413bc7 fix: an assistant attachment must be the image type it claims to be
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 59s
Forgejo Docker Build / Root app tests (push) Successful in 50s
Forgejo Android APK / Build signed APK (push) Successful in 1m56s
Forgejo Docker Build / Build Docker image (push) Successful in 9s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
The MIME type was taken on trust here. Anything at all could be posted as
image/png: it passed the size and base64 checks, was stored in the saved chat,
and was handed to a provider as a data URI. Documents and S3 uploads have always
been sniffed by fileType.js; this was the one upload path that was not.

Now sniffed with the same helper, so there is one idea of what a PNG looks like.
A PHP payload, a shell script, an ELF or PE binary, a zip, or a real PDF
labelled image/png are all refused with a message that says what is wrong.

What this does not claim: bytes hidden after a valid PNG header still make a
valid PNG, and no sniffer can promise otherwise. The protection is that the file
is never executed and never served as anything but an image.

Existing fixtures used buffers of 0x07 as stand-in images, which are correctly
refused now. They carry real file headers instead — a fixture should be the
thing it claims to be, exactly like a real upload.

Also adds the deck theme system: five palettes in assets/deck-themes.json,
render_pptx.py rebinding its palette from the theme rather than hardcoding it,
the theme carried on the deck and validated against the same catalogue the
renderer reads, a picker on the generate form, and PUT /my-resources/:id/theme
to re-skin a stored deck with no model call.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-12 19:00:10 +02:00
Daniel
cda74e1fe2 fix: trim the account-boundary message to what a reader needs
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-12 18:36:37 +02:00
Daniel
ac1adddd50 fix: the account boundary explains itself instead of reading as an SSO failure
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 55s
Forgejo Docker Build / Root app tests (push) Successful in 1m0s
Forgejo Android APK / Build signed APK (push) Successful in 2m3s
Forgejo Docker Build / Build Docker image (push) Successful in 13s
Forgejo Docker Build / Deploy to the host (push) Failing after 2s
Tapping SSO on a frozen realm did nothing at all — no navigation, no message.
The guard is correct: a page bound to one account must not start a login for
another. But silence is indistinguishable from a broken button, and it was
reported as "single sign on error" when no SSO request had been made; the
server logged no OIDC activity because the click never left the page.

The button now says what is happening and names the control that clears it.
The guard itself is unchanged — same checks, same order, same outcomes.

The recovery screen said "your saved credentials no longer match this session",
which describes the mechanism rather than the situation. The ordinary cause is
signing in as a different account on a device that already held one. It now
says that, and says plainly that nothing was lost and no data was mixed —
which is the reassurance the safeguard has earned and was not giving.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-12 18:24:14 +02:00
Daniel
74aa0c1b89 feat: Enter sends in the assistant, and the person chooses
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 2m23s
Forgejo Docker Build / Build Docker image (push) Successful in 9s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
Enter made a newline and Ctrl+Enter sent, which is backwards from every chat
people use. Enter now sends by default, with a toggle in the composer's + menu
to put it back.

Two rules hold whatever is chosen, because they are the habits people arrive
with and a setting that broke either would be worse than no setting:
Shift+Enter is always a newline, Ctrl/Cmd+Enter always sends. Both are checked
before the preference, so neither can be switched off.

A keystroke during IME composition never sends. Enter accepts a candidate word
in Chinese, Japanese and Korean, and on predictive Android keyboards; sending
there would cut a sentence off mid-word.

Stored per device rather than per account, because a keyboard preference
belongs to the keyboard: Enter-to-send suits a desk and usually does not suit a
phone, where Enter is how you get a second line. Unset, it defaults by device
class — send where there is a real keyboard, newline on a touch screen — and a
blocked localStorage falls through to that default rather than throwing.

The composer's tooltip says which key sends, where someone already looks when
they wonder.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-12 18:05:36 +02:00
Daniel
b1e039d834 fix: a newly added model reaches every picker, including the user-facing ones
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 56s
Forgejo Docker Build / Root app tests (push) Successful in 45s
Forgejo Android APK / Build signed APK (push) Successful in 1m53s
Forgejo Docker Build / Build Docker image (push) Successful in 8s
Forgejo Docker Build / Deploy to the host (push) Failing after 2s
The first pass at this covered the two admin cards. It missed the pickers that
matter most: the per-tab model selectors in app.js, which every clinical tab
uses, and the My Resources model dropdown. Both were filled once at page load,
so a model added in Admin was still invisible where people actually choose one.

app.js's boot fetch is now a named loadModelList() that also runs on
models-changed; My Resources re-runs loadOptions(), which is the same call that
decides whether the model row is shown at all.

Both rebuilds keep a choice already made. These selects can be rebuilt while
someone is halfway through a form, and silently moving them off the model they
picked would be worse than not refreshing.

Verified against a mutation: removing the app.js listener fails the test that
says every picker listens.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-12 17:46:18 +02:00
Daniel
7305443243 fix: the last run's illustration no longer sits under an empty form
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 56s
Forgejo Docker Build / Root app tests (push) Successful in 49s
Forgejo Android APK / Build signed APK (push) Has been cancelled
app.js loads a tab's component once and marks it data-loaded, so the DOM
survives leaving and returning. Nothing cleared the result area, and an
illustration from a previous generation stayed on screen under a blank form as
though it were output for a topic nobody had typed. A full page refresh rebuilt
the component and cleared it, which is why it looked like a leak that fixed
itself.

Cleared at the start of a generation, at the start of a modification, and on
re-entering the tab — not on the first visit, where there is nothing to clear.
Covers the illustration area, the searches line, the image-failure line and the
status text.

Also documented what a modification can actually change. The deck vocabulary is
structural — bullets, compare, table, callout, figure, image, section, title —
and none of those carries a colour; the palette is fixed in render_pptx.py and
the model never sees it. So "make it yellow" lands on the only field that takes
a colour, image_prompt, and yellow figures appear on an otherwise blue deck.
That is not modify reaching only the images; it is the model using the one lever
the schema gives it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-12 17:43:19 +02:00
Daniel
31e634e0ce fix: a model added in Admin now appears everywhere models are chosen
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 47s
Forgejo Docker Build / Root app tests (push) Successful in 46s
Forgejo Android APK / Build signed APK (push) Has been cancelled
Adding a discovered model refreshed exactly one dropdown — the default-model
one, in the same card. Every other picker had been filled when the admin tab
loaded, behind a guard that makes its loader run once per visit, so the
Clinical Assistant chat model, the allowed-models list, the slide reviewer and
the image-model selects all kept the roster they were given. The model was
genuinely added; it simply could not be selected until the page was reloaded,
which reads as the add having failed.

Every mutation of the roster — add, remove, clear-all, enable/disable — now
dispatches `models-changed`, and the cards that list models listen and refetch.
The event carries no payload: a listener re-reads the list itself, so there is
one source of truth rather than a copy to keep in step. Same pattern as the
existing `assistant-image-roster` event.

Each listener clears its own guard before re-running, and returns early if a
load is already in flight. The assistant loader keeps unsaved drafts, so
re-running it costs nothing but a refreshed set of options.

The add toast said "now select it as default and click Set Default" — advice
that only made sense when the default dropdown was the one thing that updated.

Verified against a mutation: removing the guard reset fails the propagation
test, because the listener then fires into a loader that returns early.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-12 17:39:49 +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
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
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
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
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
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
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
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
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
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
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
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
Daniel
b3d66caaca feat: a retried transcript goes back to the tab the audio came from
Retrying a kept recording transcribed it and put the text on the clipboard,
leaving you to find the right tab and paste. The app already had the answer: the
module was recorded with the audio. Retry now opens that tab and puts the text
in its transcript box.

Two things had to be true first.

The module was not actually being recorded. transcribeAudio never sent one, so
the server stored its default for every upload — all 28 rows in audio_backups
said "recording", and a retry had nowhere to send anything back to. Each
module's call now tags its own upload.

And the names disagreed. The recorders tagged 'encounter', 'soap', 'dictation'
while the recording-started events said 'enc', 'sick', 'dict'. One table now
holds the mapping and resolves the aliases, so the recorder that tags the
upload, the backup row that labels it and the retry that delivers it cannot
drift apart again.

Existing text is appended to, never replaced: a retry usually recovers
something on top of a live transcript, and overwriting would lose the words the
browser did hear. The box only exists once its tab's markup has been fetched, so
delivery polls briefly rather than guessing a delay, and falls back to the
clipboard if the tab never opens. An empty result says so rather than claiming
success. Backup rows now name their source and the button reads "Retry into
SOAP Note" instead of "Retry".

Verified in a browser: enc resolves to encounter, delivery switched tabs and
produced 'existing live transcript\n\nRECOVERED TEXT'.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-11 12:39:50 +02:00
Daniel
5394fc930b fix: metrics are not public, and the workspace launcher renders again
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 1m51s
Forgejo Docker Build / Build Docker image (push) Successful in 18s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
/metrics answered on every public hostname — app.pedshub.com, peds.danvics.com
and scribe.pedshub.com — with 201 lines naming routes, traffic volumes,
event-loop timings and process internals. No credential in it, but a free map of
the application for anyone probing. Prometheus scrapes the container directly
over the Docker network and never goes through the proxy, so a request carrying
X-Forwarded-For is by definition not Prometheus and now gets a 404.
METRICS_TOKEN allows an explicit override; unset, it can never match.
Verified: 404 on all three hostnames, and up{job="ped-ai"} still 1.

The workspace launcher was blank. [hidden]{display:none!important}, added to
stop "Retry loading settings" showing permanently, outranked
`body.assistant-mode-workspace .assistant-workspace-view { display:block }` —
and that view is the one element toggled by CSS rather than by JavaScript
removing the attribute, so it could never be revealed again. The attribute is
gone from the markup; the class already defaults it to display:none, so it still
starts hidden. Everything else using the attribute is JS-toggled and unaffected.
Verified with a real login: 17 cards at 208x40 in an 860x240 grid.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-11 05:18:02 +02:00
Daniel
fadf09bf4a revert: remove the signed-out assistant preview
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 2m2s
Forgejo Docker Build / Build Docker image (push) Successful in 9s
Forgejo Docker Build / Deploy to the host (push) Failing after 2s
Removed at the owner's request, entirely rather than switched off: the route's
allow-list, anonymous identity and flag lookup; the client's entry path, the
authFetch exception that let four endpoints out without an account, and the
workspace guard; the CSS, the in-page note, the admin flag and its save/load;
the test file and the assertions elsewhere that pinned it. Both settings rows
are deleted from app_settings.

Two things were checked rather than assumed on the way out. Removing the
anonymous identity collapsed every `if (!req.user.preview)` branch to its
authenticated side, so image tools, audit logging and citation storage now run
unconditionally — which is what they did before preview existed. And the route's
gate went back to a bare router.use(authMiddleware), which on a /api mount gates
every path below it in server.js; it is scoped to /clinical-assistant again, the
guard test catches it either way.

Verified after deploy: signed out, status, examples and chat all refuse with
401; signed in, chat still answers with 8 sources; extensions, encounters,
documents and admin remain shut.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-11 05:09:02 +02:00
Daniel
cd37a9d728 fix: the preview page rendered nothing
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 46s
Forgejo Docker Build / Root app tests (push) Successful in 45s
Forgejo Android APK / Build signed APK (push) Successful in 2m0s
Forgejo Docker Build / Build Docker image (push) Successful in 22s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
/assistant in the address bar, the preview class on the body, and a blank
screen. The app container starts hidden and enterApp is what reveals it;
enterPreview declined the login overlay without doing the same, which left a
hidden app under a hidden overlay. One line.

Verified in a browser: composer, chat view, example prompts and the preview note
are all visible and sized, with no page or console errors.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-11 04:55:30 +02:00
Daniel
004fb80a60 fix: the signed-out preview is reachable from the page, from any path
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 49s
Forgejo Docker Build / Root app tests (push) Successful in 49s
Forgejo Android APK / Build signed APK (push) Successful in 1m51s
Forgejo Docker Build / Build Docker image (push) Successful in 18s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
The server side has worked since this morning, but no browser could reach it.
authFetch rejects every /api request that has no account before it is sent,
and the four preview endpoints were not on its short list — so the status call
that decides whether to show the login screen never left the browser, and the
screen was always shown. The list now mirrors the server's own allow-list
exactly: status, examples, chat, chat/stream, and nothing else.

Preview now begins from any path. A visitor landing on the root met the login
wall while /assistant did not, which read as "preview doesn't work"; both now
enter the assistant, and the URL follows.

Reaching for anything that needs an account raises the sign-in screen through
one hook in authFetch rather than a check on every control — but only for
something the visitor did. The page also fetches saved chats and config in the
background on load, and the first version raised the screen for those too,
burying the assistant before a word was typed. The hook is gated on
navigator.userActivation.

The HIPAA notice is hidden on that screen in preview: it is an invitation to
sign in, not the compliance notice a clinician sees on first login.

Verified in a browser: landing on / and on /assistant both show the assistant
with no login wall and no HIPAA text; clicking Workspace raises sign-in.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-11 04:40:40 +02:00
Daniel
96a6a353fc fix: the assistant settings page says what saves what
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 2m15s
Forgejo Docker Build / Build Docker image (push) Successful in 15s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
The card holds more than one Save button and nothing said so. "Save image
settings" is injected directly above "Save model & retrieval settings", with no
indication of where one stops and the other starts, and the page saves nothing
automatically. It now states that up front, and the bottom button says which
settings it applies.

"Retry loading settings" sat beside Save looking like an ordinary control,
because it did: a bare button with the hidden attribute, which the browser's own
[hidden] rule could not hide once .btn-sm set a display. It is now inside an
error message that exists only on failure, says what failed, and says that
nothing typed has been lost.

The status line used to read "Settings ready." forever, which answers a question
nobody asks. It now reports the thing an admin actually wants to know when they
come back: whether the last save went through, and at what time. A toast is gone
in three seconds; this stays on the page.

The signed-out preview moves to Feature Flags, where it belongs. It was a second
checkbox under a row labelled "Sources", followed by two paragraphs, the first
about preview and the second about citations — so neither paragraph clearly
belonged to either checkbox. It is stored as feature.assistant_preview now, with
the old clinical_assistant.preview_enabled still honoured when the new key has
never been written. That also means an ordinary admin can toggle it under
ADMIN_LOCKDOWN: clinical_assistant.* is locked, and putting a day-to-day switch
behind host access was never the intent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-11 04:04:31 +02:00
Daniel
20ef50e8e9 fix: the hidden attribute now hides, and the gateway URL comes from the vault
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 46s
Forgejo Docker Build / Root app tests (push) Successful in 53s
Forgejo Android APK / Build signed APK (push) Successful in 1m57s
Forgejo Docker Build / Build Docker image (push) Successful in 10s
Forgejo Docker Build / Deploy to the host (push) Failing after 1s
"Retry loading settings" was permanently on screen even though the settings had
loaded fine. The browser's [hidden]{display:none} lives in the user agent
stylesheet, so any author rule setting display beats it — .btn-sm sets
display:inline-flex, so `<button hidden class="btn-sm btn-ghost">` never hid.
Earlier fixes bolted style="display:none" onto individual elements one at a
time; one [hidden]{display:none!important} covers all fourteen.

LITELLM_API_BASE is no longer pinned in Compose. Compose env wins over OpenBao,
so hardcoding http://litellm:4000 silently overrode the vault's
https://llm.danvics.com and the intended configuration had never taken effect.
The entrypoint now applies 28 secrets rather than 27, with 2 deliberate
overrides rather than 3. Transcription, text-to-speech and the signed-out
preview were all checked through the public gateway afterwards.

Worth recording since it was the reason for the change: this clears no network
dependency. ped-ai still needs danvics_mcp for mcp, the assets network for
MinIO, the openwebui network for LibreTranslate and danvics_monitoring for Loki;
none of those has a public route. It also adds about 20ms per call.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-11 02:29:55 +02:00
Daniel
ca0be0a98e fix: the sign-in line describes the app, and ped-ai drops a network it never used
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 49s
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 11s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
"AI-Powered Clinical Documentation" named about a third of what is here.
Documentation is one pillar; the assistant and the learning hub are the others,
and none of it said pediatric, which is both true and the differentiator.

danvics_speech is gone from ped-ai. Everything ped-ai dials was checked against
every network it joins: assets, libretranslate, litellm, mcp and monitoring-loki
are the only internal hosts, and litellm answers to that alias on danvics_mcp
already. supertonic/kokoro/sherpa appear here only as LiteLLM model ids and
voice names, never as hostnames — speech goes through the gateway, exactly as
intended. Joining danvics_speech therefore bought no reachability and cost a
cold-boot dependency on local-speech-gateway, which had to have been up once
before ped-ai could start at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-11 01:37:39 +02:00
Daniel
59c6ca6296 fix: transcription that returned nothing, and one Registration card
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 56s
Forgejo Docker Build / Root app tests (push) Successful in 46s
Forgejo Android APK / Build signed APK (push) Successful in 1m50s
Forgejo Docker Build / Build Docker image (push) Successful in 29s
Forgejo Docker Build / Deploy to the host (push) Failing after 2s
The recordings were never the problem. Six stored recordings were pulled back
out of object storage and examined: valid webm/opus, 3-5 seconds, 1.5-2s of
continuous speech-shaped audio each. Every one came back from
mistral-voxtral-mini-transcribe as an empty string, while the same model
transcribed synthesised speech perfectly — including a one-word clip, and
including that speech attenuated to the same level, so neither length nor
loudness explains it. Re-encoding to wav, mp3, flac, ogg and a remuxed webm
changed nothing; groq-whisper-large-v3-turbo transcribed all six. stt.model is
set to that now, and the real recording round-trips through /api/transcribe as
"Hello." instead of "".

So the server now says something when a model answers 200 with no words for a
non-trivial amount of audio. That silence is what made this look like lost
recordings; the log names the backup id, so the kept audio can be tried against
another model directly instead of suspecting the microphone.

Also: browsers report "audio/webm;codecs=opus", and deriving the extension by
splitting on "/" alone named the upload "audio.webm;codecs=opus". This gateway
tolerates it. A provider dispatching on extension would not.

And registration is one card again: enable it, decide whether it needs an
invitation, hand out codes — top to bottom. The invite-only switch sat in a
separate card far below the enable/disable toggle, which made one decision look
like two unrelated settings.

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