329 commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
d94dc0e357 |
feat: the wait is a list of steps, ticked by the stream's own events
One line of changing text is now a list the reader can follow: Analyzing the question → Searching the clinical library → Found 7 sources → Writing the answer, plus any step the server reports (looking at an image, drawing one, completing a cut-off reply). Steps are added when the stream says work began and ticked when it says it ended, so the list is a record of the real work, not an animation on a timer; nothing delays the answer. The one hand-off the server cannot signal — retrieval runs before the stream opens — is done by the stylesheet, not a JS timer, so the autosave debounce keeps its clock to itself. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
fa9970458f |
fix: the export's last table column wraps again, and citations-off strips every marker form
The export styles pinned the last column of every table to one unbreakable line (width:1%; white-space:nowrap) — right for a numbers column, wrong for a two-column clinical table whose second column is the whole finding: the table grew to the width of its longest sentence and iOS then inflated the text in those cells, so the "Finding" column read in a bigger font than the "Feature" column. The pin goes, and the sheet opts out of text inflation. With citations switched off the answer is still grounded in the retrieved sources; only the markers are removed. The stripper now also removes the escaped \[1, 2\] form and [src] placeholders, which used to survive as text. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
cb5bce13e3 |
fix: the unfinished block streams as markdown, and a deployment's JS is never an hour stale
The tail — the block still arriving — was shown as raw text until its blank line came, which is the flash of asterisks, pipes and brackets the reader saw on every paragraph. It is now parsed each frame like the finished part (Open WebUI never shows the flash because it re-renders the whole message per token; ours re-renders one block). Two exceptions stay text: a block inside an unclosed code fence, so a half-written diagram is not handed to its renderer every frame, and a table header with no body row yet. JS, CSS and component HTML were cached for an hour, and the assistant's ES module imports carry no version query, so every open browser kept the old citation renderer for an hour after the deploy that replaced it. They now revalidate on every load (no-cache with the ETag), which is a 304. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
07161c6fa8 |
feat: speech models have a roster; each brings its voices, users choose across all of them
Discover lists speech models with the voices each accepts and a + Add that puts the model on tts.roster. The Roster card lists every model with a voice picker, Test, Make default and Remove. Test on any row (or a discovered model not yet added) fills the test panel's voice list with that model's voices, so Orpheus and Kokoro can be heard one voice at a time before either is chosen. The default is a pair — PUT /config/tts/default sets tts.model and tts.voice together and refuses a voice the model does not accept, naming the ones it does. The generic setter no longer takes tts.model/tts.voice one at a time, which is how a Kokoro voice got paired with Orpheus. A default that leaves the roster stops being the default. Users pick from the voices of every roster model, grouped by model in Settings; the stored value is "model|voice" so read-aloud sends the voice to the model that accepts it. A bare voice saved before there was a roster is read as a voice of the default model. chooseTTS is the one place the pair is decided, shared by read-aloud, the admin test and the settings options. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
8a6a4df121 |
refactor: citations are a markdown-it token, and the numbers you see are display order
The old renderer rewrote the text: it found "[n]" with regexes, renumbered them, and swapped the result back in — which broke inside `arr[2][1]`, inside HTML attributes, and whenever two turns disagreed about what "[3]" meant. It also had a fallback markdown renderer of its own for when the rewrite produced something markdown-it would not parse. Now "[n]" is an inline rule registered on the same markdown-it instance that renders everything else. The parser decides what is prose and what is code, a link, or a URL, so the rule never sees "[1]" inside a code span, and it steps aside for "[1](url)". Math is two more rules on the same parser instead of a regex pre-pass, so "$" inside a URL is no longer math. Identity vs display: the stored "[n]" and each card's id are the source's identity (sourceNumber) and are never rewritten. The number a reader sees is the order of first appearance, computed at render time from the token stream (orderSourcesByCitation), so "one, then seven" cannot happen and a saved chat re-opens pointing at the same cards it was saved with. Stored messages and sources are untouched; export and the modal resolve by identity. Translated HTML gets the same links through a TreeWalker over text nodes (linkCitationsInHtml) rather than a regex over markup. Deleted: renderCitationLinks, normalizeAdjacentCitationClusters, the fallback renderer (fallbackMarkdown/renderMixedList/renderFallbackTable), renderLatexText, CITATION_SCAN. Tests that asserted rewritten text now assert token output; harnesses that render for real are given a parser. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
e376f69502 |
fix: each TTS model offers only the voices it will accept
"These settings don't work" — picking a model and testing with a voice
returned 500 every time. The gateway said why, once asked directly:
voice must be one of the following voices: [autumn diana hannah austin daniel troy]
The screen was listing twelve Orpheus voices and six Kokoro ones in a
single flat list with no indication of which model would accept which,
because LITELLM_TTS_VOICES — written for one model — was treated as a
list that applied to all of them, and the Orpheus lists were pushed in
beside it. Choosing Orpheus and testing it with a Kokoro voice is not a
configuration; it is an error, and it was the default outcome.
LiteLLM cannot supply this. /model/info reports mode audio_speech for
all four models and carries no voice field for any of them. So the
mapping lives here, keyed by family so the gateway alias and the
upstream id resolve to one list, and every list was taken from the
provider rather than from documentation:
Groq Orpheus English autumn diana hannah austin daniel troy (stated by Groq)
Groq Orpheus Arabic abdullah fahad sultan lulwa noura aisha (stated by Groq)
Fish s2.1-pro alloy (alloy returns audio; the rest 400)
Kokoro sherpa/kokoro:* from LITELLM_TTS_VOICES (the gateway's own list)
The environment still wins for the model it was written for, so the
local gateway's voices can change without a code change — but it
answers for that model only. A model with no list at all is offered
nothing rather than another model's voices, and a voice known to belong
to a different family is refused.
There were two copies of this knowledge before: getLiteLLMTTSVoicesForModel
branched by family and fell through to the env list for any model it did
not recognise — which is how Fish came to be offered six Kokoro voices.
One table now.
Also in this commit: citation renumbering skips fenced code, inline code
and math, so arr[2][1] in a code block is never rewritten. Renumbering at
render time was tried and reverted — it also has to skip HTML attributes,
and every such region is another regex branch. It stays at the answer
boundary, and the saved-chat boundary is next.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
|
||
|
|
3e8972348a |
feat: a revoked invitation can be deleted
Revoked codes had a Revoke button and nothing else — the row stayed on the list for good. The reasoning was that revoke stops a live code and leaves the row behind as a record, which is right about revoke and wrong about what comes after: a revoked code is already dead. It cannot be redeemed by anyone, so keeping it only fills the list. The rule that matters is unchanged, and it is the only one that ever mattered: a code that could still be redeemed is never deletable. Deleting one takes it off the list without taking it out of anybody's inbox — the holder keeps something that looks valid, it quietly stops working, and nothing is left to say who had it. Revoke is what stops such a code; deleting it afterwards is the second half of the same thought, and it was missing. Also removed a note that had been wrong since codes became copyable: "The code is shown once, here. Only its hash is stored, so it cannot be read again afterwards." Both sentences stopped being true when the cipher was added so an invitation could be handed to somebody later than the moment it was made. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
0e1e74882e |
feat: sources are numbered in the order the answer cites them
Borrowed from the quiz app's AI Mode, where validating citations and ordering them fall out of the same pass: it collects the sources an answer actually used into an insertion-ordered map, so the list comes back in first-citation order for free. Ours listed sources in retrieval order — an order the reader never sees and has no way to follow. An answer whose first citation was [7] opened a list that began at [1], so matching a marker to a source meant hunting. Reference lists in published writing are numbered by first appearance for exactly this reason. Cited sources now come first, renumbered by first appearance, and the markers in the text are rewritten to match. Anything retrieved and not cited keeps its place after them, labelled "not cited" — the panel is also a view of what the search returned, which is worth keeping, but it should not sit among the numbers the answer used. The marker itself now shows its number instead of the word "src". Every citation read identically, so the only way to tell one from another was to hover it — which made the numbered list beneath useless to match against. The export has shown numbers since the day "src" was introduced, with no recorded reason for the difference. Renumbering happens once the whole answer is known, never while streaming: the order is the order of first citation, so a citation that has not arrived yet cannot take its place, and numbers would shuffle under the reader mid-sentence. The text is rewritten in a single pass — number by number would turn 2 into 1 and then that 1 into whatever 1 maps to. An invented citation reserves no position and is left exactly as it was. It is still not turned into a link, and citation_audit still records it; what matters here is that it cannot push a real source down the list. Accuracy was already held: a marker with no matching source never becomes a link. This changes what a reader can do with the ones that are real. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
ae602a1852 |
fix: My Resources uses the image model you chose for it, and says so on save
"The model is chosen per request, where the deck is generated" described something that happens nowhere. There is no per-request picker, and asking where it was is what exposed the real bug. My Resources read clinical_assistant.image_model while the admin screen saved my_resources.image_model. That setting was stored, returned by the API and rendered into the form — and never used by anything. Somebody noticed the field did nothing and disabled it rather than finding out why, which left a control that could not be changed and a note explaining a mechanism that does not exist. My note repeating it was wrong too. The generator now reads its own setting and falls back to the Assistant's, so an install that only ever set one model keeps working untouched, and the field is enabled again with "leave blank to use the Clinical Assistant's" — which is now true rather than a rationalisation. Saving also says what it saved. "Saved 8:31:59 PM. Decks will be reviewed by ..." answered a different question from the one an admin actually has, which is whether the model they just picked is the one that will draw. It now names each workflow's model and fallbacks back, and spells out the blank case rather than leaving a gap. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
0287fd091b |
feat: a key per search provider, and the sign-in code email looks like our mail
Two things, both about not making someone redo work. One key per provider. There was a single websearch.api_key shared by all of them, so trying a different provider meant pasting a new key over the working one and pasting the old one back to return — and the keys are not interchangeable, so a wrong pairing fails as an authentication error that reads like a dead provider. Each now has its own slot. The old shared key is still read as a fallback: whatever was configured before this is the right key for whichever provider was selected at the time. The sign-in code email went out raw, while every other message this app sends goes through emailWrapper — so the one mail a person receives while locked out was the one that looked least like it came from us. It now uses the same wrapper, and the body is built around the thing the reader actually needs: the code, alone, large, monospaced so a 0 cannot be read as an O, in a box of its own. It also names the address it signs into. A code arriving at a shared mailbox, or to someone with two accounts, is otherwise a number with no indication of what it opens — and that line is the one thing that lets a person notice a sign-in they did not start. The address is escaped; it is the only part of that mail that did not come from us. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
1242b01286 |
feat: Exa as a search source; the speech buttons say what they do
Two unrelated small things in the admin. Exa searches by meaning rather than keywords, which suits a clinical question asked as a question — the shape the assistant actually sends. It is also the only provider that returns the page text in the same call, and the snippet is the part the model reads, so asking for it there saves a second fetch per result. type is 'auto' rather than 'neural': pinning neural makes it worse at the keyword-shaped queries the other four handle well. One adapter, one dropdown entry, no other change — the route already validated against webSearch.PROVIDERS, so the backend accepted it the moment the list grew. A test now walks both lists in both directions: a provider the server takes but the admin never offers is unreachable, and one the admin offers but the server rejects is a setting that cannot be saved. The speech and transcription buttons said "Set". Directly beneath them is a Roster card headed "What has been added", and Set is the one thing that does not add anything — there is a single voice and a single model, and choosing one replaces the last. They now say "Make default", and the note says plainly that nothing joins a list. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
d33d355627 |
feat: a streaming table renders as a table, and the wait says what it is doing
Measured against Open WebUI on the same question, sampling every 250ms: it never showed a raw pipe. A real <table> appeared with 2 rows at t+3.25s and grew to 4, then 6, as they arrived. The blank-line rule alone cannot do that, and a table is the case it serves worst: a markdown table contains no blank line, so the whole of it stayed in the tail as plain text until the line *after* it landed, then snapped into place. That is the markdown flash, on exactly the content where it shows most. So a table is now rendered while it is still arriving: once there is a header, the |---| rule and one body row, the tail is rendered as markdown rather than held as text, and every complete row that follows joins it. A half-typed row is left out and appears a frame later, which is what makes the table grow a row at a time. Re-rendered each frame rather than appended, unlike a settled block: the rows arriving next carry no header of their own, so they cannot be parsed as a separate chunk. The tail is small, so the cost is small. The status was the other half of that trace: Open WebUI keeps a skeleton beside the streaming content until the answer is done. Ours removed the status the instant the first token landed — the moment it becomes most useful, because the answer is arriving *and* the assistant is still working, drawing a figure or completing a cut-off reply. It now sits above the partial answer, shimmering, until the final render replaces the bubble. And the wait itself says what is happening. Retrieval finishes before the stream opens — deliberately, so a bad request still returns an error rather than a stream — which makes that first line the only thing a reader has during the slowest part. "Retrieving and synthesizing references" described the software; "Searching the clinical library" describes the work. Mutation-tested: dropping the table rule, rendering the incomplete trailing row, showing a table with no body row, or failing to restore the status each frame all fail a test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
c14fa25c3f |
fix: the image settings card can save again; Learning Hub is out of the admin
"Not all of it was saved: Workflow not found" on every press of Save availability. The card sent image settings for three workflows, and the server has only had two since Learning Hub was removed — the DB constraint allows clinical_assistant and my_resources and nothing else. One rejection failed the whole save, so the two settings that were valid looked unsaved as well. The frontend was the only place that still believed in it. Also gone: the learning_hub.image_behavior prompt, its Learning prompts section in the admin — which held that one prompt and nothing else — and the theme's card tints, which never applied. While there: My Resources had a Model dropdown labelled "set per request", permanently disabled and permanently empty. A control that can never do anything reads as broken rather than as not applicable, so it is now a sentence saying where the model is actually chosen. Its fallbacks stay — those apply to whichever model the request picked. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
3a40ff9b6d |
fix: the theme reaches the renderer, so changing it changes the deck
Picking a theme on a saved resource did nothing. The picker saved the
choice, the route wrote deck.theme, and the renderer knew how to apply
it — the theme was lost in between.
attachFigures, the step that puts drawn figures back onto slides,
rebuilds the deck as a fresh object:
var out = { title, subtitle, date, slides };
A fresh object keeps only the fields it names, and theme was not one.
So every export rendered in the default palette, whatever the picker
said. Rendering the same deck under clinical-blue, teaching-amber and
high-contrast produced three byte-identical files; it now produces
three different ones, and they look different.
Silent, because nothing downstream could tell the difference between a
deck with no theme and a deck whose theme had been dropped — both mean
"use the default", which is also the right behaviour for an unknown id.
Known and not fixed here: the two tinted cards on a compare slide are
hardcoded blue and amber rather than taken from the theme, so those
stay the same colour under every palette. The headings, accent, rules
and bullets do change. That is a gap in the theme definition, not in
this path.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
|
||
|
|
993442f98a |
feat: the API describes itself, at /api/openapi.json
docs/api-reference.md was hand-written, and by the time anyone checked it was documenting twenty-three endpoints that answer 404 while missing others that exist. That is what hand-written reference material does: it is correct on the day it is written and silently wrong afterwards. A second hand-written document, in YAML this time, would rot the same way. So paths, methods and mount points are read from the Express router stack at request time. They cannot disagree with the app, because they are the app: 186 paths, 215 operations, and — checked — no /learning endpoints, which is what the prose version went on claiming for weeks after that feature was deleted. What introspection cannot know is what an endpoint is *for*. That half lives in src/utils/openapiRoutes.js, keyed by "METHOD /path", and it is the half that rots, so it is the half that is enforced: a Playwright spec fetches the live document and fails when the number of operations without a summary rises above 199 — the debt as measured today. A ratchet, not a target. Adding an endpoint pushes the count over and fails the build; describing one lowers the number. The failure lists the operations by name, so it says what to write. Whether an operation is public is stated per route rather than inferred from middleware. Guessing wrong there is worse in both directions: calling a public endpoint protected hides a hole, and the reverse invites a bug report. The contract spec lives in e2e rather than the unit suite because it needs the whole app mounted, and requiring server.js from node:test pulls in the database pool and hangs the run — that has happened here before. Also: e2e now runs in CI on dev, gated by a shell check inside the step rather than a job-level "if", which this Forgejo dispatches anyway and then kills with "Early termination". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
36cb742ce7 |
ci: fix the failing job, split deploy out, and drop the Android build
Three things, one subject: making CI say the truth about this repo.
## The red on every run was ours, not the runners'
Every docker-build run came back success, success, failure — the same
shape for weeks. The failing job was `deploy`, and it was failing to
*not run*:
if: ${{ github.event.inputs.deploy == 'true' }}
On a push there is no github.event.inputs at all. This Forgejo does not
treat that as false and skip; it dispatches the job, the runner cannot
resolve it, and the task ends in "Early termination". The runners were
never at fault, and nothing about them needed changing.
The `'runs-on' key not defined` line is a red herring: the `build` job
prints it too and succeeds. It names the job's *needs* target, not the
job, and the old android-apk workflow used `needs:` happily for months.
Deploy is now its own workflow with only workflow_dispatch — no
condition to evaluate, so nothing can be dispatched by mistake. No job
in either file now carries a job-level `if`. The one conditional left is
a *step* (push to registry), and step conditions are evaluated by the
runner once the job is already running, which is why that one has always
worked.
## dev and main
docker-build now runs on `dev` as well. Both branches prove the same two
things — tests pass, image builds — and only `main` publishes the image,
so nothing on `dev` can be mistaken for something deployable. Deploying
stays a person pressing a button after looking at the change.
CONTRIBUTING.md documents the flow.
## Android
Removed: the mobile/ Capacitor project, docs/mobile-build.md, and the
Android bits of scripts/release.sh. All of it is in git history —
|
||
|
|
5f738fbe30 |
feat: a theme is shown by a sample deck you download, not a picture
A picture of one slide answers a narrower question than the one a theme picker is asked. What a person wants to know is what a deck will look like in this theme — all of it, at the size it will be shown, with the fonts substituted the way they will be. One rendered slide showed one layout, in content someone then read instead of looking at. So: a sample deck per theme. Every layout the renderer can draw — title, bullets with a sub-point, both two-column forms, table, callout, figure, full-slide figure, section divider, a custom slide with shapes, an arrow and a chart, and a references slide — with filler text throughout. Download it, open it, see the theme. The text is deliberately meaningless. Clinical content in a specimen invites you to read it, and then you are judging the teaching rather than the type; that is what the old croup slide got wrong. No engine. The previous preview needed a Gotenberg round trip and a pdftoppm to produce a PNG, cached on disk because of what it cost, and could fail in ways a missing picture cannot explain. python-pptx builds the file in ~330ms and PowerPoint draws it. The link is a plain anchor, so it is there whether or not anything on the server is well. The figure placeholder rides the same path a generated figure does — attachFigures downgrades a figure slide with no picture to bullets and an image slide to a section, so without it the sample would silently stop showing those two layouts. Verified: all five themes build, 11 slides, the chart is a real chart and the placeholder embeds as real media. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
8602c7bd14 |
fix: the theme preview says what it is doing, and says when it fails
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 46s
Forgejo Docker Build / Root app tests (push) Successful in 57s
Forgejo Android APK / Build signed APK (push) Successful in 2m4s
Forgejo Docker Build / Build Docker image (push) Successful in 19s
Forgejo Docker Build / Deploy to the host (push) Failing after 1s
The box was hidden until an image loaded and hidden again on error, so a preview that failed and a preview that did not exist looked identical — an empty gap under the dropdown. That is what it looked like from the outside, and it cost a diagnosis: the endpoint renders a correct 934x525 PNG in 0.66s when called exactly as the browser calls it, cookie and all, and nothing on the page could have told anyone that. The first view of a theme is a pptx render, a Gotenberg round trip and a pdftoppm — a second or so before anything appears, and then cached on disk. A second of silence reads as a feature that is not there, so it now says it is drawing, and on failure says the look itself is unaffected, which is true: the preview is a picture of the export, not the thing that produces it. img.src is assigned after both handlers. A cached preview can complete before a handler attached below the assignment exists, which would leave the box stuck on "Drawing...". describeTheme already ends by calling it, so the call sitting beside it fired a second identical request on every load. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
a505244b97 |
refactor: remove the embedding settings, whose only consumer is gone
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 2m8s
Forgejo Docker Build / Build Docker image (push) Successful in 10s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
Embeddings existed here for Learning Hub semantic search — the card said so itself. Learning Hub was removed, and nothing took its place: the clinical corpus is embedded by the indexing service, not by this app. What was left was a settings page that configured a model, tested it, reported its dimensions, and fed nothing. src/utils/embeddings.js had exactly one importer, src/routes/adminConfig .js, which used it for the three routes this deletes. Outside those, the only mentions of embedding in the server were a comment and a settings prefix. Gone: the module, its three admin routes, the dimension probe, the Discover & test kind and its two panels, the admin.js block behind them, the embeddings. prefix from both the writable-settings allowlist and the lockdown list (it can no longer be written at all, so locking it says nothing), and docs/embeddings-setup.md, which documented Learning Hub search end to end. 'embedding' stays in NON_CHAT_MODES — that is the filter keeping embedding models out of the chat-model list, and the gateway still serves them. Docs still describe nine /api/learning endpoints that no longer exist, left from the Learning Hub removal. Not touched here; that is its own subject. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
9df2230479 |
refactor: rebuild the admin settings page around what each card does
The page had grown by accretion: model discovery scattered across five cards with a search box each, one Save writing eight keys from the bottom of a card that also held a second Save for something else, and a banner apologising that a button "applies only the settings above it". Now it reads in groups — Accounts, Models, Assistant & prompts, Site — and every card is a <details> that folds, so Save & Close means something. The rule is that each card saves exactly what it shows, which is what removed the need for the banner. Models is one workflow in three steps. Discover & test has a single search box and a kind switch (chat / image / speech / transcription / embedding); the five discovery calls are unchanged, the switch only decides which one answers. Roster is what has been added, including the image roster, which had no visible list before. Availability is the chat model, the two allowed lists, the per-workflow image settings and the slide reviewer, under one Save. Splitting the eight-key save follows from that rule: Save & Close writes the five retrieval and citation keys; Save availability writes the chat model, both allowed lists, the reviewer and the three image-settings PUTs. No route, request shape or setting key changed. Switching kind clears the results first — a row button would otherwise add an image model to the chat roster. The kind switch dispatches its event through document.defaultView's CustomEvent. jsdom refuses one built from another realm, and the existing announceModelsChanged() has exactly that bug: its event is built from the Node global, dispatchEvent refuses it, and a try/catch swallows the error — so models-changed propagation has only ever been source-grepped, never actually tested. Left alone here to keep this change to one subject. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
c68e3a6219 |
fix: "Sign in with Nextcloud" opens the sign-in page
The button did nothing. It claimed a blank tab during the click and pointed it at the login URL once the request returned — the standard way around a popup blocker, and it cannot work in this app: helmet sends Cross-Origin-Opener-Policy: same-origin, which severs the handle to that tab the moment it goes cross-origin. Assigning its location was a no-op. A blank tab opened, nothing else happened. The handle was never needed. window.open with 'noopener' asks for none, and a click's user activation outlives the fetch, so the browser does not treat it as a popup. The status line now also carries the sign-in URL as an ordinary link, so there is a way through whatever any particular browser decides about opening windows. Verified the server side against the real Nextcloud first: the flow starts, both returned URLs pass the SSRF guard and the same-host check. The fault was entirely in the browser. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
89e71b3b86 |
fix: stream the assistant's answer a block at a time
Markdown only means anything once a block is finished. Half a table is a row of pipes; half a fence is a stray ```. Re-parsing the whole partial answer every 180ms therefore flickered between a broken parse and the real thing, and the previous answer to that was to abandon markdown past 3500 characters or eight pipe rows and dump raw text into a <pre> that had no CSS at all — inheriting the browser's black monospace default. That is the dark block people saw while a table streamed. Now the text is split at the last finished block — a blank line outside a code fence — and everything before it is rendered once and *appended*. Only the unfinished tail is plain text, styled as prose. Settled content is never re-parsed and never rebuilt, so a diagram or chart that has already drawn is not thrown away by the next token. Two blank lines are not boundaries: the gap inside a loose list, which would render one list as two each restarting at 1, and one inside indented code. Telling the first from the perfectly good boundary between a sentence and the list it introduces takes looking at both sides of the gap, not just ahead. renderEmbeddedBlocks now marks what it has drawn. mermaid.render is async and replaces the element; a second Chart on one canvas throws. Marked before the await, not after, or two frames race the same element. Streaming is a preview: 'done' still renders the whole answer from scratch, so anything transient here is settled by the final pass. That is what makes appending safe. Mutation-tested: removing fence tracking, the list guard, the backward half of that guard, the append, the drawn marker, or the state reset in fillMessageBubble each fail a test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
a7132c218e |
feat: a ciphertext now only decrypts in the row it was written for
enc1 said what a value was but not where it belonged. The blob in one user's nextcloud_token would decrypt perfectly if copied onto another user's row, and that account's exports would then go into someone else's storage. Nothing about the ciphertext objected. Adds enc2, which binds a context string — 'users:nextcloud_token:41' — as AES-GCM additional authenticated data. The auth tag covers it, so a moved ciphertext fails to open rather than opening somewhere wrong. The context is authenticated, not encrypted: it is not a secret, it is a claim about location. enc1 is still read, unchanged and forever — every existing row is enc1. Bound rows are distinguishable (isBound), so the Nextcloud token is rebound in place the next time it is successfully used, the same way a legacy plaintext one was already upgraded. That gate moved from isEncrypted to isBound; isEncrypted is true for enc1, so it would have skipped exactly the rows that need rebinding. Bound so far: Nextcloud app passwords, and invite codes — which bind to their own code_hash, since unlike the row id that exists at INSERT time. Decrypting an invite moved into list(), so the cipher and the hash that opens it no longer leave the module together. Mutation-tested: dropping the AAD, dropping the guard, gating on isEncrypted, or dropping a context at a call site each fail a test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
ba66a80491 |
chore: checkpoint before AAD binding and incremental streaming render
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
191b3b433b | wip: invite codes stay copyable | ||
|
|
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 |
||
|
|
24c8d71b7e |
feat: send a resource to your own Nextcloud, as the rendered file
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 52s
Forgejo Docker Build / Root app tests (push) Successful in 47s
Forgejo Android APK / Build signed APK (push) Successful in 1m56s
Forgejo Docker Build / Build Docker image (push) Successful in 11s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
A .pptx landing in someone's own storage is worth having; a markdown blob is not, and it is not what they would have downloaded. So this renders through exactly the path the download uses — stored deck, its figures, the chosen theme — and PUTs the bytes. The file never travels through the browser. Offered only when a Nextcloud is connected: an action that always fails is worse than one that is not offered. An article offers Word, a deck PowerPoint, and asking for slides from an article is refused with the reason. Putting a file in Nextcloud now lives in src/utils/nextcloudFiles.js. Two callers want it and neither should grow its own copy of the WebDAV dance — make the dated folder a segment at a time, PUT, migrate a legacy plaintext token — because it reaches into storage that is not ours and a second slightly different copy is how the two drift. It also replaces a route importing another route. Also: a model that leaves the roster now leaves every list that names it. clinical_assistant.allowed_models and the image roster are advisory copies of the roster, and a stale id there was invisible until someone asked a clinical question and the request failed at the gateway. Removing or disabling a model prunes it; clearing the roster clears them. Re-enabling deliberately does not re-allow it — that is a separate decision. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
b1e039d834 |
fix: a newly added model reaches every picker, including the user-facing ones
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 56s
Forgejo Docker Build / Root app tests (push) Successful in 45s
Forgejo Android APK / Build signed APK (push) Successful in 1m53s
Forgejo Docker Build / Build Docker image (push) Successful in 8s
Forgejo Docker Build / Deploy to the host (push) Failing after 2s
The first pass at this covered the two admin cards. It missed the pickers that matter most: the per-tab model selectors in app.js, which every clinical tab uses, and the My Resources model dropdown. Both were filled once at page load, so a model added in Admin was still invisible where people actually choose one. app.js's boot fetch is now a named loadModelList() that also runs on models-changed; My Resources re-runs loadOptions(), which is the same call that decides whether the model row is shown at all. Both rebuilds keep a choice already made. These selects can be rebuilt while someone is halfway through a form, and silently moving them off the model they picked would be worse than not refreshing. Verified against a mutation: removing the app.js listener fails the test that says every picker listens. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
7305443243 |
fix: the last run's illustration no longer sits under an empty form
Some checks failed
Forgejo Docker Build / Build Docker image (push) Blocked by required conditions
Forgejo Docker Build / Deploy to the host (push) Blocked by required conditions
Forgejo Android APK / Root app tests (push) Successful in 56s
Forgejo Docker Build / Root app tests (push) Successful in 49s
Forgejo Android APK / Build signed APK (push) Has been cancelled
app.js loads a tab's component once and marks it data-loaded, so the DOM survives leaving and returning. Nothing cleared the result area, and an illustration from a previous generation stayed on screen under a blank form as though it were output for a topic nobody had typed. A full page refresh rebuilt the component and cleared it, which is why it looked like a leak that fixed itself. Cleared at the start of a generation, at the start of a modification, and on re-entering the tab — not on the first visit, where there is nothing to clear. Covers the illustration area, the searches line, the image-failure line and the status text. Also documented what a modification can actually change. The deck vocabulary is structural — bullets, compare, table, callout, figure, image, section, title — and none of those carries a colour; the palette is fixed in render_pptx.py and the model never sees it. So "make it yellow" lands on the only field that takes a colour, image_prompt, and yellow figures appear on an otherwise blue deck. That is not modify reaching only the images; it is the model using the one lever the schema gives it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
31e634e0ce |
fix: a model added in Admin now appears everywhere models are chosen
Some checks failed
Forgejo Docker Build / Build Docker image (push) Blocked by required conditions
Forgejo Docker Build / Deploy to the host (push) Blocked by required conditions
Forgejo Android APK / Root app tests (push) Successful in 47s
Forgejo Docker Build / Root app tests (push) Successful in 46s
Forgejo Android APK / Build signed APK (push) Has been cancelled
Adding a discovered model refreshed exactly one dropdown — the default-model one, in the same card. Every other picker had been filled when the admin tab loaded, behind a guard that makes its loader run once per visit, so the Clinical Assistant chat model, the allowed-models list, the slide reviewer and the image-model selects all kept the roster they were given. The model was genuinely added; it simply could not be selected until the page was reloaded, which reads as the add having failed. Every mutation of the roster — add, remove, clear-all, enable/disable — now dispatches `models-changed`, and the cards that list models listen and refetch. The event carries no payload: a listener re-reads the list itself, so there is one source of truth rather than a copy to keep in step. Same pattern as the existing `assistant-image-roster` event. Each listener clears its own guard before re-running, and returns early if a load is already in flight. The assistant loader keeps unsaved drafts, so re-running it costs nothing but a refreshed set of options. The add toast said "now select it as default and click Set Default" — advice that only made sense when the default dropdown was the one thing that updated. Verified against a mutation: removing the guard reset fails the propagation test, because the listener then fires into a loader that returns early. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
ff2b2bc9d3 |
chore: Kitten and Supertonic retired; Kokoro is the local voice
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 53s
Forgejo Docker Build / Root app tests (push) Successful in 48s
Forgejo Android APK / Build signed APK (push) Successful in 2m12s
Forgejo Docker Build / Build Docker image (push) Successful in 10s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
Both were removed from the gateway in favour of Kokoro, so the app's curated voice lists for them went too. Kokoro's own check no longer has to exclude them — it names its own voices and the list is open, so it accepts anything that is not another family's voice. Voices stay curated per family rather than discovered. Models are discovered from /model/info; voices are not, because no TTS provider exposes its voice list consistently, and a list guessed from a model id is how a picker ends up offering a voice the provider rejects. Tests that pinned the retired voices now pin the families that remain. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
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 |
||
|
|
1f06a19007 |
feat: a text-only model can ask a model that can see; and the image regex is gone
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 50s
Forgejo Docker Build / Root app tests (push) Successful in 48s
Forgejo Android APK / Build signed APK (push) Successful in 2m8s
Forgejo Docker Build / Build Docker image (push) Successful in 18s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
**The regex is gone.** The route ran a pattern over the user's message and enqueued an image from the answer text when the model had not called the tool. It was a compatibility path for models without tool calling and it did more harm than good: it decided in English only, it could not see the conversation, and "image summary" fell through it while reading as an obvious image request to the model itself — which was measured, not assumed. A second and worse decision-maker sitting behind the first. Whether a message deserves a picture is now the model's call, made from the tool description, which is the only place it ever belonged. **Lending eyes.** The same shape, for a different capability. When someone attaches a photograph and the chat model cannot accept image input, the attachment was either refused by the provider or silently dropped — an answer about a picture nobody had looked at, which is worse than a refusal. The chat model is now offered look_at_image beside the image tool and decides when to use it. The attachment goes to clinical_assistant.vision_model, whose description comes back as a tool result, and the chat model answers in its own voice with its own sources. Only the seeing is delegated; the clinical reasoning stays with the model an administrator chose. The seeing model is told to report and not to diagnose, because it has a picture and no context and an opinion from it would carry weight it has not earned. Delegation triggers only on an explicit supports_vision: false from the gateway. An unknown is left alone — most of a roster reports nothing, and treating silence as blindness would route good models through a detour. The capability lookup moved to its own module, is cached for five minutes because it runs on exactly the requests that are already slowest, and is never inferred from the model id. liteLLMBaseUrl moved from the admin route to litellm.js, where the other gateway helpers live. The new setting is guarded like the slide reviewer: a model the gateway calls text-only cannot be saved as the one that looks at images. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
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 |
||
|
|
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 |
||
|
|
83206e907c |
fix: a second deck reused the first deck's pictures
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 48s
Forgejo Docker Build / Root app tests (push) Successful in 51s
Forgejo Android APK / Build signed APK (push) Successful in 2m16s
Forgejo Docker Build / Build Docker image (push) Successful in 20s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
The image job key was the request-body hash plus the figure's position — slide index for a deck, reply index for a document. Two generations from the same form produce the same body hash, so figure 4 of the second generation collided with figure 4 of the first on the unique (owner, workflow, idempotency_key). The constraint handed back the existing job, and the new deck displayed the old deck's artwork. The decks are not even the same length, so the reused picture could land on a slide about something else entirely. Keyed on what is being drawn now: the body hash stays, so submitting the identical request twice still dedupes rather than billing twice, and a hash of the prompt (plus layout and shape) is what makes two different pictures two different jobs. Same fix in deckBuild and resourceImages. Also split fileLog out of logger. logger requires the database at module load, so importing it to record a diagnostic pulls in a connection pool — wrong on its own terms, and it hung the whole test suite when imageTool started logging its refusals: a unit test that never touches a database inherited an open pool handle and never exited. logger.file now delegates to fileLog, so there is still one implementation of where a line goes and how it is redacted. With that in place, every image-tool refusal is recorded durably. There are five of them, they want five different fixes, and until now none of them left any trace once the container was replaced. Verified against a mutation: restoring the index-based key fails two of the four collision tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
94f320f140 |
fix: an empty answer from the model was the one failure never caught
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 48s
Forgejo Docker Build / Root app tests (push) Successful in 57s
Forgejo Android APK / Build signed APK (push) Successful in 2m8s
Forgejo Docker Build / Build Docker image (push) Successful in 12s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
The Clinical Assistant answered "image summary" with a zero-length string, saved that as the turn, and drew it as an empty bubble. No error was raised, nothing was logged, and the empty turn stayed in the conversation history where it degrades every answer after it. Confirmed by decrypting the saved chat: the final assistant turn is content length 0. The cause is one line. finalizeAssistantAnswer only regenerates an answer that shouldRegenerateTruncatedAnswer flags, and that function opens with `if (!answer) return false` — there is no dangling conjunction to detect in a zero-length string, so empty was classified as "not truncated" and returned as a result. Every other failure mode had a path; this one had none. An empty answer is now asked for once more — a model returning nothing is usually transient — and if it comes back empty again it raises 502 `empty_answer`, which both callers already turn into a visible error. Whitespace counts as empty, and so does an answer that strips to nothing. Without a callAI to retry with it still raises rather than returning empty. Verified against a mutation: removing the new branch fails five of the six tests. This is separate from the image question in the same request. No image job was created, because "image summary" does not match the text fallback pattern — which requires a verb (create/generate/draw/…) before the noun — and the model did not call the tool. That is left alone for now: the request is genuinely ambiguous, and guessing at it is how an assistant starts making pictures nobody asked for. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
e244ee5240 |
feat: modifying a deck can see it
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 1m0s
Forgejo Docker Build / Root app tests (push) Successful in 47s
Forgejo Android APK / Build signed APK (push) Successful in 2m13s
Forgejo Docker Build / Build Docker image (push) Successful in 24s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
The model editing a deck could not see it, which made most of what people actually ask for unanswerable: "that slide is too crowded", "the diagram is in the wrong place", "this one looks empty" are facts about the rendered page, not about the JSON. When a vision model is configured, modifying now renders the current deck — with its figures, unlike the review pass, which runs while they are still being drawn — and hands the model one image per slide alongside the JSON. Same pipeline as review, reused rather than reimplemented: pptx, Gotenberg, PDF, pdftoppm, capped at MAX_SLIDES. The vision model then does the editing, which is a second and separately measured benefit. On a real 20-slide deck, ds-deepseek-v4-flash returned the deck unchanged for "make it better" — the echo reported yesterday — while openrouter-gemini-3.8-flash applied it. So the stronger model fixes the echo even without sight. A model the author picks explicitly still wins over both. The result is rendered and reviewed again. Generation-only was the old rule, on the reasoning that refining is a text edit; it is not. The edit is made against how the deck looked before it, so a slide that gains two bullets only overflows once it is rendered again. The reviewer may reposition but is held to the same words, so a verification pass cannot quietly undo what was just asked for. Whether an instruction achieved anything is judged on the model's edit, before the reviewer runs, or a reviewer nudging a slide into two columns would mask an instruction that did nothing. Sight is an upgrade, never a dependency: no vision model, Gotenberg down, a render that fails — each falls through to editing blind, and a test covers each of those paths. Verified against two mutations: keeping the text model when images are attached, and dropping the verification pass, each fail exactly one test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
973f9d554f |
feat: a text-only model cannot be set as the slide reviewer
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 49s
Forgejo Docker Build / Root app tests (push) Successful in 48s
Forgejo Android APK / Build signed APK (push) Successful in 2m6s
Forgejo Docker Build / Build Docker image (push) Successful in 14s
Forgejo Docker Build / Deploy to the host (push) Failing after 2s
The reviewer is shown rendered images of the deck, so a text-only model there fails on every generation — at request time, long after the one moment an administrator could have picked differently. Nothing checked it. Saving my_resources.review_model now asks the gateway what it reports for that model and refuses only an explicit supports_vision === false. Three answers, not two. Most of this roster carries no supports_vision at all (every openrouter-* entry here), and refusing unknowns would block the reviewer this deployment already runs on. An unreachable gateway is not evidence about a model either, so it never blocks the save. Empty means review is off and skips the lookup entirely. The capability is read from the gateway, never inferred from the model id. Verified against a mutation: relaxing `canSee === false` to `canSee !== true` fails the two tests that say unknown must stay allowed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
491a2b0811 |
fix: the My Resources diagnostics survive a deploy
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 47s
Forgejo Docker Build / Root app tests (push) Successful in 52s
Forgejo Android APK / Build signed APK (push) Successful in 2m5s
Forgejo Docker Build / Build Docker image (push) Successful in 17s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
logRefine writes the one line that answers "did that modification change anything" — path, before and after size, CHANGED=yes/no, figures, model, instruction. It went to console, so it lived in the container's stdout and was destroyed the next time the container was recreated. That cost a diagnosis today: a modification came back unchanged, the user asked why, and the evidence had already been deleted by a deploy. The deck-fallback warnings and the deck-vocabulary gaps had the same problem, and those exist specifically to be read later — the vocabulary gaps are meant to show which shapes to build next, which is a question about weeks, not about one container. All of them now go through logger, which writes the dated file in the scribe-logs volume and ships to Loki when it is configured, and carries the event as structured data rather than only as a formatted string. console.error is left alone: those are failures, and logger.error already echoes to the console. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
07d1c541a0 |
fix: a deck the model fumbles once is asked for again, not abandoned
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 50s
Forgejo Docker Build / Root app tests (push) Successful in 48s
Forgejo Android APK / Build signed APK (push) Successful in 2m2s
Forgejo Docker Build / Build Docker image (push) Successful in 23s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
A presentation is generated as a typed deck — the model chooses each slide's layout — and that deck is what scripts/render_pptx.py renders. When the reply did not parse as a deck, the route fell straight back to asking for markdown, and slideSpec.build() then inferred slides from it. Both paths go through python-pptx, but the fallback's layout is guessed from heading and list structure rather than chosen, so everything lands as title-and-bullets. Measured on the stored library: since decks landed, 7 of 8 generations produced one and 1 did not. Models are stochastic, so one unlucky reply was costing the whole layout. It now asks a second time with the same prompt before giving up. The fallback was also invisible. It warned to the console, where the person who would simply have generated again could not see it, so they kept the plainer deck without knowing a better one was one click away. The response now carries deckFallback and the UI says it came out as plain slides, and why. Fixed the reason heuristic while adding tests for it: truncation was claimed for any reply not ending in "}", which is every prose refusal. It is now only claimed for a reply that began as JSON and stopped. The four generate tests run the handler. Verified against a mutation: removing the retry fails tests 6 and 7 and nothing else. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
e69eb9a9f7 |
fix: modifying a presentation failed whenever illustration was ticked
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 53s
Forgejo Docker Build / Root app tests (push) Successful in 54s
Forgejo Android APK / Build signed APK (push) Successful in 1m56s
Forgejo Docker Build / Build Docker image (push) Successful in 22s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
existingDeck was declared below the branch that reads it, so `var` hoisting made it undefined there. With illustration on, a deck modification therefore appended the *markdown* instruction — "Returning the markdown is still required; a tool call is not a substitute for it" — to a prompt whose body asked for deck JSON. The model was told to produce two different artifacts in one reply, the reply parsed as neither, and deckBuild.parse returned null, so the handler answered 502 "That change could not be applied." Moved the declaration above its first reader. Same class of fault as the savedFigureIds one, in the same file. Two things made it hard to see, both fixed: - The library row read created_at, so a modification that did apply left the visible timestamp on the generation time. That timestamp is what led to "modification doesn't work" — it was the only signal available, and it was reading the wrong column. Rows now show the modified time when there is one. - A model can also return the document back unchanged. That was logged server-side and answered "Applied. Download it to see the result", which sent people to download an identical file. The response now carries `unchanged` and the UI says so, keeping the instruction in the box so it can be reworded. Also surfaced has_deck on the library list: 28 of 38 stored presentations have no deck and go through the weaker flat-markdown path, and nothing in the UI distinguished them. They now read "plain text, no slide layout". test/my-resources-refine.test.js runs the handler rather than reading it, since all three faults were invisible to source reading. Verified against a mutation: putting the declaration back where it was fails test 1 and nothing else. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
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 |
||
|
|
5614a436be |
fix: a deck asked for a figure and never got one — three causes, one symptom
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 52s
Forgejo Docker Build / Root app tests (push) Successful in 52s
Forgejo Android APK / Build signed APK (push) Successful in 2m15s
Forgejo Docker Build / Build Docker image (push) Successful in 10s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
"Include a diagram" produced decks with no picture. Three separate faults, each hiding the next, found by generating the same deck after fixing each one. First, deck generation ran on the default 4000-token budget. A deck's JSON is several times the size of the prose it holds, so a long deck came back truncated, failed to parse, and fell back to markdown — which has no way to request a figure, so the model described one instead and the slide rendered a literal "![Placeholder: Flow diagram ...]" as its first bullet. Deck generation now gets room, and the fallback says how the reply failed: empty, cut short at N characters, or not a deck. Second, the figure request sat inside the layout vocabulary, one line among forty, and the model passed over it. It goes last now, after the author's own instructions — the same placement lesson the image tool taught earlier. Third, and the one that actually mattered: image_prompt is only read on the figure and image types, so an image_prompt on a bullets slide was dropped in silence. The instruction said "add image_prompt to N slides" without saying which types carry one. It now names them, and a misplaced request is honoured rather than discarded — a slide with words becomes a figure, one without becomes a full-slide image. Image markup is also stripped wherever text enters a slide, on both paths: a described figure is not a figure, and a bullet of raw markdown is worse than no bullet. Verified end to end after: the same request produced a deck with one figure, the job completed, and the exported pptx carries one embedded image across 21 slides. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
2c3fbbcf37 |
fix: decks were falling back to markdown, so no figure could ever be requested
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 47s
Forgejo Docker Build / Root app tests (push) Successful in 46s
Forgejo Android APK / Build signed APK (push) Successful in 2m3s
Forgejo Docker Build / Build Docker image (push) Successful in 22s
Forgejo Docker Build / Deploy to the host (push) Failing after 1s
A deck's JSON is several times the size of the prose it holds, and generation used the default 4000-token budget — raised for refine and for slide review, but never here. A sixteen-slide deck ran past it, came back truncated, failed to parse, and fell back to markdown. Markdown has no way to ask for a figure, so the model described one instead and the slide rendered a literal "![Placeholder: Flow diagram — "Neonate with rash" → ...]" as its first bullet, above the steps it was meant to illustrate. That is why no generated deck was arriving with an image. Deck generation now gets room for a deck. The fallback also says how the reply failed — empty, cut short at N characters, or simply not a deck — because those want different fixes and "not usable" covered all three. Image markup is stripped wherever text enters a slide, on both the deck and markdown paths, since a described figure is not a figure and a bullet of raw markdown is worse than no bullet. The model is also told plainly: if a figure is wanted say so with image_prompt, and if that is not on offer, write the slide without one rather than describing the picture you would have drawn. Separately, the Documentation list showed ARCHITECTURE, CLINICAL_ASSISTANT, DEVELOPMENT, MODULE_CONVENTIONS and SCALING shouting in caps with underscores intact: the label builder replaced hyphens but not underscores, and uppercased the first letter of each word rather than normalising the case, so a SHOUTING_FILENAME stayed shouting. It now reads "Clinical Assistant", keeps acronyms as acronyms (AI, API, OpenID, LiteLLM) and leaves joining words lower. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
3ec65a91f6 |
fix: a figure asked for while modifying a deck now belongs to a slide
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 46s
Forgejo Docker Build / Root app tests (push) Successful in 47s
Forgejo Android APK / Build signed APK (push) Successful in 1m56s
Forgejo Docker Build / Build Docker image (push) Successful in 8s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
Ticking "Add illustrations" on Modify offered the image tool regardless of what was being edited. The tool returns job ids and has no way to place them, which is fine for markdown — there is nowhere to put a figure in markdown anyway — and wrong for a deck, where figures are placed by a slide declaring them. So modifying a deck with illustrations on generated a figure, paid for it, recorded it against the resource, and referenced it from nothing. Measured: one figure recorded, zero referenced by a slide, and absent from the export. Deck mode now asks the revised deck to declare its figures and draws them with the same drawFigures() generation uses, so each one belongs to the slide that wanted it. Slides that already have a figure keep it. The tool path stays for markdown resources, where it is the only option. Verified: the same modification now records one figure, one slide references it, and the exported deck embeds one image. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
012346528c |
fix: generation stopped working whenever the slide reviewer was switched off
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 51s
Forgejo Docker Build / Root app tests (push) Successful in 1m0s
Forgejo Android APK / Build signed APK (push) Successful in 2m35s
Forgejo Docker Build / Build Docker image (push) Successful in 17s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
savedFigureIds was declared inside the review branch, so with no reviewer
configured — the default, and what everyone is running — it was undefined by the
time the INSERT stringified it. JSON.stringify(undefined) is not a string, the
column is NOT NULL, and every generation failed with "Generation failed". `var`
is function-scoped, so nothing complained until the database did.
This is the second bug of exactly this shape in this file, so the test asserts
position rather than presence: the value must be declared before both the review
and the insert read it.
Found by the logging added in the same change, which is the other half of this
commit. Every modification now says what it did:
[my-resources] refine id=29 path=deck outcome=applied 13→14 slides changed=yes
[my-resources] refine id=37 path=markdown outcome=applied 2635→3018 chars changed=yes
CHANGED=no is warn-level and deliberately shouty, because that is the failure
worth catching: the response says success either way, the row updates, and the
download is identical — which is exactly how the deck bug went unnoticed. A
refusal logs its reason. ped_ai_resource_refine_total{path,outcome} counts the
same thing over time, so "did that modification do anything" is answerable
without watching logs live.
Verified across every path rather than the one that was broken: a deck
presentation modified and exported to both pptx and docx carries the change; a
legacy presentation with no stored deck still takes the markdown path and
carries it; an article generates, modifies and exports; and a presentation
generates with the reviewer off.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
|
||
|
|
f66daf0c02 |
fix: modifying a presentation changes the presentation, not just its markdown
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 49s
Forgejo Docker Build / Root app tests (push) Successful in 52s
Forgejo Android APK / Build signed APK (push) Successful in 2m0s
Forgejo Docker Build / Build Docker image (push) Successful in 11s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
Export renders a presentation from its stored deck. Refine edited the markdown beside it and never touched the deck — so a modification reported success, updated the title and the library row, and produced a byte-identical download. Nothing said otherwise. It looked like the model had ignored the instruction. Measured before the fix, with a marker that was definitely not in the deck: refine succeeded, the stored markdown gained the new slide, the stored deck did not, and the exported pptx did not. After: the export gains the slide and the marker, twelve slides where there were eleven. A presentation with a stored deck is now edited as a deck — the deck goes to the model, a revised deck comes back, and the markdown is serialised from it, which is the same direction generation runs in. Layouts, custom slides and image_job values survive a modification instead of being flattened away. A reply that is not a usable deck is refused rather than saved as markdown: saving it would drop every layout the deck held while looking like it worked, which is the failure this commit exists to remove. Articles have no deck and keep the markdown path unchanged. The reply restates the whole resource, so the token budget is raised to match — the old default was already close to truncating a long deck's markdown. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
4f8e686907 |
feat: record what a deck wanted and could not have
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 47s
Forgejo Docker Build / Root app tests (push) Successful in 47s
Forgejo Android APK / Build signed APK (push) Successful in 1m58s
Forgejo Docker Build / Build Docker image (push) Successful in 13s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
The shape vocabulary is deliberately small, which leaves the question of what to
add next. Rather than guess, it now records demand.
Two signals, because a model asks both ways. It can say so outright —
{"kind":"unsupported","need":"a SmartArt cycle of four stages"}, which draws
nothing and is told about in the same file that validates it — or it can reach
for a kind, chart type or slide type that does not exist, which is the more
common way of asking and just as much of a signal.
Both produce a log line naming what was wanted and the topic it came up on, and
increment ped_ai_deck_vocabulary_gap_total{wanted}, so it can be counted over
time in Grafana rather than noticed once and forgotten. Deduplicated per
generation and capped at twelve: a model that asks for a hundred things it cannot
have should not write a hundred log lines. It can never fail a generation — it is
a note to whoever decides what to build next.
This is also the answer to whether to run model-authored code in a sandbox
instead. The log will say whether the gap is real. Some of it is not closeable by
any sandbox, being python-pptx's own ceiling — no SmartArt, no animations or
transitions, limited chart types — and a sandbox would only let a model write
code against the same library and hit the same wall. Documented in
docs/my-resources.md, which the in-app Docs tab serves directly.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
|
||
|
|
af2e09c1de |
feat: a slide can be drawn from primitives when the named layouts have no word for it
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 1m7s
Forgejo Docker Build / Root app tests (push) Successful in 47s
Forgejo Android APK / Build signed APK (push) Successful in 2m16s
Forgejo Docker Build / Build Docker image (push) Successful in 16s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
The nine layouts are a fixed vocabulary and a good default, but "lay the three severity levels out left to right with arrows between them" had no expression in them at all. A "custom" slide now carries a list of shapes: positioned text, eight autoshape families, lines, images, tables, and native PowerPoint charts — column, bar, line, pie, doughnut. Coordinates are percentages of the slide rather than EMU, because a model reasons about "the left half" and not about 12192000. Shapes draw in array order, so a later one sits on top. The model never emits Python. It names shapes and the renderer draws them: running model-authored code to lay out a slide would be an enormous amount of trust to buy a feature, on a server holding clinical data and secrets. Validation lives beside the text that teaches the vocabulary, in one file, so what the model is told about is exactly what is accepted. Kinds are an allowlist, colours must be six hex digits, coordinates are clamped inside the slide — a shape at x=95 w=30 is cut to the edge rather than drawn half off it — counts are capped, a pie is held to one series, and anything that cannot be understood is dropped. A custom slide that loses every shape becomes a plain one rather than a heading over an empty frame, and one bad shape is caught in the renderer so it cannot cost the slide it sits on. A figure on a custom slide is requested through an image shape, drawn by the same path as any other, and attached by job id. Word renders a custom slide as its words in reading order with its tables and figures — lossy, and better than dropping the slide. Verified live end to end: asked to "lay the three severity levels out left to right as coloured boxes with arrows between them", the model produced [rect arrow rect arrow rect], chose green/amber/red itself, and the rendered slide was looked at. A column chart beside its commentary renders with real axes and gridlines. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
154b896d5b |
feat: Word is built by python-docx from the same typed source as the deck
Some checks failed
Forgejo Docker Build / Build Docker image (push) Blocked by required conditions
Forgejo Docker Build / Deploy to the host (push) Blocked by required conditions
Forgejo Android APK / Root app tests (push) Successful in 58s
Forgejo Docker Build / Root app tests (push) Successful in 49s
Forgejo Android APK / Build signed APK (push) Has been cancelled
Pandoc reads markdown, so every Word export had to flatten the resource to markdown first — and a deck flattened to markdown stops being one. A comparison became two headings and two lists, a callout became bold text, and a figure became nothing at all, because markdown has nowhere to put it. src/utils/docSpec.js reduces either source to the same blocks: a stored deck where there is one, the markdown where there is not. scripts/render_docx.py draws them. A comparison comes out as a labelled two-column table, a callout as a shaded box, a table as a real table, a figure embedded at its own aspect ratio with its caption, and speaker notes as muted indented text. The deck wins over the markdown beside it, because that markdown is a serialisation of the deck and reading it instead would be reading a lossy copy of what is right there. Word now carries the figures too. The export route skipped fetching them for docx, which was correct when pandoc could not place them and wrong the moment this could. Pandoc stays installed and stays the fallback: a plainer document beats a failed download. Both renderers now share one spawn helper. Verified end to end: a deck with two figures exported as a six-page Word document with both images embedded (537KB, two files in word/media), rendered to PDF and looked at — the comparison is a labelled table, the figure sits at its true aspect ratio, and the notes read as notes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
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 |
||
|
|
f3c3f47d99 |
fix: voice mode reads the answer that just arrived, and reads what the page shows
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 47s
Forgejo Docker Build / Root app tests (push) Successful in 49s
Forgejo Android APK / Build signed APK (push) Successful in 2m5s
Forgejo Docker Build / Build Docker image (push) Successful in 10s
Forgejo Docker Build / Deploy to the host (push) Failing after 2s
Two bugs, one cause each. It read the previous answer. setBusy(false) is what announces assistant-answer-done, and it ran before lastAnswer was assigned — so every listener was handed the answer before last. It now fires after the answer exists both in that variable and on the page. A test asserts the order, because the order is the whole bug. And it read the markdown. The better answer than unpicking the markup is not to have any: the rendered bubble is already the answer with its headings, emphasis and tables resolved, so voice mode reads that. It cannot drift from what the reader is looking at, and it needs no rules about what "##" sounds like. Read from a clone, with the parts that are not the answer removed — the action buttons, the sources list, the follow-up suggestions, code blocks and tables — so the page itself is untouched. A bubble still thinking is never read. speakableText() stays as the fallback for when the bubble cannot be found, since raw markdown read aloud is worse than silence. Separately: e2e/seed.js hashed with bcrypt directly, so seeded accounts did not exercise the argon2id path production writes. It uses the app's own hasher now. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
0e17f553fc |
fix: hanging up the voice call stops the voice, and the answer is read not its markup
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 1m0s
Forgejo Docker Build / Root app tests (push) Successful in 47s
Forgejo Android APK / Build signed APK (push) Successful in 2m3s
Forgejo Docker Build / Build Docker image (push) Successful in 20s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
Ending the call stopped listening and aborted the request but never stopped playback, and the <audio> element was a local variable with no handle kept — so nothing could stop it and the answer talked on until the page was reloaded. conversationMode now holds whatever is speaking, whichever of the two players it is, and hanging up silences it before anything else. Starting a new turn does the same, because speaking over the previous answer is how an assistant talks past you, and a reply that arrives after the overlay has closed no longer starts talking into a closed call. The other half: the raw answer went straight to the speaker. It is markdown, so a browser voice reads "#" and "**" aloud or stumbles over them. speakableText() now reduces it to what a person would read out — headings and bullets become sentences, links keep their words, emphasis and code fences are dropped, tables are dropped entirely because a table read aloud is noise, and a removed citation marker leaves no gap before the punctuation it preceded. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
b98ffc61c7 |
fix: expired invitations can be cleared too, revoked ones still cannot
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 47s
Forgejo Docker Build / Root app tests (push) Successful in 51s
Forgejo Android APK / Build signed APK (push) Successful in 2m2s
Forgejo Docker Build / Build Docker image (push) Successful in 12s
Forgejo Docker Build / Deploy to the host (push) Failing after 2s
An expired code is as dead as a used one and just as accounted for, so it is now deletable. The rule the code enforces is the one that matters: a code that could still be redeemed is never deleted, because that takes it off the list without taking it out of anybody's inbox — the holder keeps something that looks valid, it quietly stops working, and nothing is left to say who had it. One condition, shared by the single delete and the bulk clear: (used_at IS NOT NULL OR (revoked_at IS NULL AND expires_at <= NOW())) Written that way rather than as "used OR past its date" because the second form also catches a revoked code whose date has since passed — a row the list still labels revoked and offers no delete on, so the button and the query would have disagreed about the same row. Revoked codes keep their rows. Revoking records a decision somebody took, and a handful of them is not the clutter a pile of expired codes is. Verified against the live database across every state: active refused, used deleted, expired deleted, revoked refused, and revoked-with-a-past-date refused rather than slipping through as expired. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
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 |
||
|
|
22683f3584 |
feat: sign in with a code emailed to you, offered beside the password
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 46s
Forgejo Docker Build / Root app tests (push) Successful in 56s
Forgejo Android APK / Build signed APK (push) Successful in 2m6s
Forgejo Docker Build / Build Docker image (push) Successful in 15s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
The sign-in screen asks for an email first, then offers both ways in together: a six-digit code sent to that address, or the password. Beside rather than instead — a code depends on mail being delivered and a password does not, so neither may be the only route. "Use a different email" goes back a step, and creating an account stays where it was. What keeps it from being a second, weaker front door: - Only a bcrypt hash is stored, so a code read out of the database is not a working credential. - Ten minutes, single use, marked used before the session is issued so a replay cannot race it, and requesting a new one deletes the old. - Five wrong guesses burn it. Six digits is a million possibilities, which is plenty against a person and nothing against a script with unlimited tries. - Requesting a code answers identically whether or not the address exists, and every verify failure returns one message. A sign-in screen that says "no such account" is a way of finding out who has one. - Two-factor still applies: a code proves you can read the mailbox, which is one factor, and an account that asked for a second still wants it. - Its own rate limits, tighter for requesting than for attempting, because requesting sends mail to someone else's address. These had to be separate limiters: Express matches app.use paths on segment boundaries, so /api/auth/login does not cover /api/auth/login-code — checked against a real router rather than assumed. Two bugs found while building it, both mine: authFetch keeps an allowlist of endpoints callable with no verified owner and rejects everything else before it is sent. The new endpoints were not on it, so the request never left the browser and surfaced as "Connection error". reveal() hid elements by appending 'hidden' to className and showed them with a non-global replace, so hiding twice left two copies and showing stripped one. The "use a different email" link never reappeared. It uses classList now, which is idempotent. Verified against the running server: correct code signs in, the same code again is refused, a superseded code is refused, five wrong guesses burn it, an expired one is refused, and the stored value is a hash. In the browser: requesting a code advances the screen, a wrong code is refused without losing the screen, and the password route still signs in. Not yet demonstrated: a correct code typed into the browser. The harness keeps racing the one-live-code rule — the page's own request supersedes whatever code the test holds, and with SMTP off the delivered one cannot be read. The same request reaches the server on the wrong-code path, and the endpoint itself is verified, but that last step is untested end to end. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
087f717f55 |
feat: the model designs the deck instead of writing markdown for a parser to guess at
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 59s
Forgejo Docker Build / Root app tests (push) Successful in 53s
Forgejo Android APK / Build signed APK (push) Successful in 2m2s
Forgejo Docker Build / Build Docker image (push) Successful in 8s
Forgejo Docker Build / Deploy to the host (push) Failing after 2s
Markdown could express about five of the things the renderer can draw, so the model had no way to say "put this figure beside these three bullets" or "make this a comparison with two labelled columns" — my parser inferred a layout from the shape of a list, and inferring is what made every deck look the same. A presentation is now described as a deck: the model returns JSON naming a layout per slide and the prompt it wants each figure drawn from. Four layouts were added to the renderer for it — two tinted labelled columns for a comparison, a callout card for a red flag or a dose, a figure beside its bullets, and a full-slide figure. Articles stay markdown, which is what prose wants. Markdown is still produced, serialised from the deck, so Word export and text editing keep working and the stored artifact stays readable by a person. The deck is stored alongside it because that serialisation is lossy by design: round-tripping through markdown would throw away exactly the layout choices this was built to capture. A resource made before this, or an article forced into slides, still renders by inferring from its markdown. Nothing here can cost more than the thing that went wrong. A reply that is not a deck falls back to asking for markdown rather than saving the model's apology; a malformed slide degrades to bullets rather than throwing; a comparison with one column is not a comparison; a figure that cannot be queued leaves a slide of text rather than an empty frame; and JSON wrapped in fences or a covering sentence is read rather than refused. Verified live on "croup versus epiglottitis": the model chose section, bullets, table, compare, figure, callout and image layouts across thirteen slides, and the exported deck was rendered to PDF, rasterised and looked at — the comparison renders as two tinted cards, the red flag as a callout, and the figure sits beside its bullets. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
73ce4049d4 |
feat: decks are built with python-pptx instead of pandoc, and carry their figures
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 48s
Forgejo Docker Build / Root app tests (push) Successful in 59s
Forgejo Android APK / Build signed APK (push) Successful in 2m6s
Forgejo Docker Build / Build Docker image (push) Successful in 25s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
Pandoc's pptx writer was the ceiling on how good a generated deck could be, and the model on top made no difference to it. It maps markdown onto a handful of reference layouts with no per-slide layout, no positioning and no control over how large an image is drawn, which is why every deck came out as bullets on a template — and why autofit had to be injected into its emitted OOXML by hand afterwards, because LibreOffice ignores the autofit pandoc leaves off. scripts/render_pptx.py draws the deck and src/utils/slideSpec.js decides what each slide is. Markdown stays the stored artifact, so "change slide 4" is still a text edit and Word export is untouched — pandoc still writes docx, where its output is good. What that buys, all of it visible in a rendered deck rather than argued for: - 16:9, not pandoc's 4:3. - A pipe table becomes a real table with a header band and banded rows, not eight lines of text with pipes in them. - A list longer than seven items becomes two columns instead of a wall of text. - Text is measured and sized to fit before the file is written, so nothing depends on a renderer honouring autofit. - Wrapped lines hang under the text instead of running back to the margin, which is the clearest single tell that a deck was generated. - An image is drawn at its own aspect ratio, centred, with a caption. Figures now reach the deck at all, which they never did. They were queued and shown on the page, but nothing recorded that they belonged to the resource, so an export could not include them: user_resources.image_ids holds them, a modification adds to that list rather than replacing it, and export fetches the finished ones to a scratch directory. They are spread through the deck rather than appended, because ending on three unexplained pictures is worse than showing each near its material, and a References slide stays last. If the renderer fails for any reason, pandoc still produces a deck — a plainer deck beats a failed download. Verified end to end: a seven-slide request with three figures exported as a 13-page deck; the slides were rendered to PDF, rasterised and looked at. All three formats still download. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
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 |
||
|
|
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 |
||
|
|
05dcd1146d |
test(e2e): repair the harness, taking the browser suite from 96 failures to 11
Three separate reasons tests were failing, none of them a defect in the app. The calculators. e2e-harness.html loaded calculators.js and drugs-loader.js with `defer` after they were split into ES modules; index.html was updated at the time and this page was not. A module parsed as a classic script throws "Cannot use import statement outside a module" before a line runs, so no click handler was ever attached: the pills rendered from static HTML and did nothing. Only the first calculator appeared to pass, because it carries `active` in the markup and needs no click. That was 52 failures. Settings and FAQ. Both moved from the tab rail into the account-card menu; the helper still clicked button.tab-btn[data-tab=…] and timed out. Ten more. The AI mocks, which had stopped intercepting for two independent reasons and so were calling the real model on every run — spending credits and comparing genuine output against strings like "MOCK HPI from dictation". A '**/api/x' glob matches no URL on Playwright 1.50, and page.route fails silently when nothing matches; measured against a real URL, that glob and '*/**/api/x' both matched zero times where a regex matched. Fixing that alone was not enough: the app registers a service worker that answers every /api/ request with its own fetch(), and a request made inside a service worker never reaches page.route. Blocking registration in the config puts them back in the page. The mocked dictation test now finishes in 1.6s rather than 7.5s, which is what a real model call costs. Whole suite: 204 passed / 96 failed in 15.8 minutes, now 289 passed / 11 failed in 6.8. The remaining eleven are spread across nine specs with no shared cause and are not touched here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
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 |
||
|
|
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 |
||
|
|
79c329ceda |
test(e2e): seed an admin account, and fix the sign-in that broke the browser suite
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 55s
Forgejo Docker Build / Root app tests (push) Successful in 48s
Forgejo Android APK / Build signed APK (push) Successful in 2m0s
Forgejo Docker Build / Build Docker image (push) Successful in 16s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
Adds the admin fixture the Search Sources screen needed, and repairs the reason no browser-driving e2e test could log in at all. The sign-in failure first. The suite drove the app over http on a container hostname, which is not a secure context, so the browser provides no crypto.randomUUID. AccountBoundary calls it to mint a session generation on every sign-in; the call threw, the boot handler's catch swallowed it, and every test landed on the login screen holding a perfectly valid session. Measured: isSecureContext false and randomUUID undefined on http://pediatric-ai-scribe-e2e:3000, both true on http://127.0.0.1:3553, where boundary.enter() returns true and the app enters. Chrome's --unsafely-treat-insecure-origin-as-secure was tried first and does not work: Playwright rejects the --user-data-dir it must be paired with, and the flag alone leaves isSecureContext false. Loopback needs no flags, so the runner now uses the host network and the published port. The seed is new. The e2e user was a registration someone did by hand once that the shared Postgres happened to keep — enough to log in and no more. There was no admin account, so nothing under /api/admin could be tested through a real request, which is how the Search Sources card came to be verified by reading its markup. e2e/seed.js creates both accounts and reconciles an existing one, so a leftover with the wrong role cannot fail the suite for a reason unrelated to the code. It resets passwords and grants admin, so it refuses any address outside @ped-ai.test. The runner seeds before it tests. The new spec covers what markup-reading could not: that an ordinary account is refused the settings and never offered the Admin menu item, that no API key comes back readable, that the Test button reports each source separately, and that every control the save handler reads exists in a real render. Each account gets its own browser context, because AccountBoundary allows one owner per document and freezing the page on a second is the behaviour, not a bug. 10/10 pass on both projects. Two unit tests pin the loopback requirement and the seed's domain guard so neither can be undone quietly. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
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
|
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
a0d81789ff |
feat: My Resources — anyone can generate teaching material, privately
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 49s
Forgejo Docker Build / Root app tests (push) Successful in 47s
Forgejo Android APK / Build signed APK (push) Successful in 2m2s
Forgejo Docker Build / Build Docker image (push) Successful in 9s
Forgejo Docker Build / Deploy to the host (push) Failing after 2s
Learning is moderator-owned: content published into categories that everyone sees, behind router.use(moderatorMiddleware). That is right for institutional material and wrong as the only way in — an ordinary user could not generate anything at all. So this is a separate pathway rather than a loosening of that one. Learning is untouched; the moderator gate stays exactly where it was. A signed-in user can generate a deck or an article for their own use, keep it, refine it and export it, and nobody else ever sees it. Private by construction. Every statement filters on the owner and there is no route that returns another person's work, which a test asserts statement by statement rather than trusting. The foreign key cascades, so deleting an account takes its drafts with it. There is no category, no publish state and no sharing: adding sharing later should be a deliberate feature, not something that leaks out of a forgotten WHERE clause. Markdown is the artifact. Every format is rendered from it on demand — pptx and docx by pandoc, both carrying the house reference deck, and PDF by Gotenberg, whose LibreOffice preserves a deck's layout in a way rendering from markdown would not. That is what makes "add a slide on when to admit" a text edit rather than a binary patch. Gotenberg was published on the host but on a network of its own, so reaching it from a container went out and back through the host gateway. It now joins danvics_convert, owned by danvics-net like the others. PDF is the one export allowed to fail: if that service is down, the deck and the document still download and the error says which. Verified end to end as a plain user: the moderator route still refuses with 403, generation returned a deck grounded on 12 corpus excerpts, the library lists only their own, pptx/docx/pdf all downloaded valid, "add a slide on when to admit" put the slide in the right place and left References last, and an unauthenticated request gets 401 while someone else's id gets 404. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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
|
||
|
|
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
|
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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
|
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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
|
||
|
|
fed4bd154f |
fix: recordings that produced nothing, and the boxes that zoomed on iOS
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
|
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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
|
||
|
|
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 |
||
|
|
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 |