The caps were raised to the schema's own limit, so the suite asserts it where a
person would: the form's max is 60, and 60 typed reaches the route as '60'
rather than being clamped back. 36 passed on the rebuilt stack.
Twenty was the review ceiling while the form allowed thirty, so a 21-30 slide
deck was written, stored and then silently never reviewed — and modifying one
above twenty was shown only its first twenty pages while the prompt claimed
one image per slide. The three numbers are the schema's own limit now, so a
deck that can be asked for can also be stored, reviewed and edited: the form's
max, both generation clamps and MAX_SLIDES are all 60.
Measured at that size before this change: 45 pages cost 28,796 prompt tokens
and 2.2s to review, a 46-page deck with a fault on page 24 was found and
re-laid correctly, and a 44-slide deck used ~5,900 output tokens of the
16,000 ceiling.
The switch was opt-in, and the callers that said nothing were the ones that
paid for it: a 16,000-token deck reasoned its whole budget away and wrote
nothing (reasoning_chars=51573), the four 2,000-token reviews of that deck did
the same, and a 16,000-token revision reasoned for a minute and a half before
writing a word — which is past the point a browser waits for a request. Every
clinical route (encounters, notes, chart review, the visits, take-home, and
the rest) passes no reasoning option at all, so all of them were in that
position.
Silence now means off for DeepSeek, in one place rather than in fifteen, and
an explicit effort still means what it says: a task that wants reasoning asks
for it. The retry after a reasoning-starved reply no longer asks for 'low'
either — on DeepSeek that means thinking on, which is how a starved call came
back starved with four times the budget. It adds room instead.
Other providers are untouched, the Groq Qwen profile included.
The page polls the job list as soon as it opens, so a stub that finished the
job on the second poll had already finished it before Generate was pressed —
and there was no in-flight to landed transition left to announce, which is the
behaviour these two specs exist to pin. The job now stays running until the
click is recorded, and the run is green: 34 passed.
Two faults kept every spec in this file from running, both in the same code
path and both from the SSO-only change. The fixtures minted a session by
shelling out to `docker compose exec`, which cannot work from inside the
Playwright container — it has no docker CLI and no socket — so all seventeen
specs (both viewports) died at the auth fixture before touching the page. The
harness now mints both sessions on the host and passes them in, and the seed's
connection banner is no longer mistaken for the token: it prints before it,
so the token is the last line. The host-side docker path stays as the fallback
for `npx playwright test` run directly on the host.
The specs then move to what generation is now: the click is answered with a
job, so the assertions follow the job list. A generation in flight is listed
and survives a reload, a job that lands reloads the library and says what it
was written from, a job that fails says why, and what was searched for is
reported when the job lands rather than when the button is pressed.
Writing a resource held the request open for as long as it took: a library
search, one or two long model calls, a review, then figures. Measured end to
end that was six minutes on 2026-09-16 (00:40 to 00:46:16, resource 53), and
Firefox abandons a request that has said nothing for five — the browser
reported "NetworkError when attempting to fetch resource" while the server
carried on and saved the deck anyway, so a generation that worked looked
like a failure and left no status line.
The request now records what was asked and answers 202; the work runs on the
server as a job; the page lists what is being written, what landed and what
failed, polls while anything is in flight, and reloads the library when one
lands. Several can run at once, a reload loses nothing, and a boot pass marks
jobs stranded by a restart as failed rather than spinning for ever.
The same generation also ran with DeepSeek's thinking on, which is what made
it take minutes rather than seconds: the 16,000-token write spent the whole
budget reasoning and returned an empty reply (completion_tokens=16000,
reasoning_chars=51573), which fired the automatic retry at four times the
budget, and the 2,000-token reviews of that deck starved the same way four
times over. Thinking is now off for the writing, the review of it and a
revision — DeepSeek's own field, sent by the model wrapper. Other clinical
routes are deliberately untouched and keep the provider default.
The review inherits the writer's rule rather than hard-coding it, so a task
that wants reasoning can still ask. Migration 1781500000000_resource-jobs.js
adds user_resource_jobs; the container entrypoint applies it before the app
starts.
LITELLM_API_BASE came from OpenBao as https://llm.danvics.com, so every AI
call in the app also depended on Caddy, public DNS and edge TLS being up.
Pinned to the compose network while the public hostname is being taken off
the internet, at the user's request.
The download-transcript button is removed for anyone who is not an admin,
but the event binder still called addEventListener on it unguarded. The
throw stopped the binder before the saved-chat list loaded, before the
click handler for opening old chats was registered, and before the send
box was fully wired, so a non-admin saw no chats until New chat, could not
open old ones, and could not chat. The button is now bound only if present.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016fZGJNyDvERbMgS2Uc2msP
POST /api/auth/login answers 410 for everyone, administrators included. The
sign-in screen never draws an email or a password: it is the provider's
button, or a sentence saying sign-in is not configured. The admin CLI no
longer resets passwords. The e2e harness mints its sessions inside the
container instead of signing in with a password.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016fZGJNyDvERbMgS2Uc2msP
With OIDC on, every password route is shut except /api/auth/login for
administrators — the way back in if the provider is down, reached from an
"Administrator sign-in" link. The disable-local-auth setting, the
registration CLI command and the docs that described them are removed;
accounts, roles and invitations live in authentik.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016fZGJNyDvERbMgS2Uc2msP
Every account comes through One Sign In; an administrator sends an
invitation link from there. The register route answers 410, the
registration-status route and the admin toggle are removed, and the
setting no longer exists in defaults, lockdown lists or seeds.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016fZGJNyDvERbMgS2Uc2msP
The user, seeing four full questions stacked on the empty page: "the former
way… was better". Labels, three of them, as before.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016fZGJNyDvERbMgS2Uc2msP
Tested with a pasted board question, a topic list and a SOAP case and no other
instruction: all of it was used. The prompt now says so explicitly, so it stays
that way whatever model writes the deck.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016fZGJNyDvERbMgS2Uc2msP
The re-render swapped "Pediatric Clinical Assistant" for a different heading
and line the moment the starter questions loaded. Same words as the page now.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016fZGJNyDvERbMgS2Uc2msP
The page class that hides the app sidebar and gives the chat its full height
was added only after the initialiser returned, so any error there left the
app menu and the chat rail side by side with the panels at half height. The
class goes on first now. The starter chips show the question itself rather
than a two-word label, four of them.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016fZGJNyDvERbMgS2Uc2msP
Batches three onwards reuse the first batches' snippets, and an identical
request came back with the identical list: every category stopped at exactly
40 of its quota. The list of what is covered makes each request, and each
answer, new.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016fZGJNyDvERbMgS2Uc2msP
The pool was cases — "A 6-week-old with 3 days of projectile vomiting… which
fluid do you start?" — and the user, reading them on the assistant's front
page: "Vague nonsense clinical scenarios. I want concrete things people learn
from and pathophysiology if possible, all related to peds. No more clinical
scenarios. Use deepseek with its reasoning off."
So the brief is inverted. A question asks one definite thing with a definite
answer — a mechanism, a distinction between two look-alikes, the number that
decides, the reason behind a rule of practice — mechanism first, naming its
disease, drug or finding, and staying paediatric. The filter refuses a
vignette opener and a bare definition instead of demanding a number; the
prompt version moves to 3 so every deployment rebuilds the pool once; the
model defaults to DeepSeek flash with thinking disabled, which the assistant
already knows how to ask for.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016fZGJNyDvERbMgS2Uc2msP
The picker labels carried each format's typical length ('15–25 cases, three
or four slides each, 60–90 minutes'), which sat under a slide-count field and
read as a second, contradicting number. The label is the name, the hint says
who the format is for, and the brief tells the model that the slide count
asked for is the length and the parts fit into it.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016fZGJNyDvERbMgS2Uc2msP
Compare cards were as tall as the body whatever they held, so two columns of
four short bullets sat in cards 60% empty; a one-sentence callout sat in a
card two thirds of the slide. Both now size to the text. The callout card's
fixed brown ink was the worst contrast in the catalogue on Board Review's
mint card; it takes the theme's ink. A question slide may carry image_prompt
— a film beside the stem, drawn or left as a labelled frame like a figure.
The evidence-based formats (board review, journal club, QI, abstract, debrief)
gain a References part and the brief says the deck ends with it: DeepSeek
skipped it when it was only suggested.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016fZGJNyDvERbMgS2Uc2msP
A refresh landed on a new empty chat with the conversation a click away in
the list. The open chat's id is remembered per account and reopened on
load; New chat forgets it; a chat deleted elsewhere is quietly gone.
DeepSeek models think by default: measured on ds-deepseek-v4.1-flash, a
three-sentence clinical answer spent 301 reasoning tokens and 2.9 s before
writing and gave the same answer in 0.9 s with thinking off. The assistant's
reasoning effort now comes from CLINICAL_ASSISTANT_REASONING_EFFORT ('low'
as before; 'none' switches thinking off where a model allows it, sent as
DeepSeek's own thinking field through LiteLLM).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016fZGJNyDvERbMgS2Uc2msP
DeepSeek flash wrote 'A. Start IVIG' into option text the renderer already
letters, so the slide read 'A A. Start IVIG'; and an answer of just 'A' sat
alone on the answer card, sending the room back a slide to see what A was.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016fZGJNyDvERbMgS2Uc2msP
With illustration off, or for a picture no model should draw — a real
radiograph, a run chart, a photograph — the figure or image slide used to
become a slide of text, and the deck forgot it had wanted a picture. It now
keeps a dashed, labelled frame in the picture's place, on figure slides,
full-slide images and the image shapes of custom slides, so the author can
drop their own in after downloading; the Word export says what belongs
there. Figures already drawn are never turned into frames. The one format
guidance that implied a slide authored live now says 'fill in by hand'.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016fZGJNyDvERbMgS2Uc2msP
The morning's change forced every table to the screen width below 640px and
let cells break mid-word, so a four-column table read as columns of broken
syllables. A swipe is the better trade; the hint under the table says so.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016fZGJNyDvERbMgS2Uc2msP
A theme is a look and the slide vocabulary is what one slide can be; neither
said what a journal club is (PICO, methods, appraisal, bottom line) or that a
board review runs in case-question-answer cycles, so every deck came out as
the same lecture. assets/deck-formats.json holds ten formats distilled from
how departments run each session — grand rounds, journal club, morning
report, M&M, board review, noon conference, simulation debrief, parent
education, QI project, research abstract — each a sequence of parts naming
the layouts it is usually drawn with and whether it may repeat. The brief is
prose after the layout vocabulary; a part is a unit the model reuses as many
times as the material needs, and every slide stays one of the twelve types.
Chosen beside the theme, 'Free' by default, stored on the deck and carried
through modifications.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016fZGJNyDvERbMgS2Uc2msP
The search service can hold several Milvus collections, each with its own
embedder, but a search names one or gets the default, so a second collection
was invisible to the assistant. The client now learns the list off the query
path (at warm-up and on the session timer), and only when the service lists
more than the default does a search fan out — one call per collection in
parallel, fused by reciprocal rank so scores from different embedders are
never compared. With one collection, today's case, the request is byte-for-
byte what it was and no listing call is made while anyone waits.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016fZGJNyDvERbMgS2Uc2msP
Deleting a generated image asked with the browser's own confirm(), a bare
dialog that looks nothing like the rest of the app. It now goes through
showConfirm like every other confirmation, with the danger styling and a
Delete button.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015j4L7Eu5Jp8gVMPsXdPaZ2
Three things the load path lacked. The retrieval slots had an unbounded
line behind them, so a burst meant silent waiting; past a bounded line, or
after eight seconds in it, a caller now gets 'the library is busy' and a
503 with a retry hint. The paid routes had no per-account ceiling; they
now get one, counted in Redis so every replica sees the same count and
nothing is refused when Redis is absent. And the same library search
asked twice within a minute (a retry, a refresh) went to the library
twice; it is now answered from Redis, with 0 turning that off.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
The flow lived in a Map inside the web process; with more than one
replica the poll could land where the flow was never started and every
sign-in would look expired. It now sits in Redis with the same 20-minute
life and one-per-account rule, and falls back to memory when there is
no Redis, which is what tests and a single-process box always had.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
Any call with a small max_tokens could come back blank from a reasoning
model: finish_reason=length, content empty, the budget spent thinking.
The shared LiteLLM call now retries that one signature once, with at least
6000 tokens and low reasoning effort, so no caller has to guess a budget.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
The sideways swipe was not reachable on the phone, so wide tables were cut
off. Below 640px the cells break their words and the table takes the full
width; wide tables get taller, never clipped.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
The 1,800-token ceiling was spent entirely on hidden reasoning, so the
sheet came back empty. Same budget as the answer path now, with one retry
at a larger budget if the reply is still blank.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
The fragment's markup was inserted before its own stylesheet resolved, so a
first visit to the assistant tab flashed unstyled content. The pending
marker app.js already sets now also hides the markup; the Loading status
line is all that shows until the tab reports ready.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
The reader can start at the top of a long answer and scroll at their own
pace; the per-token and on-completion jumps to the bottom are gone, which
also stops a finger on the screen fighting the auto-scroll.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU