v6.1: Turnstile bot protection, LiteLLM provider, PPTX tables, audio backup fixes, docs

- 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
This commit is contained in:
Daniel 2026-04-04 22:56:24 +02:00
parent f98b9b7b71
commit a36235c646
31 changed files with 4123 additions and 303 deletions

View file

@ -91,6 +91,10 @@ ELEVENLABS_API_KEY=
# App
PORT=3000
APP_URL=https://your-domain.com
# Cloudflare Turnstile (anti-bot on registration, optional)
# TURNSTILE_SITE_KEY=your-site-key
# TURNSTILE_SECRET_KEY=your-secret-key
JWT_SECRET=generate-a-random-64-char-string-here
# Email (for verification & password reset)

361
README.md
View file

@ -1,67 +1,78 @@
# 🩺 Pediatric AI Scribe v3
# Pediatric AI Scribe v6
AI-powered clinical documentation platform for pediatric medicine. Generates HPIs, hospital courses, chart reviews, SOAP notes, and developmental milestone assessments from voice recordings or dictation — in seconds, in plain copy-ready text.
AI-powered clinical documentation platform for pediatric medicine. Generates HPIs, hospital courses, chart reviews, SOAP notes, well/sick visit notes, and developmental milestone assessments from voice recordings or dictation.
## Features
- **Live Encounter → HPI** — record a live doctor-patient conversation, AI generates a structured OLDCARTS HPI
- **Voice Dictation → HPI / SOAP** — dictate your narrative, AI cleans and restructures it
- **Hospital Course Generator** — paste progress notes, AI generates prose, day-by-day, organ-system (ICU), or psych format summaries
- **Chart Review / Precharting** — summarize outpatient, subspecialty, and ED notes into a precharting brief
- **SOAP Note Generator** — full SOAP or subjective-only from dictation
- **Well Visit / Preventive Care** — AAP 2025 Bright Futures periodicity; vaccines, screenings, billing codes; By Visit Age, Milestones, SSHADESS (12+), and Visit Note subtabs
- **Sick Visit Note** — quick documentation with auto-suggested ROS and PE systems from chief complaint
- **Developmental Milestones** — AAP/Nelson milestone tracker (birth11 years) with narrative, structured list, or 3-sentence summary; copy to Visit Note
- **SSHADESS Assessment** — adolescent psychosocial screening for ages 12+; auto-fills into Visit Note
- **Vaccine Schedule** — full AAP immunization schedule reference
- **Catch-Up Schedule** — catch-up immunization guide
- **Plain text output** — all documents generated without markdown, ready to paste into any EHR
- **Read Aloud** — browser TTS reads generated documents; ElevenLabs (Adam voice) supported
- **Copy & Export** — one-click copy or export to Nextcloud
- **Refine & Shorten** — edit any document with plain-language AI instructions
- **Per-tab model selector** — choose fast vs. smart vs. reasoning models per task
- **Collapsible sidebar** — desktop sidebar collapses to icon rail, state persisted
- **Save & Resume** — encounters saved with unique IDs; persist across page refresh
- **Admin Panel** — user management, registration control, audit logs
### Clinical Documentation
- **Live Encounter** — record doctor-patient conversations, AI generates structured OLDCARTS HPI
- **Voice Dictation** — dictate narrative, AI cleans and restructures
- **Hospital Course** — paste progress notes, generates prose, day-by-day, organ-system (ICU), or psych format
- **Chart Review / Precharting** — summarize outpatient, subspecialty, and ED notes
- **SOAP Notes** — full SOAP or subjective-only from dictation
- **Well Visit** — AAP 2025 Bright Futures periodicity with vaccines, screenings, billing codes, SSHADESS (12+), milestones
- **Sick Visit** — quick documentation with auto-suggested ROS and PE from chief complaint
- **Developmental Milestones** — AAP/Nelson tracker (birth-11y) with narrative/structured/summary output
### AI & Speech
- **5 AI Providers** — OpenRouter, AWS Bedrock, Azure OpenAI, Google Vertex AI, LiteLLM
- **5 STT Providers** — Google Gemini, Amazon Transcribe (Medical), OpenAI Whisper, Local Whisper, LiteLLM
- **3 TTS Providers** — Google Cloud TTS, LiteLLM (OpenAI), ElevenLabs
- **Browser Whisper** — fully offline in-browser transcription via WebAssembly (HIPAA-safe)
- **Per-tab model selector** — choose fast vs. smart vs. premium models per task
- **Physician memory system** — Dragon-like learning from your corrections
### Learning Hub
- **Content Management** — articles, clinical pearls, quizzes, presentations
- **AI Content Generation** — generate from topics, uploaded PDFs, or Nextcloud files
- **Marp Presentations** — slide editor with preview and PPTX export
- **Semantic Search** — vector-based search via pgvector embeddings
- **Quiz System** — MCQ, multi-select, true/false with scoring and progress tracking
### Platform
- **Multi-user with roles** — admin, moderator, user
- **OIDC/SSO** — Azure AD, Okta, Keycloak, PocketID, Google
- **2FA** — TOTP-based two-factor authentication
- **Multi-provider AI** — OpenRouter, AWS Bedrock, or Azure OpenAI
- **Cloudflare Turnstile** — bot protection on login, register, password reset
- **Email verification** — with customizable templates
- **Nextcloud integration** — WebDAV export
- **S3 Document Storage** — AWS S3, Backblaze B2, MinIO
- **PWA** — installable, works on mobile
- **Admin Panel** — user management, settings, prompt editor, model configuration, logs
---
## Quick Start (Docker)
## Quick Start
### 1. Clone and configure
### 1. Configure
```bash
git clone https://github.com/ifedan-ed/pediatric-ai-scribe-v3.git
cd pediatric-ai-scribe-v3
cp .env.example .env
```
Edit `.env` — at minimum set:
```env
OPENROUTER_API_KEY=sk-or-v1-...
OPENAI_API_KEY=sk-... # for Whisper transcription
JWT_SECRET=<64-char random string>
AI_PROVIDER=litellm # or openrouter, bedrock, azure, vertex
LITELLM_API_BASE=https://your-litellm.example.com
LITELLM_API_KEY=sk-...
OPENAI_API_KEY=sk-... # for Whisper transcription (if not using LiteLLM STT)
JWT_SECRET=<64-char random> # openssl rand -hex 32
DB_PASSWORD=<strong password>
APP_URL=https://your-domain.com
```
Generate a strong JWT secret:
```bash
openssl rand -hex 32
```
### 2. Start
```bash
docker compose up -d
```
App runs on **port 3552** by default. The first user to register becomes admin automatically.
App runs on **port 3552**. First user to register becomes admin.
### 3. Admin CLI (inside container)
### 3. Admin CLI
```bash
docker exec pediatric-ai-scribe node admin-cli.js list-users
@ -74,13 +85,90 @@ docker exec pediatric-ai-scribe node admin-cli.js stats
---
## AI Provider Configuration
Switch providers by setting `AI_PROVIDER` in `.env`. No code changes needed.
| Provider | HIPAA | Config |
|----------|-------|--------|
| **LiteLLM** | Depends on backend | `LITELLM_API_BASE`, `LITELLM_API_KEY` |
| **AWS Bedrock** | Yes (with BAA) | `AWS_BEDROCK_REGION`, `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` |
| **Azure OpenAI** | Yes (with BAA) | `AZURE_OPENAI_ENDPOINT`, `AZURE_OPENAI_API_KEY`, `AZURE_DEPLOYMENT_NAME` |
| **Google Vertex AI** | Yes (with BAA) | `GOOGLE_VERTEX_PROJECT`, `GOOGLE_VERTEX_LOCATION` |
| **OpenRouter** | No | `OPENROUTER_API_KEY` |
---
## Transcription (Speech-to-Text)
Set `TRANSCRIBE_PROVIDER` or let the app auto-detect.
| Provider | HIPAA | Config |
|----------|-------|--------|
| **Google Gemini** | Yes | `GOOGLE_VERTEX_PROJECT`, `GOOGLE_STT_MODEL` |
| **Amazon Transcribe** | Yes | AWS creds + `TRANSCRIBE_PROVIDER=aws` |
| **Amazon Transcribe Medical** | Yes | `AWS_TRANSCRIBE_MEDICAL=true`, `AWS_TRANSCRIBE_SPECIALTY=PRIMARYCARE` |
| **Local Whisper** | Yes (offline) | `TRANSCRIBE_PROVIDER=local`, `WHISPER_BINARY`, `WHISPER_MODEL_SIZE` |
| **OpenAI Whisper** | No | `OPENAI_API_KEY` |
| **LiteLLM** | Depends | `TRANSCRIBE_PROVIDER=litellm`, `LITELLM_STT_MODEL` |
| **Browser Whisper** | Yes (client-side) | No config needed — toggle in user settings |
---
## Text-to-Speech
| Provider | HIPAA | Config |
|----------|-------|--------|
| **Google Cloud TTS** | Yes | `GOOGLE_VERTEX_PROJECT`, `GOOGLE_TTS_VOICE` |
| **LiteLLM** | Depends | `LITELLM_TTS_MODEL`, `LITELLM_TTS_VOICE` |
| **ElevenLabs** | No | `ELEVENLABS_API_KEY` |
---
## OpenID Connect / SSO
Supports Azure AD, Okta, Keycloak, PocketID, Google, and any OIDC-compliant provider.
1. Register callback URL: `https://your-domain.com/api/auth/oidc/callback`
2. Admin Panel > Settings > Configure OIDC (Issuer URL, Client ID, Client Secret)
3. Users are auto-created and linked by email on first SSO login
See [OPENID_SETUP.md](OPENID_SETUP.md) for provider-specific guides.
---
## Cloudflare Turnstile (Bot Protection)
Optional CAPTCHA on login, registration, and password reset forms.
```env
TURNSTILE_SITE_KEY=0x4AAA...
TURNSTILE_SECRET_KEY=0x4AAA...
```
---
## Email
Without SMTP, email verification is skipped and users are auto-verified.
```env
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=your-email@gmail.com
SMTP_PASS=your-app-password
SMTP_FROM=noreply@yourdomain.com
```
---
## Docker Hub
```bash
docker pull danielonyejesi/pediatric-ai-scribe-v3:latest
```
### Minimal docker-compose without building
Minimal compose without building:
```yaml
services:
@ -95,7 +183,7 @@ services:
restart: unless-stopped
postgres:
image: postgres:16-alpine
image: pgvector/pgvector:pg16
environment:
POSTGRES_DB: pedscribe
POSTGRES_USER: pedscribe
@ -114,182 +202,35 @@ volumes:
---
## AI Provider Configuration
Switch providers by changing `AI_PROVIDER` in `.env`. No code changes needed.
### OpenRouter (default — cheapest, NOT HIPAA)
```env
AI_PROVIDER=openrouter
OPENROUTER_API_KEY=sk-or-v1-...
```
### AWS Bedrock (HIPAA compliant with BAA)
```env
AI_PROVIDER=bedrock
AWS_BEDROCK_REGION=us-east-1
AWS_ACCESS_KEY_ID=AKIA...
AWS_SECRET_ACCESS_KEY=...
```
Or use an IAM role (no keys needed when running on EC2/ECS — just set the region).
Available Bedrock models (auto-selected when `AI_PROVIDER=bedrock`):
- vendor model Opus 4.6 — best language nuance (`anthropic.agent-config-opus-4-6-20251001-v1:0`)
- vendor model Sonnet 4.6 — recommended (`anthropic.agent-config-sonnet-4-6-20251001-v1:0`)
- vendor model Sonnet 4 (`anthropic.agent-config-sonnet-4-20250514-v1:0`)
- vendor model 3.5 Sonnet (`anthropic.agent-config-3-5-sonnet-20241022-v2:0`)
- vendor model 3 Haiku — cheapest (`anthropic.agent-config-3-haiku-20240307-v1:0`)
- Llama 3.1 70B / 8B
- Mistral Large
### Azure OpenAI (HIPAA compliant with BAA)
```env
AI_PROVIDER=azure
AZURE_OPENAI_ENDPOINT=https://your-resource.openai.azure.com
AZURE_OPENAI_API_KEY=...
AZURE_DEPLOYMENT_NAME=gpt-4o-mini
AZURE_OPENAI_API_VERSION=2024-02-01
```
---
## Transcription (Speech-to-Text)
Two providers supported. The app auto-selects AWS Transcribe when `AWS_BEDROCK_REGION` is set, otherwise falls back to OpenAI Whisper.
### Amazon Transcribe (recommended for clinical use — HIPAA eligible)
Uses your existing Bedrock AWS credentials. No S3 bucket required — audio streams directly to AWS.
**Requires `ffmpeg` installed on the server** (handles audio format conversion from browser WebM to PCM). The Docker image includes ffmpeg automatically.
```env
# Auto-enabled when AWS_BEDROCK_REGION is set.
# To force it explicitly:
TRANSCRIBE_PROVIDER=aws
# Amazon Transcribe Medical — trained on clinical speech.
# Knows drug names, diagnoses, procedures. Recommended.
AWS_TRANSCRIBE_MEDICAL=true
AWS_TRANSCRIBE_SPECIALTY=PRIMARYCARE
# Other specialty options: CARDIOLOGY, NEUROLOGY, ONCOLOGY, RADIOLOGY, UROLOGY
```
Install ffmpeg on non-Docker servers:
```bash
# Ubuntu/Debian
sudo apt-get install -y ffmpeg
# Amazon Linux / RHEL
sudo yum install -y ffmpeg
# macOS
brew install ffmpeg
```
### OpenAI Whisper (default fallback)
```env
OPENAI_API_KEY=sk-...
# Optionally force Whisper even when AWS is configured:
# TRANSCRIBE_PROVIDER=openai
```
---
## OpenID Connect / SSO (optional — PocketID, Keycloak, Azure AD, etc.)
Enable Single Sign-On authentication with any OpenID Connect provider. Users can log in with their SSO provider, and existing accounts are automatically linked by email.
**Supported providers:** PocketID, Keycloak, Azure AD / Entra ID, Okta, Google Workspace, and any OIDC-compliant provider.
### Quick Setup
1. Register this app with your OIDC provider using callback URL:
```
https://your-domain.com/api/auth/oidc/callback
```
2. Log into the app as admin and navigate to **Admin Panel** → **Settings**
3. Configure OIDC settings:
- Enable OIDC
- Set Issuer URL (e.g., `https://id.example.com`)
- Enter Client ID and Client Secret from your provider
- Customize the button label (e.g., "Sign in with PocketID")
4. Save and test the login
**Detailed setup guide with examples for all providers:** [OPENID_SETUP.md](OPENID_SETUP.md)
### Linking Existing Users
When a user signs in via SSO, the system automatically links their account if the email matches an existing user. No manual intervention needed.
For advanced scenarios (manual linking, CLI commands, troubleshooting), see [OPENID_SETUP.md](OPENID_SETUP.md).
---
## Email (optional — for verification & password reset)
Without SMTP configured, email verification is skipped and users are auto-verified on registration.
```env
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=your-email@gmail.com
SMTP_PASS=your-app-password
SMTP_FROM=noreply@yourdomain.com
```
---
## Environment Variables Reference
| Variable | Required | Description |
|---|---|---|
| `OPENROUTER_API_KEY` | If using OpenRouter | OpenRouter API key |
| `AI_PROVIDER` | No | `openrouter` (default), `bedrock`, or `azure` |
| `AWS_BEDROCK_REGION` | If using Bedrock | e.g. `us-east-1` |
| `AWS_ACCESS_KEY_ID` | If using Bedrock (no IAM role) | AWS access key |
| `AWS_SECRET_ACCESS_KEY` | If using Bedrock (no IAM role) | AWS secret key |
| `AZURE_OPENAI_ENDPOINT` | If using Azure | Azure OpenAI endpoint URL |
| `AZURE_OPENAI_API_KEY` | If using Azure | Azure API key |
| `AZURE_DEPLOYMENT_NAME` | If using Azure | Deployment name, e.g. `gpt-4o-mini` |
| `OPENAI_API_KEY` | For Whisper transcription | OpenAI key (Whisper fallback) |
| `TRANSCRIBE_PROVIDER` | No | `aws` or `openai` — auto-detected from AWS config |
| `AWS_TRANSCRIBE_MEDICAL` | No | `true` to use Transcribe Medical (clinical accuracy) |
| `AWS_TRANSCRIBE_SPECIALTY` | No | `PRIMARYCARE` (default), `CARDIOLOGY`, `NEUROLOGY`, etc. |
| `ELEVENLABS_API_KEY` | No | ElevenLabs TTS (optional) |
| `JWT_SECRET` | **Yes** | Random 64-char string — keep secret |
| `DATABASE_URL` | No | PostgreSQL URL (auto-set by docker-compose) |
| `DB_PASSWORD` | **Yes** | PostgreSQL password |
| `APP_URL` | Recommended | Public URL e.g. `https://scribe.example.com` (used for CORS, emails) |
| `PORT` | No | Internal port, default `3000` |
| `SMTP_HOST` | No | SMTP server for email |
| `SMTP_PORT` | No | Default `587` |
| `SMTP_USER` | No | SMTP username |
| `SMTP_PASS` | No | SMTP password / app password |
| `SMTP_FROM` | No | From address for emails |
---
## HIPAA Notice
This application processes data through third-party AI APIs.
- ✅ All connections use HTTPS/TLS
- ✅ Authentication required for all AI endpoints
- ✅ 2FA available
- ✅ No patient data stored on server (only audit logs)
- ⚠️ **OpenRouter does not offer a BAA** — do not use with real PHI
- ✅ **AWS Bedrock** and **Azure OpenAI** offer BAAs — suitable for PHI with proper configuration
- All connections use HTTPS/TLS
- Authentication required for all AI endpoints
- 2FA and SSO available
- Cloudflare Turnstile bot protection
- **AWS Bedrock**, **Azure OpenAI**, and **Google Vertex AI** offer BAAs
- **OpenRouter** and **ElevenLabs** do NOT offer BAAs
- **Browser Whisper** and **Local Whisper** keep audio fully private
**Recommendation:** Do not enter real patient data until your organization has executed BAAs with all AI providers in use.
**Do not use real PHI without executed BAAs with all providers in your deployment.**
---
## Documentation
See the [docs/](docs/) directory for detailed documentation:
- [Architecture Overview](docs/architecture.md)
- [API Reference](docs/api-reference.md)
- [Database Schema](docs/database.md)
- [Authentication & Security](docs/authentication.md)
- [AI Providers & Models](docs/ai-providers.md)
- [Speech (STT/TTS)](docs/speech.md)
- [Learning Hub & CMS](docs/learning-hub.md)
- [Configuration Reference](docs/configuration.md)
- [Deployment Guide](docs/deployment.md)
---
@ -298,6 +239,6 @@ This application processes data through third-party AI APIs.
```bash
npm install
cp .env.example .env # edit with your keys
# Requires a running PostgreSQL instance (see DATABASE_URL in .env)
# Requires PostgreSQL with pgvector
node server.js
```

View file

@ -1,8 +1,9 @@
services:
pediatric-scribe:
image: danielonyejesi/pediatric-ai-scribe-v3:v6
build: .
image: ped-ai-local:latest
ports:
- "3552:3000"
- "127.0.0.1:3552:3000"
env_file:
- .env
volumes:
@ -20,7 +21,7 @@ services:
start_period: 20s
postgres:
image: postgres:16-alpine
image: pgvector/pgvector:pg16
environment:
POSTGRES_DB: pedscribe
POSTGRES_USER: pedscribe

114
docs/ai-providers.md Normal file
View file

@ -0,0 +1,114 @@
# AI Providers
This document covers the AI provider system, model management, prompt configuration, and usage logging for the Pediatric AI Scribe application.
---
## Provider Selection
The active AI provider is determined in one of two ways:
1. **Explicit**: Set the `AI_PROVIDER` environment variable to one of the supported provider names.
2. **Auto-detect**: If `AI_PROVIDER` is not set, the system checks for provider-specific credentials in the environment and selects the first match using this priority order:
`bedrock` > `azure` > `vertex` > `litellm` > `openrouter`
All providers expose a unified `callAI()` interface defined in `src/utils/ai.js`. Calling code does not need to know which backend is active.
---
## Provider Details
### 1. AWS Bedrock (HIPAA-eligible with BAA)
- **SDK**: `@aws-sdk/client-bedrock-runtime`
- Uses **inference profiles** for newer models, enabling cross-region routing.
- **Available model families**: vendor model (Anthropic), Amazon Nova, Llama (Meta), Mistral, DeepSeek, Cohere.
- **Default temperature**: 0.3
### 2. Azure OpenAI (HIPAA-eligible with BAA)
- **SDK**: OpenAI SDK configured to point at an Azure endpoint.
- Requires a **deployment name** that maps to the desired model.
- **Available model families**: GPT-4o family, GPT-4.1 family.
### 3. Google Vertex AI (HIPAA-eligible with BAA)
- **SDK**: `@google-cloud/vertexai`
- In addition to text generation, Vertex AI handles:
- **Speech-to-Text (STT)**: via Gemini inline audio capabilities.
- **Text-to-Speech (TTS)**: via Vertex AI TTS endpoint.
- **Available model families**: Gemini 2.5/2.0, vendor model on Vertex (Anthropic models hosted on Google Cloud), Llama.
### 4. LiteLLM Proxy (self-hosted)
- **SDK**: OpenAI SDK pointed at the `LITELLM_API_BASE` URL.
- Acts as a proxy that routes requests to any backend configured within LiteLLM.
- **Model discovery**: queries `/v1/models` on the LiteLLM instance to populate the available model list.
- Also supports **TTS and STT** passthrough.
- Model names are used **as-configured in LiteLLM** -- the application does not auto-prefix or transform them.
### 5. OpenRouter (NOT HIPAA-compliant)
- **SDK**: OpenAI SDK pointed at `https://openrouter.ai`.
- **Cost discovery**: queries the OpenRouter API to retrieve per-model pricing.
- Offers the **cheapest option** and the **widest model selection** across many providers.
- Not suitable for environments that require HIPAA compliance.
---
## Model Management
### Model Definitions
Models are defined in `src/utils/models.js` and organized into four categories:
| Category | Description |
|-----------|------------------------------------------------|
| `free` | No-cost models (typically smaller or rate-limited) |
| `fast` | Low-latency models optimized for speed |
| `smart` | Balanced models with strong reasoning ability |
| `premium` | Top-tier models with the highest capability |
### Admin Controls
- Administrators can **enable or disable** individual models from the Admin Panel.
- A **default model** can be set for new users.
- **Custom models** can be added with a user-defined name and cost per token.
### Model Discovery
The system queries the active provider's API to retrieve the list of models that are actually available. This list is intersected with the configured model definitions to determine what appears in the UI.
### Frontend Display
The model selector dropdown groups models by category (free, fast, smart, premium) for easy navigation.
---
## AI Prompts
### Storage and Override
- All default prompts are defined in `src/utils/prompts.js`.
- Prompts can be **overridden via the database** using the `app_settings` table with keys following the pattern `prompt.{name}`.
- Administrators can view and edit all prompts directly from the Admin Panel.
### Physician Memories
When a physician saves corrections or preferences (referred to as "memories"), these are injected into the prompt as **low-priority style hints**. This allows the AI to adapt its output to the physician's preferred documentation style without overriding the core clinical prompt.
---
## Logging
Every AI call is recorded in the `api_log` database table with the following fields:
| Field | Description |
|------------|-----------------------------------------------------|
| `model` | The model identifier used for the request |
| `tokens` | Input and output token counts |
| `cost` | Estimated cost of the call |
| `duration` | Wall-clock time for the request in milliseconds |
Cost estimates are calculated from **hardcoded per-model rates** defined in the codebase. For OpenRouter, rates may also be fetched from the OpenRouter pricing API.

2144
docs/api-reference.md Normal file

File diff suppressed because it is too large Load diff

172
docs/architecture.md Normal file
View file

@ -0,0 +1,172 @@
# Pediatric AI Scribe - Architecture Overview
## System Overview
The Pediatric AI Scribe is a self-hosted, Dockerized clinical documentation assistant built on the following stack:
- **Runtime:** Node.js 20 (Alpine) with Express
- **Database:** PostgreSQL 16 with the pgvector extension for embedding-based similarity search
- **Containerization:** Docker Compose with two services (app + database)
- **Frontend:** Vanilla JavaScript single-page application (no framework)
The application provides AI-powered transcription, note generation, and learning tools for pediatric clinicians. It runs entirely behind a reverse proxy and is designed for single-institution or personal deployment.
---
## File Structure
```
/
├── server.js # Application entry point
├── package.json
├── Dockerfile
├── docker-compose.yml
├── sw.js # Service worker (copied into public/)
├── src/
│ ├── routes/ # 27 route files (Express routers)
│ │ ├── encounters.js
│ │ ├── auth.js
│ │ ├── admin.js
│ │ ├── learning.js
│ │ ├── ... # (27 total)
│ │
│ ├── utils/
│ │ ├── ai.js # LLM client abstraction (OpenAI-compatible)
│ │ ├── models.js # Model registry and selection
│ │ ├── prompts.js # System/user prompt templates
│ │ ├── config.js # App settings helpers (DB-backed)
│ │ ├── logger.js # Winston logger setup
│ │ ├── embeddings.js # pgvector embedding generation
│ │ ├── transcribeAWS.js # AWS Transcribe integration
│ │ ├── transcribeGoogle.js # Google Cloud Speech-to-Text
│ │ ├── transcribeLocal.js # Local Whisper WASM transcription
│ │ └── ttsGoogle.js # Google Cloud Text-to-Speech
│ │
│ ├── middleware/
│ │ ├── auth.js # JWT + session authentication
│ │ └── logging.js # Request/response logging middleware
│ │
│ └── db/
│ └── database.js # PostgreSQL connection pool + query helpers
├── public/ # Static frontend assets
│ ├── index.html # SPA shell
│ ├── app.js # Tab/navigation manager
│ ├── components/ # HTML partials loaded via fetch
│ ├── js/ # 20+ JS modules
│ └── css/ # Stylesheets
└── scripts/ # Utility and migration scripts
```
---
## Request Flow
Every incoming HTTP request passes through the following middleware chain in order:
```
Client Request
|
v
Helmet (CSP headers, security hardening)
|
v
CORS (origin validation)
|
v
Cookie Parser (signed cookies for sessions)
|
v
Rate Limiting (per-IP and per-route limits)
|
v
Static File Serving (public/ directory)
|
v
Route Matching (src/routes/*.js)
|
v
Auth Middleware (JWT verification, role checks)
|
v
Route Handler (business logic, DB queries, AI calls)
|
v
JSON Response
```
Static assets are served before route matching, so unauthenticated users can load the SPA shell and login page. All API routes under `/api/` require authentication unless explicitly excluded (e.g., `/api/auth/login`, `/api/auth/register`).
---
## Frontend Architecture
The frontend is a vanilla JavaScript SPA with no build step and no framework.
### Loading
`index.html` serves as the application shell. It contains a `<div class="app-body">` placeholder and loads 20+ JS modules via `<script defer>` tags. On startup, `app.js` initializes the tab system and fetches HTML partials from `components/` into the `.app-body` container.
### Module Communication
Because there is no framework or module bundler, frontend modules communicate through two mechanisms:
1. **Window globals** -- Shared state and utility functions are attached to `window` (e.g., `window.currentUser`, `window.apiCall`).
2. **CustomEvents** -- Modules dispatch and listen for `CustomEvent` instances on `document` to coordinate loosely-coupled updates (e.g., when an encounter is saved, other tabs refresh their data).
### Tab Navigation
Tabs are managed by `app.js`. Clicking a tab fetches the corresponding HTML partial from `components/`, injects it into `.app-body`, and invokes the module's initialization function. Only one tab is active at a time; previous tab content is replaced.
---
## Docker Configuration
### Application Container
- **Base image:** `node:20-alpine`
- **System dependencies:** `ffmpeg` (audio processing for transcription)
- **Bundled models:** Self-hosted Whisper WASM models for browser-side and server-side local transcription
- **Internal port:** 3000
### Database Container
- **Image:** `pgvector/pgvector:pg16`
- **Extension:** pgvector is loaded automatically for vector similarity search on learning content embeddings
### Port Mapping
The application binds to the loopback interface only:
```
127.0.0.1:3552 -> container:3000
```
This means the app is not directly accessible from the network. A reverse proxy (e.g., Nginx, Caddy) should terminate TLS and forward traffic to `127.0.0.1:3552`.
### Volumes
| Volume | Purpose |
|---|---|
| `pgdata` | PostgreSQL data directory (persistent) |
| `scribe-logs` | Application file logs written by Winston |
---
## Service Worker
The file `sw.js` is registered by the frontend and implements a two-strategy caching model:
### Static Assets (Cache-First)
Requests for CSS, JS, images, fonts, and HTML partials are served from the cache first. If the cache misses, the network is used and the response is cached for future requests. This enables fast repeat loads and basic offline shell rendering.
### API Requests (Network-First)
Requests to `/api/` endpoints always attempt the network first. If the network fails (e.g., offline or timeout), the service worker falls back to a cached response if one exists. This ensures users always see the freshest data when connected.
### Precaching
On installation, the service worker precaches the application shell: `index.html`, `app.js`, core CSS, and critical component partials. This set of assets is enough to render the login screen and basic UI skeleton without any network requests.

147
docs/authentication.md Normal file
View file

@ -0,0 +1,147 @@
# Authentication and Security
This document covers the complete authentication, authorization, and security system for the Pediatric AI Scribe application.
---
## Authentication Methods
### Local Authentication
- Passwords are hashed using **bcryptjs** with 12 salt rounds.
- On successful login, a **JSON Web Token (JWT)** is issued with a 7-day expiry.
- The token is stored in an **httpOnly cookie** named `ped_auth`.
- In production: `secure: true`, `sameSite: lax`.
- In development: `secure: false`, `sameSite: lax`.
### Auth Middleware
The authentication middleware checks credentials in the following order:
1. Looks for a `Bearer` token in the `Authorization` header.
2. If the token is empty or missing (including the case where the header is literally `"Bearer "` with no token), falls back to reading the `ped_auth` cookie.
This two-step approach was specifically fixed to handle empty Bearer strings gracefully, preventing false authentication failures from clients that send the header with no value.
### TOTP Two-Factor Authentication (2FA)
- Implemented using the **speakeasy** library.
- Setup flow: server generates a TOTP secret, encodes it as a QR code, and the user scans it with an authenticator app.
- Verification: 6-digit code, with `window=1` (accepts codes from the previous and next 30-second interval in addition to the current one).
### OIDC / SSO (Single Sign-On)
- Implements the **Authorization Code + PKCE** flow using the **openid-client** library.
- State parameters are stored **in-memory** with a 5-minute TTL to prevent replay attacks.
- On first login via OIDC, a local user account is **auto-created** using claims from the identity provider.
- Supported providers:
- Azure AD
- Okta
- Keycloak
- PocketID
- Google
### Email Verification
- A 32-byte random hex token is generated and sent to the user's email address.
- The token expires after **24 hours**.
### Password Reset
- A 32-byte random hex token is generated and sent to the user's email address.
- The token expires after **1 hour**.
---
## Cloudflare Turnstile (CAPTCHA)
Turnstile is applied to the following routes:
- User registration
- User login
- Password reset request
### Frontend
- The `cf-turnstile` widget is rendered with the configured site key.
- The form validates that the Turnstile challenge was completed before allowing submission.
- On failure, the widget resets so the user can retry.
### Backend
- The server sends a `POST` request to `https://challenges.cloudflare.com/turnstile/v0/siteverify` with the secret key and the client-provided token.
- Turnstile is **only enforced when `TURNSTILE_SECRET_KEY` is set** in the environment. If the variable is absent, the check is skipped entirely. This allows development and self-hosted environments to run without Cloudflare integration.
---
## Rate Limiting
Rate limiting is implemented using **express-rate-limit** with the following windows:
| Endpoint | Limit | Window |
|----------------------------------|---------------|----------|
| `/api/*` (general) | 60 requests | 1 minute |
| `/api/auth/login` | 10 requests | 15 minutes |
| `/api/auth/register` | 5 requests | 1 hour |
| `/api/auth/forgot-password` | 5 requests | 1 hour |
| `/api/auth/resend-verification` | 3 requests | 15 minutes |
---
## Content Security Policy (Helmet)
The application uses **Helmet** to set HTTP security headers. The Content Security Policy directives are configured as follows:
| Directive | Values |
|------------------|------------------------------------------------------------------------|
| `script-src` | `'self'`, `'wasm-unsafe-eval'`, `'unsafe-eval'`, `cdn.jsdelivr.net`, `challenges.cloudflare.com` |
| `script-src-attr`| `'none'` (blocks inline event handlers like `onclick`) |
| `style-src` | `'self'`, `'unsafe-inline'`, `fonts.googleapis.com`, `cdnjs.cloudflare.com` |
| `frame-src` | `'self'`, `challenges.cloudflare.com` |
| `connect-src` | `'self'` + CDN domains + HuggingFace + Cloudflare |
| `object-src` | `'none'` |
---
## CORS
- **Production** (when `APP_URL` is set): restricts the allowed origin to the value of `APP_URL`.
- **Development** (when `APP_URL` is not set): allows all origins.
- Requests with **no origin** (such as those from mobile apps or `curl`) are always permitted.
- `credentials: true` is set to allow cookies to be sent cross-origin.
---
## Roles and Authorization
The application defines three user roles:
| Role | Access Level |
|-------------|-------------------------------------------------------|
| `admin` | Full access to all features, including the Admin Panel. The **first registered user** is automatically promoted to admin. |
| `moderator` | Standard user access plus Learning Hub CMS management. |
| `user` | Standard access to patient encounters and AI features. |
---
## Audit Logging
All authentication-related events are recorded in the `audit_log` database table.
### Fields
| Column | Description |
|--------------|--------------------------------------------------|
| `user_id` | The ID of the user involved (null for failed attempts by unknown users) |
| `action` | The type of event (see below) |
| `ip_address` | The client IP address |
| `details` | A JSON object with additional context |
### Tracked Actions
- `register` -- new account created
- `login` -- successful login
- `login_failed` -- incorrect credentials
- `login_blocked` -- blocked by rate limiter or other policy
- `email_verified` -- user confirmed their email address
- Additional actions for password resets, 2FA changes, and OIDC logins

219
docs/configuration.md Normal file
View file

@ -0,0 +1,219 @@
# Configuration
This document covers all configuration options for the Pediatric AI Scribe, including environment variables, database-backed settings, and the admin panel.
---
## Environment Variables
Environment variables are set in the `.env` file or passed to the Docker container. They are read at startup.
### AI Provider
| Variable | Description |
|----------|-------------|
| `AI_PROVIDER` | AI backend: `openrouter`, `bedrock`, `azure`, `vertex`, or `litellm`. |
| `OPENROUTER_API_KEY` | API key for OpenRouter. |
| `AWS_BEDROCK_REGION` | AWS region for Bedrock (e.g., `us-east-1`). |
| `AWS_ACCESS_KEY_ID` | AWS access key (shared by Bedrock and Transcribe). |
| `AWS_SECRET_ACCESS_KEY` | AWS secret key (shared by Bedrock and Transcribe). |
| `AZURE_OPENAI_ENDPOINT` | Azure OpenAI resource endpoint URL. |
| `AZURE_OPENAI_API_KEY` | Azure OpenAI API key. |
| `AZURE_DEPLOYMENT_NAME` | Azure OpenAI deployment/model name. |
| `AZURE_OPENAI_API_VERSION` | Azure OpenAI API version string. |
| `GOOGLE_VERTEX_PROJECT` | Google Cloud project ID for Vertex AI. |
| `GOOGLE_VERTEX_LOCATION` | Google Cloud region for Vertex AI (e.g., `us-central1`). |
| `GOOGLE_APPLICATION_CREDENTIALS` | Path to the Google service account JSON key file. |
| `LITELLM_API_BASE` | Base URL of the LiteLLM proxy server. |
| `LITELLM_API_KEY` | API key for LiteLLM proxy authentication. |
### Transcription (Speech-to-Text)
| Variable | Description |
|----------|-------------|
| `TRANSCRIBE_PROVIDER` | STT backend: `google`, `aws`, `local`, `openai`, or `litellm`. Auto-detects if unset (google > aws > openai). |
| `OPENAI_API_KEY` | API key for OpenAI Whisper. |
| `GOOGLE_STT_MODEL` | Google Gemini model for transcription (default: `gemini-2.0-flash`). |
| `AWS_TRANSCRIBE_MEDICAL` | Enable Amazon Transcribe Medical mode (`true`/`false`). |
| `AWS_TRANSCRIBE_SPECIALTY` | Medical specialty: `PRIMARYCARE`, `CARDIOLOGY`, `NEUROLOGY`, `ONCOLOGY`, `RADIOLOGY`, `UROLOGY`. |
| `WHISPER_BINARY` | Path to the local whisper binary (`whisper.cpp` or `faster-whisper`). |
| `WHISPER_MODEL_SIZE` | Local Whisper model size: `tiny`, `base`, `small`, `medium`, `large`. |
| `WHISPER_MODEL_PATH` | Path to the local Whisper model file. |
| `WHISPER_LANGUAGE` | Language code for local Whisper (e.g., `en`). |
| `WHISPER_THREADS` | Number of CPU threads for local Whisper. |
| `LITELLM_STT_MODEL` | Model name for LiteLLM-routed transcription. |
### Text-to-Speech
| Variable | Description |
|----------|-------------|
| `GOOGLE_TTS_VOICE` | Google Cloud TTS voice name (e.g., `en-US-Journey-F`). |
| `ELEVENLABS_API_KEY` | API key for ElevenLabs TTS. |
| `LITELLM_TTS_MODEL` | Model name for LiteLLM-routed TTS. |
| `LITELLM_TTS_VOICE` | Voice name for LiteLLM-routed TTS. |
### Embeddings
| Variable | Description |
|----------|-------------|
| `EMBEDDING_MODEL` | Embedding model name (default: Vertex AI `text-embedding-005`). |
| `EMBEDDING_DIMENSIONS` | Embedding vector dimensions (default: `768`). |
### Application
| Variable | Description |
|----------|-------------|
| `PORT` | HTTP listen port (default: `3000`). |
| `APP_URL` | Public-facing base URL of the application. |
| `JWT_SECRET` | Secret key for signing JWT tokens. |
| `SESSION_SECRET` | Secret key for session cookies. |
| `DATABASE_URL` | Full PostgreSQL connection string. |
| `DB_PASSWORD` | Database password (used if `DATABASE_URL` is not set). |
### Email (SMTP)
| Variable | Description |
|----------|-------------|
| `SMTP_HOST` | SMTP server hostname. |
| `SMTP_PORT` | SMTP server port. |
| `SMTP_USER` | SMTP authentication username. |
| `SMTP_PASS` | SMTP authentication password. |
| `SMTP_FROM` | Sender address for outgoing emails. |
### Security
| Variable | Description |
|----------|-------------|
| `TURNSTILE_SITE_KEY` | Cloudflare Turnstile site key for bot protection. |
| `TURNSTILE_SECRET_KEY` | Cloudflare Turnstile server-side secret key. |
### Integrations
| Variable | Description |
|----------|-------------|
| `NEXTCLOUD_URL` | Nextcloud instance URL for WebDAV file access. |
| `S3_BUCKET` | S3 bucket name for file storage. |
| `S3_REGION` | S3 bucket region. |
| `S3_PREFIX` | Key prefix (folder) within the S3 bucket. |
| `S3_ENDPOINT` | Custom S3 endpoint URL (for S3-compatible storage like MinIO). |
| `S3_ACCESS_KEY_ID` | S3 access key. |
| `S3_SECRET_ACCESS_KEY` | S3 secret key. |
| `S3_FORCE_PATH_STYLE` | Use path-style S3 URLs instead of virtual-hosted (`true`/`false`). Required for most S3-compatible providers. |
---
## Database-Backed Settings
Runtime settings are stored in the `app_settings` table and can be modified through the admin panel without restarting the application.
### Caching
Settings are cached **in memory for 2 minutes**. The cache is **invalidated immediately on write**, so changes made through the admin panel take effect right away.
### Setting Keys
#### Registration and Site
| Key | Description |
|-----|-------------|
| `registration_enabled` | Allow new user registration (`true`/`false`). |
| `site.name` | Display name of the application. |
| `site.auto_delete_days` | Number of days after which encounter data is automatically deleted. |
#### Announcements
| Key | Description |
|-----|-------------|
| `announcement.text` | Banner message text displayed to all users. |
| `announcement.type` | Banner style: `info`, `warning`, `error`, or `success`. |
#### Feature Flags
| Key Pattern | Description |
|-------------|-------------|
| `feature.*` | Toggle individual features on or off. |
#### SMTP (Overrides Environment)
| Key Pattern | Description |
|-------------|-------------|
| `smtp.host`, `smtp.port`, `smtp.user`, `smtp.pass`, `smtp.from` | SMTP configuration. Overrides the corresponding environment variables when set. |
#### Email Templates
| Key Pattern | Description |
|-------------|-------------|
| `email.*.subject` | Email subject line template. |
| `email.*.body` | Email body template. |
#### OIDC / SSO
| Key | Description |
|-----|-------------|
| `oidc.enabled` | Enable OpenID Connect authentication (`true`/`false`). |
| `oidc.issuer` | OIDC provider issuer URL. |
| `oidc.clientId` | OIDC client ID. |
| `oidc.clientSecret` | OIDC client secret. |
| `oidc.buttonLabel` | Label for the SSO login button. |
| `oidc.disableLocalAuth` | Hide the local login form when SSO is enabled. |
#### AI and Model Configuration
| Key | Description |
|-----|-------------|
| `stt.model` | Default speech-to-text model. |
| `tts.model` | Default text-to-speech model. |
| `tts.voice` | Default text-to-speech voice. |
| `models.default` | Default AI model for note generation. |
| `prompt.*` | AI prompt overrides. Each key corresponds to a specific prompt template. |
| `embeddings.*` | Embedding model and dimension configuration. |
---
## Admin Panel Settings
The admin panel provides a web interface for managing the application without editing configuration files.
### User Management
- List all registered users.
- Verify unverified accounts.
- Disable or re-enable user accounts.
- View per-user usage statistics.
### Registration
- Enable or disable new user registration globally.
### Announcement Banner
- Set banner text displayed at the top of the application.
- Choose banner type: `info`, `warning`, `error`, or `success`.
### SMTP Configuration
- Configure SMTP settings through the UI.
- These settings override the corresponding `.env` values when set.
### OIDC / SSO Configuration
- Enable or disable OpenID Connect authentication.
- Configure issuer URL, client ID, client secret, and button label.
- Option to disable local authentication entirely when SSO is active.
### AI Prompts
- View the default prompt templates used for note generation.
- Override any prompt with custom text.
- Reset overridden prompts back to their defaults.
### Model Management
- Enable or disable available AI models.
- Set the default model for new users.
- Add custom models with cost and category metadata.
### TTS and STT Configuration
- Test TTS and STT providers from the admin panel.
- Configure default TTS voice and STT model for all users.

363
docs/database.md Normal file
View file

@ -0,0 +1,363 @@
# Pediatric AI Scribe - Database Schema
## Overview
The application uses PostgreSQL 16 with the **pgvector** extension enabled for vector similarity search. The database runs in a `pgvector/pgvector:pg16` container with a persistent `pgdata` volume.
### Connection Pool
| Setting | Value |
|---|---|
| Max connections | 20 |
| Idle timeout | 30 seconds |
| Connection timeout | 5 seconds |
---
## Extensions
```sql
CREATE EXTENSION IF NOT EXISTS vector; -- pgvector for embedding search
```
---
## Tables
### users
Core user accounts with authentication, TOTP two-factor, OIDC federation, and per-user preferences.
| Column | Type | Notes |
|---|---|---|
| id | SERIAL PRIMARY KEY | |
| email | VARCHAR UNIQUE NOT NULL | |
| password | VARCHAR | bcrypt hash; NULL for OIDC-only users |
| name | VARCHAR | Display name |
| role | VARCHAR | `user`, `admin` |
| totp_secret | VARCHAR | TOTP shared secret (encrypted) |
| totp_enabled | BOOLEAN | Whether 2FA is active |
| email_verified | BOOLEAN | |
| verify_token | VARCHAR | Email verification token |
| verify_expires | TIMESTAMP | Expiry for verify_token |
| reset_token | VARCHAR | Password reset token |
| reset_expires | TIMESTAMP | Expiry for reset_token |
| oidc_sub | VARCHAR | OpenID Connect subject identifier |
| disabled | BOOLEAN | Soft-disable account |
| nextcloud_url | VARCHAR | User's Nextcloud/WebDAV server URL |
| nextcloud_user | VARCHAR | WebDAV username |
| nextcloud_pass | VARCHAR | WebDAV password (encrypted) |
| stt_model | VARCHAR | Preferred speech-to-text model |
| tts_voice | VARCHAR | Preferred text-to-speech voice |
| webdav_learning_path | VARCHAR | WebDAV path for learning exports |
| created_at | TIMESTAMP | DEFAULT NOW() |
| updated_at | TIMESTAMP | DEFAULT NOW() |
---
### app_settings
Key-value store for application configuration. Values are cached in memory with a 2-minute TTL to avoid repeated DB reads on every request.
| Column | Type | Notes |
|---|---|---|
| key | VARCHAR PRIMARY KEY | Setting name |
| value | TEXT | JSON or plain text value |
| updated_at | TIMESTAMP | DEFAULT NOW() |
| updated_by | INTEGER | FK to users.id |
---
### audit_log
High-level audit trail for security-relevant and AI-related actions.
| Column | Type | Notes |
|---|---|---|
| id | SERIAL PRIMARY KEY | |
| user_id | INTEGER | FK to users.id |
| action | VARCHAR | Action name (e.g., `generate_note`, `login`) |
| category | VARCHAR | Grouping category |
| details | TEXT | Free-form detail string or JSON |
| ip_address | VARCHAR | Client IP |
| user_agent | VARCHAR | Client User-Agent header |
| model_used | VARCHAR | LLM model identifier (if applicable) |
| tokens_used | INTEGER | Total tokens consumed |
| duration_ms | INTEGER | Wall-clock time of the operation |
| status | VARCHAR | `success`, `error`, etc. |
| timestamp | TIMESTAMP | DEFAULT NOW() |
---
### api_log
Per-request API telemetry with cost tracking.
| Column | Type | Notes |
|---|---|---|
| id | SERIAL PRIMARY KEY | |
| user_id | INTEGER | FK to users.id |
| endpoint | VARCHAR | Route path |
| method | VARCHAR | HTTP method |
| status_code | INTEGER | Response status |
| request_size | INTEGER | Request body bytes |
| response_size | INTEGER | Response body bytes |
| model_used | VARCHAR | LLM model identifier |
| tokens_input | INTEGER | Input/prompt tokens |
| tokens_output | INTEGER | Output/completion tokens |
| cost_estimate | NUMERIC | Estimated USD cost |
| duration_ms | INTEGER | Request duration |
| ip_address | VARCHAR | Client IP |
| error | TEXT | Error message if status >= 400 |
| timestamp | TIMESTAMP | DEFAULT NOW() |
---
### access_log
Lightweight authentication event log.
| Column | Type | Notes |
|---|---|---|
| id | SERIAL PRIMARY KEY | |
| user_id | INTEGER | FK to users.id |
| action | VARCHAR | `login`, `logout`, `failed_login`, etc. |
| ip_address | VARCHAR | Client IP |
| user_agent | VARCHAR | Client User-Agent header |
| success | BOOLEAN | Whether the action succeeded |
| timestamp | TIMESTAMP | DEFAULT NOW() |
---
### saved_encounters
Transcribed clinical encounters with generated notes. Rows auto-expire after 7 days.
| Column | Type | Notes |
|---|---|---|
| id | SERIAL PRIMARY KEY | |
| user_id | INTEGER | FK to users.id |
| label | VARCHAR | User-assigned label |
| enc_type | VARCHAR | Encounter type (e.g., `well_child`, `sick`) |
| transcript | TEXT | Raw transcript text |
| generated_note | TEXT | AI-generated clinical note |
| partial_data | JSONB | In-progress form state |
| status | VARCHAR | `draft`, `complete`, etc. |
| idempotency_key | VARCHAR | Prevents duplicate submissions |
| created_at | TIMESTAMP | DEFAULT NOW() |
| updated_at | TIMESTAMP | DEFAULT NOW() |
| expires_at | TIMESTAMP | DEFAULT NOW() + INTERVAL '7 days' |
---
### user_memories
Persistent per-user preferences and correction history that the AI uses to personalize output.
| Column | Type | Notes |
|---|---|---|
| id | SERIAL PRIMARY KEY | |
| user_id | INTEGER | FK to users.id |
| category | VARCHAR | See categories below |
| name | VARCHAR | Human-readable label |
| content | TEXT | Memory content |
| created_at | TIMESTAMP | DEFAULT NOW() |
| updated_at | TIMESTAMP | DEFAULT NOW() |
**Categories:**
- `physical_exam` -- Default physical exam templates
- `ros` -- Review of systems preferences
- `encounter_format` -- Note formatting preferences
- `custom` -- Free-form user preferences
- `template_*` -- User-defined note templates (prefix pattern)
- `correction_*` -- Learned corrections from user edits (prefix pattern)
---
### audio_backups
Temporary storage for raw audio recordings. Data is gzip-compressed before storage. Rows auto-expire after 24 hours.
| Column | Type | Notes |
|---|---|---|
| id | SERIAL PRIMARY KEY | |
| user_id | INTEGER | FK to users.id |
| module | VARCHAR | Source module (e.g., `encounter`, `dictation`) |
| mime_type | VARCHAR | Original audio MIME type |
| size_bytes | INTEGER | Original uncompressed size |
| compressed_bytes | INTEGER | Stored compressed size |
| audio_data | BYTEA | Gzip-compressed audio binary |
| created_at | TIMESTAMP | DEFAULT NOW() |
| expires_at | TIMESTAMP | DEFAULT NOW() + INTERVAL '24 hours' |
---
### user_documents
Metadata for user-uploaded documents stored in S3-compatible object storage.
| Column | Type | Notes |
|---|---|---|
| id | SERIAL PRIMARY KEY | |
| user_id | INTEGER | FK to users.id |
| s3_key | VARCHAR | Object storage key |
| filename | VARCHAR | Original filename |
| mime_type | VARCHAR | File MIME type |
| size_bytes | INTEGER | File size |
| description | TEXT | User-provided description |
| created_at | TIMESTAMP | DEFAULT NOW() |
---
### learning_categories
Top-level groupings for educational content.
| Column | Type | Notes |
|---|---|---|
| id | SERIAL PRIMARY KEY | |
| name | VARCHAR | Category display name |
| slug | VARCHAR UNIQUE | URL-safe identifier |
| description | TEXT | Category description |
| sort_order | INTEGER | Display ordering |
| created_at | TIMESTAMP | DEFAULT NOW() |
---
### learning_content
Educational articles and reference material with vector embeddings for semantic search.
| Column | Type | Notes |
|---|---|---|
| id | SERIAL PRIMARY KEY | |
| title | VARCHAR | Content title |
| slug | VARCHAR UNIQUE | URL-safe identifier |
| body | TEXT | Full content body (Markdown or HTML) |
| category_id | INTEGER | FK to learning_categories.id |
| subject | VARCHAR | Subject area tag |
| content_type | VARCHAR | `article`, `reference`, `case`, etc. |
| published | BOOLEAN | Visibility flag |
| author_id | INTEGER | FK to users.id |
| embedding | VECTOR(768) | pgvector embedding for similarity search |
| created_at | TIMESTAMP | DEFAULT NOW() |
| updated_at | TIMESTAMP | DEFAULT NOW() |
---
### learning_questions
Quiz questions attached to learning content.
| Column | Type | Notes |
|---|---|---|
| id | SERIAL PRIMARY KEY | |
| content_id | INTEGER | FK to learning_content.id ON DELETE CASCADE |
| question_text | TEXT | The question prompt |
| question_type | VARCHAR | `multiple_choice`, `true_false`, etc. |
| explanation | TEXT | Post-answer explanation |
| sort_order | INTEGER | Display ordering within content |
---
### learning_options
Answer options for quiz questions.
| Column | Type | Notes |
|---|---|---|
| id | SERIAL PRIMARY KEY | |
| question_id | INTEGER | FK to learning_questions.id ON DELETE CASCADE |
| option_text | TEXT | Answer text |
| is_correct | BOOLEAN | Whether this is the correct answer |
| explanation | TEXT | Option-specific explanation |
| sort_order | INTEGER | Display ordering within question |
---
### learning_progress
Tracks user scores on learning content quizzes.
| Column | Type | Notes |
|---|---|---|
| id | SERIAL PRIMARY KEY | |
| user_id | INTEGER | FK to users.id ON DELETE CASCADE |
| content_id | INTEGER | FK to learning_content.id ON DELETE CASCADE |
| score | INTEGER | Number of correct answers |
| total | INTEGER | Total number of questions |
| completed_at | TIMESTAMP | DEFAULT NOW() |
---
### developmental_milestones
Pediatric developmental milestone reference data, organized by age group and domain.
| Column | Type | Notes |
|---|---|---|
| id | SERIAL PRIMARY KEY | |
| age_group | VARCHAR | e.g., `2 months`, `4 months`, `6 months` |
| domain | VARCHAR | e.g., `motor`, `language`, `social`, `cognitive` |
| milestone_text | TEXT | Description of the milestone |
| sort_order | INTEGER | Display ordering within age group + domain |
| created_at | TIMESTAMP | DEFAULT NOW() |
| updated_at | TIMESTAMP | DEFAULT NOW() |
---
## Indexes
The schema defines 22 indexes to support query patterns across the application:
| # | Table | Index | Columns |
|---|---|---|---|
| 1 | users | unique | email |
| 2 | users | index | oidc_sub |
| 3 | users | index | verify_token |
| 4 | users | index | reset_token |
| 5 | audit_log | index | user_id |
| 6 | audit_log | index | timestamp |
| 7 | audit_log | index | action |
| 8 | audit_log | index | category |
| 9 | api_log | index | user_id |
| 10 | api_log | index | timestamp |
| 11 | api_log | index | endpoint |
| 12 | access_log | index | user_id |
| 13 | access_log | index | timestamp |
| 14 | saved_encounters | index | user_id |
| 15 | saved_encounters | index | expires_at |
| 16 | saved_encounters | index | idempotency_key |
| 17 | user_memories | index | user_id, category |
| 18 | audio_backups | index | user_id |
| 19 | audio_backups | index | expires_at |
| 20 | user_documents | index | user_id |
| 21 | learning_content | index | category_id |
| 22 | learning_progress | index | user_id, content_id |
---
## Auto-Cleanup
Expired rows are automatically purged by a scheduled cleanup job:
| Target | Expiry Rule | Affected Table |
|---|---|---|
| Encounters | `expires_at < NOW()` (default 7 days after creation) | saved_encounters |
| Audio backups | `expires_at < NOW()` (default 24 hours after creation) | audio_backups |
### Schedule
- The cleanup function runs **hourly** via `setInterval`.
- An initial cleanup also runs **10 seconds after server startup** to clear any rows that expired while the application was down.
### Behavior
The cleanup executes two `DELETE` statements inside the hourly tick:
```sql
DELETE FROM saved_encounters WHERE expires_at < NOW();
DELETE FROM audio_backups WHERE expires_at < NOW();
```
Deleted row counts are logged at the `info` level via the Winston logger.

189
docs/deployment.md Normal file
View file

@ -0,0 +1,189 @@
# Deployment Guide
## Requirements
- Docker and Docker Compose
- PostgreSQL 16 with pgvector extension (included in pgvector/pgvector:pg16 image)
- Reverse proxy (Nginx, Caddy, or Traefik) for HTTPS termination
- At least one AI provider configured
## Docker Deployment
### 1. Configure Environment
```bash
cp .env.example .env
```
Required settings:
```env
AI_PROVIDER=litellm # or bedrock, azure, vertex, openrouter
JWT_SECRET=<64-char random> # openssl rand -hex 32
DB_PASSWORD=<strong password>
APP_URL=https://your-domain.com # used for CORS, emails, verification links
```
### 2. Build and Start
```bash
docker compose up -d --build
```
This starts two containers:
- `pediatric-ai-scribe` -- Node.js app on port 3552 (mapped to container port 3000)
- `pedscribe-db` -- PostgreSQL 16 with pgvector
### 3. Verify
```bash
docker compose ps
curl http://localhost:3552/api/health
```
### 4. First User
Navigate to `https://your-domain.com` and register. The first user is automatically promoted to admin.
## Reverse Proxy
The app binds to `127.0.0.1:3552` by default. You need a reverse proxy for HTTPS.
### Nginx
```nginx
server {
listen 443 ssl http2;
server_name scribe.example.com;
ssl_certificate /etc/ssl/certs/scribe.example.com.pem;
ssl_certificate_key /etc/ssl/private/scribe.example.com.key;
client_max_body_size 100M;
location / {
proxy_pass http://127.0.0.1:3552;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
### Caddy
```
scribe.example.com {
reverse_proxy localhost:3552
}
```
### Trust Proxy
If you see `X-Forwarded-For` warnings in logs, add `trust proxy` to Express. The app currently runs behind a local reverse proxy, so rate limiting uses the direct connection IP.
## Volumes
| Volume | Purpose | Backup Priority |
|--------|---------|----------------|
| `pgdata` | PostgreSQL data (all user data, settings, content) | Critical |
| `scribe-logs` | Application log files (YYYY-MM-DD.log) | Low |
### Backup PostgreSQL
```bash
docker exec pedscribe-db pg_dump -U pedscribe pedscribe > backup.sql
```
### Restore
```bash
cat backup.sql | docker exec -i pedscribe-db psql -U pedscribe pedscribe
```
## Updating
```bash
git pull
docker compose build --no-cache
docker compose up -d
```
Database migrations run automatically on startup (CREATE TABLE IF NOT EXISTS, ALTER TABLE ADD COLUMN IF NOT EXISTS patterns).
## Health Check
The container includes a built-in health check:
```
wget --spider -q http://localhost:3000/api/health
```
Runs every 30 seconds with a 20-second start period. Docker marks the container as healthy/unhealthy automatically.
## Resource Requirements
- Memory: 256MB minimum, 512MB recommended
- Disk: ~200MB for Docker image (includes self-hosted Whisper WASM models)
- PostgreSQL: depends on usage (audio backups use BYTEA storage, auto-deleted after 24h)
## Environment-Specific Notes
### Production Checklist
- Set a strong `JWT_SECRET` (64+ characters)
- Set a strong `DB_PASSWORD`
- Set `APP_URL` to your actual domain (required for CORS, email links)
- Configure SMTP for email verification and password reset
- Use a HIPAA-eligible AI provider if handling PHI (Bedrock, Azure, Vertex)
- Enable Cloudflare Turnstile for bot protection
- Set up regular PostgreSQL backups
- Configure OIDC/SSO for enterprise environments
### Development
```bash
npm install
cp .env.example .env
# Start PostgreSQL separately or use docker compose for just the DB:
docker compose up -d postgres
node server.js
```
The app runs on port 3000 by default. Without `APP_URL` set, CORS allows all origins.
## Ports
| Service | Internal | External (default) |
|---------|----------|--------------------|
| Node.js app | 3000 | 127.0.0.1:3552 |
| PostgreSQL | 5432 | Not exposed |
To change the external port, edit `docker-compose.yml`:
```yaml
ports:
- "127.0.0.1:YOUR_PORT:3000"
```
## Logs
Application logs are written to:
- Console (visible via `docker compose logs`)
- `/app/data/logs/YYYY-MM-DD.log` inside the container (mapped to `scribe-logs` volume)
- Database tables: `audit_log`, `api_log`, `access_log`
View logs:
```bash
docker compose logs -f pediatric-scribe
docker compose logs --since=1h pediatric-scribe
```
## Auto-Cleanup
The application automatically cleans up expired data:
- Saved encounters: deleted after 7 days (configurable via `site.auto_delete_days`)
- Audio backups: deleted after 24 hours
- Cleanup runs hourly and 10 seconds after startup

130
docs/learning-hub.md Normal file
View file

@ -0,0 +1,130 @@
# Learning Hub and CMS
This document covers the Learning Hub feature, including content types, user-facing features, the content management system (CMS), presentation export, semantic search, and database schema.
---
## Content Types
The Learning Hub supports four content types:
| Type | Description |
|------|-------------|
| `article` | Rich HTML body with an optional attached quiz. |
| `pearl` | Concise clinical snippets for quick reference. |
| `quiz` | Quiz-only resources (no article body). |
| `presentation` | Marp markdown rendered as slides. |
---
## User Features
### Browsing and Search
- Browse content by category.
- Search supports three modes: **keyword**, **semantic** (vector similarity), and **hybrid** (combined).
### Articles
- View articles with rich HTML content.
- Articles may include an embedded quiz.
### Quizzes
- Question types: multiple choice (MCQ), multi-select, and true/false.
- Scoring is calculated on submission.
- Explanations are shown per question after submission.
- Users can view quiz progress and past attempts.
### Presentations
- Marp-rendered slides displayed in a modal viewer.
- Navigation via keyboard arrows and touch/swipe gestures.
---
## CMS (Moderator and Admin)
### Content Editing
- Create, edit, and publish content using a **Tiptap** rich text editor.
- Content can be saved as draft or published.
### AI Content Generation
AI can generate content from several input sources:
- **Topic description:** Provide a text prompt describing the desired content.
- **Uploaded files:** Supports PDF, TXT, MD, HTML, CSV, and JSON. Up to 100 MB per file, maximum 10 files.
- **Nextcloud WebDAV:** Pull files directly from a connected Nextcloud instance.
Generation options:
- Select the AI model used for generation.
- Configure target **slide count** (for presentations) or **word count** (for articles).
### Quiz Builder
- Add and remove questions.
- Add and remove answer options per question.
- Mark correct answers and provide explanations.
### Marp Slide Editor
- Edit Marp markdown directly.
- **Preview** button renders slides in real time.
- **PPTX Download** exports slides to PowerPoint format.
---
## PPTX Export
Presentation export uses the `pptxgenjs` library to produce PowerPoint files.
### Layout
- 16:9 widescreen aspect ratio.
- Slide numbers rendered in the bottom-right corner.
### Supported Content
- **Tables:** Header row with alternating row colors.
- **Inline formatting:** Bold, italic, and inline code.
- **Numbered lists** and **bullet lists**.
- **Code blocks:** Rendered with a grey background.
- **Blockquotes:** Rendered with a blue accent bar on the left.
- **Sub-headings.**
- **Mixed content per slide:** Slides can contain any combination of the above elements.
---
## Semantic Search
### Vector Storage
- Uses the **pgvector** PostgreSQL extension.
- Embeddings are stored as **768-dimensional** vectors.
- An **IVFFLAT** index is used for fast approximate nearest-neighbor similarity search.
### Embedding Models
| Priority | Model | Provider |
|----------|-------|----------|
| Default | `text-embedding-005` | Google Vertex AI |
| Fallback | `text-embedding-3-small` | OpenAI |
### Hybrid Search
Hybrid search combines keyword matching (PostgreSQL full-text search) with vector similarity results to produce a merged, ranked result set.
---
## Database Tables
| Table | Purpose |
|-------|---------|
| `learning_categories` | Content categories for organizing resources. |
| `learning_content` | Content records including body, metadata, and an `embedding` vector column. |
| `learning_questions` | Quiz questions linked to content. |
| `learning_options` | Answer options for each question. |
| `learning_progress` | Per-user quiz attempt history and scores. |

129
docs/speech.md Normal file
View file

@ -0,0 +1,129 @@
# Speech-to-Text and Text-to-Speech Systems
This document covers all audio processing capabilities in the Pediatric AI Scribe, including server-side transcription, client-side transcription, live speech preview, text-to-speech, and audio backup.
---
## Speech-to-Text
### Overview
The transcription system supports multiple providers with automatic fallback. The active provider is selected via the `TRANSCRIBE_PROVIDER` environment variable, or auto-detected in priority order: Google > AWS > OpenAI.
- **Endpoint:** `POST /api/transcribe`
- **Max upload size:** 25 MB (multipart form data via multer)
- **User override:** Each user can select a preferred STT model in their settings, stored in the `stt_model` column of the `users` table.
- **Admin default:** Administrators can set the system-wide default STT model via the admin settings panel.
### Providers
#### 1. Google Gemini
- Sends inline audio data within chat completion requests (not a separate transcription API).
- Model is configurable; default is `gemini-2.0-flash`.
- HIPAA eligible.
#### 2. Amazon Transcribe
- Uses streaming audio for real-time transcription.
- Supports **Medical mode** with specialty selection:
- `PRIMARYCARE`, `CARDIOLOGY`, `NEUROLOGY`, `ONCOLOGY`, `RADIOLOGY`, `UROLOGY`
- Configured via `AWS_TRANSCRIBE_MEDICAL` and `AWS_TRANSCRIBE_SPECIALTY` environment variables.
- HIPAA eligible.
#### 3. Local Whisper
- Runs `whisper.cpp` or `faster-whisper` as a local binary process.
- Supported model sizes: `tiny`, `base`, `small`, `medium`, `large`.
- Configurable threads and language via environment variables (`WHISPER_THREADS`, `WHISPER_LANGUAGE`).
- No external API calls -- fully offline.
#### 4. OpenAI Whisper
- Uses the `whisper-1` model via the OpenAI API.
- Sends a medical context prompt: `"Medical patient encounter. Pediatric."`
#### 5. LiteLLM
- Routes transcription through LiteLLM's `chat/completions` endpoint using Gemini-style inline audio.
- Does **not** use the `/audio/transcriptions` endpoint.
- Model name configured via `LITELLM_STT_MODEL`.
---
## Browser Whisper (Client-Side Transcription)
Client-side transcription runs entirely in the browser with zero network traffic, providing maximum privacy.
- **Runtime:** WebAssembly via `@xenova/transformers`
- **Available models:**
- `whisper-tiny.en` -- 39 MB
- `whisper-base.en` -- 74 MB
- `whisper-small.en` -- 244 MB
- **Self-hosted:** Model files are bundled in the Docker image. There is no CDN dependency.
- **Web Worker:** Transcription runs in a dedicated Web Worker to avoid blocking the UI thread.
- **Caching:** Downloaded models are cached in IndexedDB so subsequent loads are instant.
- **User toggle:** Enabled or disabled per user in settings. If browser transcription fails, it falls back to server-side transcription automatically.
---
## Web Speech Recognition (Live Preview)
- Uses the Chrome/Edge **Web Speech API** (`webkitSpeechRecognition`) for live preview during recording.
- Streams interim (partial) results to the UI while the user is still speaking.
- This is **not** used for final transcription. It serves only as a real-time visual preview. The actual transcription is performed by the configured STT provider (server-side or browser Whisper) after recording completes.
---
## Text-to-Speech
### Overview
- **Endpoint:** `POST /api/text-to-speech`
- **Character limit:** 5000 characters per request.
- **Response format:** `audio/mpeg`
- **Provider header:** The response includes an `X-TTS-Provider` header indicating which provider was used.
- **User override:** Each user can select a preferred voice in their settings, stored in the `tts_voice` column of the `users` table.
### Providers
#### 1. Google Cloud TTS
- Uses the `@google-cloud/text-to-speech` client library.
- Supported voice families:
- **Journey** voices: `Journey-F`, `Journey-D`
- **Studio** voices
- **Neural2** voices
#### 2. LiteLLM
- Routes TTS requests to downstream providers (OpenAI, ElevenLabs, Gemini) via the configured LiteLLM model name.
- Configured via `LITELLM_TTS_MODEL` and `LITELLM_TTS_VOICE`.
#### 3. ElevenLabs
- Uses the `eleven_turbo_v2_5` model.
- **Not HIPAA compliant.** Do not use in production environments handling protected health information.
---
## Audio Backup System
The audio backup system preserves original audio recordings when transcription fails, allowing later retry.
### Storage
- Audio is saved to **PostgreSQL** only when transcription fails (not on every recording).
- Stored as gzip-compressed binary data in a `BYTEA` column.
- Backups auto-expire after **24 hours**.
### User Interface
- The Settings page displays a list of saved audio backups.
- Each backup has two actions:
- **Retry** -- re-submits the audio to the transcription provider.
- **Delete** -- permanently removes the backup.
### Browser Fallback
- If the server-side backup save fails (e.g., network error), the audio is saved to **IndexedDB** in the browser as a secondary fallback.

View file

@ -245,7 +245,7 @@
<!-- Row 4: instructions -->
<div class="lh-ai-opt-field" style="width:100%;">
<label>Special instructions <span style="color:var(--g400);font-weight:400;">(optional)</span></label>
<input type="text" id="lh-ai-refinement" class="cms-input-sm" style="width:100%;" placeholder="e.g., Focus on ER management, suitable for residents, case-based format">
<textarea id="lh-ai-refinement" class="cms-input-sm" style="width:100%;min-height:90px;resize:vertical;font-family:inherit;line-height:1.5;" placeholder="e.g., Focus on ER management, suitable for residents, case-based format"></textarea>
</div>
</div>

View file

@ -410,8 +410,8 @@ textarea.full-input{resize:vertical;}
.announcement-banner.ann-error{background:#fef2f2;color:#991b1b;border-color:#fca5a5;}
.announcement-banner.ann-success{background:#f0fdf4;color:#166534;border-color:#86efac;}
.announcement-banner span{flex:1;}
.announcement-close{background:none;border:none;cursor:pointer;opacity:0.6;font-size:14px;padding:0 4px;color:inherit;}
.announcement-close:hover{opacity:1;}
.announcement-close{background:none;border:none;cursor:pointer;opacity:0.6;font-size:15px;padding:6px 8px;color:inherit;line-height:1;border-radius:4px;}
.announcement-close:hover{opacity:1;background:rgba(0,0,0,0.08);}
/* Save bar (encounter label + save/load) */
.save-bar-wrap{position:relative;margin-bottom:10px;}

View file

@ -10,6 +10,7 @@
<link rel="preconnect" href="https://cdnjs.cloudflare.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css">
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#2563eb">
<meta name="apple-mobile-web-app-capable" content="yes">
@ -50,6 +51,7 @@
<label>2FA Code</label>
<input type="text" id="login-totp" placeholder="6-digit code" maxlength="6">
</div>
<div class="cf-turnstile" id="turnstile-login" data-sitekey="0x4AAAAAAC0VtKAhC8rzpMx6" data-theme="light"></div>
<button type="submit" class="btn-auth" id="btn-local-login">Sign In</button>
<div id="sso-divider" class="hidden" style="display:none;text-align:center;margin:16px 0 12px;position:relative;">
<span style="background:white;padding:0 12px;color:#9ca3af;font-size:12px;position:relative;z-index:1;">or</span>
@ -83,6 +85,7 @@
<label>Password (8+ characters)</label>
<input type="password" id="reg-password" required minlength="8" placeholder="••••••••">
</div>
<div class="cf-turnstile" data-sitekey="0x4AAAAAAC0VtKAhC8rzpMx6" data-theme="light"></div>
<button type="submit" class="btn-auth">Create Account</button>
<div class="auth-links">
<a href="#" id="show-login">Back to sign in</a>
@ -96,6 +99,7 @@
<label>Email</label>
<input type="email" id="forgot-email" required placeholder="your@email.com">
</div>
<div class="cf-turnstile" id="turnstile-forgot" data-sitekey="0x4AAAAAAC0VtKAhC8rzpMx6" data-theme="light"></div>
<button type="submit" class="btn-auth">Send Reset Link</button>
<div class="auth-links">
<a href="#" id="show-login-2">Back to sign in</a>
@ -156,7 +160,7 @@
<div id="announcement-banner" class="announcement-banner hidden" role="alert">
<i id="announcement-icon" class="fas fa-info-circle"></i>
<span id="announcement-text"></span>
<button class="announcement-close" onclick="this.parentElement.classList.add('hidden')" aria-label="Dismiss"><i class="fas fa-times"></i></button>
<button id="announcement-close" class="announcement-close" aria-label="Dismiss"><i class="fas fa-times"></i></button>
</div>
<div class="app-body">

View file

@ -392,6 +392,17 @@ function loadAnnouncement() {
.catch(function() {}); // silently ignore if endpoint not yet available
}
// Close button — dismiss for this page view only (reappears on refresh/re-login)
(function() {
var closeBtn = document.getElementById('announcement-close');
if (closeBtn) {
closeBtn.addEventListener('click', function() {
var banner = document.getElementById('announcement-banner');
if (banner) banner.classList.add('hidden');
});
}
})();
function getSelectedModel() {
// Prefer the active tab's own model selector if present
var activeTab = document.querySelector('.tab-content.active');
@ -576,11 +587,6 @@ AudioRecorder.prototype.stop = function() {
self.mediaRecorder.onstop = function() {
var blob = new Blob(self.chunks, { type: self.mediaRecorder.mimeType });
if (self.stream) self.stream.getTracks().forEach(function(t) { t.stop(); });
// Auto-save to IndexedDB backup before transcription
if (blob.size > 0 && typeof saveAudioBackup === 'function') {
var module = self._module || 'unknown';
saveAudioBackup(blob, module).catch(function() {});
}
resolve(blob);
};
self.mediaRecorder.stop();
@ -614,14 +620,12 @@ function transcribeAudio(blob) {
.then(function(text) {
var elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
showToast('Transcribed locally (' + elapsed + 's)', 'success');
if (window._lastAudioBackupId && typeof deleteAudioBackup === 'function') {
deleteAudioBackup(window._lastAudioBackupId);
window._lastAudioBackupId = null;
}
window._lastAudioBackupId = null;
return { success: true, text: text, provider: 'browser-whisper' };
})
.catch(function(err) {
console.warn('[BrowserWhisper] Failed:', err.message, '— falling back to server');
if (typeof saveAudioBackup === 'function') saveAudioBackup(blob, 'browser-whisper-failed').catch(function() {});
return _serverTranscribe(blob);
});
}
@ -646,11 +650,23 @@ function _serverTranscribe(blob) {
if (data.success && data.provider) {
showToast('Transcribed via ' + data.provider + ' (' + elapsed + 's)', 'info');
}
if (data.success && window._lastAudioBackupId) {
if (typeof deleteAudioBackup === 'function') deleteAudioBackup(window._lastAudioBackupId);
window._lastAudioBackupId = null;
if (!data.success && blob.size > 0 && typeof saveAudioBackup === 'function') {
console.log('[AudioBackup] Transcription failed, saving backup...');
saveAudioBackup(blob, 'failed-transcription').then(function(id) {
console.log('[AudioBackup] Saved with id:', id);
showToast('Audio backed up for retry', 'info');
}).catch(function(e) { console.error('[AudioBackup] Save failed:', e); });
}
return data;
}).catch(function(err) {
if (blob.size > 0 && typeof saveAudioBackup === 'function') {
console.log('[AudioBackup] Transcription error, saving backup...');
saveAudioBackup(blob, 'failed-transcription').then(function(id) {
console.log('[AudioBackup] Saved with id:', id);
showToast('Audio backed up for retry', 'info');
}).catch(function(e) { console.error('[AudioBackup] Save failed:', e); });
}
return { success: false, error: err.message };
});
}

View file

@ -44,16 +44,18 @@
};
function saveToServer(blob, module) {
var token = window.AUTH_TOKEN || localStorage.getItem('ped_scribe_token') || '';
if (!token) return Promise.resolve(null);
var formData = new FormData();
formData.append('audio', blob, 'audio.webm');
formData.append('module', module || 'encounter');
var headers = {};
var token = window.AUTH_TOKEN || localStorage.getItem('ped_scribe_token') || '';
if (token) headers['Authorization'] = 'Bearer ' + token;
return fetch('/api/audio-backups', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + token },
headers: headers,
credentials: 'same-origin',
body: formData
})
.then(function(r) { return r.json(); })
@ -142,10 +144,12 @@
};
function fetchServerBackups() {
var headers = { 'Content-Type': 'application/json' };
var token = window.AUTH_TOKEN || localStorage.getItem('ped_scribe_token') || '';
if (!token) return Promise.resolve([]);
if (token) headers['Authorization'] = 'Bearer ' + token;
return fetch('/api/audio-backups', {
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' }
headers: headers,
credentials: 'same-origin'
})
.then(function(r) { return r.json(); })
.then(function(data) { return data.success ? (data.backups || []) : []; })

View file

@ -311,9 +311,17 @@ document.addEventListener('DOMContentLoaded', function() {
return false;
}
// Cloudflare Turnstile
var loginTurnstile = document.querySelector('#login-form [name="cf-turnstile-response"]');
var loginToken = loginTurnstile ? loginTurnstile.value : '';
if (!loginToken) {
showToast('Please complete the verification', 'error');
return false;
}
showLoading('Signing in...');
var body = { email: email, password: password };
var body = { email: email, password: password, turnstileToken: loginToken };
if (totpCode) body.totpCode = totpCode;
fetch('/api/auth/login', {
@ -344,12 +352,14 @@ document.addEventListener('DOMContentLoaded', function() {
showToast('Welcome, ' + data.user.name + '!', 'success');
} else {
showToast(data.error || 'Login failed', 'error');
if (window.turnstile) turnstile.reset('#turnstile-login');
}
})
.catch(function(err) {
hideLoading();
console.error('[Auth] Login error:', err);
showToast('Connection error', 'error');
if (window.turnstile) turnstile.reset('#turnstile-login');
});
return false;
@ -407,12 +417,20 @@ document.addEventListener('DOMContentLoaded', function() {
return false;
}
// Cloudflare Turnstile verification
var turnstileResponse = document.querySelector('[name="cf-turnstile-response"]');
var turnstileToken = turnstileResponse ? turnstileResponse.value : '';
if (!turnstileToken) {
showToast('Please complete the verification challenge', 'error');
return false;
}
showLoading('Creating account...');
fetch('/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: name, email: email, password: password })
body: JSON.stringify({ name: name, email: email, password: password, turnstileToken: turnstileToken })
})
.then(function(r) { return r.json(); })
.then(function(data) {
@ -426,12 +444,14 @@ document.addEventListener('DOMContentLoaded', function() {
showLoginForm();
} else {
showToast(data.error || 'Registration failed', 'error');
if (window.turnstile) turnstile.reset();
}
})
.catch(function(err) {
hideLoading();
console.error('[Auth] Register error:', err);
showToast('Connection error', 'error');
if (window.turnstile) turnstile.reset();
});
return false;
@ -447,12 +467,20 @@ document.addEventListener('DOMContentLoaded', function() {
var email = document.getElementById('forgot-email').value.trim();
if (!email) { showToast('Enter email', 'error'); return false; }
// Cloudflare Turnstile
var forgotTurnstile = document.querySelector('#forgot-form [name="cf-turnstile-response"]');
var forgotToken = forgotTurnstile ? forgotTurnstile.value : '';
if (!forgotToken) {
showToast('Please complete the verification', 'error');
return false;
}
showLoading('Sending...');
fetch('/api/auth/forgot-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: email })
body: JSON.stringify({ email: email, turnstileToken: forgotToken })
})
.then(function(r) { return r.json(); })
.then(function(data) {
@ -463,6 +491,7 @@ document.addEventListener('DOMContentLoaded', function() {
.catch(function(err) {
hideLoading();
showToast('Error', 'error');
if (window.turnstile) turnstile.reset('#turnstile-forgot');
});
return false;

View file

@ -22,7 +22,7 @@ app.use(helmet({
// 'wasm-unsafe-eval' required for WebAssembly (Whisper in-browser transcription)
// cdn.jsdelivr.net required for @xenova/transformers worker script
// 'unsafe-eval' needed for transformers.js dynamic imports in worker
scriptSrc: ["'self'", "'wasm-unsafe-eval'", "'unsafe-eval'", 'https://cdn.jsdelivr.net'],
scriptSrc: ["'self'", "'wasm-unsafe-eval'", "'unsafe-eval'", 'https://cdn.jsdelivr.net', 'https://challenges.cloudflare.com'],
scriptSrcAttr: ["'none'"],
styleSrc: ["'self'", "'unsafe-inline'", 'https://fonts.googleapis.com', 'https://cdnjs.cloudflare.com'],
fontSrc: ["'self'", 'https://fonts.gstatic.com', 'https://cdnjs.cloudflare.com'],
@ -32,11 +32,13 @@ app.use(helmet({
// HuggingFace CDN for Whisper model downloads
'https://huggingface.co', 'https://cdn-lfs.huggingface.co', 'https://cdn-lfs-us-1.huggingface.co', 'https://cdn-lfs-us-2.huggingface.co',
// jsdelivr for worker importScripts
'https://cdn.jsdelivr.net'],
'https://cdn.jsdelivr.net',
// Cloudflare Turnstile
'https://challenges.cloudflare.com'],
workerSrc: ["'self'", 'blob:'],
// Allow workers to load scripts from jsdelivr
childSrc: ["'self'", 'blob:', 'https://cdn.jsdelivr.net'],
frameSrc: ["'none'"],
frameSrc: ["'self'", 'https://challenges.cloudflare.com'],
objectSrc: ["'none'"],
}
}

View file

@ -8,8 +8,9 @@ async function authMiddleware(req, res, next) {
var authHeader = req.headers.authorization;
if (authHeader && authHeader.startsWith('Bearer ')) {
token = authHeader.substring(7);
} else if (req.cookies && req.cookies.ped_auth) {
token = authHeader.substring(7) || null;
}
if (!token && req.cookies && req.cookies.ped_auth) {
token = req.cookies.ped_auth;
}

View file

@ -541,7 +541,7 @@ router.post('/config/tts/test', async function(req, res) {
} else if (provider === 'litellm') {
if (!process.env.LITELLM_API_BASE) return res.json({ success: false, error: 'LITELLM_API_BASE not set' });
var rawModel = process.env.LITELLM_TTS_MODEL || 'tts-1';
var ttsModel = rawModel.indexOf('/') === -1 ? 'openai/' + rawModel : rawModel;
var ttsModel = rawModel.indexOf('/') !== -1 || rawModel.indexOf('-tts-') !== -1 || rawModel.startsWith('openai-') || rawModel.startsWith('elevenlabs-') || rawModel.startsWith('gemini-') ? rawModel : 'openai/' + rawModel;
usedVoice = voice || process.env.LITELLM_TTS_VOICE || 'alloy';
var base = process.env.LITELLM_API_BASE.replace(/\/+$/, '');
var ttsHeaders = { 'Content-Type': 'application/json' };

View file

@ -17,6 +17,7 @@ router.use(authMiddleware);
// ── POST save audio backup (compressed) ──────────────────────────────────
router.post('/audio-backups', upload.single('audio'), async function(req, res) {
console.log('[AudioBackup] POST received, hasFile=' + !!req.file + ', user=' + (req.user ? req.user.id : 'none'));
try {
if (!req.file) return res.status(400).json({ error: 'No audio file' });
@ -36,7 +37,7 @@ router.post('/audio-backups', upload.single('audio'), async function(req, res) {
);
var ratio = originalSize > 0 ? Math.round((1 - compressed.length / originalSize) * 100) : 0;
console.log('[AudioBackup] Saved ' + (originalSize / 1024).toFixed(0) + 'KB → ' + (compressed.length / 1024).toFixed(0) + 'KB (' + ratio + '% compression)');
console.log('[AudioBackup] Saved ' + (originalSize / 1024).toFixed(0) + 'KB → ' + (compressed.length / 1024).toFixed(0) + 'KB (' + ratio + '% compression) id=' + result.lastInsertRowid + ' user=' + req.user.id);
res.json({ success: true, id: result.lastInsertRowid, originalSize: originalSize, compressedSize: compressed.length });
} catch (e) {

View file

@ -143,10 +143,25 @@ router.post('/register', async (req, res) => {
return res.status(403).json({ error: 'Registration is currently disabled. Contact an administrator.' });
}
var { email, password, name } = req.body;
var { email, password, name, turnstileToken } = req.body;
if (!email || !password || !name) return res.status(400).json({ error: 'All fields required' });
if (password.length < 8) return res.status(400).json({ error: 'Password must be 8+ characters' });
// Cloudflare Turnstile verification
if (process.env.TURNSTILE_SECRET_KEY) {
if (!turnstileToken) return res.status(400).json({ error: 'Please complete the verification challenge' });
var turnstileRes = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ secret: process.env.TURNSTILE_SECRET_KEY, response: turnstileToken, remoteip: req.ip })
});
var turnstileData = await turnstileRes.json();
if (!turnstileData.success) {
console.error('[Auth] Turnstile verification failed:', turnstileData['error-codes']);
return res.status(400).json({ error: 'Bot verification failed. Please try again.' });
}
}
var existing = await db.get('SELECT id FROM users WHERE email = ?', [email.toLowerCase()]);
if (existing) return res.status(400).json({ error: 'Email already registered' });
@ -238,9 +253,20 @@ router.post('/resend-verification', async (req, res) => {
// ============================================================
router.post('/login', async (req, res) => {
try {
var { email, password, totpCode } = req.body;
var { email, password, totpCode, turnstileToken } = req.body;
if (!email || !password) return res.status(400).json({ error: 'Email and password required' });
// Cloudflare Turnstile verification
if (process.env.TURNSTILE_SECRET_KEY) {
if (!turnstileToken) return res.status(400).json({ error: 'Please complete the verification' });
var tsRes = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ secret: process.env.TURNSTILE_SECRET_KEY, response: turnstileToken, remoteip: req.ip })
});
var tsData = await tsRes.json();
if (!tsData.success) return res.status(400).json({ error: 'Verification failed. Please try again.' });
}
var user = await db.get('SELECT * FROM users WHERE email = ?', [email.toLowerCase()]);
if (!user) return res.status(401).json({ error: 'Invalid credentials' });
@ -309,6 +335,18 @@ router.post('/disable-2fa', authMiddleware, async (req, res) => {
// Password reset
router.post('/forgot-password', async (req, res) => {
try {
// Cloudflare Turnstile verification
if (process.env.TURNSTILE_SECRET_KEY) {
var turnstileToken = req.body.turnstileToken;
if (!turnstileToken) return res.status(400).json({ error: 'Please complete the verification' });
var tsRes = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ secret: process.env.TURNSTILE_SECRET_KEY, response: turnstileToken, remoteip: req.ip })
});
var tsData = await tsRes.json();
if (!tsData.success) return res.status(400).json({ error: 'Verification failed. Please try again.' });
}
var user = await db.get('SELECT id, name FROM users WHERE email = ?', [req.body.email.toLowerCase()]);
if (!user) return res.json({ success: true, message: 'If account exists, reset email sent' });
var token = crypto.randomBytes(32).toString('hex');

View file

@ -81,7 +81,7 @@ router.post('/generate-hospital-course', authMiddleware, async (req, res) => {
prompt += `\n\nADDITIONAL INSTRUCTIONS FROM PHYSICIAN:\n${additionalInstructions}`;
}
if (physicianMemories) clinicalData += '\n\n[PHYSICIAN PREFERENCES & LEARNED PATTERNS — Apply these preferences to your output. These reflect how this physician writes notes, their preferred style, terminology, and corrections from past outputs. Adapt your response accordingly, but user instructions in the current prompt take priority if they conflict.]\n' + physicianMemories + '\n[END PREFERENCES]';
if (physicianMemories) clinicalData += '\n\n[STYLE HINTS (low priority — only apply if relevant to the current note, never copy content from these examples)]\n' + physicianMemories + '\n[END STYLE HINTS]';
const result = await callAI([
{ role: 'system', content: prompt },

View file

@ -13,7 +13,7 @@ router.post('/generate-hpi-encounter', authMiddleware, async (req, res) => {
const prompt = setting === 'inpatient' ? PROMPTS.hpiInpatient : PROMPTS.hpiEncounter;
var context = `Patient: ${patientAge || 'Unknown'}, ${patientGender || 'Unknown'}\nSetting: ${setting || 'outpatient'}\n\nTRANSCRIPT:\n${transcript}`;
if (physicianMemories) context += '\n\n[PHYSICIAN PREFERENCES & LEARNED PATTERNS — Apply these preferences to your output. These reflect how this physician writes notes, their preferred style, terminology, and corrections from past outputs. Adapt your response accordingly, but user instructions in the current prompt take priority if they conflict.]\n' + physicianMemories + '\n[END PREFERENCES]';
if (physicianMemories) context += '\n\n[STYLE HINTS (low priority — only apply if relevant to the current note, never copy content from these examples)]\n' + physicianMemories + '\n[END STYLE HINTS]';
const result = await callAI([
{ role: 'system', content: prompt },
@ -35,7 +35,7 @@ router.post('/generate-hpi-dictation', authMiddleware, async (req, res) => {
const prompt = setting === 'inpatient' ? PROMPTS.hpiInpatient : PROMPTS.hpiDictation;
var context = `Patient: ${patientAge || 'Unknown'}, ${patientGender || 'Unknown'}\nSetting: ${setting || 'outpatient'}\n\nDICTATION:\n${transcript}`;
if (physicianMemories) context += '\n\n[PHYSICIAN PREFERENCES & LEARNED PATTERNS — Apply these preferences to your output. These reflect how this physician writes notes, their preferred style, terminology, and corrections from past outputs. Adapt your response accordingly, but user instructions in the current prompt take priority if they conflict.]\n' + physicianMemories + '\n[END PREFERENCES]';
if (physicianMemories) context += '\n\n[STYLE HINTS (low priority — only apply if relevant to the current note, never copy content from these examples)]\n' + physicianMemories + '\n[END STYLE HINTS]';
const result = await callAI([
{ role: 'system', content: prompt },

View file

@ -465,73 +465,230 @@ router.post('/generate-pptx', async function(req, res) {
pptx.layout = 'LAYOUT_WIDE'; // 16:9
// Strip frontmatter
var md = markdown.replace(/^---[\s\S]*?---\n?/, '').trim();
// ── Helpers ────────────────────────────────────────────────
// Split into slides on ---
// Parse inline markdown (**bold**, *italic*, `code`, ***both***) into pptxgenjs text objects
function parseInline(text, defaults) {
var parts = [];
var re = /(\*\*\*(.+?)\*\*\*|\*\*(.+?)\*\*|\*(.+?)\*|`(.+?)`)/g;
var last = 0;
var m;
while ((m = re.exec(text)) !== null) {
if (m.index > last) parts.push({ text: text.slice(last, m.index), options: Object.assign({}, defaults) });
if (m[2]) parts.push({ text: m[2], options: Object.assign({}, defaults, { bold: true, italic: true }) });
else if (m[3]) parts.push({ text: m[3], options: Object.assign({}, defaults, { bold: true }) });
else if (m[4]) parts.push({ text: m[4], options: Object.assign({}, defaults, { italic: true }) });
else if (m[5]) parts.push({ text: m[5], options: Object.assign({}, defaults, { fontFace: 'Courier New', color: '7c3aed' }) });
last = m.index + m[0].length;
}
if (last < text.length) parts.push({ text: text.slice(last), options: Object.assign({}, defaults) });
return parts.length ? parts : [{ text: text, options: Object.assign({}, defaults) }];
}
// Parse a markdown table block into { headers: string[], rows: string[][] }
function parseTable(lines) {
var headers = lines[0].split('|').map(function(c) { return c.trim(); }).filter(Boolean);
var rows = [];
for (var i = 2; i < lines.length; i++) {
var cells = lines[i].split('|').map(function(c) { return c.trim(); }).filter(Boolean);
if (cells.length) rows.push(cells);
}
return { headers: headers, rows: rows };
}
// Classify a line by type
function classifyLine(line) {
if (/^\|.+\|/.test(line)) return 'table';
if (/^\|[\s:-]+\|/.test(line)) return 'table-sep';
if (/^>\s+/.test(line)) return 'blockquote';
if (/^```/.test(line)) return 'code-fence';
if (/^\d+\.\s+/.test(line)) return 'ordered';
if (/^[-*]\s+/.test(line)) return 'bullet';
if (/^#{1,3}\s+/.test(line)) return 'heading';
if (line.trim() === '') return 'blank';
return 'paragraph';
}
// ── Parse slides ───────────────────────────────────────────
var md = markdown.replace(/^---[\s\S]*?---\n?/, '').trim();
var rawSlides = md.split(/\n---\n/);
rawSlides.forEach(function(slideText) {
rawSlides.forEach(function(slideText, slideIdx) {
slideText = slideText.trim();
if (!slideText) return;
var slide = pptx.addSlide();
// Add subtle background
slide.background = { color: 'FFFFFF' };
// Extract title (first # heading)
// Extract slide title (first # heading)
var titleMatch = slideText.match(/^#{1,3}\s+(.+)$/m);
var slideTitle = titleMatch ? titleMatch[1].trim() : '';
var body = slideText.replace(/^#{1,3}\s+.+$/m, '').trim();
// Title
if (slideTitle) {
slide.addText(slideTitle, {
x: 0.5, y: 0.4, w: '90%', h: 0.8,
fontSize: 28, bold: true, color: '1e40af',
fontFace: 'Calibri'
slide.addText(parseInline(slideTitle, { fontSize: 28, bold: true, color: '1e40af', fontFace: 'Calibri' }), {
x: 0.5, y: 0.3, w: '90%', h: 0.8, valign: 'middle'
});
}
// Parse body: bullet lines (- item) and paragraphs
if (body) {
var lines = body.split('\n').filter(function(l) { return l.trim(); });
var bullets = [];
var paragraphs = [];
if (!body) {
slide.addText((slideIdx + 1).toString(), { x: '90%', y: '92%', w: '8%', h: 0.3, fontSize: 9, color: 'bbbbbb', align: 'right', fontFace: 'Calibri' });
return;
}
lines.forEach(function(line) {
var bulletMatch = line.match(/^[-*]\s+(.+)/);
if (bulletMatch) {
bullets.push(bulletMatch[1].trim());
} else if (!line.startsWith('#')) {
// Strip inline markdown
var clean = line.replace(/\*\*(.+?)\*\*/g, '$1').replace(/\*(.+?)\*/g, '$1').replace(/`(.+?)`/g, '$1');
paragraphs.push(clean);
var contentY = slideTitle ? 1.3 : 0.6;
var maxH = 5.2 - contentY;
var lines = body.split('\n');
var i = 0;
var inCodeBlock = false;
var codeLines = [];
while (i < lines.length) {
var line = lines[i];
var type = classifyLine(line);
// ── Code block ──
if (type === 'code-fence' || inCodeBlock) {
if (type === 'code-fence' && !inCodeBlock) {
inCodeBlock = true; codeLines = []; i++; continue;
}
});
var contentY = slideTitle ? 1.4 : 0.8;
if (bullets.length > 0) {
var bulletObjs = bullets.map(function(b) {
return { text: b, options: { bullet: { type: 'bullet' }, fontSize: 18, color: '374151', paraSpaceBefore: 6 } };
});
slide.addText(bulletObjs, {
x: 0.6, y: contentY, w: '88%', h: (5 - contentY),
fontFace: 'Calibri', valign: 'top'
});
} else if (paragraphs.length > 0) {
slide.addText(paragraphs.join('\n'), {
x: 0.5, y: contentY, w: '90%', h: (5 - contentY),
fontSize: 18, color: '374151', fontFace: 'Calibri',
valign: 'top', wrap: true
});
if (type === 'code-fence' && inCodeBlock) {
inCodeBlock = false;
if (codeLines.length) {
var codeText = codeLines.join('\n');
var codeH = Math.min(Math.max(codeLines.length * 0.28, 0.6), maxH - (contentY - (slideTitle ? 1.3 : 0.6)));
slide.addShape(pptx.ShapeType.rect, { x: 0.5, y: contentY, w: '90%', h: codeH, fill: { color: 'f3f4f6' }, rectRadius: 0.08 });
slide.addText(codeText, {
x: 0.65, y: contentY + 0.08, w: '86%', h: codeH - 0.16,
fontSize: 13, fontFace: 'Courier New', color: '1f2937', valign: 'top', wrap: true
});
contentY += codeH + 0.2;
codeLines = [];
}
i++; continue;
}
codeLines.push(line);
i++; continue;
}
// ── Table ──
if (type === 'table') {
var tableLines = [];
while (i < lines.length && /^\|/.test(lines[i])) { tableLines.push(lines[i]); i++; }
var tbl = parseTable(tableLines);
if (tbl.headers.length) {
var colW = (12 / tbl.headers.length);
var tblRows = [];
// Header row
tblRows.push(tbl.headers.map(function(h) {
return { text: h, options: { bold: true, fontSize: 13, color: 'ffffff', fill: { color: '1e40af' }, fontFace: 'Calibri', align: 'center', valign: 'middle' } };
}));
// Data rows
tbl.rows.forEach(function(row, ri) {
tblRows.push(row.map(function(cell) {
return { text: cell, options: { fontSize: 12, color: '374151', fill: { color: ri % 2 === 0 ? 'f9fafb' : 'ffffff' }, fontFace: 'Calibri', valign: 'middle' } };
}));
});
var tblH = Math.min((tblRows.length * 0.38) + 0.1, maxH - (contentY - (slideTitle ? 1.3 : 0.6)));
slide.addTable(tblRows, {
x: 0.5, y: contentY, w: 12,
colW: Array(tbl.headers.length).fill(colW),
border: { type: 'solid', pt: 0.5, color: 'dee2e6' },
rowH: 0.36,
autoPage: false
});
contentY += tblH + 0.15;
}
continue;
}
// ── Blockquote ──
if (type === 'blockquote') {
var quoteText = line.replace(/^>\s*/, '').trim();
i++;
while (i < lines.length && /^>\s*/.test(lines[i])) { quoteText += '\n' + lines[i].replace(/^>\s*/, '').trim(); i++; }
var qH = Math.max(0.5, Math.ceil(quoteText.length / 100) * 0.35);
slide.addShape(pptx.ShapeType.rect, { x: 0.5, y: contentY, w: '90%', h: qH, fill: { color: 'eff6ff' }, rectRadius: 0.06 });
slide.addShape(pptx.ShapeType.rect, { x: 0.5, y: contentY, w: 0.06, h: qH, fill: { color: '3b82f6' } });
slide.addText(parseInline(quoteText, { fontSize: 16, italic: true, color: '1e40af', fontFace: 'Calibri' }), {
x: 0.8, y: contentY + 0.06, w: '85%', h: qH - 0.12, valign: 'middle', wrap: true
});
contentY += qH + 0.15;
continue;
}
// ── Bullets (unordered) ──
if (type === 'bullet') {
var bulletItems = [];
while (i < lines.length && /^[-*]\s+/.test(lines[i])) {
bulletItems.push(lines[i].replace(/^[-*]\s+/, '').trim());
i++;
}
var bulletObjs = [];
bulletItems.forEach(function(b) {
var inlineParts = parseInline(b, { fontSize: 17, color: '374151', fontFace: 'Calibri' });
inlineParts[0].options.bullet = { type: 'bullet' };
inlineParts[0].options.paraSpaceBefore = 4;
bulletObjs.push(inlineParts);
});
var flat = []; bulletObjs.forEach(function(arr) { arr.forEach(function(p) { flat.push(p); }); });
var bH = Math.min(Math.max(bulletItems.length * 0.35, 0.6), maxH - (contentY - (slideTitle ? 1.3 : 0.6)));
slide.addText(flat, { x: 0.6, y: contentY, w: '88%', h: bH, fontFace: 'Calibri', valign: 'top' });
contentY += bH + 0.1;
continue;
}
// ── Numbered list ──
if (type === 'ordered') {
var orderedItems = [];
while (i < lines.length && /^\d+\.\s+/.test(lines[i])) {
orderedItems.push(lines[i].replace(/^\d+\.\s+/, '').trim());
i++;
}
var orderedObjs = [];
orderedItems.forEach(function(item, idx) {
var inlineParts = parseInline(item, { fontSize: 17, color: '374151', fontFace: 'Calibri' });
inlineParts[0].options.bullet = { type: 'number', numberStartAt: idx === 0 ? 1 : undefined };
inlineParts[0].options.paraSpaceBefore = 4;
orderedObjs.push(inlineParts);
});
var flatOrd = []; orderedObjs.forEach(function(arr) { arr.forEach(function(p) { flatOrd.push(p); }); });
var oH = Math.min(Math.max(orderedItems.length * 0.35, 0.6), maxH - (contentY - (slideTitle ? 1.3 : 0.6)));
slide.addText(flatOrd, { x: 0.6, y: contentY, w: '88%', h: oH, fontFace: 'Calibri', valign: 'top' });
contentY += oH + 0.1;
continue;
}
// ── Sub-heading (## or ### inside body) ──
if (type === 'heading') {
var hText = line.replace(/^#{1,3}\s+/, '').trim();
slide.addText(parseInline(hText, { fontSize: 22, bold: true, color: '1e3a5f', fontFace: 'Calibri' }), {
x: 0.5, y: contentY, w: '90%', h: 0.45, valign: 'bottom'
});
contentY += 0.5;
i++; continue;
}
// ── Paragraph ──
if (type === 'paragraph') {
var paraText = line.trim();
i++;
// Merge consecutive paragraph lines
while (i < lines.length && classifyLine(lines[i]) === 'paragraph') { paraText += ' ' + lines[i].trim(); i++; }
var pParts = parseInline(paraText, { fontSize: 17, color: '374151', fontFace: 'Calibri' });
var pH = Math.max(0.4, Math.ceil(paraText.length / 110) * 0.3);
slide.addText(pParts, { x: 0.5, y: contentY, w: '90%', h: pH, valign: 'top', wrap: true });
contentY += pH + 0.1;
continue;
}
// ── Blank / skip ──
i++;
}
// Slide number bottom right
slide.addText('', { x: '85%', y: '90%', w: '12%', h: 0.3, fontSize: 10, color: 'aaaaaa', align: 'right' });
// Slide number
slide.addText((slideIdx + 1).toString(), { x: '90%', y: '92%', w: '8%', h: 0.3, fontSize: 9, color: 'bbbbbb', align: 'right', fontFace: 'Calibri' });
});
// Write to buffer and send

View file

@ -98,9 +98,24 @@ router.get('/memories/context', async function(req, res) {
});
}
if (corrections.length > 0) {
context += '\n\nPHYSICIAN CORRECTION HISTORY (learn from these preferences — apply similar corrections to future outputs):\n';
corrections.slice(-20).forEach(function(r) {
context += '--- CORRECTION (' + r.category.replace('correction_', '').toUpperCase() + '): ' + r.name + ' ---\n' + r.content + '\n\n';
context += '\n\nSTYLE CORRECTIONS (these are examples of past edits — use only the writing style, never the clinical content):\n';
corrections.slice(-10).forEach(function(r) {
// Trim to just the key style difference, not full encounter text
var lines = r.content.split('\n').filter(function(l) { return l.trim(); });
var orig = '', corr = '';
var inCorrected = false;
lines.forEach(function(l) {
if (l.startsWith('CORRECTED TO:')) { inCorrected = true; corr = l.replace('CORRECTED TO:', '').trim(); }
else if (l.startsWith('ORIGINAL:')) { orig = l.replace('ORIGINAL:', '').trim(); }
else if (inCorrected) { corr += ' ' + l.trim(); }
else { orig += ' ' + l.trim(); }
});
// Keep only first 200 chars of each to avoid flooding the prompt
orig = orig.substring(0, 200);
corr = corr.substring(0, 200);
if (orig && corr) {
context += '- Before: "' + orig + '..."\n After: "' + corr + '..."\n';
}
});
}
res.json({ success: true, context: context.trim() });

View file

@ -49,7 +49,7 @@ router.post('/sick-visit/note', authMiddleware, async function(req, res) {
}
if (physicianMemories) {
context += '[PHYSICIAN PREFERENCES & LEARNED PATTERNS — Apply these preferences to your output. These reflect how this physician writes notes, their preferred style, terminology, and corrections from past outputs. Adapt your response accordingly, but user instructions in the current prompt take priority if they conflict.]\n' + physicianMemories + '\n[END PREFERENCES]\n\n';
context += '[STYLE HINTS (low priority — only apply if relevant to the current note, never copy content from these examples)]\n' + physicianMemories + '\n[END STYLE HINTS]\n\n';
}
var result = await callAI([

View file

@ -21,7 +21,7 @@ router.post('/generate-soap', authMiddleware, async (req, res) => {
}
var context = `Patient: ${patientAge || 'Unknown'}, ${patientGender || 'Unknown'}\n\nINPUT:\n${transcript}`;
if (physicianMemories) context += '\n\n[PHYSICIAN PREFERENCES & LEARNED PATTERNS — Apply these preferences to your output. These reflect how this physician writes notes, their preferred style, terminology, and corrections from past outputs. Adapt your response accordingly, but user instructions in the current prompt take priority if they conflict.]\n' + physicianMemories + '\n[END PREFERENCES]';
if (physicianMemories) context += '\n\n[STYLE HINTS (low priority — only apply if relevant to the current note, never copy content from these examples)]\n' + physicianMemories + '\n[END STYLE HINTS]';
const result = await callAI([
{ role: 'system', content: prompt },

View file

@ -50,9 +50,9 @@ router.post('/text-to-speech', authMiddleware, async (req, res) => {
if (!process.env.LITELLM_API_BASE) return res.status(400).json({ error: 'LITELLM_API_BASE not set.' });
// Use the full model path (e.g. vertex_ai/google-tts) or the model_name alias
// from your LiteLLM model_list. Full path is safer if your config uses it directly.
// Prefix with openai/ so LiteLLM routes correctly to the TTS endpoint
// Use model name as-is if set explicitly, else prefix with openai/ for generic names like 'tts-1'
var rawModel = adminModel || process.env.LITELLM_TTS_MODEL || 'tts-1';
var ttsModel = rawModel.indexOf('/') === -1 ? 'openai/' + rawModel : rawModel;
var ttsModel = rawModel.indexOf('/') !== -1 || rawModel.indexOf('-tts-') !== -1 || rawModel.startsWith('openai-') || rawModel.startsWith('elevenlabs-') || rawModel.startsWith('gemini-') ? rawModel : 'openai/' + rawModel;
var ttsVoice = userVoice || adminVoice || process.env.LITELLM_TTS_VOICE || 'alloy';
var base = process.env.LITELLM_API_BASE.replace(/\/+$/, '');
var ttsHeaders = { 'Content-Type': 'application/json' };

View file

@ -187,7 +187,7 @@ router.post('/well-visit/note', authMiddleware, async function(req, res) {
context += diagnoses + '\n\n';
}
if (physicianMemories) {
context += '[PHYSICIAN PREFERENCES & LEARNED PATTERNS — Apply these preferences to your output. These reflect how this physician writes notes, their preferred style, terminology, and corrections from past outputs. Adapt your response accordingly, but user instructions in the current prompt take priority if they conflict.]\n' + physicianMemories + '\n[END PREFERENCES]\n\n';
context += '[STYLE HINTS (low priority — only apply if relevant to the current note, never copy content from these examples)]\n' + physicianMemories + '\n[END STYLE HINTS]\n\n';
}
// Add growth reference and feeding guidance for this age