Learning is moderator-owned: content published into categories that everyone
sees, behind router.use(moderatorMiddleware). That is right for institutional
material and wrong as the only way in — an ordinary user could not generate
anything at all.
So this is a separate pathway rather than a loosening of that one. Learning is
untouched; the moderator gate stays exactly where it was. A signed-in user can
generate a deck or an article for their own use, keep it, refine it and export
it, and nobody else ever sees it.
Private by construction. Every statement filters on the owner and there is no
route that returns another person's work, which a test asserts statement by
statement rather than trusting. The foreign key cascades, so deleting an account
takes its drafts with it. There is no category, no publish state and no sharing:
adding sharing later should be a deliberate feature, not something that leaks
out of a forgotten WHERE clause.
Markdown is the artifact. Every format is rendered from it on demand — pptx and
docx by pandoc, both carrying the house reference deck, and PDF by Gotenberg,
whose LibreOffice preserves a deck's layout in a way rendering from markdown
would not. That is what makes "add a slide on when to admit" a text edit rather
than a binary patch.
Gotenberg was published on the host but on a network of its own, so reaching it
from a container went out and back through the host gateway. It now joins
danvics_convert, owned by danvics-net like the others. PDF is the one export
allowed to fail: if that service is down, the deck and the document still
download and the error says which.
Verified end to end as a plain user: the moderator route still refuses with 403,
generation returned a deck grounded on 12 corpus excerpts, the library lists
only their own, pptx/docx/pdf all downloaded valid, "add a slide on when to
admit" put the slide in the right place and left References last, and an
unauthenticated request gets 401 while someone else's id gets 404.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
mcp-server_mcp-data carried the Compose project prefix, which tied its name to a
directory that has since been renamed to clinical-assist-deploy. It is now
clinical-assist-data, named explicitly in both stacks so it no longer depends on
a project name at all.
Migrated rather than renamed, because Docker cannot rename a volume: writers
stopped, 2.9GB copied, then verified before switching — 13265 files both sides,
3080478708 bytes of file content both sides, and an md5 over every file in
sorted order matching exactly. The old volume is kept, and is now referenced by
nothing, so it can be removed once this has run for a while.
Also removed mcp-server_qdrant-data: 1.7GB belonging to a Qdrant deployment that
no longer exists and which no container mounted.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
"mcp" said nothing — this host runs several MCP servers. The clinical corpus
query service now also answers to clinical-mcp, and ped-ai asks for it by that
name. The bare service name stays an alias automatically, so anything still
pointing at http://mcp:8000 keeps working. Hyphens rather than underscores:
underscores are legal in Docker's embedded DNS but not in hostnames generally.
LITELLM_API_BASE goes back to being unset here, so OpenBao's
https://llm.danvics.com applies. The public hostname is the deliberate choice for
consistency across the estate. It is not a speed question — the gateway hop is
~19ms on calls taking hundreds of milliseconds, and describing that as "4x" was
a misleading way to put it. The real cost is that AI calls now depend on Caddy,
public DNS and edge TLS; one line here pins it back to the container network.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
ped-ai joined open-webui_openwebui-network to resolve exactly one hostname:
libretranslate. LibreTranslate is its own Compose project but had no network of
its own, so it borrowed Open WebUI's — which meant a clinical app's startup was
coupled to an unrelated stack's lifecycle for a translation call.
LibreTranslate now owns danvics_translate and ped-ai joins that instead. It also
keeps its Open WebUI attachment, because Open WebUI is configured to use it
(webui.db refers to it) and cutting that would have broken translation there
silently. Verified after the move that Open WebUI still reaches it.
LITELLM_API_BASE is pinned to the container network again, now deliberately and
with the reason recorded: clinical traffic should not leave the host or depend on
Caddy, public DNS and edge TLS. Speed is not the reason — it is ~19ms on calls
taking hundreds of ms, and quoting that as "4x" was misleading. OpenBao still
stores the public URL for this key and Compose wins, so the vault value is dead
until someone changes it by hand; the app's AppRole is read-only by design.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
"Retry loading settings" was permanently on screen even though the settings had
loaded fine. The browser's [hidden]{display:none} lives in the user agent
stylesheet, so any author rule setting display beats it — .btn-sm sets
display:inline-flex, so `<button hidden class="btn-sm btn-ghost">` never hid.
Earlier fixes bolted style="display:none" onto individual elements one at a
time; one [hidden]{display:none!important} covers all fourteen.
LITELLM_API_BASE is no longer pinned in Compose. Compose env wins over OpenBao,
so hardcoding http://litellm:4000 silently overrode the vault's
https://llm.danvics.com and the intended configuration had never taken effect.
The entrypoint now applies 28 secrets rather than 27, with 2 deliberate
overrides rather than 3. Transcription, text-to-speech and the signed-out
preview were all checked through the public gateway afterwards.
Worth recording since it was the reason for the change: this clears no network
dependency. ped-ai still needs danvics_mcp for mcp, the assets network for
MinIO, the openwebui network for LibreTranslate and danvics_monitoring for Loki;
none of those has a public route. It also adds about 20ms per call.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
"AI-Powered Clinical Documentation" named about a third of what is here.
Documentation is one pillar; the assistant and the learning hub are the others,
and none of it said pediatric, which is both true and the differentiator.
danvics_speech is gone from ped-ai. Everything ped-ai dials was checked against
every network it joins: assets, libretranslate, litellm, mcp and monitoring-loki
are the only internal hosts, and litellm answers to that alias on danvics_mcp
already. supertonic/kokoro/sherpa appear here only as LiteLLM model ids and
voice names, never as hostnames — speech goes through the gateway, exactly as
intended. Joining danvics_speech therefore bought no reachability and cost a
cold-boot dependency on local-speech-gateway, which had to have been up once
before ped-ai could start at all.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
Reproducibility means two things here: the same commit builds the same image,
and the running container can be asked which commit it is.
- Base images are pinned by digest, not by tag. A tag moves; two builds of one
commit could otherwise differ. These are manifest-list digests, so buildx
still picks the right architecture.
- scripts/build-image.sh also writes ped-ai-local:<revision>, an immutable
name a deploy can refer to instead of chasing :latest. Its summary goes to
stderr so stdout stays the Compose invocation.
- Compose takes the image from PED_AI_IMAGE, so a deploy runs a specific
revision-tagged image while a local build still uses the local tag.
- scripts/deploy.sh pins that image in the file Compose interpolates from,
waits for health, then asks /api/build which revision is actually serving
and rolls back to the previous image if it does not match. Healthy is not
the same as running what you asked for. The rollback path was exercised.
- The entrypoint applies migrations before the app starts, so code and schema
arrive together. node-pg-migrate takes an advisory lock; losing it is not an
error, it waits and looks again, so a rolling restart does not fail. A real
migration failure stops the container rather than serving on a schema that
does not match the build. RUN_MIGRATIONS=false opts out.
- The Forgejo workflow builds through that same script, tags by full revision,
and has an opt-in deploy job. It refuses to run if the deploy directory has
uncommitted work rather than resetting over it.
The running image was labelled revision=unknown, and /api/build said "unknown",
because `docker compose up --build` never passes GIT_REVISION. That is exactly
the hole this closes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
ped-ai-storage held the personal assistant index, and nextcloud-mcp-server
held ped-ai's clinical index and runs no MCP server at all — only etcd,
MinIO and Milvus. The names said the opposite of the truth:
ped-ai-storage -> personal-assistant-storage-milvus
nextcloud-mcp-server -> clinical-storage-milvus
Only the secret bind mounts here name those paths. Both compose files pin
`name:`, so containers, networks and volumes keep their existing names and
no data moves; that also means the container prefixes still read
ped-ai-storage_*, which can only change by copying each volume.
Verified: all three stacks' compose configs validate, ped-ai recreated with
the new secret paths and reads them, and the storage stack still resolves
its own containers from the renamed directory.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
The app key carries a second policy covering only the audio-backups bucket,
so the already-mounted credentials serve both. Verified on the live stack:
storage=object, key recordings/<user>/<stamp>, round trip byte-identical,
and the object is removed with its row.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds `npm run maint:check` (health report) and `npm run maint:reindex`
(REINDEX DATABASE + REFRESH COLLATION VERSION + ANALYZE) for post-
upgrade maintenance, modelled after Nextcloud's occ maintenance.
Documented in README.
Also relaxes postgres image from digest pin back to tag-pin
(pgvector/pgvector:pg16) — the auto-REINDEX-on-drift check in
database.js and the COLLATE "C" protection on critical indexes
make the digest pin redundant while blocking ordinary `compose
pull` updates.
Root cause of recent "invalid credentials on correct password" was
a silent btree index corruption: pgvector/pgvector:pg16 was pulled
with a different ICU library than the one used to build existing
indexes. Queries returned 0 rows even though matching heap rows
existed. Postgres logged nothing (corrupt index → empty result set
is a "successful" query) and the login path never logged unknown-
user attempts (enumeration protection).
Three defenses:
1. Pin postgres image by digest in docker-compose.yml so a
silent pull can't change ICU under our feet.
2. Startup collation-drift check in src/db/database.js:
compares pg_database.datcollversion to the library's actual
version and, on mismatch, runs REINDEX DATABASE + ALTER
DATABASE REFRESH COLLATION VERSION. Logs "Collation versions:
aligned" on clean boot.
3. Server-side console.warn on login lookup-miss (no email, no
audit row — preserves enumeration protection but gives
Grafana/Loki a signal for unusual miss rates).
- 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
BREAKING FIX: Browser Whisper now fully self-contained
Previous issue:
- Loaded transformers.js from cdn.jsdelivr.net
- Downloaded models from cdn-lfs.huggingface.co
- Failed in corporate/clinical networks with firewall
- Stuck at "Initializing..." with no progress
Solution:
- Bundle transformers.js library (~876KB)
- Bundle Whisper tiny.en model (~42MB)
- Serve everything from local server
- Works in ANY network environment
Changes:
- whisperWorker.js: Load transformers from /models/ instead of CDN
- Dockerfile: Download models during Docker build
- Add download script for local dev
- Add comprehensive setup documentation
Docker image size: +~42MB (one-time cost, runtime benefit)
Tested: Works on unrestricted and firewalled networks
- Raise express.json limit from 1MB to 10MB — handles large chart reviews
with many notes (50 full clinic notes ≈ 600KB, well within new limit)
- Client-side: warn user if payload >8MB, friendly toast if >30 notes
- Bump to v13.0.0
- APK: Add WAKE_LOCK, BOOT_COMPLETED, ACCESS_NETWORK_STATE permissions
- APK: Disable allowBackup for medical data security
- APK: AudioRecordingService now acquires wake lock, has stop action in notification
- Serve /.well-known/assetlinks.json for TWA domain verification
- Service worker: cache app shell, stale-while-revalidate for assets, network-first for API
- Admin model management: validate model ID format, prevent built-in conflicts, audit toggle actions, prevent disabling all models
- Bump version to v9.0.0, Docker tag to v9
- Add try-catch to /nextcloud/disconnect route (was crashing on DB errors)
- Update docker-compose.yml image tag from v7 to v8
- Remove unused SESSION_SECRET from .env.example
- Fix word repetition: use sessionFinals pattern so each browser SR
session starts fresh; no overlap when recognition auto-restarts
- Fix HTML injection / '>' parse error: escape < > & in live transcript
innerHTML before inserting speech recognition text
- Add 24 MB blob guard: fall back to live SR transcript if audio file
is too large for Whisper API (long sessions)
- Bump version to 7.0.0, update docker-compose image tag to v7
- New Learning Hub under Pediatric menu with content feed, category browsing,
search, content viewer, and interactive quizzes (MCQ, true/false)
- Quiz system with per-option wrong-answer explanations and general explanations
- Admin CMS for creating/editing categories, content (articles, pearls, quizzes),
and inline question builder with answer options
- 3-role system: admin (full access), moderator (can manage Learning Hub content),
user (all clinical features + learning hub read access)
- 5 new database tables: learning_categories, learning_content, learning_questions,
learning_options, learning_progress
- User progress tracking with score history per quiz
- Moderators see "Content Manager" tab instead of full Admin panel
- Bedrock: Add 14 non-vendor model models (Amazon Nova, Meta Llama 4, DeepSeek R1/V3,
Mistral Large 3, Cohere Command R+, AI21 Jamba) with verified AWS model IDs
- Bedrock: Implement Converse API for non-Anthropic models (unified cross-model API),
keep native Messages API for vendor model models (best performance)
- Milestones: Add Newborn / 1 month developmental milestones (Gross Motor, Fine Motor,
Language, Social/Emotional, Cognitive) — previously started at 2 months
- Bump version to 3.0.0, docker-compose tag to v3.0