pediatric-ai-scribe-v3/docs/architecture.md
Daniel a505244b97
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 47s
Forgejo Docker Build / Root app tests (push) Successful in 45s
Forgejo Android APK / Build signed APK (push) Successful in 2m8s
Forgejo Docker Build / Build Docker image (push) Successful in 10s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
refactor: remove the embedding settings, whose only consumer is gone
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
2026-09-12 21:47:09 +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
    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 and image 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.