The feature was removed; the docs still described it as live. Worst of
it was api-reference.md, which documented nine /api/learning endpoints
and fourteen /api/admin/learning CMS endpoints — routes that answer 404
— plus POST /api/user/webdav-path, whose column was dropped by
migration. Anyone reading them was reading fiction.
Checked against the running system rather than assumed: no learning
table exists, users.webdav_learning_path is gone, generated_image_links
is gone, and no route mounts /api/learning or /api/admin/learning.
Two things that look like Learning Hub and are not, so they stay:
- learningRetrieval.js is live — My Resources uses it. Its settings keep
the learning.* names because renaming them would orphan whatever an
administrator has already set. retrieval-tuning.md now says so instead
of listing the rows under two different feature names.
- the moderator role is still assignable. It gated the CMS and now
grants nothing; authentication.md says that rather than implying
powers it does not have. moderatorMiddleware has no callers left,
which is worth removing on its own.
auth-admin-learning.md is now auth-admin.md.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
Requested: no more Android building, focus on the Docker app build and
the web app.
android-apk.yml also ran on every push to every branch, so every commit
started an APK build beside the one workflow anyone was watching. Worse,
its runner — ped-ai-android-runner — advertises the same forgejo-local
label as the general runner, so it was also picking up the Docker build
job and dying instantly with "'runs-on' key not defined". That is why
docker-build showed a failure next to a success on the same commit.
Removed: .forgejo/workflows/android-apk.yml, and the two GitHub APK
workflows, which never ran at all — there is no GitHub remote on this
repo, only forgejo.
The Capacitor project under mobile/ is untouched and still builds by
hand; docs/mobile-build.md now says plainly that nothing builds it for
you.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
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
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
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
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
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
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
**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
**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
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
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
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
scripts/deploy.sh landed on 2026-09-11 as "a deploy you can repeat, and prove
afterwards". It moves the PED_AI_IMAGE pin, waits for health, reads /api/build
to find out which revision is actually serving, and rolls back when that is not
the revision asked for. Its own comment says it: "Healthy is not the same as
running what was asked for."
No doc mentioned it. Both deployment.md and DEVELOPMENT.md instead showed
`build-image.sh` then `docker compose up -d --no-build` then `curl /api/health`
— a sequence that moves no pin and proves no revision. Following it deployed
nothing four times in one session, eventually reverting the app by 31 commits
and removing a feature, which was then reported as a bug.
Both docs now lead with deploy.sh and say why `up` by hand is not a deploy.
Also replaced yesterday's build-image.sh warning, which told the operator to
`sed` the pin themselves. That reimplemented, badly, one of the three things
deploy.sh already does — and it was written while auditing these very files
without noticing the script was there. It now prints the deploy.sh line to run.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
`docker compose up` starts whatever PED_AI_IMAGE in .env names, and
build-image.sh does not move that pin. A pin left from an earlier deploy
therefore starts the older image while every signal reports success: the build
completes, `up` says the container started, and /api/health returns {ok:true}
from the wrong revision.
This is not hypothetical. The pin here had been sitting on a revision from two
hours before the Modify card was added, so a rebuild-and-restart rolled My
Resources back 31 commits and removed the feature. The missing card was then
reported as a new bug, and three deploys in this session had in fact deployed
nothing.
build-image.sh now compares the pin to the revision it just built and, when they
differ, prints the pin, says that `up` will start it instead, and gives the
command to move it. It does not correct the pin: naming a revision is also how a
deliberate rollback is done, so this is said rather than silently overridden.
Both deployment docs now check /api/build against `git rev-parse HEAD` after
starting, because /api/health passing only proves a container is up, not that it
is the one you built.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
Three pairs of docs described the same thing twice, and the copies had drifted
apart. Merged each into one file, keeping the unique content from both:
- ARCHITECTURE.md -> architecture.md (its operational map: ownership, request
flow, runtime boundaries, source of truth, deployment shape)
- DEVELOPMENT.md -> developer-guide.md (change workflow, Clinical Assistant
high-risk areas, frontend rendering rules, deployment checks)
- transcription-options.md -> speech.md (the clinic setup table, and the list
of browser-Whisper paths that must stay removed)
Then audited what remained against the code and the live database rather than
against the previous docs. Corrected:
- Google Vertex was still documented as a provider across nine files. The SDK
is gone; AI_PROVIDER=vertex now logs an advisory and falls back to
OpenRouter, and Gemini is reached through LiteLLM. Fixed the provider
selection order to match src/utils/ai.js, which starts from LITELLM_API_BASE.
- promptSafe was documented on 8 routes; it is on 13.
- Node 20 -> 24, "24 vanilla JS modules" -> no fixed count, and
transcribe.js/tts.js -> sttProvider.js/ttsProvider.js, which is what exists.
- STT/TTS are LiteLLM-only; README listed direct Google, AWS Transcribe and
ElevenLabs paths that are not in the runtime.
- Learning Hub PPTX export was documented as pptxgenjs, which is not a
dependency. It is pandoc against a reference deck.
- POST /api/admin/milestones/seed does not exist; it is /bulk-import.
- NEXTCLOUD_URL and NTFY_TOPIC are not read anywhere. Nextcloud is per-user in
the users table, and the ntfy topic is derived as pedscribe-{userId}.
- A prose paragraph sat inside the Clinical Assistant settings table, so half
the rows rendered as text.
Filled the gaps the audit exposed:
- database.md was missing 12 of 29 tables, including user_resources,
personal_notes, login_codes, registration_invites and generated_image_jobs.
- developer-guide.md was missing 11 routers and 10 frontend modules.
- api-reference.md detailed 121 of 244 endpoints and said so, but whole
features were absent. Added an endpoint index covering Clinical Assistant,
My Resources, Notes, Diagrams, ED Encounters, invites and sign-in codes.
- configuration.md was missing METRICS_TOKEN, REDIS_URL, API_RATE_LIMIT_MAX,
the LITELLM_* model variables, the DB_* ones maintenance.js reads, and the
per-purpose S3 resolution scheme.
- clinical-assistant.md documented 2 of its 17 environment variables.
- features-explained.md had no entry for My Resources or Clinical Assistant.
Renamed the three remaining SHOUTING filenames to kebab-case, which is what the
docs viewer's prettyName() was working around, and rewrote README's index,
which listed architecture.md twice and omitted nine files.
Noted but not changed: the Turnstile site key is hardcoded in index.html rather
than read from TURNSTILE_SITE_KEY, and /api/health/detailed can report
tts: 'elevenlabs' though no ElevenLabs path exists.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
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
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
The README's feature list never mentioned accounts at all. It now says how
somebody signs in — password or an emailed code, side by side — that passwords
are argon2id with bcrypt rows rehashed on next sign-in, and that registration can
be open, closed or invite-only with codes that are revocable while live and
deletable only once spent.
The browser-STT note now says why the Web Speech gate exists rather than only
that it exists: Chrome and Edge send that audio to Google, so it is off unless a
user turns it on.
And the one thing that could not be demonstrated before now can. With SMTP
configured, a real send returns true, and driving the sign-in screen in a browser
— request a code, type it — returns 200 from /login-code/verify and enters the
app. docs/authentication.md records that.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
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
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
Nothing documented My Resources, the slide renderer, PubMed or web search, and
the authentication doc predated both sign-in codes and registration invitations.
docs/my-resources.md is new and covers the feature end to end: what a resource
is, where its material comes from, why both searches run in the route rather
than as tools the model never called, why keyword engines get the topic while
retrieval gets the instruction too, how a presentation is designed as a deck
rather than written as markdown, the separate multi-image path, and what the
export pipeline is made of.
docs/authentication.md gains sign-in codes — storage, lifetime, reuse,
supersession, guessing, and that two-factor still applies — and registration
invitations, including the exact condition that decides when a code may be
deleted and why it is written to match the status the list displays. Both new
rate limits are in the table, with a note that Express matches app.use paths on
segment boundaries, so a new sign-in endpoint needs its own limiter or it has
none at all.
docs/deployment.md now says what the runtime image carries and why — pandoc for
Word, python3 with apk-installed lxml and pillow for the slide renderer,
python-pptx pinned, and that PDF conversion is not in the image at all but goes
to Gotenberg, so Word and PowerPoint still work when it is down.
docs/configuration.md picks up LOGIN_RATE_LIMIT_MAX, LOGIN_CODE_RATE_LIMIT_MAX
and GOTENBERG_URL, none of which were listed. README gains a My Resources
section and indexes the two new docs.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
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
It asked for 8 while the reranker capped everything at 12, so 8 is what it ever
actually received — and its answers were judged good on that. Now that the cap
defers to the caller, 12 is the number worth asking for: it is what the corpus
has been tuned against, and the reranker still decides which 12.
Verified live: the assistant returns 12 sources.
Also records PubMed as a search source of its own rather than a provider option
under web search. It returns structured records — title, journal, year, PMID,
abstract — so a reference can be exact instead of reconstructed from a page
title, and a model should be able to reach for "the literature" distinctly from
"the web".
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
Answering a fair question: needing two settings in two repositories to agree
before one number changes is not a design anyone chose. It is two codebases each
assuming it owned the decision, and the symptom was a caller asking for 30
excerpts and silently receiving 12.
rerank_results computes min(reranker_top_k or limit, limit), so RERANKER_TOP_K=0
reads as "however many the caller asked for". The app setting is now the only
knob. Verified: Learning asks 30 and receives 30, the assistant asks 8 and
receives 8.
Zero costs nothing extra — the reranker is billed on documents sent, which is
candidate_limit and unchanged; top_n only decides how many come back. A real
number there is now what it should always have been: an optional hard ceiling
for when someone deliberately wants one, not an invisible default.
docs/retrieval-tuning.md covers the per-feature budgets, why the assistant's are
so much smaller than Learning's, that My Resources deliberately shares the
Learning budget, how to read what actually happened from the MCP logs and the
grounding field, and why raising these is not free.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
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
CLINICAL_ASSISTANT.md described DeepL as an admin-configurable translation
provider with its own keys and a fallback between providers. There is no
DeepL client in the code: TRANSLATE_PROVIDERS is ['libretranslate'], and an
unrecognised setting silently falls back rather than being rejected — the
first wording I wrote claimed a rejection that does not happen either.
TODO.md now names clinical-storage-milvus and
personal-assistant-storage-milvus, and records the MinIO audio-backup work
as finished.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
Written in the operator-handbook style of the Ops Docs collective (title,
last-updated line, short prose, tables, a quick-checks block, no secrets).
Ready to paste into the collective, or to publish once writing to Nextcloud
is permitted — that call was blocked by my permission layer.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
The nc_semantic_search alias is gone from the MCP server, so accepting it
here would point retrieval at a tool that no longer exists. A stale
override now stops the app at startup instead of silently retrieving
nothing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
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
- docs/speech.md: a new Recording section (wake lock and why it is re-taken
on visibility, idempotent start, what happens when a recording ends by
itself, what signing out does), the retention rules, and step-by-step
instructions for moving audio backups to MinIO.
- Records that the model for a transcription is the user's choice first,
which is why the picker must only ever offer models the gateway has, and
that LITELLM_STT_MODELS is a fallback rather than a list known to work.
- api-reference: /api/transcribe takes `module` and returns `backupId`, and
the Audio Backups group is no longer failure-only.
- configuration.md and .env.example document AUDIO_BACKUPS_S3_*, preferring
the _FILE credential variants.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
Feature audit
- Settings claimed you could reference a template by saying "use my normal
physical exam" in dictation. No phrase handling exists anywhere, and the
prompt says the opposite: "Never copy clinical content from a template —
only formatting and structure." So a template can never supply findings.
The text now says what happens, and keeps the true privacy statement that
only template categories go to the AI (Custom is filtered out in
/memories/context by AI_CONTEXT_CATEGORIES).
- Templates themselves are real: CRUD plus /memories/context, injected as
style hints by hpi, soap, sickVisit, wellVisit, edEncounters and
hospitalCourse, behind the `memories` feature flag.
Docs
- docs/CLINICAL_ASSISTANT.md listed six settings and offered `deepl`, which
no longer exists in the code. The table now covers all seventeen keys the
server reads, with their fallbacks, plus how a model reaches a user.
Testing
- Every test file's stdout is now pure TAP, which is the stream node:test
parses results from. Three sources: a leftover debug console.log dumping
600 characters of HTML, page modules logging into a JSDOM without a
virtual console, and the server startup banners. The banners are guarded
by NODE_TEST_CONTEXT, set only inside node:test children, so production
and `node server.js` output is unchanged (verified both ways).
- Three consecutive full-suite runs at 671/671.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
The Turnstile challenge failed reliably inside the Capacitor WebView,
which blocked login and registration from the Android app.
Three separate causes:
1. Android WebView blocks third-party cookies by default. Turnstile runs
in a cross-origin iframe from challenges.cloudflare.com and needs its
own storage, so the widget never emitted a token. MainActivity now
calls setAcceptThirdPartyCookies on the app's own WebView.
2. The register handler read the Turnstile response with an unscoped
document.querySelector, which matched the *login* widget's input (it
comes first in the DOM). Registration therefore submitted the login
widget's token — single-use with a 5 minute expiry, so any prior login
attempt or slow signup made it fail server-side.
3. The register and forgot-password widgets auto-rendered inside forms
that start at display:none, where Turnstile does not reliably complete
a challenge, and nothing re-rendered them when the form was shown.
Widgets are now rendered explicitly when their form first becomes
visible, and tokens are captured from the render callback instead of
being read back out of the injected input — which makes the unscoped
lookup in (2) structurally impossible. Added error/expired/timeout
callbacks so a widget failure surfaces the Cloudflare error code instead
of failing silently behind a generic toast.
Login is no longer gated at all. It is the path mobile users hit
constantly, and it is already covered by a 10-per-15-min per-IP rate
limit, a constant-time credential check, and TOTP 2FA. Registration and
password reset — the endpoints that actually attract bots — stay gated.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three concurrent themes from this session:
═══════════════════════════════════════════════════════════════════
ED ENCOUNTERS — per-stage cards + consolidate→MDM finalize
═══════════════════════════════════════════════════════════════════
UX redesign per Daniel's feedback ("every stage note should be shown,
if AI is told to modify that particular note then the modified version
is used in final mdm"):
- Each generated stage stays on screen as its own editable card with
its own embedded "Don't Miss" panel. No more single rolling note
element that gets replaced on each generation.
- gatherCurrentNotes() reads contenteditable text from each stage card
before any operation (advance, finalize, persist) so inline edits
flow into the next AI call and the final consolidate.
- Stage badge is now state-accurate. "Stage N (recording)" with yellow
background after Add-more before generation; "Stage N" with gray
after generation. Fixes the bug where the badge flipped to Stage 2
the moment Add-more was clicked.
- Save & Done now runs TWO server-side AI calls in /finalize:
1. edConsolidate (new prompt) → polished single final note that
integrates every stage chronologically (HPI / ROS / PE / ED Course /
A&P with disposition).
2. edFinalize (rewritten with full inline 2023 AMA E/M element
rubric — problems / data / risk definitions, level mapping with
concrete examples) → MDM JSON.
- Two new cards render after finalize: blue-bordered Final Consolidated
Note + green-bordered MDM. Stage cards become read-only.
- partial_data on the saved row now stores {stages, finalNote, mdm,
finalized} so resume re-renders the full state.
Why two-call finalize: a single combined prompt makes the model cut
corners on one task. Two focused calls cost ~2× latency at the very end
of an encounter — acceptable since finalize is a one-time terminal
action, not a per-stage hot path.
Files: public/components/ed-encounter.html, public/js/ed-encounters.js,
src/routes/edEncounters.js, src/utils/prompts.js (edConsolidate added,
edFinalize rewritten).
═══════════════════════════════════════════════════════════════════
EXTENSIONS / PAGERS — visual polish
═══════════════════════════════════════════════════════════════════
Multiple iterations based on Daniel's feedback:
- Layout: align-items:flex-start so action buttons stay pinned top-right
when long numbers wrap (was align-items:center → buttons drifted into
the text area, causing visible overlap).
- Number: word-break:break-all + min-width:0 + font-feature-settings:tnum
so long numbers wrap within their column instead of pushing under the
buttons. Click-to-copy with a 0.55s green flash + ✓ copied badge.
- Phone/pager Font Awesome icon next to the number in the type color —
at-a-glance type signal (replacing an earlier 3px left stripe that
Daniel found visually bulky).
- Name: font-weight 700, font-size 14.5px, color g900, letter-spacing
-0.012em — scan-target headline typography for long lists.
- Alternating subtle backgrounds by index (white vs #fafbfc) so a long
list reads as distinct rows.
- Hover: card lifts 1px with a soft shadow; action buttons fade from
55% to 100% opacity. Cubic-bezier transition on transform.
- Entrance: staggered fade-up animation per card (35ms × index, capped
at 12). prefers-reduced-motion media query disables motion.
- Empty state: 48px FA icon + heading instead of plain gray text.
Files: public/js/extensions.js, public/css/styles.css.
═══════════════════════════════════════════════════════════════════
DOCS REORGANIZATION + APPLICATION-LOGIC DOCS + ADMIN VIEWER
═══════════════════════════════════════════════════════════════════
Document moves (preserving git history via git mv):
BROWSER_WHISPER_SETUP.md → docs/browser-whisper-setup.md
BROWSER_WHISPER_TROUBLESHOOTING.md → docs/browser-whisper-troubleshooting.md
DEVELOPER_GUIDE.md → docs/developer-guide-extended.md
EMBEDDINGS_SETUP.md → docs/embeddings-setup.md
FEATURES_EXPLAINED.md → docs/features-explained.md
IMPROVEMENTS.md → docs/improvements.md
OPENID_SETUP.md → docs/openid-setup.md
TRANSCRIPTION_OPTIONS.md → docs/transcription-options.md
README.md updated with the new paths + a Documentation section that
links to docs/logic/ at the top.
New application-logic doc series (~8,300 lines total) at docs/logic/.
Built with 5 parallel doc-writing agents per Daniel's "use multiple
agents" directive. Each doc explains how a part of the app actually
works — application logic, data flow, design decisions, sacred zones,
how-to-extend recipes — at a depth that lets a new dev (or an AI
assistant) modify the code confidently.
docs/logic/README.md — index + recommended reading order
docs/logic/architecture.md (2166 L) — frontend IIFE pattern, lazy tab
load, backend route convention,
schema, encryption, deployment
docs/logic/clinical-notes.md (1546L) — every note tab + helper trio
docs/logic/bedside-and-calculators.md (1373L) — bedside ES module
pocket + calculators + PE Guide
+ suture selector
docs/logic/auth-admin-learning.md (1281L) — auth (local+OIDC+2FA) +
admin panel + Learning Hub
(Quiz engine logic at sub-detail
only — TODO follow-up)
docs/logic/ai-and-voice.md (1128 L) — callAI 5-provider routing,
prompts, voice/STT, helper trio
docs/logic/ed-encounters.md (821 L) — multi-stage ED + MDM (this
session's worked example)
Admin-only docs viewer:
- New route /api/admin/docs/{tree,file}: recursively walks docs/, returns
the tree as JSON; /file?path=X validates path stays inside docs/ and
renders markdown via marked. Both gated by req.user.role==='admin'.
- New tab "Docs" (book icon) in the sidebar, hidden by default and
revealed in auth.js when user.role==='admin' (same pattern as the
existing Admin and CMS tabs).
- New component public/components/admin-docs.html: split-pane layout
with a tree sidebar + filter input + a markdown reader pane.
- New module public/js/admin-docs.js: lazy-loads the tree on first tab
activation, renders collapsible folders, persists expanded state and
last-opened path via UIState. Server-rendered HTML so no client
markdown parser needed.
- CSS for the viewer (responsive split-pane, code-block styling, table
scrolling, etc.).
- Mounted at /api/admin/docs (NOT /api) — important: mounting a router
with router.use(authMiddleware) at /api accidentally 401s every other
/api/* path (caught and fixed during testing — /api/health was 401'ing).
Files: docs/* (moved + new), README.md, public/components/admin-docs.html
(new), public/js/admin-docs.js (new), src/routes/adminDocs.js (new),
public/index.html (tab + section + script), public/js/auth.js (admin
gate + logout cleanup), public/css/styles.css (viewer styles), server.js
(mount).
═══════════════════════════════════════════════════════════════════
KNOWN GAPS (TODO follow-ups)
═══════════════════════════════════════════════════════════════════
- Learning Hub quiz engine (MCQ / multi-select / T-F scoring + attempt
tracking + progress dashboard) is covered at the architectural level
in docs/logic/auth-admin-learning.md but not drilled into the quiz
data model and scoring flow. Worth a focused follow-up doc.
- ED finalize: if MDM step JSON parse fails, server returns 502 with
the consolidated finalNote in the error payload, but client doesn't
surface the partial result. Add a "MDM failed, retry" affordance.
- No e2e Playwright coverage for ED encounters or the new docs viewer.