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:
parent
9c1d86d4b6
commit
b97c5f146b
31 changed files with 4124 additions and 304 deletions
|
|
@ -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
361
README.md
|
|
@ -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 (birth–11 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
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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,11 +21,11 @@ services:
|
|||
start_period: 20s
|
||||
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
image: pgvector/pgvector:pg16
|
||||
environment:
|
||||
POSTGRES_DB: pedscribe
|
||||
POSTGRES_USER: pedscribe
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD:-pedscribe_secret_change_me}
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD:-zy9LK958SAcfUM7SJTkdthSYyYy6}
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
restart: unless-stopped
|
||||
|
|
|
|||
114
docs/ai-providers.md
Normal file
114
docs/ai-providers.md
Normal 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
2144
docs/api-reference.md
Normal file
File diff suppressed because it is too large
Load diff
172
docs/architecture.md
Normal file
172
docs/architecture.md
Normal 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
147
docs/authentication.md
Normal 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
219
docs/configuration.md
Normal 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
363
docs/database.md
Normal 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
189
docs/deployment.md
Normal 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
130
docs/learning-hub.md
Normal 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
129
docs/speech.md
Normal 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.
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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;}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 || []) : []; })
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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'"],
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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' };
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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() });
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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' };
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue