Commit graph

176 commits

Author SHA1 Message Date
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
Daniel
b8d2153cca fix: phone menu links, Workspace pill and sources column; image model discovery
Phone
- The desktop collapse rules (52px icon rail) were unscoped, and menu-hidden
  is remembered across viewports, so after collapsing on a desktop the phone
  sheet opened with its links, the Assistant/Workspace switch and the account
  name all display:none. Scoped them to min-width:769px.
- Workspace pill: the sheet now closes on tap, and the launcher is no longer
  display:none below 640px, so the pill opens the workspace instead of
  toggling a view that could never appear.
- The menu button has its own 48px row instead of floating over the page;
  layouts subtract --mobile-bar (0px on desktop, so desktop is unchanged).
- Sources open as their own scrollable band under that row, and the toggle
  sits in the row, instead of a sheet/FAB covering the composer.
- The sheet uses dvh, so the account card is not pushed below the visible
  screen by the browser toolbar.

Admin
- Image Generation card beside TTS and STT: search the gateway's image
  models and test one. No Set button: an image model is chosen per workflow.
  It searches only on demand, so opening Admin still makes one discovery call.
- Account card sits at the same height in every rail state; the model
  availability list points to where new models are added.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W4rptBNvn6RYieQw54GXNS
2026-09-10 11:39:32 +02:00
Daniel
2c9c2d12c0 fix: one mobile menu, and sources reachable on a phone
Three faults, all visible on a phone at once.

Two hamburgers, neither useful. The assistant added a drawer button of its own
AND the app's button was still there, stacked on top of it. Worse, the assistant
hid the app sidebar unconditionally, so the app's button opened something
invisible. A phone now has exactly one menu — the app sidebar — and the assistant
only replaces it with its own rail on desktop.

The collapse control had nothing to collapse on a phone, where the menu is a
sheet rather than a rail. It closes the sheet instead.

Sources were `display:none` below 640px with no alternative, so every SRC chip in
an answer pointed at nothing. They now slide up as a dismissible sheet, opened by
a button that appears only when an answer actually has citations, or by tapping a
citation itself. Tapping away or pressing Escape closes it — a sheet with no way
out is a trap.

The menu itself is sized for a thumb, borrowing the reference layout: a wide
sheet, close and search leading, tappable rows, and padding clear of the home
indicator.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0161xNW1z4vPusrXKGWcQdQu
2026-09-10 10:19:20 +02:00
Daniel
531996de1e feat: search finds what is inside a tab, and the menu head reads toggle, mark, search
Search now reaches sub-navigation, because people look for "bili" rather than
"Calculators". It reads whatever a loaded component exposes — data-calc,
data-subtab, data-section — so it covers every tab with sub-navigation instead of
one hard-coded list, and the relevant components are warmed when the palette
opens. Opening a result survives the component still loading, and reaching one
from the assistant navigates first and opens it after.

Menu head order is now show/hide menu, then the mark, then search. In the
collapsed rail every item is the same centred 52px box, so the icons finally
share one axis — the brand was a flex row with a gap and sat off-centre from the
buttons beneath it.

Settings, FAQ and Admin are no longer listed in the menu: they already have a
place in the account card, and listing them twice only made the tab list longer.
Their sections remain, and activateTab already tolerates a tab whose section
exists without a sidebar button.

The retry loops are named functions rather than IIFEs, which the module
entrypoint rules forbid.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GmpYHPSLGmXGZMyLpn2Lbe
2026-09-10 05:16:05 +02:00
Daniel
2d6eaccd96 fix: collapse to an icon rail, keep the menu scrollable, and brand as PedAI
Collapsing hid the sidebar entirely, and the toggle had moved into .menu-head
while the hide rule still targeted direct children of .sidebar-nav — so the head
went, taking the toggle with it, and there was no way to reopen the menu.

Collapsed is now a 52px icon rail rather than a disappearance: the mark, the
toggle, search and the account avatar stay reachable in place, so nothing has to
be floated over the content and there is always a way back. The assistant rail
collapses to the same strip.

Scrolling. The workspace list in the assistant rail had no overflow rule, so its
list of every app tab ran off the bottom and scrolling only appeared once you
left for the app. In the app sidebar the opposite: the whole sidebar scrolled, so
the account card scrolled away with the list and Settings and Log out became
something to hunt for. Both now scroll only the list, with the head and the
account card pinned.

The toggle was also being styled twice — as a head-row icon button and by its own
older rule with a different size and margin. It is now only the behavioural hook
that flips the icon.

Brand is PedAI, which is short enough to sit in the head row without squeezing
the controls; the collapsed rail shows the mark alone.

The reachability test now walks the toggle's real ancestor chain and fails if any
collapse rule hides a branch it sits on, rather than asserting one rule's text —
this class of bug has broken twice by moving the toggle deeper.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GmpYHPSLGmXGZMyLpn2Lbe
2026-09-10 05:06:23 +02:00
Daniel
c7820c5ae6 feat: one shell for every view, no header bar, and a search palette
The header bar is gone. Settings and Log out moved into the account card, so a
whole band of chrome had nothing left to hold. The brand now heads the menu
alongside the search and collapse controls, on one row rather than three stacked
full-width ones. btn-settings and btn-logout are retained hidden as the canonical
handlers other code already calls, rather than duplicating that logic.

One shell for every view. Each tab was a plain white page while the assistant and
the workspace launcher sat on a tiled card, so opening any menu item changed the
whole face of the app. The tiled ground and the card edge now belong to
.main-content, and only the contents differ. The assistant replaces the shell
card rather than nesting a second one inside it.

Search, in both views, from the same palette: the workspace searches the app
menu, the assistant searches saved chats. Both sources are already in memory, so
this needs no new endpoint. Cmd/Ctrl+K opens it, arrows and Enter work, and the
assistant publishes its chats rather than the palette reaching into its
internals.

The collapse icon is now directional angles that rotate when collapsed, so it
always states what the click will do; fa-table-columns said nothing.

Mobile: the sidebar slides in whole, so the in-sidebar collapse control is hidden
there, the account card sticks to the foot of a scrolling menu, and the palette
is full-bleed instead of a centred dialog on a small screen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GmpYHPSLGmXGZMyLpn2Lbe
2026-09-10 04:56:37 +02:00
Daniel
91699e45cb feat: one menu for both views, a slim top bar, and an account card
The view now decides only what the menu LISTS. Everything structural is shared,
so switching redraws the content and nothing else:

- One width. The app sidebar is 210px; the assistant rail was 260px, so every
  switch shifted the content sideways.
- One surface. The tiled ground belongs to the shared panel rather than to one
  view's empty state, so the transcript and the workspace launcher sit on the
  same background.
- One menu ending. An account card closes both menus with Settings, FAQ, Log out
  and — for admins only — Admin. These were unlabelled icon buttons in the old
  header; log out reuses the existing flow rather than a second implementation.

The blue gradient header is now a slim white bar. Being the loudest element on
screen, it made any view without it look like a different product. Everything
measured against it follows from 66px to 52px.

The composer has room around it and centres on an empty transcript instead of
sitting at the very top, and Create image joins take home, export and attach in
the + menu — it acts on the conversation, so it belongs with them rather than as
its own button on the rail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GmpYHPSLGmXGZMyLpn2Lbe
2026-09-10 04:45:51 +02:00
Daniel
b55a748361 fix: remove the assistant's second top bar and give the toggle a menu icon
The assistant carried its own bar above the layout, on top of the blue app
header that both pages now share. On desktop it held a visually-hidden title, a
mobile-only drawer button and a status pill — so it was mostly dead space, and it
made the assistant taller than the app page it is supposed to match.

The bar is gone. The ready/busy indicator moved into the composer footer, next to
the work it describes, and the drawer button floats on phones rather than
occupying a strip the desktop layout does not have. The rail now sticks to the
top of the layout instead of 52px below a bar that no longer exists.

The menu toggle used fa-table-columns, a split-pane glyph that does not read as
"menu". Both copies now use fa-bars and rotate when collapsed to show which way
they will act.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GmpYHPSLGmXGZMyLpn2Lbe
2026-09-10 04:25:10 +02:00
Daniel
db83255c58 feat: display-only sources toggle, signed-out preview, and a composer that carries the toolbar
Sources (correcting what I built earlier)
The previous toggle branched the SYSTEM PROMPT, so the same question could get a
different answer depending on a display setting — the bias this was meant to
avoid. The prompt is now unconditional: buildSystemPrompt takes no display
argument and is byte-identical either way. Hiding sources happens on the way out
— the server omits them and strips the now-orphaned [n] markers from the copy it
sends. The answer is generated, stored and exported with citations intact, so
turning the setting back on restores them without re-asking anything. Renamed to
clinical_assistant.show_sources; the old key is still honoured.

Signed-out preview (admin opt-in, default off)
A visitor may try the assistant; reaching for the workspace asks them to sign in.
Deliberately narrow:
- Reachable paths are an exact allow-list, not a pattern, so a new endpoint is
  private unless someone adds it on purpose.
- A preview visitor gets no identity at all (id: null), so nothing can be owned,
  saved, billed or addressed to them.
- The image tool is withheld rather than left to fail on a null owner, and no
  audit rows are written.
- A caller presenting a token is authenticated normally, so preview can never
  downgrade a real session; if the setting cannot be read, authentication is
  required.
- Actions needing an account are hidden rather than offered and refused.

Composer
The bar above the transcript is gone. Patient take home, Export PDF, Download
transcript and Attach images moved into a + menu in the composer, and the model
selector moved beside send — shown only when there is more than one model, as
before. Both views now start at the same top edge, so switching modes cannot
nudge the page up or down. On an empty transcript the tiled ground runs behind
and below the composer, which floats on it above centre.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GmpYHPSLGmXGZMyLpn2Lbe
2026-09-10 04:12:36 +02:00
Daniel
dd25e0edb7 feat: one Assistant/Workspace switch and one menu toggle, shared by both screens
The app IS workspace mode, so the switch belongs in both places and the only
difference between the two screens is which pill is highlighted:

- The app sidebar's "AI Assistant" nav entry is replaced by the switch, with
  Workspace highlighted. The assistant rail carries the same control with
  Assistant highlighted. Clicking Assistant from the app navigates to
  /assistant; inside the assistant, Workspace opens the launcher in place.
- Accent corrected: I had it backwards last round and turned the assistant
  purple. The app's accent is blue (.tab-btn.active in styles.css); the
  assistant now uses it throughout, so neither screen looks like a different
  product. Citation chips keep purple so evidence stays distinct from
  interactive blue.
- One menu toggle labelled "Hide menu", shared by the app sidebar and the
  assistant rail. It replaces three separate controls: a pin button, a floating
  expand button, and the assistant's own rail toggle. The toggle stays reachable
  when the rail it collapses is hidden, or there would be no way back.
- Workspace cards list clinical work only. Settings, FAQ, Admin, Docs and
  Content Manager stay in the app sidebar under Account, where they belong.
- Model availability is discovery-only again: a hand-typed id cannot be verified
  at that point, so the manual entry field is gone.

The in-page mode state machine is removed along with the whole class of
hidden-versus-display bugs it carried.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WjVc5oaAaYFNbZGLeJp6TX
2026-09-10 02:39:42 +02:00
Daniel
41ceaca413 fix: actually merge assistant and workspace into one interface
The mode switch was toggling the `hidden` attribute on elements that each carry
their own `display:` rule. A class selector beats the UA stylesheet's
[hidden]{display:none}, so every one of those toggles silently did nothing —
which is why workspace mode still showed the chat, and why coming back never
restored the saved-chat list. Every affected element was in that state.

CSS now owns both modes from a single body class, in one block, and the JS does
nothing but set that class. No element.hidden juggling remains.

One accent. The assistant used --blue for user bubbles, focus rings, blockquotes
and example pills while citations, cards and the mode switch used --purple, so
the two halves read as two different apps. Purple was already dominant (23 uses
to 6) and is the assistant's identity, so blue is gone entirely. Source cards
pick up the same shape, shadow and hover accent as the workspace cards, and a
targeted citation highlights in the accent instead of a bare border.

Go back is removed from both the topbar and the rail: the Assistant/Workspace
switch is how you leave now, and the dead handler went with it.

Create image no longer takes a full-width gradient row competing with New chat;
it is a square icon button beside it with a tooltip and an aria-label.

Also guards the delegated document listeners against double registration.
initIfNeeded already prevented a second bind in production, but nothing stopped
a stray bindEvents() from double-firing every click — which is exactly what the
test harness hit, activating a tab twice from one click.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WjVc5oaAaYFNbZGLeJp6TX
2026-09-10 01:41:53 +02:00
Daniel
1971436c26 feat: workspace mode keeps both the rail menu and the cards, merged into one look
Both surfaces render from the same pass over the app's real .tab-btn list, so
neither can drift from the menu it mirrors.

- The card view now shares the empty state's graph-paper ground, card language
  and spacing, and is centred with a max width, so switching modes reads as the
  same app instead of a page that looks nothing like it.
- Cards carry the clinical work tabs only: Settings, Admin, Docs and FAQ stay in
  the rail list, where someone looking for them will go, rather than sitting
  alongside Encounter HPI and Calculators.
- On phones the card grid is hidden entirely and the chat stays on screen — the
  drawer already is the menu there, so a second copy only pushed it away.
- The Sources column and its 330px grid track both go in workspace mode.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BkfrkQwA4YGrGw9LZSpeAq
2026-09-10 00:04:25 +02:00
Daniel
5940ebede7 fix: Workspace swaps the menu, it does not jump to a page of its own
Workspace mode rendered the app menu twice — once in the rail and again as a
card grid that replaced the chat column — so clicking it landed you somewhere
that looked nothing like the assistant. There is one menu now: the rail swaps,
the chat column stays where it is.

The Sources panel was already hidden in workspace mode, but its 330px grid track
was not, leaving an empty gutter. Both collapse states now drop the track.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BkfrkQwA4YGrGw9LZSpeAq
2026-09-10 00:00:12 +02:00
Daniel
9d6bfed3b5 feat: compose the assistant's empty state after the reference UI
Borrowed from the Tinfoil chat layout, keeping our own palette:

- The empty state leads with a title and a single status pill instead of an
  oversized icon plus two paragraphs, so the composer is what the eye lands on.
- Example prompts are raised pill buttons rather than flat outlines.
- A faint graph-paper ground behind the transcript makes the composer read as a
  lifted surface; it switches off as soon as there are messages, where it would
  fight the text.
- New chat carries its Ctrl+Shift+O shortcut on the right, and the shortcut now
  actually works — scoped to the assistant workspace so it does not hijack the
  key elsewhere in the app.
- Saved Chats is a collapsible group with a chevron, remembered per browser.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BkfrkQwA4YGrGw9LZSpeAq
2026-09-09 23:50:52 +02:00
Daniel
750efc4106 feat: Assistant / Workspace mode switch in the rail, the way Home and Code swap the page
The rail's Workspace section was a collapsible extra sitting under the chats.
It is now the second of two modes, selected by a segmented switch at the top of
the rail:

- Assistant keeps the chat column, the saved-chat list, New chat and Create image.
- Workspace REPLACES the chat column and the Sources panel with the app's own
  menu — a card grid in the main column and the same list in the rail — so
  nothing from the assistant is left half-visible beside it.

Both the rail list and the cards are built from the app's real .tab-btn
elements, so a tab added, renamed or hidden in index.html follows automatically.

Learning Hub moved down from second place to sit immediately before Content
Manager, with the other content tools rather than above the clinical ones.

Also removed dead CSS for .assistant-view-switch, .assistant-learning-view and
#assistant-learning-root: that markup no longer exists anywhere in the app.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BkfrkQwA4YGrGw9LZSpeAq
2026-09-09 23:47:41 +02:00
Daniel
9788b167f2 refactor: retrieval is text-only; admins can add model ids discovery never returns
Multimodal removal
The multimodal path called nc_multimodal_search against a second hardcoded
collection whose embedding service (multimodal-embeddings:7999) was never
deployed and ENABLE_MULTIMODAL_RAG has always been false, so it only ever logged
"multimodal search skipped". Removed rather than left as dead weight:

- clinicalRetrieval: normalizeMcpMultimodalResponse, isVisualSourceQuery,
  isRadiologyQuery, buildMultimodalSearchQuery, classifyAndRerankMultimodalResults,
  selectMultimodalResults, visualIntent, visualMetadataScore,
  shouldRejectVisualSource, allowsFrontMatterQuery, looksLikeFrontMatterPage,
  looksLikeTextOnlyPage and MULTIMODAL_CANDIDATE_LIMIT (~140 lines).
- clinicalMcpClient: multimodalSearch.
- The route's visual/text slot split is gone; the whole search limit is text.
- The "[visual PDF page match]" prompt label and the "visual PDF page" source
  badge are gone with it.

Adding models
Model availability could only be ticked from what the gateway advertised, so an
admin could never offer a model discovery did not list. Each list now has a text
field: a typed id joins the same checkbox list, is enabled by default, is
de-duplicated, and persists through the normal allowed_models save.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BkfrkQwA4YGrGw9LZSpeAq
2026-09-09 23:35:06 +02:00
Daniel
b992c6600b feat: admin can turn citations off
New setting clinical_assistant.citations_enabled (default true, admin checkbox).
With it off, retrieval, grounding and every other rule are unchanged — answers
are still built only from retrieved sources — but:

- buildSystemPrompt swaps only the citation block: the "cite factual claims with
  [1]" rules are replaced with "do not include citations, source numbers or
  bracketed markers", and the note that the sourcing requirement itself is
  unchanged. Grounding, scope, table formatting and tone rules are byte-identical
  between the two modes.
- The server strips any stray [n] the model emits anyway, from the stored answer
  rather than only the view, so saved chats and exports match what was shown.
- No sources are sent to the client at all, and the status endpoint reports the
  mode so the UI hides the Sources panel and gives its 330px column back to the
  chat instead of showing an empty rail.

Validated as a boolean in adminConfig, like the feature.* keys.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BkfrkQwA4YGrGw9LZSpeAq
2026-09-09 23:06:13 +02:00
Daniel
022869f01d fix: mobile layout regression, workspace menu in the assistant rail, prompt as principles
Mobile
body.assistant-workspace .assistant-layout is both later in the file and more
specific than the .assistant-layout rule inside @media (max-width:640px), so
phones were inheriting height: calc(100vh - 64px) — an offset for the app header
that workspace mode already hides — plus grid rules on a flex container. The
topbar then overlapped the content. Added a matching-specificity mobile override
restoring 100dvh and the flex layout.

Workspace menu in the rail
The saved-chats rail gains a collapsible Workspace section listing the app's own
tabs, so the assistant sidebar carries both its chats and the workspace
navigation. It is built from the real .tab-btn elements rather than a second copy
of the menu, so tabs added, renamed or hidden in index.html follow automatically
and admin-only tabs stay hidden.

Prompt
Rewritten as principles instead of an enumerated rulebook: "respond to the user's
latest message, not to an earlier one", "if it carries no question, ask what they
would like you to look up", "never repeat a previous answer", and for the tool
"use it when the latest message asks for a picture, or for a change to one you
just made, and not otherwise". No example words in any language remain — the
model reads the message as written. 1032 -> 788 characters.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BkfrkQwA4YGrGw9LZSpeAq
2026-09-09 22:24:10 +02:00
Daniel
d790d427ac feat: saved chats grouped by recency, a visible sidebar toggle, and acknowledgements answered cheaply
Saved chats
Grouped the way Open WebUI groups them: Pinned, Today, Yesterday, Previous 3
days, Previous 7 days, Previous 30 days, then calendar months (with the year
once it is not the current one). Chats with no usable timestamp land in Undated
rather than disappearing.

Sidebar toggle
The collapse button used <i class="fas fa-sidebar">, which is a Font Awesome PRO
icon; on the Free 6.5.0 build this app loads it rendered nothing, so the toggle
has been an invisible button since it shipped. It now uses fa-table-columns,
keeps aria-expanded/aria-controls in sync, flips its label between "Hide saved
chats" and "Show saved chats" so the collapsed state is escapable, and animates.

Acknowledgements
"Окей", "Nice", "Perfect", "gracias" and friends now get the existing
"What clinical question would you like me to look up?" reply instead of a
retrieval and a paid generation. "yes", "sure", "no" and "more" are deliberately
excluded: answers end by offering more detail, so those must still be answered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BkfrkQwA4YGrGw9LZSpeAq
2026-09-09 18:59:45 +02:00