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
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
266 lines
12 KiB
Markdown
266 lines
12 KiB
Markdown
# 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:
|
|
|
|
```txt
|
|
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:
|
|
|
|
```txt
|
|
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:
|
|
|
|
```txt
|
|
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.
|