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.
- Add neonatal assessment calculator: GA classification (extremely preterm through
post term), weight-for-GA percentile (AGA/SGA/LGA) using Fenton 2013 LMS data,
birth weight category (ELBW/VLBW/LBW/normal/macrosomia)
- Add DOCX support via mammoth, PPTX/ODT/EPUB via jszip in Learning Hub content
generator file upload
- Add gatewayUrl() helper for consistent API URL construction — handles
LITELLM_API_BASE with or without /v1 suffix, works with any OpenAI-compatible
gateway (LiteLLM, Bifrost, etc.)
- Fix TTS model/voice separation: discovery now tags items as MODEL or VOICE,
auto-detects provider from voice name (Vertex, ElevenLabs, OpenAI)
- Fix STT discovery to include ElevenLabs Scribe and Chirp models
- Fix TTS discovery to include ElevenLabs and Vertex voices alongside models
- Fix admin model test to bypass allowlist check (skipAllowlistCheck) so
discovered models can be tested before adding
- Fix Nextcloud token decryption in learningAI.js WebDAV browse and file import
- Fix admin embedding test to show DB model name instead of hardcoded default
- Fix admin STT test to use correct endpoint for Whisper models
- Add AI gateway migration guide to configuration docs
- Add Grafana dashboard JSON for Loki log visualization
- Drop first/second-person voice; reference-style prose throughout
- Remove stale information; align with current code (argon2id primary, hybrid cookie/Bearer auth, sliding 24h idle, AES-256-GCM PHI at rest, backup codes, node-pg-migrate, collation-drift guard, multi-arch Docker, auto-version pipeline)
- Preserve all technical accuracy and code examples
- Remove any remaining references to separate PedsHub Quiz app
- Keep consistent tone across files (tables + code blocks, imperatives where needed)
- api-reference.md and developer-guide.md route tables expanded to reflect current routes (billing, sessions)
mobile-build.md:
- Removed "PedsHub Quiz" sections. That app lives in a separate
repo (quiz/mobile/) and has its own build pipeline. Docs here
are PedScribe-only now.
- Reorganized around CI as the primary flow, local build as
fallback. Added explicit secret names, JDK requirement, single-
quote-password caveat, QEMU/argon2 note.
- File-map section at the end so the native sources are
discoverable without grepping.
CONTRIBUTING.md:
- Cut the narrative prose. Dev-facing tables + single-line
commands only. Decision-tree removed (the table suffices).
- Release pipeline and mobile build link out rather than
duplicating content.
Infrastructure only — no existing data or tables modified.
src/db/migrate.js — programmatic runner, fires at boot after
the existing idempotent initDatabase()
migrations/1744600000000... — intentionally empty example, documents
the file shape. Registered in the new
pgmigrations tracking table so it won't
rerun.
.node-pg-migraterc.json — CLI config (migrations-dir, utc naming)
docs/migrations.md — workflow + conventions
package.json — migrate:up/down/new/status npm scripts
(status is a direct pgmigrations query
since node-pg-migrate v7 lacks a status
subcommand)
src/utils/sessions.js:
- parseUserAgent now recognizes the Capacitor wrapper (UA suffix
"PedScribe-Android" / "PedScribe-iOS") and labels sessions
"PedScribe (Android)" instead of "Chrome on Android".
Going forward: schema changes go in /migrations as versioned files
with up() + down(); the inline init in database.js is the implicit
baseline for everything already in production.
- JWT_SECRET fails fast at startup in production
- CORS fails closed if APP_URL + CORS_ORIGINS are both missing
- Explicit HSTS (1y, includeSubDomains, preload)
- Rate limit sensitive auth endpoints (change-password, 2FA)
- /api/health now returns {ok:true}; details gated behind admin auth
- Login enumeration removed — generic 401 + dummy bcrypt on miss
- ReDoS guard: 20KB input cap on /suggest-codes
- showToast uses textContent, no innerHTML
- clearSession() clears service worker caches on logout
- OIDC state is now HMAC-signed and stateless (survives restart)
- SSRF guard on admin-set OIDC issuer (blocks private IPs, requires HTTPS)
Adds docs/mobile-build.md covering APK build, release, git push,
keystore, and troubleshooting for both PedScribe and PedsHub apps.
Security:
- Add session management: users can view/revoke active sessions in Settings
- Add password change in Settings (requires current password, HIBP check)
- Force logout all sessions on password reset
- Fix logout to destroy server-side session (was only clearing cookie)
- Add trust proxy for correct client IP in rate limiting and audit logs
- Add CORS support for multiple domains (CORS_ORIGINS env var)
- Add HIBP breach check endpoint and inline warnings on password fields
Audit logging:
- Add audit logging to all 24 PHI-handling endpoints across 13 route files
- Covers: generation, transcription, TTS, refine, encounters, documents, Nextcloud
- All fire-and-forget (no response delay)
AI improvements:
- Refine now includes original source material (transcript, notes, labs)
so AI can reference the full input when modifying output
- Add correction tracking (trackAIOutput) to sick visit and well visit tabs
- Fix sickvisit missing from encounter save noteIdMap
UI fixes:
- Non-blocking busy bar for transcription and AI generation (replaces full-screen overlay)
- Fix encounter recording: hide record button during recording (was showing two stop buttons)
- Fix ROS/PE "All WNL" stacking duplicate event handlers; add Clear buttons
- Enlarge AI instructions textarea in Learning Hub CMS
Domain:
- Primary domain now app.pedshub.com, with scribe.pedshub.com and peds.danvics.com as CORS origins
- New docs/developer-guide.md: full walkthrough of frontend SPA architecture,
backend middleware stack, database layer, AI integration, settings system,
how to add features/routes/tables, key design decisions, file references
- Expand ai-providers.md: detailed admin model management (add custom models
with ID/name/cost/category, discover from provider, enable/disable, set default)
- Update README docs index
- Add Cloudflare Turnstile to login, register, and password reset forms
- Switch AI provider to LiteLLM, transcription to OpenAI Whisper
- Change domain to scribe.pedshub.com
- Fix PPTX export: add tables, bold/italic, numbered lists, code blocks, blockquotes
- Fix announcement banner close button (CSP was blocking inline onclick)
- Fix auth middleware: empty Bearer token now falls through to cookie auth
- Fix audio backups: only save on transcription failure, stop auto-deleting on success
- Soften AI correction injection to prevent model hallucination from correction history
- Fix LiteLLM TTS model name handling (no incorrect openai/ prefix)
- Expand AI instructions textarea in Learning Hub CMS
- Update README for v6 with all features and providers
- Add comprehensive docs/: architecture, API reference, database schema,
authentication, AI providers, speech, learning hub, configuration, deployment