Commit graph

726 commits

Author SHA1 Message Date
Daniel
94f1290aae fix: references at the end, never in the body
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 46s
Forgejo Docker Build / Root app tests (push) Successful in 54s
Forgejo Android APK / Build signed APK (push) Has been cancelled
A slide carrying [1] markers is unreadable from the back of a room, and an
article that cites inline reads as a paper rather than as teaching material. The
model is now told explicitly not to cite in the body — no bracketed numbers, no
parenthetical "(Nelson, p. 2604)" inside sentences — and to put everything it
drew on in a References section at the end, which in a presentation is the final
slide.

Checked rather than assumed: a six-slide deck generated through the grounded
path contains zero in-text citation markers, and ends with a References slide.
The prose keeps the specificity that grounding is for — bilirubin produced at
two to three times the adult rate, conjugation immature until about two weeks,
thresholds in mg/dL — without a single marker interrupting it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-11 14:37:47 +02:00
Daniel
f7cd8b39a3 feat: a grounded resource ends with the references it drew on
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 8s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
The grounding metadata went to the API response and the logs, which is no use to
someone holding the deck. A teaching resource shown to trainees should carry its
own provenance, so a grounded one now ends with a References section — the final
slide in a presentation — listing the library excerpts it actually used, by
title and page.

Restricted deliberately: only excerpts actually drawn on, nothing invented. That
was worth checking rather than trusting. Generated a deck and compared every
citation against the source metadata: "Kliegman R. Nelson Textbook of
Pediatrics, 22nd ed., 2024, p. 2604" against a stored title of "Kliegman R.
Nelson Textbook of Pediatrics 2-Volume Set 22ed 2024" at page 2604, and the same
for Fleisher & Ludwig, Rosen's, Understanding Pathophysiology and the AAP
compendium. The model reformatted filename-derived titles into readable
citations using only what it was given.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-11 14:23:49 +02:00
Daniel
4f5687982d feat: Learning resources can be grounded in the clinical corpus
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 2m2s
Forgejo Docker Build / Build Docker image (push) Successful in 19s
Forgejo Docker Build / Deploy to the host (push) Failing after 1s
Learning generated everything from the model alone. A deck on bronchiolitis was
whatever the model remembered about bronchiolitis, with no connection to the
documents this institution actually indexed — while the assistant had been
searching that corpus all along.

Same collection, deliberately. mcp_bge_m3_1024 is already embedded with
openrouter-bge-m3 at 1024 dimensions; a second index over the same documents
with the same embedder would be a copy that drifts. What differs is the budget:
a chat answer wants a few tight excerpts because the reader is waiting, a
teaching resource synthesises a whole topic. So learning.search_limit and
learning.context_chars default to 30 and 2500 against the assistant's 8 and
1400, and are separate keys so tuning one cannot move the other.

Not unbounded, though. "No limit" only moves the ceiling from a setting to the
model's context window, where overflow truncates the middle of the prompt
silently — the worst place to lose source material. 60 results and 8000
characters per excerpt are the caps.

Opt in per generation: a resource on something the library does not cover is
better written without it than padded with the nearest unrelated excerpts.
Retrieval never fails a generation — the resource is then written from the model
alone, which is what happened before this existed — and every response reports
what it was grounded on, so a caller can say "24 excerpts" or "the library had
nothing on this" rather than quietly serving ungrounded material.

Verified against the live corpus: bronchiolitis, neonatal jaundice and febrile
seizure each returned 12 excerpts and ~23k characters from Nelson, Rudolph and
the Pediatric Clinical Practice Guidelines. A deck generated through the full
chain came back with textbook specificity that is not general recall —
bronchiolar diameter, birth-weight thresholds, the full pathogen list.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-11 14:03:05 +02:00
Daniel
1d031af5d6 refactor: Google models go through LiteLLM; the Vertex SDK is gone
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 49s
Forgejo Docker Build / Root app tests (push) Successful in 58s
Forgejo Android APK / Build signed APK (push) Successful in 2m9s
Forgejo Docker Build / Build Docker image (push) Successful in 12s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
npm audit reports 0 vulnerabilities. It reported 2 high and 2 moderate this
morning.

@google-cloud/vertexai was the last source of findings — gaxios and a uuid with
a missing buffer bounds check, neither reachable in this deployment because
GOOGLE_VERTEX_PROJECT is unset and the require sits inside that check. Dormant
is not the same as gone, and the provider is available through the gateway
anyway, so the direct path has been removed rather than left to rot:

  - the SDK client and callVertex, which without the package could never run
  - the dispatch and discovery branches that reached them
  - VERTEX_MODELS, a list of ids nothing could route any more, and the two
    places in adminConfig that concatenated it into the built-in set
  - the health endpoint's vertex line, and the env vars documented for it

AI_PROVIDER=vertex now says where to configure the model instead of quietly
becoming something else. The Google STT and TTS paths keyed off the same
variable are untouched; neither ever used this SDK.

Verified after deploy: provider litellm, the assistant answers with 8 sources,
/api/models returns 10, and @aws-sdk/s3-request-presigner — which documents.js
needs for presigned MinIO URLs — is still declared and resolvable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-11 13:51:22 +02:00
Daniel
689e9bc6c7 fix: the slide prompt carries the rules the renderer actually enforces
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 2m2s
Forgejo Docker Build / Build Docker image (push) Successful in 14s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
Generated a deck with ds-deepseek-v4-flash and rendered it to look at. The model
produced exactly the six headings it was asked for; the deck came out with eight
slides. The extra ones were pandoc's, not the model's.

Two rules, both found by rendering rather than reading:

  - pandoc splits a slide after a table. Anything following one becomes a new
    slide with no title — that was the stray "Key differentials to consider:"
    slide floating with no heading.
  - A table with no blank line before it is not parsed as a table at all. It
    renders as literal pipe characters in the preceding paragraph.

And one that was visible on the slide itself: a nested ordered list inside a
bullet ran off the bottom.

None of these are the model failing. A cheap model writes perfectly good slide
markdown — bold, italics, nested lists and a table with a subscript all came
through correctly. It just needs to be told the shape the renderer wants.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-11 13:29:12 +02:00
Daniel
15a8b399ba feat: slides are built by pandoc from markdown, with a reference template
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 57s
Forgejo Docker Build / Root app tests (push) Successful in 53s
Forgejo Android APK / Build signed APK (push) Successful in 1m59s
Forgejo Docker Build / Build Docker image (push) Has been cancelled
Forgejo Docker Build / Deploy to the host (push) Has been cancelled
pptxgenjs is gone, and with it 269 lines of hand-rolled markdown parsing.

It stretched every image. Reading the slide XML it emitted shows why: it writes
the target box verbatim with <a:stretch/> and a no-op srcRect, so a 200x800
image handed an 11.8x3.9 box came out 1:4 squashed to 3:1. It could not do
better — it never measures an image, and its own getSizeFromImage is commented
out and marked "currently unused", reaching for a package called sizeof that
does not exist. pandoc measures them: a 300x175 source renders at aspect 1.714
and a 160x360 at 0.445, verified by rendering the deck to PDF and looking at it.

Tables, ordered and unordered lists, bold, italic and subscripts all come out
natively, and the fonts, palette and slide layouts come from
assets/learning/slides-reference.pptx. Design now lives in that file: restyling
the decks means editing it in PowerPoint, not editing this route.

Only images the requester owns can reach a deck. pandoc resolves an image link
against the filesystem, so a markdown link naming any local path would read that
file into the presentation. Images are fetched by id through the ownership
check, written into a per-request temporary directory under names we choose, and
every image link that did not resolve is removed rather than passed through. The
directory is removed in a finally block, and the conversion has a 60s timeout so
it cannot hang a request.

pandoc is in the image rather than a sidecar, because an export must not fail
for reasons outside this container. It costs 197MB (307 -> 504).

Removing pptxgenjs also removed image-size, and with it both high-severity
advisories — GHSA-w3rx-r6r6-pgpr and GHSA-5p2g-fcmc-qvqq, ICNS/JXL/HEIF parser
denial of service, ranged <=2.0.2 with no fixed release to upgrade to. npm audit
goes from 2 high and 2 moderate to 2 moderate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-11 13:25:24 +02:00
Daniel
61da9e6bf2 fix: slide images keep their shape, and only safe parsers measure them
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 50s
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 23s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
Every generated image in an exported deck was distorted. PowerPoint scales an
image to whatever extent it is given, and the export handed it the content box
verbatim. pptxgenjs has a `sizing: contain` option that looks like it solves
this; reading the emitted slide XML shows it does not — a 200x800 image in an
11.8x3.9 box came out as cx=10789920 cy=3566160 with <a:stretch/>, stretched
from 1:4 to 3:1. It cannot do better: it never measures the image, and its own
getSizeFromImage is commented out and marked "currently unused".

So the export measures the image itself and hands PowerPoint an extent that
already has the right shape, centred in the space available. Verified: a 200x800
image now places 0.97x3.90 and a 4x3 places 5.20x3.90, both matching their
source aspect exactly, neither overflowing. An image that cannot be measured
keeps the old behaviour rather than failing the export.

image-size becomes a real dependency rather than one borrowed transitively, and
an override collapses it to a single copy — pptxgenjs declares it but the string
appears in none of its four shipped bundles, so npm was placing a second copy in
the production image that nothing could load.

Its ICNS, JXL and HEIF parsers have open denial-of-service advisories against
every published version (GHSA-w3rx-r6r6-pgpr, GHSA-5p2g-fcmc-qvqq, both ranged
<=2.0.2), so there is no release to upgrade to. They are disabled instead:
this application measures PNG, JPEG, WebP and GIF and nothing else. An ICNS
buffer is now refused and falls back to the box rather than entering the parser.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-11 12:46:11 +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
97454a8941 refactor: the clinical index volume is named for what it holds
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 1m56s
Forgejo Docker Build / Build Docker image (push) Successful in 12s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
mcp-server_mcp-data carried the Compose project prefix, which tied its name to a
directory that has since been renamed to clinical-assist-deploy. It is now
clinical-assist-data, named explicitly in both stacks so it no longer depends on
a project name at all.

Migrated rather than renamed, because Docker cannot rename a volume: writers
stopped, 2.9GB copied, then verified before switching — 13265 files both sides,
3080478708 bytes of file content both sides, and an md5 over every file in
sorted order matching exactly. The old volume is kept, and is now referenced by
nothing, so it can be removed once this has run for a while.

Also removed mcp-server_qdrant-data: 1.7GB belonging to a Qdrant deployment that
no longer exists and which no container mounted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-11 04:26:04 +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
85dbbc1a4a fix: the clinical MCP has a name that says what it is; gateway from the vault
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 2m14s
Forgejo Docker Build / Build Docker image (push) Successful in 18s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
"mcp" said nothing — this host runs several MCP servers. The clinical corpus
query service now also answers to clinical-mcp, and ped-ai asks for it by that
name. The bare service name stays an alias automatically, so anything still
pointing at http://mcp:8000 keeps working. Hyphens rather than underscores:
underscores are legal in Docker's embedded DNS but not in hostnames generally.

LITELLM_API_BASE goes back to being unset here, so OpenBao's
https://llm.danvics.com applies. The public hostname is the deliberate choice for
consistency across the estate. It is not a speed question — the gateway hop is
~19ms on calls taking hundreds of milliseconds, and describing that as "4x" was
a misleading way to put it. The real cost is that AI calls now depend on Caddy,
public DNS and edge TLS; one line here pins it back to the container network.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-11 03:49:41 +02:00
Daniel
a3dee83ac2 docs: record the sign-in code and preview behaviour still to build
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 47s
Forgejo Docker Build / Root app tests (push) Successful in 48s
Forgejo Android APK / Build signed APK (push) Successful in 1m59s
Forgejo Docker Build / Build Docker image (push) Successful in 14s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
Both came out of using the app: the preview works but is all-or-nothing, and
signing in should be possible without a password without replacing it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-11 02:42:07 +02:00
Daniel
f735e2e910 fix: ped-ai leaves Open WebUI's network, and pins LiteLLM internally on purpose
Some checks failed
Forgejo Android APK / Build signed APK (push) Blocked by required conditions
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 48s
Forgejo Docker Build / Root app tests (push) Has been cancelled
ped-ai joined open-webui_openwebui-network to resolve exactly one hostname:
libretranslate. LibreTranslate is its own Compose project but had no network of
its own, so it borrowed Open WebUI's — which meant a clinical app's startup was
coupled to an unrelated stack's lifecycle for a translation call.

LibreTranslate now owns danvics_translate and ped-ai joins that instead. It also
keeps its Open WebUI attachment, because Open WebUI is configured to use it
(webui.db refers to it) and cutting that would have broken translation there
silently. Verified after the move that Open WebUI still reaches it.

LITELLM_API_BASE is pinned to the container network again, now deliberately and
with the reason recorded: clinical traffic should not leave the host or depend on
Caddy, public DNS and edge TLS. Speed is not the reason — it is ~19ms on calls
taking hundreds of ms, and quoting that as "4x" was misleading. OpenBao still
stores the public URL for this key and Compose wins, so the vault value is dead
until someone changes it by hand; the app's AppRole is read-only by design.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-11 02:39:57 +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
cc76c66953 fix: the signed-out preview never worked, because /api was gated wholesale
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 52s
Forgejo Docker Build / Root app tests (push) Successful in 52s
Forgejo Android APK / Build signed APK (push) Successful in 2m5s
Forgejo Docker Build / Build Docker image (push) Successful in 17s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
Eleven routers are mounted on '/api' and called router.use(authMiddleware) with
no path. Mounted that way, the gate applies to every /api request that reaches
the router — including routes belonging to routers mounted further down
server.js. extensions.js did it from line 295; the assistant is mounted at 305.
So a signed-out request to /api/clinical-assistant/status was refused ten lines
before the preview middleware could look at it, whatever the admin setting said.
server.js line 250 already warned about this shape.

Each gate now names its own prefix, so a router protects its own routes and
nothing else. Verified afterwards that every namespace which must stay shut
still answers 401 signed out: extensions, encounters, memories, notes, diagrams,
generated images, image jobs, documents, audio backups, ED encounters,
don't-miss, patient education, billing, well visit, admin, transcribe and the
rest. Two of these routers were gating routes nobody realised they were gating.

Second defect in the same path: authMiddleware only ever looks for a token, so
calling it unconditionally after the preview identity had been assigned rejected
exactly the requests preview exists to serve. Only that identity may skip it;
authMiddleware stays strict everywhere else.

Preview now answers with a real cited answer, and stays as narrow as it was
designed to be — four allow-listed paths, no identity, nothing ownable. A test
now walks every /api router and fails on a blanket gate, which is how the last
six were found.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-11 02:00:59 +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
Daniel
8d0dc968b3 feat: a deploy you can repeat, and prove afterwards
Reproducibility means two things here: the same commit builds the same image,
and the running container can be asked which commit it is.

  - Base images are pinned by digest, not by tag. A tag moves; two builds of one
    commit could otherwise differ. These are manifest-list digests, so buildx
    still picks the right architecture.

  - scripts/build-image.sh also writes ped-ai-local:<revision>, an immutable
    name a deploy can refer to instead of chasing :latest. Its summary goes to
    stderr so stdout stays the Compose invocation.

  - Compose takes the image from PED_AI_IMAGE, so a deploy runs a specific
    revision-tagged image while a local build still uses the local tag.

  - scripts/deploy.sh pins that image in the file Compose interpolates from,
    waits for health, then asks /api/build which revision is actually serving
    and rolls back to the previous image if it does not match. Healthy is not
    the same as running what you asked for. The rollback path was exercised.

  - The entrypoint applies migrations before the app starts, so code and schema
    arrive together. node-pg-migrate takes an advisory lock; losing it is not an
    error, it waits and looks again, so a rolling restart does not fail. A real
    migration failure stops the container rather than serving on a schema that
    does not match the build. RUN_MIGRATIONS=false opts out.

  - The Forgejo workflow builds through that same script, tags by full revision,
    and has an opt-in deploy job. It refuses to run if the deploy directory has
    uncommitted work rather than resetting over it.

The running image was labelled revision=unknown, and /api/build said "unknown",
because `docker compose up --build` never passes GIT_REVISION. That is exactly
the hole this closes.

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
Daniel
fed4bd154f fix: recordings that produced nothing, and the boxes that zoomed on iOS
All checks were successful
Forgejo Android APK / Root app tests (push) Successful in 49s
Forgejo Android APK / Build signed APK (push) Successful in 2m8s
Measured in a real browser against the app rather than reasoned about.

The transcript boxes are contenteditable divs, and an editable div zooms on
focus exactly like an <input>. The earlier 16px sweep covered input, textarea
and select, so every workspace tab still zoomed while the calculators did not —
which is exactly what was reported. Every focusable text control in every tab
now measures 16px at phone width; the count of ones below it is zero.

Three ways a recording could end with nothing to show for it:

  - Safari supports none of the audio/webm types and throws NotSupportedError
    when handed one. Six modules built their own recorder on resume with
    "opus, else audio/webm", so resuming threw there and the recording stopped.
    There is now one codec chain in the app, and no module constructs a
    MediaRecorder of its own.

  - audio-recorder-failed is dispatched on document, and the encounter tab
    stopped its recording on any of them. The assistant's microphone failing
    ended a consultation being recorded in another tab. The recorder now
    travels with the event and the listener checks it is its own.

  - The server answers {success:true, text:''} for silence, and five modules
    assigned that straight into the transcript — emptying the box the browser
    had been filling live. It reads as a recording that vanished. Text is now
    required before overwriting, and a recording that captured nothing says so
    instead of resetting the button over an empty box.

Also: the citation counters were registered on prom-client's default registry
while the app serves its own, so they were never scraped. They read zero at
/metrics now instead of being absent, which is what the Grafana panels need.

And the reference linter passes for the first time, so scripts/e2e.sh gets past
its preflight: KaTeX is vendored (it was referenced by the assistant's LaTeX
rendering but never shipped — three 404s a page load and no math), and the
JavaScript left behind by the removed image picker, saved-chats toggle, image
gallery and visual-output panel is gone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-11 00:16:19 +02:00
Daniel
050a7d5241 feat: citation quality tracking, and the SSO settings fit a phone
Citation quality
- A citation naming a source that never came back is never rendered as a
  link, so it appears as plain text and nobody learns it happened. It is now
  measured on the server, where the answer and the sources both exist, so it
  is seen whether or not a browser rendered it.
- Four Prometheus counters feed a Grafana dashboard (Ped-AI Citation
  Quality): answers, citations written, answers affected, and individual
  unresolved markers. Only answers with at least one unresolved citation are
  stored, with the question and the titles retrieval returned, so an operator
  can judge whether retrieval came back thin or the model over-cited. Rows
  expire after 30 days: this is a quality signal, not a transcript log.
- Both answer paths are covered. /chat/stream is normal; /chat is the
  fallback the client uses when streaming fails, so auditing only the first
  would have hidden exactly the answers produced under failure.
- The tracker is resolved on demand and allowed to be absent. Seven test
  files load this route with a hand-built list of permitted imports, and
  adding a hard dependency would mean editing all seven — and the eighth
  written later would break. Observation must never be able to fail an
  answer, so a missing module simply means no tracking.
- Metric registration reuses an already-registered counter, because this
  module can legitimately load twice in one process.

SSO settings on mobile
- Six rows were laid out inline: flex with a 160px label and an input that
  would not shrink, so on a phone the row was wider than the screen with
  nothing to scroll and no way to reach the rest. They use .admin-row now,
  which already stacks below 640px. Verified at 390px and 360px: nothing
  off-screen, no sideways overflow.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-10 23:44:43 +02:00
Daniel
272ea94768 feat: admin lockdown, so several admins do not all get to change everything
With more than one admin, every setting in the panel was editable by all of
them — prompts, model policy, retrieval budgets, SMTP, email templates.
ADMIN_LOCKDOWN separates running the service from changing how it behaves.

It is an environment variable on purpose: a setting could be switched off by
the very admin it restrains, so lifting this needs host access and a restart.

The server is the control. One gate refuses configuration writes rather than
a check in each of the fifteen write routes, because that list grows and a
route added later would quietly miss it. Reads always pass — lockdown hides
nothing. Day-to-day operation stays available: invitations, announcements,
registration, feature flags, and the test endpoints, which persist nothing.
A setting invented later is locked until someone deliberately makes it
editable, rather than defaulting to open.

The panel disables what it cannot save and says why, but that is courtesy;
the refusal is what enforces it.

Two things this taught me, both fixed: my first version painted the panel
from an IIFE, which the module conventions forbid, and fetched the whole
config a second time just to read one flag — breaking the test that pins
admin loaders firing exactly once. The state now rides on the invites
response the panel already requests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-10 23:20:29 +02:00
Daniel
39c1663334 feat: invite-only registration
registration_enabled was a single switch: open to anyone, or closed to
everyone. This adds the setting an operator actually wants in between — open
to people you invited.

A code is single-use, expires (7 days by default, 90 maximum), and can be
revoked or deleted. It is stored hashed with only its last four characters
kept, because an invite grants account creation and a database dump should
not hand someone a working one. The code is readable exactly once, in the
response that creates it.

The claim is a single conditional UPDATE carrying every condition, so two
registrations racing the same code cannot both succeed. It happens after the
account exists, so a code is never spent on a failed registration — and if
the race is lost, the just-created account is removed rather than left behind
as a free registration. The rejection never says which of the four reasons
applied; distinguishing them would tell someone probing codes which guesses
were closer.

Codes avoid I, L, O and U so they survive being read aloud or copied off a
screen, and matching ignores case and separators.

The sign-up field appears only when the server says a code is required. The
admin card creates, lists, revokes and deletes, and carries the toggle.

Verified against the live database: create, claim, second claim refused,
unknown code refused, revoking a used code refused, delete. 684 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-10 23:12:07 +02:00
Daniel
cd27293a52 docs: .env.example lists every variable the app reads
It documented 18 of 67, so anyone setting up a deployment had to find the
rest by reading source — including CORS_ORIGINS, REDIS_URL, NODE_ENV and the
whole Clinical Assistant retrieval group. Each entry now shows the default
the code falls back to, so an unset line reads as "this default" rather than
"broken".

Also documents the OpenBao switch: the entrypoint only fetches secrets when
OPENBAO_ADDR is set, a compose or env_file value always beats the OpenBao
copy, and turning it off means unsetting that one variable and putting the
keys here — with the command to list the key names without printing values.

A test now derives the set of variables from source, so this cannot drift
again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-10 22:09:56 +02:00
Daniel
6dcdf36c81 fix: resolve a citation by its number, not its position in the array
A marker names a source by the `number` dedupeSources assigns server-side.
Rendering looked it up as sources[n - 1], which works only while the array
order and the numbers agree. Nothing breaks that today, but it is an implicit
contract across a network boundary: any later filtering or reordering of the
list — hiding low-score sources, say — would point citations at the wrong
source silently, which is worse than not linking at all.

Matching on the number cannot drift. Positional lookup remains as the fallback
for a list whose entries carry no number.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-10 19:10:54 +02:00
Daniel
e2444066a0 feat: the announcement banner renders Markdown, safely
It was textContent, so formatting appeared literally. It now renders inline
Markdown only — bold, italic, code, links, breaks — because this text is
injected into every page and must not be able to introduce headings, images
or block layout that shifts the app around.

parseInline rather than parse, a tag allow-list, event-handler and style
attributes forbidden, data attributes off, and one innerHTML assignment that
can only be reached through DOMPurify. Missing either library, or any failure
while rendering, falls back to the literal text rather than to unsanitised
markup.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-10 19:08:12 +02:00
Daniel
881534234d docs: correct the DeepL claim and refresh the renamed paths
CLINICAL_ASSISTANT.md described DeepL as an admin-configurable translation
provider with its own keys and a fallback between providers. There is no
DeepL client in the code: TRANSLATE_PROVIDERS is ['libretranslate'], and an
unrecognised setting silently falls back rather than being rejected — the
first wording I wrote claimed a rejection that does not happen either.

TODO.md now names clinical-storage-milvus and
personal-assistant-storage-milvus, and records the MinIO audio-backup work
as finished.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-10 18:47:49 +02:00
Daniel
db208031ae docs: Ops Docs page on how Ped-AI and the two Milvus servers work
Written in the operator-handbook style of the Ops Docs collective (title,
last-updated line, short prose, tables, a quick-checks block, no secrets).
Ready to paste into the collective, or to publish once writing to Nextcloud
is permitted — that call was blocked by my permission layer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-10 18:37:41 +02:00
Daniel
d5001ed0b8 chore: follow the storage stack's renamed assets network
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-10 18:08:55 +02:00
Daniel
717c363eb4 chore: the storage repos are named for what they hold
ped-ai-storage held the personal assistant index, and nextcloud-mcp-server
held ped-ai's clinical index and runs no MCP server at all — only etcd,
MinIO and Milvus. The names said the opposite of the truth:

  ped-ai-storage       -> personal-assistant-storage-milvus
  nextcloud-mcp-server -> clinical-storage-milvus

Only the secret bind mounts here name those paths. Both compose files pin
`name:`, so containers, networks and volumes keep their existing names and
no data moves; that also means the container prefixes still read
ped-ai-storage_*, which can only change by copying each volume.

Verified: all three stacks' compose configs validate, ped-ai recreated with
the new secret paths and reads them, and the storage stack still resolves
its own containers from the renamed directory.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-10 17:58:20 +02:00
Daniel
596fd897f6 docs: record the mail fixes and the collection rename
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 17:42:30 +02:00
Daniel
846143ebe5 refactor: ped-ai calls clinical_semantic_search only
The nc_semantic_search alias is gone from the MCP server, so accepting it
here would point retrieval at a tool that no longer exists. A stale
override now stops the app at startup instead of silently retrieving
nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-10 17:22:40 +02:00
Daniel
2200587b40 chore: audio backups now live in MinIO
The app key carries a second policy covering only the audio-backups bucket,
so the already-mounted credentials serve both. Verified on the live stack:
storage=object, key recordings/<user>/<stamp>, round trip byte-identical,
and the object is removed with its row.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 17:18:59 +02:00
Daniel
f89dc01729 refactor: one place decides which bucket, on which S3, with which credentials
Three S3 configurations had grown separately — S3_* for documents,
GENERATED_IMAGES_S3_* for images, and AUDIO_BACKUPS_S3_* after them — with
different key names and their own client construction. That is why moving
storage meant hunting through several files.

src/utils/objectStorage.js now resolves settings for any purpose: its own
variables first, then the shared S3_* ones, with a per-purpose bucket name
(S3_BUCKET_AUDIO_BACKUPS). One endpoint plus three bucket names is enough
for the whole app, and a purpose that needs its own account still overrides
everything. Audio backups and documents use it; generated images keeps its
own tested storage module, whose variable names the resolver already
understands.

Nothing existing has to change: S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY and
the AWS_* fallbacks still resolve, and path-style addressing keeps each
purpose's previous default — off for documents, so a Backblaze endpoint
behaves as before, on where a custom endpoint implies MinIO. A _FILE
credential now always beats an inline one, so a mounted secret cannot be
shadowed by an inherited environment variable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-10 17:09:13 +02:00
Daniel
713ed830a3 chore: script to move audio backups onto MinIO; record where things stand
Same MinIO, its own bucket, as asked. The audio-backups bucket is created;
what remains is a MinIO IAM change, which needs root credentials — my
permission layer blocked that call, so it is a script to run rather than
something I applied.

The script is additive and reversible: it attaches a second policy covering
only the new bucket and carries the existing generated-images grant over
rather than replacing it (attaching only the new one would break image
storage). It prints the AUDIO_BACKUPS_S3_* values to set, and how to undo.

A bucket policy alone does not work here: MinIO evaluates the user policy
first and it denies by default. Verified — the app key gets AccessDenied on
the new bucket until its own policy allows it.

TODO records that, and the indexer being repointed from Documents to
Personal assistant so mail is finally reached.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-10 17:00:02 +02:00
Daniel
80d468a85d feat: a recording survives switching to the Assistant, and signing out keeps it
Switching to the Assistant set window.location, which reloads the document
and silently ended any running recording. The assistant is a tab in the same
page and activateTab already rewrites the URL to /assistant, so the switch
now happens in place; the reload stays as a fallback. Verified in Chromium:
recorder still running, no reload, URL /assistant, assistant visible.

Signing out mid-recording used to end it with nothing kept. It now says so
first — "the audio will be saved for 24 hours so you can transcribe it
later" — and stores the audio either way, tagged with the module that
produced it ('encounter', 'soap', 'dictation'), which is what makes it
findable in Settings afterwards. Sign-out completes whether or not the save
worked, and the rescue never rejects, because the caller is on its way out.
Verified: the warning appears and the upload carries module=encounter.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-10 16:54:01 +02:00
Daniel
8e4c70aaa8 docs: recording, 24-hour retention, and the STT picker
- docs/speech.md: a new Recording section (wake lock and why it is re-taken
  on visibility, idempotent start, what happens when a recording ends by
  itself, what signing out does), the retention rules, and step-by-step
  instructions for moving audio backups to MinIO.
- Records that the model for a transcription is the user's choice first,
  which is why the picker must only ever offer models the gateway has, and
  that LITELLM_STT_MODELS is a fallback rather than a list known to work.
- api-reference: /api/transcribe takes `module` and returns `backupId`, and
  the Audio Backups group is no longer failure-only.
- configuration.md and .env.example document AUDIO_BACKUPS_S3_*, preferring
  the _FILE credential variants.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-10 16:44:17 +02:00
Daniel
523926ab17 feat: keep the screen awake while recording, and keep every recording 24h
Recording
- A screen wake lock is held for as long as a recording runs. Browsers drop
  the lock whenever the page is hidden, so it is taken again on return —
  without that, one glance away ended it for the session. The lock is
  reference counted (two recorders cannot release each other's), never
  requested while hidden (the request would just be rejected), and a denial
  or an unsupported browser leaves the recording running.
- Signing out releases it and stops the recording; nothing is sent, because
  the session that owned the audio is gone.
- start() on an already-running recorder is now a no-op instead of replacing
  the MediaRecorder and silently dropping everything captured so far.
- A recording that ends by itself — recorder error, or the microphone taken
  by another app, unplugged or revoked — takes the same path as pressing
  Stop, so it is transcribed and stored rather than left in a tab that still
  says "recording". Moving around the workspace already kept recording.

Retention
- Every recording is kept for 24 hours now, not only the ones whose
  transcription failed. /api/transcribe already has the audio, so this costs
  no second upload, and a storage failure is logged rather than thrown: it
  must never lose the transcription someone is waiting for.
- One store (src/utils/audioBackupStore.js) is shared by /api/transcribe and
  /api/audio-backups so the two cannot drift. Payload goes to object storage
  when AUDIO_BACKUPS_S3_* is set and to the encrypted Postgres column
  otherwise; metadata always stays in Postgres, so listing, ownership and
  expiry behave the same either way. Object keys are scoped by owner, and
  the expiry sweep deletes the object with the row.

Verified against the live database: round trip byte-identical, another user
reads null, 950 -> 48 bytes compressed, expired rows take their objects.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-10 16:42:31 +02:00
Daniel
dca1ebb6fe fix: admin routers authenticate themselves; track the audit in TODO
- adminMilestones was reached only because adminConfig is mounted on
  /api/admin ahead of it and guards the whole path. adminMiddleware checks
  req.user.role and nothing else, so it failed closed (403) rather than
  open — but on mount order, not intent. It now states the requirement,
  with a test covering all four admin routers.
- TODO.md records the whole audit: what was verified working (live
  transcription round trip, voice mode wiring), what was fixed, the two
  advisories that are unreachable and why, and the CI/CD and Kubernetes
  work worth doing before scaling out.

Security review found nothing else exploitable: parameterised SQL
throughout (the one interpolated table name is allowlisted), CORS refuses
to start open in production, JWT_SECRET refuses to start unset in
production, rate limits on /api and each auth route, a real CSP, and no
secrets in the repo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-10 16:03:20 +02:00
Daniel
48a3b06ebb feat: export a recording; report a recorder that has silently died
Export
- Both stores already held the audio (server /audio-backups/:id/audio and
  the local IndexedDB record) but nothing exposed it, so a recording could
  not be taken out of the app. Each backup row now has a download, naming
  the file by its timestamp and using the extension actually recorded
  (webm, or m4a on iOS). A local record is only handed over to the account
  that owns it.

Robustness
- MediaRecorder had no onerror and nothing watched the audio track, so a
  recorder that failed, or a microphone claimed by another app, unplugged,
  or revoked, left the tab saying "recording" while capturing nothing.
  Both are now reported once, with the chunks captured so far kept, so
  stopping still returns the audio up to the failure.

Deliberately not added: a wake lock. Stopping when the screen sleeps or the
session ends is the intended behaviour — recording is meant to be
deliberate, and nothing is left behind.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-10 15:58:58 +02:00
Daniel
f0f48a3578 fix: patch nodemailer; Settings offers STT models the gateway really has
Security
- nodemailer 9.0.1 -> 9.1.1, clearing four high advisories, two of which
  are delivery bugs that matter for an app that sends mail: recipient-domain
  validation bypass via RFC 5322 comments, and an IDN/punycode allow-list
  bypass, both of which can route mail to an attacker-controlled domain.

Live transcription
- The Settings picker was a hardcoded list of six ids
  (local-whisper-*, local-parakeet-v3, gemini-*). None of them resolve on
  this gateway, and /api/transcribe prefers the user's choice over the admin
  default, so picking one broke every recording with "Invalid model name".
  Verified against the live gateway: local-whisper-large-v3-turbo -> 400.
- The picker now lists what /model/info advertises as audio_transcription,
  cached for five minutes, with the built-in list kept only as a fallback
  and the admin default marked.
- The pipeline itself is healthy: local-kokoro-tts produced 92KB of speech
  and mistral-voxtral-mini-transcribe returned the sentence back verbatim.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-10 15:54:57 +02:00
Daniel
31abddb6e6 fix: correct a false Settings claim; make every test child's stdout pure TAP
Feature audit
- Settings claimed you could reference a template by saying "use my normal
  physical exam" in dictation. No phrase handling exists anywhere, and the
  prompt says the opposite: "Never copy clinical content from a template —
  only formatting and structure." So a template can never supply findings.
  The text now says what happens, and keeps the true privacy statement that
  only template categories go to the AI (Custom is filtered out in
  /memories/context by AI_CONTEXT_CATEGORIES).
- Templates themselves are real: CRUD plus /memories/context, injected as
  style hints by hpi, soap, sickVisit, wellVisit, edEncounters and
  hospitalCourse, behind the `memories` feature flag.

Docs
- docs/CLINICAL_ASSISTANT.md listed six settings and offered `deepl`, which
  no longer exists in the code. The table now covers all seventeen keys the
  server reads, with their fallbacks, plus how a model reaches a user.

Testing
- Every test file's stdout is now pure TAP, which is the stream node:test
  parses results from. Three sources: a leftover debug console.log dumping
  600 characters of HTML, page modules logging into a JSDOM without a
  virtual console, and the server startup banners. The banners are guarded
  by NODE_TEST_CONTEXT, set only inside node:test children, so production
  and `node server.js` output is unchanged (verified both ways).
- Three consecutive full-suite runs at 671/671.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-10 15:41:44 +02:00
Daniel
cf7a3161a8 fix: stop iOS zoom, compact model control, quieter composer, better search
iOS zoom (and the menu button scrolling away with it)
- Safari zooms the whole page when a text field under 16px takes focus and
  never zooms back; while zoomed, fixed chrome sits outside the visual
  viewport, which is why the menu button had to be scrolled to. All 144
  visible text controls were 13-15px. They are 16px on phones now.
  !important is deliberate: per-component rules like `.demo-field select`
  outrank any element selector, and a new component would slip through.
- The composer set 15px AFTER its own mobile 16px rule, so at equal
  specificity the base rule won; the size is per breakpoint now.
- Pinch-zoom is untouched: no user-scalable=no. Desktop unchanged (13/15px).

Composer
- "Ready" / "Looking up sources..." / "saved" repeated what the send button
  already shows by turning into Stop, and on a phone they wrapped under the
  model control. Kept in the DOM as live regions; nothing is drawn.
- The model control shows the word "Model" (76px) instead of ids as long as
  "openrouter-gemini-3.1-flash-image-preview", and opens the full list on
  click. The select stays as the state holder, so choosing still saves
  through the same change event.

Search
- Nothing typed lists the 19 destinations only; sub-items appear once there
  is something to match.
- Listing, matching and opening now read one attribute list instead of three
  that could drift, and bedside's emergency sections are searchable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-10 15:26:43 +02:00
Daniel
609305538a fix: drop the dead admin jump links; stop a test flake at its source
Admin links
- The [data-admin-jump] click handler had been pasted inside
  discoverModels(), so it only registered once someone pressed Search in AI
  Model Management, and re-registered on every later search. Normally
  nothing intercepted the click, so href="#" did what it says: jump to the
  top and leave "#" in the URL.
- This is an app, so the pointers are plain text naming the sections rather
  than links, and the handler is gone.

Test flake
- admin-clinical-assistant-wiring failed about 1 run in 4 with "Unable to
  deserialize cloned data due to invalid or unsupported version": node:test
  reads a test file's results back over the child's stdout, and app.js's own
  console.log landed inside a serialized frame.
- The page's console is now forwarded to stderr (jsdom 29: forwardTo).
  Verified: child stdout clean, 0 failures in 32 stress runs (was 6 in 24),
  and three full-suite runs at 668/668.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-10 15:16:47 +02:00
Daniel
adcea2a0ca fix: image models can be added and offered; solid phone top bar on iOS
Image models
- The Clinical Assistant "Image models" list waited on an old
  #assistant-image-model dropdown that no longer exists, so discovery never
  reached it and only four hard-coded fallbacks appeared — with no way to
  add any of the gateway's 50 image models.
- Image Generation search rows now have + Add / Added. Added models are
  saved as clinical_assistant.image_model_roster (validated server-side:
  up to 100 ids) and appear in the Clinical Assistant list at once; ticking
  one there offers it to users. Anything already allowed or configured
  stays listed. Unsaved ticks survive an add.
- The roster notification is guarded, so it can never fail the settings load.

Phone top bar
- The page is drawn under the status bar (viewport-fit=cover) and its
  theme colour was the removed header's blue, so on an iPhone content showed
  scrolling at the top of the screen. The row is now a real fixed element
  that extends behind the status bar (env(safe-area-inset-top), 0 in a
  normal tab), the menu button, sources pill and drawers clear it, and the
  theme colour is white.

Verified in Chromium: + Add -> saved roster -> listed unticked; tick kept;
remove works. Top bar is the only thing in the top 48px on all 22 pages;
phone menu positions unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W4rptBNvn6RYieQw54GXNS
2026-09-10 13:46:19 +02:00
Daniel
3af11e8b4a fix: source cards fit the phone; long names wrap, no nested scroll
.assistant-sources was a grid with an auto column, which grows to its
widest unbreakable content — so a long file name such as
"2021_Fleisher_&_Ludwig's_Textbook_..." widened every card past the screen.
The column is now minmax(0,1fr), cards shrink (min-width:0) and names break
onto new lines (overflow-wrap:anywhere, with word-break for iOS < 15.4).
On phones the list is one vertical scroll: no sideways pan and no excerpt
scroll box inside it.

Measured on iPhone 14 / SE, Android 360 and Pixel: before, cards reached
636px on every phone (list overflowed by 236-288px); after, the widest card
ends 12px inside the screen and nothing scrolls sideways.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W4rptBNvn6RYieQw54GXNS
2026-09-10 13:31:37 +02:00
Daniel
6e0609f285 refactor: drop the "Saved Chats" header and collapse arrow from the rail
The list is simply there now. Its collapse preference is removed too, so a
list someone had collapsed cannot come back hidden with no control to open
it. The header's CSS (desktop, phone and preview) goes with it.

Sizes, measured in Chromium before and after: switch, New chat and the
name card are unchanged (desktop card 848, phone 790). The list takes the
header's 16px, and the first date heading sits 10px below New chat on both
desktop (167 -> 151) and phone (173 -> 165; phone list top padding 2px ->
10px), matching the menu's 10px rhythm. The rail itself still never scrolls.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W4rptBNvn6RYieQw54GXNS
2026-09-10 13:27:29 +02:00