pediatric-ai-scribe-v3/docs/architecture.md
Daniel 67e416c6d9
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 47s
Forgejo Docker Build / Root app tests (push) Successful in 48s
Forgejo Android APK / Build signed APK (push) Successful in 2m38s
Forgejo Docker Build / Build Docker image (push) Successful in 12s
Forgejo Docker Build / Deploy to the host (push) Failing after 2s
docs: merge the duplicate pairs and correct them against the running app
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
2026-09-12 04:57:35 +02:00

12 KiB

Architecture

Self-hosted clinical documentation platform. Dockerized Node.js server, PostgreSQL, Redis, and vanilla-JS SPA. No build step on the frontend.

Stack

Layer Technology
Runtime Node.js 24 (Alpine, digest-pinned) + Express 4
Database PostgreSQL 16 via the digest-pinned pgvector/pgvector:pg16 image
Cache / state Redis for operational cache, prompt suggestions, and queue groundwork
Frontend Vanilla JavaScript SPA, service-worker cache
Mobile Capacitor 6 wrapper (Android + iOS)
Container Docker Compose (app + db + Redis)
Observability Prometheus metrics at /metrics; structured app logs in files, Postgres, and optional Loki
Reverse proxy External (Caddy, Nginx, Traefik — any)

Repository layout

server.js                        # Express entry
Dockerfile                       # node:24-alpine base, plus pandoc, python3/python-pptx/python-docx, poppler
docker-compose.yml               # app + postgres
migrations/                      # node-pg-migrate files (versioned)
scripts/
  maintenance.js                 # REINDEX / collation-drift CLI
  release.sh                     # semver bump + tag + push

src/
  db/
    database.js                  # pg pool, idempotent baseline init, helpers
    migrate.js                   # programmatic node-pg-migrate runner
  middleware/
    auth.js                      # JWT + session-table validation, sliding idle
    logging.js                   # request log
  utils/
    ai.js                        # callAI() multi-provider router
    models.js                    # model registry + server-side whitelist
    prompts.js                   # prompt templates (DB-overridable)
    crypto.js                    # AES-256-GCM (PHI at rest)
    passwords.js                 # argon2id with bcrypt fallback + rehash
    sessions.js                  # token hashing, UA parser, session-id gen
    platform.js                  # isMobileClient() detection
    redact.js                    # PHI redactor for audit details
    auditQueue.js                # batched audit/api/access log writer
    fileType.js                  # magic-byte upload verification
    promptSafe.js                # <UNTRUSTED_*> LLM prompt wrapper
    logger.js                    # audit/api/access + Loki shipper
    errors.js                    # generic 500 responder
    embeddings.js                # LiteLLM embeddings
    sttProvider.js, ttsProvider.js  # speech-to-text and text-to-speech routing
    documentExport.js            # pptx/docx/pdf export
    slideSpec.js, docSpec.js     # markdown -> typed spec for the renderers
    deckSchema.js, deckBuild.js  # the deck a presentation is designed as
    deckReview.js                # optional vision pass over a rendered deck
  routes/                        # Express routers (auth, hpi, soap, patient education, …)

public/                          # SPA
  index.html                     # shell, loads components on demand
  sw.js                          # service worker (cache shell, network-first API)
  js/                            # vanilla JS modules, no build step
  components/                    # per-tab HTML fragments
  css/styles.css

mobile/                          # Capacitor wrapper
  capacitor.config.json          # appId com.pedshub.scribe
  src/                           # launcher (server-URL picker)
  android/                       # generated AS project + native Java

.forgejo/workflows/
  android-apk.yml                # signed APK on tag push; optional Play upload
  docker-build.yml               # Forgejo registry Docker image build

.github/workflows/
  auto-version.yml               # conventional-commits → semver bump → tag
  android-release.yml            # legacy GitHub tag APK release path
  docker-publish.yml             # multi-arch image on tag push
  version-bump.yml               # manual dispatch override
  build-apk.yml                  # legacy TWA APK

Request pipeline

request
  → helmet          (CSP, HSTS, X-Content-Type-Options, …)
  → CORS            (APP_URL + CORS_ORIGINS whitelist, fail-closed in prod)
  → cookieParser
  → express.json    (10 MB cap)
  → rate limiters   (general 200 req/min, per-endpoint tighter on auth)
  → static          (public/ with no-cache on HTML, 1h on JS/CSS; ?v=BUILD_ID busts cache per deploy)
  → route           (feature routers under /api/*)
    → authMiddleware (on protected routes: JWT, DB session check, 24h idle, last_activity update)
  → handler
  → response

On boot, server.js:

  • Validates JWT_SECRET and DATA_ENCRYPTION_KEY — refuses to start in production without them.
  • Runs initDatabase() (idempotent baseline) then node-pg-migrate (versioned delta).
  • Checks pg_database collation version; auto-REINDEXes + refreshes on drift.
  • Reads git HEAD for BUILD_ID; injects ?v=BUILD_ID into every local /js/*.js and /css/*.css reference in index.html.
  • Registers SIGTERM/SIGINT handlers that drain the audit queue and close the pool before exit.

Auth model

Hybrid, runtime-selected by User-Agent and X-Client header:

Client Token transport Persistence Idle policy
Web browser ped_auth httpOnly cookie, sameSite=lax 30 d maxAge (sliding) 24 h from last write request
Capacitor app (PedScribe-Android / Capacitor UA) Authorization: Bearer <jwt> iOS Keychain / Android EncryptedSharedPreferences via capacitor-secure-storage-plugin No server-side idle check (persistent)

Sessions are validated against user_sessions.token_hash on every request. Any logout / password-change / admin-revoke drops the row and the next request gets 401. The service worker clears its caches on logout so a stale shell never shows PHI on a shared workstation.

Conventional-commits auto-tag workflow can push a new semver tag using a RELEASE_PAT PAT secret so downstream release workflows fire on the tag push (the default GITHUB_TOKEN is blocked from triggering other workflows by design).

Frontend

Single HTML document with #auth-screen and #main-app sections. Tabs are per-feature HTML fragments under public/components/ fetched on demand. JS modules talk via window globals and CustomEvent on document — no bundler, no framework. Loader order is fixed in index.html.

Post-note helpers such as billing suggestions, don't-miss review, and patient education handouts are reusable browser-side actions backed by authenticated JSON APIs. The patient education helper generates a parent-facing plain-text draft from the edited note and keeps the clinician in the review loop before copying or sharing.

authFetch.js installs a global fetch interceptor that treats any 401 on an authenticated request as a signal to clear local session state and redirect to login. A BroadcastChannel('pedscribe-auth') pushes that signal to sibling tabs so logging out in one tab drops UI in every open tab.

Docker topology

Container Image Internal port External
pediatric-ai-scribe ped-ai-local:latest (built from repo) 3000 127.0.0.1:3552
pedscribe-db pgvector/pgvector:pg16 5432 not exposed
ped-ai-redis Redis 6379 not exposed

Named volumes: pgdata (database), scribe-logs (filesystem audit logs), and Redis data if persistence is enabled by compose. Application health-check polls GET /api/health.

A reverse proxy terminates TLS and forwards to 127.0.0.1:3552. The app is never bound to a public interface directly.

Service worker

sw.js implements two strategies:

  • Shell assets (/, /js/*, /css/*, /components/*) — cache-first.
  • /api/* — network-first with cached fallback. Ensures fresh data online, last-known-good when offline.

Precached on install: index.html, core JS, main stylesheet, login component. Cleared on logout (caches.keys() → caches.delete()).

Clinical Assistant And MCP

The clinical assistant can call an external MCP-backed retrieval service. Ped-AI remains responsible for the user workflow, provider selection, prompts, and display. MCP remains responsible for Nextcloud access, indexing, retrieval, and vector search. Clinical answer response caching is intentionally disabled; Redis is used for operational metadata and prompt suggestions, not answer reuse.

Speech

Browser Whisper and browser-local Whisper model downloads are removed from runtime. Speech-to-text routes through LiteLLM; upstream provider choice belongs in LiteLLM config. Browser-native Web Speech remains available only when explicitly enabled by user settings and browser support.

Operational map

The sections above describe the code. These describe the running system: who owns what, what crosses each boundary, and where the truth lives when two places disagree.

Ownership

Ped-AI is a self-hosted Express application with a browser frontend, PostgreSQL storage, Redis operational state, LiteLLM model routing, and optional MCP-backed clinical retrieval.

Area Owner Notes
Web app Ped-AI Auth, UI, clinical workflows, admin settings, notes, Learning Hub, bedside tools
Database PostgreSQL Users, sessions, settings, saved app data, audit/API/access logs
Operational cache Redis Prompt suggestions, lightweight state, queue groundwork; not clinical answer caching
Model gateway LiteLLM Text, speech, image, embedding model discovery and routing
Clinical retrieval MCP service Nextcloud access, indexing, search, rerank, source metadata
Reverse proxy Caddy or equivalent TLS and public routing

Request Flow

Normal app request:

browser
  -> reverse proxy
  -> Express middleware
  -> auth/session check when protected
  -> route handler
  -> PostgreSQL/Redis/provider calls as needed
  -> JSON or HTML fragment response

Clinical Assistant request:

browser
  -> Ped-AI clinical assistant route
  -> MCP semantic search for indexed clinical sources
  -> Ped-AI builds grounded answer prompt
  -> LiteLLM chat model
  -> Ped-AI returns answer plus source metadata
  -> browser renders markdown, citations, and source cards

Ped-AI owns the user workflow and rendering. MCP owns retrieval and indexed source metadata. LiteLLM owns model routing.

Runtime Boundaries

Boundary Main Risk Current Direction
Browser to Ped-AI XSS, stale shell, session handling Sanitized rendering, httpOnly cookie for web, cache busting
Ped-AI to PostgreSQL schema drift, slow queries migrations, maintenance checks, indexes where needed
Ped-AI to Redis unavailable operational state Redis is useful but should not hold required clinical answers
Ped-AI to LiteLLM provider downtime, wrong model mode metadata-based model discovery and timeouts
Ped-AI to MCP retrieval latency/failure explicit MCP client layer and graceful fallback messages
MCP to Nextcloud stale indexed metadata scanner/indexer updates source metadata over time

Source Of Truth

Data Source Of Truth
User accounts and sessions Ped-AI PostgreSQL
Admin app settings Ped-AI PostgreSQL app_settings
Clinical source documents Nextcloud and MCP index
Clinical source title/path shown to users MCP result metadata, especially indexed file_path
Clinical answer text Generated per request; intentionally not cached
Model availability LiteLLM metadata and configured fallbacks

Deployment Shape

Production usually runs:

Caddy/TLS
  -> pediatric-ai-scribe container
     -> pedscribe-db container
     -> ped-ai-redis container
     -> LiteLLM endpoint
     -> MCP endpoint

The app should stay private behind the reverse proxy. Do not expose PostgreSQL, Redis, MCP internals, or provider keys publicly.

Design Principles

  • Keep Ped-AI stateless enough to run more than one app container.
  • Keep clinical answer generation live and source-grounded; do not cache final clinical answers.
  • Prefer model capability metadata over model-name regexes.
  • Prefer indexed file names and paths over embedded PDF metadata for source titles.
  • Keep renderer fixes narrow and tested because LLM markdown is messy.
  • Keep old frontend globals working until the affected feature is intentionally converted to ESM.