Compare commits
179 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b64a39f8ea | ||
|
|
ce170f6fc1 | ||
|
|
5beb6cd562 | ||
|
|
4cb1080881 | ||
|
|
9a437c831c | ||
|
|
65a5dff9b4 | ||
|
|
0d6d91e8ef | ||
|
|
ed69fb0cc8 | ||
|
|
0b0bfc4a8a | ||
|
|
26857d52da | ||
|
|
ce466570ee | ||
|
|
c6d238c560 | ||
|
|
5439c1a742 | ||
|
|
6d6b4b90d2 | ||
|
|
0360685306 | ||
|
|
3b67d325fc | ||
|
|
2de10dc544 | ||
|
|
a125bf9e9c | ||
|
|
13eb968249 | ||
|
|
66ea127574 | ||
|
|
b03232c963 | ||
|
|
18811afbb5 | ||
|
|
7336e318be | ||
|
|
ab94239659 | ||
|
|
c98c571c66 | ||
|
|
907e131dc8 | ||
|
|
553449dbec | ||
|
|
d4546b7d02 | ||
|
|
f63d93807b | ||
|
|
ef6c90a889 | ||
|
|
09d07d7e0f | ||
|
|
87b2017919 | ||
|
|
c266ff2541 | ||
|
|
ec7e3d84b7 | ||
|
|
5888a9da0e | ||
|
|
ea03db3d45 | ||
|
|
63f77aa9cf | ||
|
|
8893e484fd | ||
|
|
dafbf44a32 | ||
|
|
a8992aee5a | ||
|
|
b5abbb69fc | ||
|
|
6febf6c914 | ||
|
|
37e58be5ec | ||
|
|
c7a04626a3 | ||
|
|
fc17032649 | ||
|
|
e161c221c4 | ||
|
|
6dffdf91e5 | ||
|
|
b294150781 | ||
|
|
cdf178b1c3 | ||
|
|
fa16cb13cb | ||
|
|
4a26abed10 | ||
|
|
d748dcc0d2 | ||
|
|
b23cb3300e | ||
|
|
9b407d1e18 | ||
|
|
e283bb8cda | ||
|
|
13e8937a00 | ||
|
|
5dde108e4a | ||
|
|
42984e355b | ||
|
|
3e05d8eec9 | ||
|
|
9bfadd7344 | ||
|
|
cb17a12172 | ||
|
|
93bc44b5e0 | ||
|
|
8409a49c74 | ||
|
|
942647871a | ||
|
|
369e440aa1 | ||
|
|
0c8a4db5c3 | ||
|
|
30300f169c | ||
|
|
5d988c397d | ||
|
|
e700ab1c8b | ||
|
|
6a690f6483 | ||
|
|
d29f55f8a6 | ||
|
|
04030b1ded | ||
|
|
bdf0916fe7 | ||
|
|
0630e460e8 | ||
|
|
64546a743d | ||
|
|
baa6362d29 | ||
|
|
7a957e856e | ||
|
|
f03ca5cb94 | ||
|
|
6978ed708c | ||
|
|
17a0371a0f | ||
|
|
7582e3563d | ||
|
|
d0d65446f6 | ||
|
|
aa33a55d0b | ||
|
|
d079c6d6c9 | ||
|
|
bf586daf4d | ||
|
|
ee88e51f14 | ||
|
|
4fbfc913d0 | ||
|
|
79994f4781 | ||
|
|
adf1365fa2 | ||
|
|
4fa2b58d75 | ||
|
|
04f3aa56cb | ||
|
|
020e831b3c | ||
|
|
a535ff6c15 | ||
|
|
a36235c646 | ||
|
|
f98b9b7b71 | ||
|
|
4fb038a745 | ||
|
|
215de4cac8 | ||
|
|
d86625c7e6 | ||
|
|
77eabbd4df | ||
|
|
970c946093 | ||
|
|
e459d34a13 | ||
|
|
b7adb4c3c7 | ||
|
|
a528bcc283 | ||
|
|
f95a03c13c | ||
|
|
0d33d3dce8 | ||
|
|
b8b9e8974b | ||
|
|
ca14094c0a | ||
|
|
b035f7d7b4 | ||
|
|
89daba420c | ||
|
|
7c27213451 | ||
|
|
cf4ba2a1e8 | ||
|
|
bbfe55f03b | ||
|
|
f18a87d0ff | ||
|
|
dd25d235d7 | ||
|
|
068cb258e9 | ||
|
|
d96a008dfe | ||
|
|
42daff2343 | ||
|
|
ef0f986c2f | ||
|
|
096d40f72d | ||
|
|
106e4baf17 | ||
|
|
0658b31df3 | ||
|
|
67c8638654 | ||
|
|
f126cf9fd7 | ||
|
|
2875e0cefd | ||
|
|
6db6a99eb2 | ||
|
|
ef80b75b6f | ||
|
|
f78f25e42f | ||
|
|
28fe1f520e | ||
|
|
f5ed67ccaf | ||
|
|
f2730bdc83 | ||
|
|
7e22902e47 | ||
|
|
d5d0ddcb95 | ||
|
|
6a7103a3f9 | ||
|
|
4808d08aa7 | ||
|
|
7a50bc061d | ||
|
|
63d8a881cb | ||
|
|
2423f4601e | ||
|
|
325575576c | ||
|
|
832fbc1283 | ||
|
|
d1138c8cc2 | ||
|
|
0ada98a13e | ||
|
|
38b1818148 | ||
|
|
1ab9878425 | ||
|
|
6f1bd97596 | ||
|
|
58c8f1c549 | ||
|
|
683afeea0b | ||
|
|
e4daa7590c | ||
|
|
9ec4cbf6b1 | ||
|
|
e9cab13c4f | ||
|
|
1371d705da | ||
|
|
364d564fca | ||
|
|
f609891d0d | ||
|
|
54c9aa1843 | ||
|
|
9e79a05676 | ||
|
|
268b6977cf | ||
|
|
72e91e940c | ||
|
|
35f03ac0ba | ||
|
|
28c3758eb6 | ||
|
|
cd3698f698 | ||
|
|
7d453094a0 | ||
|
|
88b8a5d418 | ||
|
|
ee60d269a5 | ||
|
|
007eef6887 | ||
|
|
044c809ff3 | ||
|
|
1191ba0d2d | ||
|
|
08a8fb26c4 | ||
|
|
5cad43d19a | ||
|
|
898036bfcd | ||
|
|
17646af5e3 | ||
|
|
25c462bfd6 | ||
|
|
6d1c2e5422 | ||
|
|
a53124a747 | ||
|
|
1f66b7c0e1 | ||
|
|
9758ecbea2 | ||
|
|
6e1b6ca3d7 | ||
|
|
eb63d9973d | ||
|
|
9eaec4f2de | ||
|
|
b997d6d388 | ||
|
|
7a2c569b63 |
119
.env.example
|
|
@ -20,19 +20,91 @@ OPENROUTER_API_KEY=sk-or-v1-your-key
|
||||||
# AZURE_DEPLOYMENT_NAME=gpt-4o-mini
|
# AZURE_DEPLOYMENT_NAME=gpt-4o-mini
|
||||||
# AZURE_OPENAI_API_VERSION=2024-02-01
|
# AZURE_OPENAI_API_VERSION=2024-02-01
|
||||||
|
|
||||||
|
# Option 4: Google Vertex AI (HIPAA compliant with BAA)
|
||||||
|
# AI_PROVIDER=vertex
|
||||||
|
# GOOGLE_VERTEX_PROJECT=your-gcp-project-id
|
||||||
|
# GOOGLE_VERTEX_LOCATION=us-central1
|
||||||
|
# GOOGLE_APPLICATION_CREDENTIALS=/path/to/service-account.json
|
||||||
|
# (Or use default credentials if running on GCE/GKE/Cloud Run)
|
||||||
|
#
|
||||||
|
# Google STT — Gemini inline audio (auto-detected when GOOGLE_VERTEX_PROJECT set)
|
||||||
|
# TRANSCRIBE_PROVIDER=google
|
||||||
|
# GOOGLE_STT_MODEL=gemini-2.0-flash # or gemini-2.5-flash for better accuracy
|
||||||
|
#
|
||||||
|
# Google TTS — Google Cloud Text-to-Speech (auto-detected when GOOGLE_VERTEX_PROJECT set)
|
||||||
|
# TTS_PROVIDER=google
|
||||||
|
# GOOGLE_TTS_VOICE=en-US-Journey-F # female | en-US-Journey-D = male
|
||||||
|
# Other options: en-US-Studio-O, en-US-Neural2-C, en-US-Neural2-J
|
||||||
|
|
||||||
|
# Option 5: LiteLLM Proxy (self-hosted, routes to any provider)
|
||||||
|
# AI_PROVIDER=litellm
|
||||||
|
# LITELLM_API_BASE=http://localhost:4000
|
||||||
|
# LITELLM_API_KEY=sk-litellm-your-key
|
||||||
|
# Admin can discover available models via the admin panel
|
||||||
|
#
|
||||||
|
# LiteLLM Speech-to-Text
|
||||||
|
# TRANSCRIBE_PROVIDER=litellm
|
||||||
|
# LITELLM_STT_MODEL=whisper-1 # Use the model name from your LiteLLM model_list
|
||||||
|
# If your LiteLLM config uses full paths as model names, use the full path:
|
||||||
|
# LITELLM_STT_MODEL=openai/whisper-1
|
||||||
|
# NOTE: vertex_ai/chirp does NOT work via LiteLLM audio proxy.
|
||||||
|
# For Vertex AI speech, use TRANSCRIBE_PROVIDER=google (Gemini inline audio).
|
||||||
|
#
|
||||||
|
# LiteLLM TTS
|
||||||
|
# TTS_PROVIDER=litellm (auto-detected when LITELLM_API_BASE set)
|
||||||
|
# LITELLM_TTS_MODEL=tts-1 # Use model name from your LiteLLM model_list
|
||||||
|
# If your config uses full paths: LITELLM_TTS_MODEL=vertex_ai/google-tts
|
||||||
|
# LITELLM_TTS_VOICE=en-US-Journey-F # Google Cloud voice name (or alloy/nova for OpenAI)
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# Whisper (always OpenAI for now)
|
# TRANSCRIPTION (speech-to-text)
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|
||||||
|
# Option A: OpenAI Whisper (default if no AWS configured)
|
||||||
OPENAI_API_KEY=sk-your-openai-key
|
OPENAI_API_KEY=sk-your-openai-key
|
||||||
|
|
||||||
|
# Option B: Amazon Transcribe (HIPAA eligible, no S3 needed)
|
||||||
|
# Uses same AWS credentials as Bedrock above.
|
||||||
|
# Set TRANSCRIBE_PROVIDER=aws to force AWS even if OPENAI_API_KEY is set.
|
||||||
|
# Leave unset to auto-detect (uses AWS when AWS_BEDROCK_REGION is configured).
|
||||||
|
# TRANSCRIBE_PROVIDER=aws
|
||||||
|
|
||||||
|
# Option C: Local Whisper (privacy-first, no cloud API needed)
|
||||||
|
# Requires whisper.cpp or faster-whisper installed on the server.
|
||||||
|
# TRANSCRIBE_PROVIDER=local
|
||||||
|
# WHISPER_MODEL_SIZE=small # tiny, base, small, medium, large
|
||||||
|
# WHISPER_BINARY=whisper-cpp # or: whisper, faster-whisper
|
||||||
|
# WHISPER_MODEL_PATH= # custom path to .bin model file
|
||||||
|
# WHISPER_LANGUAGE=en
|
||||||
|
# WHISPER_THREADS=4 # defaults to CPU count - 1
|
||||||
|
|
||||||
|
# Amazon Transcribe Medical — better accuracy for clinical dictation
|
||||||
|
# Knows drug names, diagnoses, procedures, SOAP terminology
|
||||||
|
# HIPAA eligible (ensure your AWS account has a BAA)
|
||||||
|
# AWS_TRANSCRIBE_MEDICAL=true
|
||||||
|
# AWS_TRANSCRIBE_SPECIALTY=PRIMARYCARE
|
||||||
|
# Other options: CARDIOLOGY, NEUROLOGY, ONCOLOGY, RADIOLOGY, UROLOGY
|
||||||
|
|
||||||
# Optional
|
# Optional
|
||||||
ELEVENLABS_API_KEY=
|
ELEVENLABS_API_KEY=
|
||||||
|
|
||||||
|
# Push Notifications (ntfy — self-hosted, optional)
|
||||||
|
# NTFY_URL=https://ntfy.yourdomain.com
|
||||||
|
# NTFY_TOKEN=tk_your_token_here
|
||||||
|
|
||||||
# App
|
# App
|
||||||
PORT=3000
|
PORT=3000
|
||||||
APP_URL=https://your-domain.com
|
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
|
JWT_SECRET=generate-a-random-64-char-string-here
|
||||||
SESSION_SECRET=generate-another-random-string-here
|
|
||||||
|
# Application-layer encryption key for PHI at rest (Nextcloud tokens, audio backups)
|
||||||
|
# Generate with: openssl rand -hex 32
|
||||||
|
# REQUIRED in production. Rotating invalidates existing encrypted data.
|
||||||
|
DATA_ENCRYPTION_KEY=generate-with-openssl-rand-hex-32
|
||||||
|
|
||||||
# Email (for verification & password reset)
|
# Email (for verification & password reset)
|
||||||
SMTP_HOST=smtp.gmail.com
|
SMTP_HOST=smtp.gmail.com
|
||||||
|
|
@ -44,6 +116,49 @@ SMTP_FROM=noreply@yourdomain.com
|
||||||
# Nextcloud (optional)
|
# Nextcloud (optional)
|
||||||
NEXTCLOUD_URL=https://cloud.yourdomain.com
|
NEXTCLOUD_URL=https://cloud.yourdomain.com
|
||||||
|
|
||||||
|
# S3 Document Storage (optional — works with AWS S3, Backblaze B2, MinIO)
|
||||||
|
# S3_BUCKET=your-bucket-name
|
||||||
|
# S3_REGION=us-east-1
|
||||||
|
# S3_PREFIX=documents/
|
||||||
|
#
|
||||||
|
# For AWS S3: uses same AWS credentials as Bedrock above, or set S3-specific keys:
|
||||||
|
# S3_ACCESS_KEY_ID=...
|
||||||
|
# S3_SECRET_ACCESS_KEY=...
|
||||||
|
#
|
||||||
|
# For Backblaze B2:
|
||||||
|
# S3_ENDPOINT=https://s3.us-west-004.backblazeb2.com
|
||||||
|
# S3_REGION=us-west-004
|
||||||
|
# S3_ACCESS_KEY_ID=your-b2-application-key-id
|
||||||
|
# S3_SECRET_ACCESS_KEY=your-b2-application-key
|
||||||
|
#
|
||||||
|
# For MinIO (self-hosted):
|
||||||
|
# S3_ENDPOINT=http://minio:9000
|
||||||
|
# S3_REGION=us-east-1
|
||||||
|
# S3_ACCESS_KEY_ID=minio-access-key
|
||||||
|
# S3_SECRET_ACCESS_KEY=minio-secret-key
|
||||||
|
# S3_FORCE_PATH_STYLE=true
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# EMBEDDINGS (for Learning Hub semantic search)
|
||||||
|
# ============================================================
|
||||||
|
# Enables vector-based semantic search in Learning Hub
|
||||||
|
# Requires pgvector extension: apt-get install postgresql-16-pgvector
|
||||||
|
|
||||||
|
# Default model (Vertex AI text-embedding-005, 768 dims, English + code optimized)
|
||||||
|
EMBEDDING_MODEL=vertex_ai/text-embedding-005
|
||||||
|
EMBEDDING_DIMENSIONS=768
|
||||||
|
|
||||||
|
# Other Vertex AI embedding models:
|
||||||
|
# - vertex_ai/text-embedding-005 → 768 dims, English + code (recommended)
|
||||||
|
# - vertex_ai/gemini-embedding-001 → up to 3072 dims, multilingual + code
|
||||||
|
# - vertex_ai/text-multilingual-embedding-002 → 768 dims, multilingual focus
|
||||||
|
#
|
||||||
|
# LiteLLM usage (if using LiteLLM proxy):
|
||||||
|
# EMBEDDING_MODEL=text-embedding-005 # LiteLLM will route to configured provider
|
||||||
|
#
|
||||||
|
# OpenAI fallback (NOT HIPAA-eligible):
|
||||||
|
# Uses text-embedding-3-small if OPENAI_API_KEY is set and no Vertex/LiteLLM configured
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# DATABASE
|
# DATABASE
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|
|
||||||
119
.github/workflows/android-release.yml
vendored
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
name: Build & release Android APK
|
||||||
|
|
||||||
|
# Fires whenever a semver tag is pushed (e.g. v6.1.1). Use
|
||||||
|
# scripts/release.sh <version> --push from your laptop to mint the
|
||||||
|
# tag; this workflow does everything downstream.
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v[0-9]+.[0-9]+.[0-9]+'
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
version:
|
||||||
|
description: 'Manual tag to build (e.g. v6.1.1)'
|
||||||
|
required: true
|
||||||
|
|
||||||
|
env:
|
||||||
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true'
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write # needed to create GitHub releases from the runner
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: Build signed APK
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Resolve tag
|
||||||
|
id: tag
|
||||||
|
run: |
|
||||||
|
TAG="${GITHUB_REF_NAME}"
|
||||||
|
if [[ -z "$TAG" || "$TAG" == "main" ]]; then
|
||||||
|
TAG="${{ github.event.inputs.version }}"
|
||||||
|
fi
|
||||||
|
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "version=${TAG#v}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Set up JDK 17
|
||||||
|
uses: actions/setup-java@v4
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: '17'
|
||||||
|
|
||||||
|
- name: Set up Node 20
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: mobile/package-lock.json
|
||||||
|
|
||||||
|
- name: Set up Android SDK
|
||||||
|
uses: android-actions/setup-android@v3
|
||||||
|
|
||||||
|
- name: Cache Gradle packages
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.gradle/caches
|
||||||
|
~/.gradle/wrapper
|
||||||
|
key: gradle-${{ runner.os }}-${{ hashFiles('mobile/android/**/*.gradle*', 'mobile/android/gradle/wrapper/gradle-wrapper.properties') }}
|
||||||
|
restore-keys: gradle-${{ runner.os }}-
|
||||||
|
|
||||||
|
- name: Install Capacitor + sync
|
||||||
|
working-directory: mobile
|
||||||
|
run: |
|
||||||
|
npm install --no-audit --no-fund
|
||||||
|
npx cap sync android
|
||||||
|
|
||||||
|
- name: Restore keystore from secret
|
||||||
|
env:
|
||||||
|
KEYSTORE_B64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||||
|
run: |
|
||||||
|
echo "$KEYSTORE_B64" | base64 -d > $RUNNER_TEMP/pedscribe-release.jks
|
||||||
|
ls -la $RUNNER_TEMP/pedscribe-release.jks
|
||||||
|
|
||||||
|
- name: Build signed release APK
|
||||||
|
working-directory: mobile/android
|
||||||
|
env:
|
||||||
|
KS_PASS: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||||
|
KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||||
|
KEY_PASS: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||||
|
run: |
|
||||||
|
./gradlew assembleRelease \
|
||||||
|
-Pandroid.injected.signing.store.file=$RUNNER_TEMP/pedscribe-release.jks \
|
||||||
|
-Pandroid.injected.signing.store.password="$KS_PASS" \
|
||||||
|
-Pandroid.injected.signing.key.alias="$KEY_ALIAS" \
|
||||||
|
-Pandroid.injected.signing.key.password="$KEY_PASS" \
|
||||||
|
--no-daemon --stacktrace
|
||||||
|
|
||||||
|
- name: Locate APK
|
||||||
|
id: apk
|
||||||
|
run: |
|
||||||
|
APK=$(find mobile/android/app/build/outputs/apk/release -name '*.apk' | head -1)
|
||||||
|
test -n "$APK" || { echo "no APK found"; exit 1; }
|
||||||
|
echo "path=$APK" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "found: $APK ($(stat -c%s "$APK") bytes)"
|
||||||
|
|
||||||
|
- name: Rename APK with version
|
||||||
|
id: rename
|
||||||
|
run: |
|
||||||
|
DST="pedscribe-${{ steps.tag.outputs.version }}.apk"
|
||||||
|
cp "${{ steps.apk.outputs.path }}" "$DST"
|
||||||
|
echo "path=$DST" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Create or update GitHub release
|
||||||
|
uses: softprops/action-gh-release@v2
|
||||||
|
with:
|
||||||
|
tag_name: ${{ steps.tag.outputs.tag }}
|
||||||
|
name: PedScribe ${{ steps.tag.outputs.version }}
|
||||||
|
make_latest: 'true'
|
||||||
|
generate_release_notes: true
|
||||||
|
files: |
|
||||||
|
${{ steps.rename.outputs.path }}
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
148
.github/workflows/auto-version.yml
vendored
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
name: Auto version & release
|
||||||
|
|
||||||
|
# Fires on every push to main. Parses commit messages since the
|
||||||
|
# last semver tag, decides patch/minor/major bump, creates the
|
||||||
|
# tag, pushes. The tag push then triggers android-release.yml and
|
||||||
|
# docker-publish.yml. Fully hands-off — you never pick a version
|
||||||
|
# number; your commit messages do.
|
||||||
|
#
|
||||||
|
# Commit message grammar (Conventional Commits):
|
||||||
|
# feat: → minor bump (new feature, backward-compatible)
|
||||||
|
# fix: → patch bump (bug fix)
|
||||||
|
# feat!: / BREAKING CHANGE in body → major bump
|
||||||
|
# everything else (docs, refactor, chore, style, ci, test) → no bump
|
||||||
|
#
|
||||||
|
# Skip conditions (no new release created):
|
||||||
|
# - No commits match the above patterns
|
||||||
|
# - The most recent commit is itself a release commit ("Release v…")
|
||||||
|
# - [skip ci] appears in any commit message since the last tag
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
# Opt in to Node 24 runtime early (deprecation of Node 20 begins 2026-06-02)
|
||||||
|
env:
|
||||||
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true'
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
version:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: "!contains(github.event.head_commit.message, 'Release v') && !contains(github.event.head_commit.message, '[skip ci]')"
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
# Use RELEASE_PAT (a Personal Access Token you add as a repo
|
||||||
|
# secret) so the tag push this workflow performs actually
|
||||||
|
# triggers the downstream tag-based workflows (android-release,
|
||||||
|
# docker-publish). GITHUB_TOKEN pushes are deliberately
|
||||||
|
# blocked from triggering other workflows by GitHub.
|
||||||
|
# Fine-grained PAT with "Contents: Read and write" on this
|
||||||
|
# repo is enough.
|
||||||
|
token: ${{ secrets.RELEASE_PAT || secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Find last semver tag
|
||||||
|
id: last
|
||||||
|
run: |
|
||||||
|
LAST=$(git tag --list 'v[0-9]*.[0-9]*.[0-9]*' --sort=-v:refname | head -1)
|
||||||
|
if [[ -z "$LAST" ]]; then
|
||||||
|
LAST="v0.0.0"
|
||||||
|
echo "no previous tag, starting from v0.0.0"
|
||||||
|
fi
|
||||||
|
echo "tag=$LAST"
|
||||||
|
echo "tag=$LAST" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "version=${LAST#v}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Decide bump type from commit messages
|
||||||
|
id: decide
|
||||||
|
env:
|
||||||
|
LAST: ${{ steps.last.outputs.tag }}
|
||||||
|
run: |
|
||||||
|
# All commits from the last tag → HEAD (exclusive of tag commit)
|
||||||
|
if [[ "$LAST" == "v0.0.0" ]]; then
|
||||||
|
MSGS=$(git log --format='%s%n%b%n---')
|
||||||
|
else
|
||||||
|
MSGS=$(git log "${LAST}..HEAD" --format='%s%n%b%n---')
|
||||||
|
fi
|
||||||
|
|
||||||
|
BUMP=none
|
||||||
|
if echo "$MSGS" | grep -qE '(^|\n)(BREAKING CHANGE:|[a-z]+(\([^)]+\))?!:)'; then
|
||||||
|
BUMP=major
|
||||||
|
elif echo "$MSGS" | grep -qE '(^|\n)feat(\([^)]+\))?: '; then
|
||||||
|
BUMP=minor
|
||||||
|
elif echo "$MSGS" | grep -qE '(^|\n)fix(\([^)]+\))?: '; then
|
||||||
|
BUMP=patch
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Bump type decided: $BUMP"
|
||||||
|
echo "bump=$BUMP" >> "$GITHUB_OUTPUT"
|
||||||
|
{
|
||||||
|
echo "### Commits since $LAST"
|
||||||
|
echo '```'
|
||||||
|
if [[ "$LAST" == "v0.0.0" ]]; then
|
||||||
|
git log --oneline | head -20
|
||||||
|
else
|
||||||
|
git log "${LAST}..HEAD" --oneline
|
||||||
|
fi
|
||||||
|
echo '```'
|
||||||
|
echo ""
|
||||||
|
echo "**Bump decision**: \`$BUMP\`"
|
||||||
|
} >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
|
||||||
|
- name: Stop if no release-worthy commits
|
||||||
|
if: steps.decide.outputs.bump == 'none'
|
||||||
|
run: |
|
||||||
|
echo "No feat / fix / BREAKING commits since last tag — not cutting a release."
|
||||||
|
echo "::notice::No release cut. Commit with 'feat:', 'fix:', or BREAKING CHANGE to trigger one."
|
||||||
|
|
||||||
|
- name: Compute next version
|
||||||
|
id: next
|
||||||
|
if: steps.decide.outputs.bump != 'none'
|
||||||
|
env:
|
||||||
|
CUR: ${{ steps.last.outputs.version }}
|
||||||
|
BUMP: ${{ steps.decide.outputs.bump }}
|
||||||
|
run: |
|
||||||
|
IFS='.' read -r MAJ MIN PAT <<< "$CUR"
|
||||||
|
case "$BUMP" in
|
||||||
|
major) NEXT="$((MAJ+1)).0.0" ;;
|
||||||
|
minor) NEXT="${MAJ}.$((MIN+1)).0" ;;
|
||||||
|
patch) NEXT="${MAJ}.${MIN}.$((PAT+1))" ;;
|
||||||
|
esac
|
||||||
|
echo "next=$NEXT" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "### Next version: v$NEXT" >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
|
||||||
|
- name: Configure git
|
||||||
|
if: steps.decide.outputs.bump != 'none'
|
||||||
|
run: |
|
||||||
|
git config user.name "github-actions[bot]"
|
||||||
|
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||||
|
|
||||||
|
- name: Bump version strings + tag + push
|
||||||
|
if: steps.decide.outputs.bump != 'none'
|
||||||
|
env:
|
||||||
|
V: ${{ steps.next.outputs.next }}
|
||||||
|
run: |
|
||||||
|
IFS='.' read -r MAJ MIN PAT <<< "$V"
|
||||||
|
ANDROID_CODE=$(( MAJ * 100000 + MIN * 1000 + PAT ))
|
||||||
|
|
||||||
|
sed -i -E "0,/(\"version\"[[:space:]]*:[[:space:]]*\")[^\"]+(\")/ s//\1${V}\2/" package.json
|
||||||
|
sed -i -E "0,/(\"version\"[[:space:]]*:[[:space:]]*\")[^\"]+(\")/ s//\1${V}\2/" mobile/package.json
|
||||||
|
sed -i -E \
|
||||||
|
-e "s/versionCode +[0-9]+/versionCode ${ANDROID_CODE}/" \
|
||||||
|
-e "s/versionName +\"[^\"]+\"/versionName \"${V}\"/" \
|
||||||
|
mobile/android/app/build.gradle
|
||||||
|
|
||||||
|
git add package.json mobile/package.json mobile/android/app/build.gradle
|
||||||
|
git commit -m "Release v${V}"
|
||||||
|
git tag -a "v${V}" -m "Release v${V}"
|
||||||
|
|
||||||
|
git push origin HEAD
|
||||||
|
git push origin "v${V}"
|
||||||
|
|
||||||
|
echo "### Released v$V" >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
echo "android-release + docker-publish workflows will now run." >> "$GITHUB_STEP_SUMMARY"
|
||||||
103
.github/workflows/build-apk.yml
vendored
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
name: Build TWA APK
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags: ['v*']
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
app_url:
|
||||||
|
description: 'App URL override (default: https://peds.danvics.com)'
|
||||||
|
required: false
|
||||||
|
|
||||||
|
env:
|
||||||
|
APP_URL: ${{ github.event.inputs.app_url || secrets.APP_URL || 'https://peds.danvics.com' }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-apk:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up JDK 17
|
||||||
|
uses: actions/setup-java@v4
|
||||||
|
with:
|
||||||
|
distribution: 'temurin'
|
||||||
|
java-version: '17'
|
||||||
|
|
||||||
|
- name: Setup Android SDK
|
||||||
|
uses: android-actions/setup-android@v3
|
||||||
|
|
||||||
|
- name: Setup Gradle
|
||||||
|
uses: gradle/actions/setup-gradle@v4
|
||||||
|
|
||||||
|
- name: Generate Gradle wrapper
|
||||||
|
working-directory: android
|
||||||
|
run: |
|
||||||
|
gradle wrapper --gradle-version=8.5
|
||||||
|
|
||||||
|
- name: Build APK
|
||||||
|
working-directory: android
|
||||||
|
run: |
|
||||||
|
TWA_HOST=$(echo "${{ env.APP_URL }}" | sed 's|https://||;s|http://||;s|/.*||')
|
||||||
|
./gradlew assembleRelease -PTWA_HOST="${TWA_HOST}"
|
||||||
|
|
||||||
|
- name: Sign APK
|
||||||
|
if: success() && env.HAS_SIGNING_KEY == 'true'
|
||||||
|
env:
|
||||||
|
HAS_SIGNING_KEY: ${{ secrets.ANDROID_SIGNING_KEY != '' }}
|
||||||
|
run: |
|
||||||
|
# Decode signing key
|
||||||
|
echo "${{ secrets.ANDROID_SIGNING_KEY }}" | base64 -d > /tmp/release.jks
|
||||||
|
|
||||||
|
# Find the latest build-tools version
|
||||||
|
BUILD_TOOLS=$(ls -d $ANDROID_HOME/build-tools/*/ | sort -V | tail -1)
|
||||||
|
echo "Using build-tools: $BUILD_TOOLS"
|
||||||
|
|
||||||
|
UNSIGNED=$(find android/app/build/outputs/apk/release -name "*.apk" | head -1)
|
||||||
|
echo "Signing: $UNSIGNED"
|
||||||
|
|
||||||
|
# Zipalign
|
||||||
|
${BUILD_TOOLS}zipalign -v -p 4 "$UNSIGNED" /tmp/aligned.apk
|
||||||
|
|
||||||
|
# Sign with apksigner
|
||||||
|
${BUILD_TOOLS}apksigner sign \
|
||||||
|
--ks /tmp/release.jks \
|
||||||
|
--ks-key-alias "${{ secrets.ANDROID_KEY_ALIAS }}" \
|
||||||
|
--ks-pass "pass:${{ secrets.ANDROID_KEYSTORE_PASSWORD }}" \
|
||||||
|
--key-pass "pass:${{ secrets.ANDROID_KEY_PASSWORD }}" \
|
||||||
|
--out android/app/build/outputs/apk/release/PedScribe-v9-signed.apk \
|
||||||
|
/tmp/aligned.apk
|
||||||
|
|
||||||
|
# Verify
|
||||||
|
${BUILD_TOOLS}apksigner verify --print-certs android/app/build/outputs/apk/release/PedScribe-v9-signed.apk
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
rm -f /tmp/release.jks /tmp/aligned.apk
|
||||||
|
|
||||||
|
- name: Upload APK to Release
|
||||||
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
|
uses: softprops/action-gh-release@v2
|
||||||
|
with:
|
||||||
|
files: android/app/build/outputs/apk/release/*.apk
|
||||||
|
generate_release_notes: true
|
||||||
|
|
||||||
|
- name: Upload artifact
|
||||||
|
if: success()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: pediatric-scribe-apk
|
||||||
|
path: android/app/build/outputs/apk/release/*.apk
|
||||||
|
retention-days: 30
|
||||||
|
|
||||||
|
- name: Summary
|
||||||
|
run: |
|
||||||
|
echo "### TWA APK Build" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "Built for: ${{ env.APP_URL }}" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "**Install options:**" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "- Download from GitHub Releases" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "- Obtainium: add repo \`https://github.com/ifedan-ed/pediatric-ai-scribe-v3\`" >> $GITHUB_STEP_SUMMARY
|
||||||
137
.github/workflows/docker-publish.yml
vendored
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
name: Build & Push Docker Image
|
||||||
|
|
||||||
|
# Multi-arch build using NATIVE runners for each platform, then a
|
||||||
|
# manifest-list push. No QEMU emulation — amd64 builds on x86 runner,
|
||||||
|
# arm64 builds on ubuntu-24.04-arm runner. argon2 and every other
|
||||||
|
# native dep compile natively on their target arch.
|
||||||
|
#
|
||||||
|
# Result: `danielonyejesi/pediatric-ai-scribe-v3:X.Y.Z` (and :latest)
|
||||||
|
# is one tag serving the correct variant to amd64 or arm64 hosts.
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags: ['v*']
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
tag:
|
||||||
|
description: 'Tag to publish (e.g. v6.2.0)'
|
||||||
|
required: false
|
||||||
|
default: 'latest'
|
||||||
|
|
||||||
|
env:
|
||||||
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true'
|
||||||
|
IMAGE: danielonyejesi/pediatric-ai-scribe-v3
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
# Build one variant per matrix entry, push by digest only.
|
||||||
|
name: Build ${{ matrix.platform }}
|
||||||
|
runs-on: ${{ matrix.runner }}
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- platform: linux/amd64
|
||||||
|
runner: ubuntu-latest
|
||||||
|
- platform: linux/arm64
|
||||||
|
runner: ubuntu-24.04-arm
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Docker metadata (for labels)
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ env.IMAGE }}
|
||||||
|
|
||||||
|
- name: Set up Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Login to Docker Hub
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build & push by digest
|
||||||
|
id: build
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
platforms: ${{ matrix.platform }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true
|
||||||
|
cache-from: type=gha,scope=${{ matrix.platform }}
|
||||||
|
cache-to: type=gha,mode=max,scope=${{ matrix.platform }}
|
||||||
|
|
||||||
|
- name: Export digest for the merge job
|
||||||
|
run: |
|
||||||
|
mkdir -p /tmp/digests
|
||||||
|
DIG="${{ steps.build.outputs.digest }}"
|
||||||
|
touch "/tmp/digests/${DIG#sha256:}"
|
||||||
|
|
||||||
|
- name: Upload digest artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: digests-${{ matrix.platform == 'linux/amd64' && 'amd64' || 'arm64' }}
|
||||||
|
path: /tmp/digests/*
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 1
|
||||||
|
|
||||||
|
merge:
|
||||||
|
# Combine the two single-platform digests into one multi-arch manifest
|
||||||
|
# published under the real tags (vX.Y.Z and latest).
|
||||||
|
name: Merge manifests
|
||||||
|
needs: build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Download digests
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
path: /tmp/digests
|
||||||
|
pattern: digests-*
|
||||||
|
merge-multiple: true
|
||||||
|
|
||||||
|
- name: Set up Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Login to Docker Hub
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Resolve tag
|
||||||
|
id: tag
|
||||||
|
run: |
|
||||||
|
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||||
|
echo "tag=${{ github.event.inputs.tag || 'latest' }}" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
echo "tag=${GITHUB_REF_NAME}" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Docker metadata
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ env.IMAGE }}
|
||||||
|
tags: |
|
||||||
|
type=raw,value=${{ steps.tag.outputs.tag }}
|
||||||
|
type=raw,value=latest
|
||||||
|
|
||||||
|
- name: Create manifest list & push
|
||||||
|
working-directory: /tmp/digests
|
||||||
|
run: |
|
||||||
|
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||||
|
$(printf "${{ env.IMAGE }}@sha256:%s " *)
|
||||||
|
|
||||||
|
- name: Inspect final image
|
||||||
|
run: docker buildx imagetools inspect ${{ env.IMAGE }}:${{ steps.tag.outputs.tag }}
|
||||||
|
|
||||||
|
- name: Summary
|
||||||
|
run: |
|
||||||
|
echo "### Multi-arch image published" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "- \`${{ env.IMAGE }}:${{ steps.tag.outputs.tag }}\`" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "- \`${{ env.IMAGE }}:latest\`" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "- Platforms: linux/amd64, linux/arm64 (built on native runners)" >> $GITHUB_STEP_SUMMARY
|
||||||
102
.github/workflows/version-bump.yml
vendored
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
name: Version bump & release
|
||||||
|
|
||||||
|
# Manual trigger — click "Run workflow" in the Actions tab, choose
|
||||||
|
# patch / minor / major. The workflow computes the next semver,
|
||||||
|
# updates package.json, mobile/package.json, and the Android
|
||||||
|
# build.gradle, commits the change, tags it, and pushes — which
|
||||||
|
# triggers the android-release and docker-publish workflows.
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
bump:
|
||||||
|
description: 'Semver bump type'
|
||||||
|
required: true
|
||||||
|
type: choice
|
||||||
|
default: patch
|
||||||
|
options:
|
||||||
|
- patch
|
||||||
|
- minor
|
||||||
|
- major
|
||||||
|
custom:
|
||||||
|
description: 'Or exact version (e.g. 7.0.0) — overrides bump'
|
||||||
|
required: false
|
||||||
|
|
||||||
|
env:
|
||||||
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true'
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
bump:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
token: ${{ secrets.RELEASE_PAT || secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Compute next version
|
||||||
|
id: v
|
||||||
|
run: |
|
||||||
|
CUR=$(grep -m1 '"version"' package.json | sed -E 's/.*"version"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/')
|
||||||
|
echo "current=$CUR"
|
||||||
|
IFS='.' read -r MAJ MIN PAT <<< "$CUR"
|
||||||
|
|
||||||
|
if [[ -n "${{ github.event.inputs.custom }}" ]]; then
|
||||||
|
NEXT="${{ github.event.inputs.custom }}"
|
||||||
|
else
|
||||||
|
case "${{ github.event.inputs.bump }}" in
|
||||||
|
major) NEXT="$((MAJ+1)).0.0" ;;
|
||||||
|
minor) NEXT="${MAJ}.$((MIN+1)).0" ;;
|
||||||
|
patch) NEXT="${MAJ}.${MIN}.$((PAT+1))" ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! [[ "$NEXT" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||||
|
echo "::error::invalid version: $NEXT"; exit 1
|
||||||
|
fi
|
||||||
|
echo "next=$NEXT" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "current=$CUR" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "### Version bump" >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
echo "- Current: $CUR" >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
echo "- Next: $NEXT" >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
|
||||||
|
- name: Configure git
|
||||||
|
run: |
|
||||||
|
git config user.name "github-actions[bot]"
|
||||||
|
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||||
|
|
||||||
|
- name: Bump version strings
|
||||||
|
env:
|
||||||
|
V: ${{ steps.v.outputs.next }}
|
||||||
|
run: |
|
||||||
|
IFS='.' read -r MAJ MIN PAT <<< "$V"
|
||||||
|
ANDROID_CODE=$(( MAJ * 100000 + MIN * 1000 + PAT ))
|
||||||
|
|
||||||
|
# package.json (top-level "version": "...")
|
||||||
|
sed -i -E "0,/(\"version\"[[:space:]]*:[[:space:]]*\")[^\"]+(\")/ s//\1${V}\2/" package.json
|
||||||
|
sed -i -E "0,/(\"version\"[[:space:]]*:[[:space:]]*\")[^\"]+(\")/ s//\1${V}\2/" mobile/package.json
|
||||||
|
|
||||||
|
# Android
|
||||||
|
sed -i -E \
|
||||||
|
-e "s/versionCode +[0-9]+/versionCode ${ANDROID_CODE}/" \
|
||||||
|
-e "s/versionName +\"[^\"]+\"/versionName \"${V}\"/" \
|
||||||
|
mobile/android/app/build.gradle
|
||||||
|
|
||||||
|
git diff --stat
|
||||||
|
|
||||||
|
- name: Commit, tag, push
|
||||||
|
env:
|
||||||
|
V: ${{ steps.v.outputs.next }}
|
||||||
|
run: |
|
||||||
|
git add package.json mobile/package.json mobile/android/app/build.gradle
|
||||||
|
git commit -m "Release v${V}"
|
||||||
|
git tag -a "v${V}" -m "Release v${V}"
|
||||||
|
git push origin HEAD
|
||||||
|
git push origin "v${V}"
|
||||||
|
echo "### Pushed" >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
echo "- tag: v${V}" >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
echo "- android-release + docker-publish workflows will now run" >> "$GITHUB_STEP_SUMMARY"
|
||||||
15
.gitignore
vendored
|
|
@ -15,3 +15,18 @@ npm-debug.log*
|
||||||
*.swp
|
*.swp
|
||||||
dist/
|
dist/
|
||||||
build/
|
build/
|
||||||
|
|
||||||
|
# Android TWA
|
||||||
|
android/.gradle/
|
||||||
|
android/app/build/
|
||||||
|
android/build/
|
||||||
|
android/local.properties
|
||||||
|
android/captures/
|
||||||
|
android/.idea/
|
||||||
|
*.apk
|
||||||
|
*.aab
|
||||||
|
*.keystore
|
||||||
|
*.jks
|
||||||
|
public/models/
|
||||||
|
.env.backup-*
|
||||||
|
*.env.backup*
|
||||||
|
|
|
||||||
22
.gitmessage
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
# <type>: <short summary>
|
||||||
|
#
|
||||||
|
# Types that cut a release:
|
||||||
|
# fix: → patch (6.1.1 → 6.1.2) bug fix
|
||||||
|
# feat: → minor (6.1.1 → 6.2.0) new feature
|
||||||
|
# feat!: → major (6.1.1 → 7.0.0) breaking change
|
||||||
|
#
|
||||||
|
# Types that commit but don't release:
|
||||||
|
# docs: documentation
|
||||||
|
# refactor: code reshape, no behavior change
|
||||||
|
# chore: tooling, deps, housekeeping
|
||||||
|
# test: tests only
|
||||||
|
# style: formatting / whitespace
|
||||||
|
# ci: CI/CD configuration
|
||||||
|
# build: build system / external deps
|
||||||
|
#
|
||||||
|
# Full reference: https://www.conventionalcommits.org/
|
||||||
|
# Or see CONTRIBUTING.md in this repo.
|
||||||
|
#
|
||||||
|
# ---- body below (optional) -------------------------------------------
|
||||||
|
# Explain the WHY more than the what. Breaking changes must include a
|
||||||
|
# line starting with "BREAKING CHANGE: <description>".
|
||||||
8
.node-pg-migraterc.json
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"migrations-dir": "migrations",
|
||||||
|
"migration-filename-format": "utc",
|
||||||
|
"migration-file-language": "js",
|
||||||
|
"migrations-table": "pgmigrations",
|
||||||
|
"schema": "public",
|
||||||
|
"verbose": true
|
||||||
|
}
|
||||||
174
BROWSER_WHISPER_SETUP.md
Normal file
|
|
@ -0,0 +1,174 @@
|
||||||
|
# Browser Whisper Self-Hosted Setup
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
As of v3, Browser Whisper is **fully self-hosted** with **zero CDN dependencies**. All models and libraries are bundled with the application and served from your own server.
|
||||||
|
|
||||||
|
## What Changed
|
||||||
|
|
||||||
|
**Before (v2 and earlier):**
|
||||||
|
- Loaded transformers.js from `cdn.jsdelivr.net`
|
||||||
|
- Downloaded models from `cdn-lfs.huggingface.co`
|
||||||
|
- Failed in corporate/clinical networks with firewall restrictions
|
||||||
|
|
||||||
|
**Now (v3+):**
|
||||||
|
- Transformers.js library (v2.6.2) bundled at `/models/transformers.min.js` (760KB)
|
||||||
|
- Whisper model bundled at `/models/Xenova/whisper-tiny.en/` (42MB)
|
||||||
|
- Everything served from your own server
|
||||||
|
- **Works in any network environment** (firewalled, air-gapped, offline)
|
||||||
|
|
||||||
|
## Files Included
|
||||||
|
|
||||||
|
```
|
||||||
|
public/models/
|
||||||
|
├── transformers.min.js (760KB) - Transformers.js v2.6.2 (worker-compatible)
|
||||||
|
└── Xenova/
|
||||||
|
└── whisper-tiny.en/ (42MB total)
|
||||||
|
├── config.json
|
||||||
|
├── tokenizer.json
|
||||||
|
├── preprocessor_config.json
|
||||||
|
├── generation_config.json
|
||||||
|
└── onnx/
|
||||||
|
├── encoder_model_quantized.onnx
|
||||||
|
└── decoder_model_merged_quantized.onnx
|
||||||
|
```
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
1. **Worker loads transformers.js locally:**
|
||||||
|
```javascript
|
||||||
|
importScripts('/models/transformers.min.js');
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Transformers.js configured for local models:**
|
||||||
|
```javascript
|
||||||
|
T.env.localModelPath = '/models/';
|
||||||
|
T.env.allowRemoteModels = false;
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Models load from your server:**
|
||||||
|
- Browser requests: `GET /models/Xenova/whisper-tiny.en/config.json`
|
||||||
|
- Served by Express static middleware
|
||||||
|
- No external network calls
|
||||||
|
|
||||||
|
## Docker Build
|
||||||
|
|
||||||
|
Models are downloaded **during Docker build** (not runtime):
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
RUN curl -sL -o onnx/encoder_model_quantized.onnx \
|
||||||
|
https://huggingface.co/Xenova/whisper-tiny.en/resolve/main/onnx/encoder_model_quantized.onnx
|
||||||
|
```
|
||||||
|
|
||||||
|
This means:
|
||||||
|
- Docker image is ~200MB larger (one-time cost)
|
||||||
|
- Runtime has zero dependencies
|
||||||
|
- Works in air-gapped environments (after image is pulled)
|
||||||
|
|
||||||
|
## Development Setup
|
||||||
|
|
||||||
|
If you're running locally (not Docker), download models:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd public/models
|
||||||
|
mkdir -p Xenova/whisper-tiny.en/onnx
|
||||||
|
|
||||||
|
# Download transformers.js
|
||||||
|
curl -L -o transformers.min.js \
|
||||||
|
https://cdn.jsdelivr.net/npm/@xenova/transformers@2.17.2/dist/transformers.min.js
|
||||||
|
|
||||||
|
# Download model files
|
||||||
|
cd Xenova/whisper-tiny.en
|
||||||
|
curl -L -o config.json \
|
||||||
|
https://huggingface.co/Xenova/whisper-tiny.en/resolve/main/config.json
|
||||||
|
curl -L -o tokenizer.json \
|
||||||
|
https://huggingface.co/Xenova/whisper-tiny.en/resolve/main/tokenizer.json
|
||||||
|
curl -L -o preprocessor_config.json \
|
||||||
|
https://huggingface.co/Xenova/whisper-tiny.en/resolve/main/preprocessor_config.json
|
||||||
|
curl -L -o generation_config.json \
|
||||||
|
https://huggingface.co/Xenova/whisper-tiny.en/resolve/main/generation_config.json
|
||||||
|
curl -L -o onnx/encoder_model_quantized.onnx \
|
||||||
|
https://huggingface.co/Xenova/whisper-tiny.en/resolve/main/onnx/encoder_model_quantized.onnx
|
||||||
|
curl -L -o onnx/decoder_model_merged_quantized.onnx \
|
||||||
|
https://huggingface.co/Xenova/whisper-tiny.en/resolve/main/onnx/decoder_model_merged_quantized.onnx
|
||||||
|
```
|
||||||
|
|
||||||
|
Or use the helper script:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/download-whisper-models.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Adding More Models
|
||||||
|
|
||||||
|
To add base or small models:
|
||||||
|
|
||||||
|
1. **Create directory:**
|
||||||
|
```bash
|
||||||
|
mkdir -p public/models/Xenova/whisper-base.en/onnx
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Download from HuggingFace:**
|
||||||
|
- https://huggingface.co/Xenova/whisper-base.en
|
||||||
|
- https://huggingface.co/Xenova/whisper-small.en
|
||||||
|
|
||||||
|
3. **Update UI in `settings.html`:**
|
||||||
|
```html
|
||||||
|
<option value="Xenova/whisper-base.en">Base (~74MB, better quality)</option>
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Update Dockerfile** to download during build
|
||||||
|
|
||||||
|
## Benefits
|
||||||
|
|
||||||
|
✅ **Works everywhere** - No firewall/CDN issues
|
||||||
|
✅ **Privacy-first** - Audio never leaves browser
|
||||||
|
✅ **Offline capable** - After initial page load
|
||||||
|
✅ **No API costs** - Zero transcription expenses
|
||||||
|
✅ **Predictable** - Same model, same results
|
||||||
|
✅ **Fast** - Local processing, no network latency
|
||||||
|
|
||||||
|
## Limitations
|
||||||
|
|
||||||
|
- Docker image is larger (~200MB vs ~150MB)
|
||||||
|
- Only tiny model included by default (base/small optional)
|
||||||
|
- Slower than cloud APIs for long recordings
|
||||||
|
- Requires modern browser with WebAssembly support
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Start server
|
||||||
|
docker-compose up -d
|
||||||
|
|
||||||
|
# 2. Open browser DevTools → Network tab
|
||||||
|
# 3. Go to Settings → Browser Transcription
|
||||||
|
# 4. Click "Pre-download model"
|
||||||
|
# 5. Watch for requests to /models/* (should all be 200 OK from your server)
|
||||||
|
# 6. NO requests to cdn.jsdelivr.net or huggingface.co
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
**Issue: "Failed to load transformers library"**
|
||||||
|
- Check: `GET /models/transformers.min.js` returns 200 OK
|
||||||
|
- Verify file exists: `ls public/models/transformers.min.js`
|
||||||
|
|
||||||
|
**Issue: "Model load failed"**
|
||||||
|
- Check: `GET /models/Xenova/whisper-tiny.en/config.json` returns 200 OK
|
||||||
|
- Verify files exist: `ls public/models/Xenova/whisper-tiny.en/`
|
||||||
|
|
||||||
|
**Issue: Still seeing CDN requests**
|
||||||
|
- Clear browser cache (Ctrl+Shift+R)
|
||||||
|
- Check you're running v18+ (`/api/health` should show version)
|
||||||
|
|
||||||
|
## Migration from v17
|
||||||
|
|
||||||
|
If upgrading from v17:
|
||||||
|
|
||||||
|
1. Pull new Docker image: `docker-compose pull`
|
||||||
|
2. Restart: `docker-compose up -d`
|
||||||
|
3. Clear browser cache
|
||||||
|
4. Test: Settings → Browser Transcription → Pre-download
|
||||||
|
|
||||||
|
No configuration changes needed - it just works!
|
||||||
240
BROWSER_WHISPER_TROUBLESHOOTING.md
Normal file
|
|
@ -0,0 +1,240 @@
|
||||||
|
# Browser Whisper Troubleshooting
|
||||||
|
|
||||||
|
## 🎙️ What is Browser Whisper?
|
||||||
|
|
||||||
|
Browser Whisper is an **optional** client-side transcription feature that runs entirely in your browser using WebAssembly. It provides:
|
||||||
|
- ✅ Zero network transmission (HIPAA-safe)
|
||||||
|
- ✅ No API costs
|
||||||
|
- ✅ Works offline
|
||||||
|
- ✅ Privacy-first (audio never leaves device)
|
||||||
|
|
||||||
|
**However**, it requires downloading AI models from CDN servers.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ Common Issue: CDN Blocked
|
||||||
|
|
||||||
|
### Error Message:
|
||||||
|
```
|
||||||
|
NetworkError: Failed to execute 'importScripts' on 'WorkerGlobalScope':
|
||||||
|
The script at 'https://cdn.jsdelivr.net/npm/@xenova/transformers@2.17.2' failed to load.
|
||||||
|
```
|
||||||
|
|
||||||
|
### What This Means:
|
||||||
|
Your network/firewall is blocking access to:
|
||||||
|
- `cdn.jsdelivr.net` (JavaScript library CDN)
|
||||||
|
- `cdn-lfs.huggingface.co` (AI model files)
|
||||||
|
|
||||||
|
### Why It Happens:
|
||||||
|
1. **Corporate firewall** - Many organizations block CDN domains
|
||||||
|
2. **Browser extensions** - Ad blockers, privacy tools may block CDN
|
||||||
|
3. **Network proxy** - Company proxy might filter JavaScript CDN
|
||||||
|
4. **CSP restrictions** - Very strict Content Security Policy
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Solutions
|
||||||
|
|
||||||
|
### Option 1: Use Server Transcription (Recommended)
|
||||||
|
|
||||||
|
**Browser Whisper is optional!** The app works perfectly fine with server-side transcription.
|
||||||
|
|
||||||
|
**Server transcription providers:**
|
||||||
|
- Google Gemini (via Vertex AI) - HIPAA-eligible
|
||||||
|
- AWS Transcribe - HIPAA-eligible
|
||||||
|
- OpenAI Whisper - Fast, accurate
|
||||||
|
- LiteLLM - Routes to any provider
|
||||||
|
|
||||||
|
**To use server transcription:**
|
||||||
|
1. Go to Settings → Browser Transcription
|
||||||
|
2. **Leave it disabled** (or if stuck, disable it)
|
||||||
|
3. Record audio normally - will use server
|
||||||
|
|
||||||
|
**Advantages:**
|
||||||
|
- More accurate (larger models)
|
||||||
|
- No download needed
|
||||||
|
- Works immediately
|
||||||
|
- Professional grade
|
||||||
|
|
||||||
|
### Option 2: Whitelist CDN Domains
|
||||||
|
|
||||||
|
If you control your network/firewall, whitelist these domains:
|
||||||
|
|
||||||
|
```
|
||||||
|
cdn.jsdelivr.net
|
||||||
|
cdn-lfs.huggingface.co
|
||||||
|
cdn-lfs-us-1.huggingface.co
|
||||||
|
cdn-lfs-us-2.huggingface.co
|
||||||
|
huggingface.co
|
||||||
|
```
|
||||||
|
|
||||||
|
**For corporate IT:**
|
||||||
|
- These are legitimate AI/JavaScript CDNs
|
||||||
|
- Used by major companies worldwide
|
||||||
|
- No security risk (public CDN content)
|
||||||
|
- Required only for browser-based AI features
|
||||||
|
|
||||||
|
### Option 3: Disable Browser Extensions
|
||||||
|
|
||||||
|
Try disabling:
|
||||||
|
- Ad blockers (uBlock Origin, AdBlock Plus)
|
||||||
|
- Privacy extensions (Privacy Badger, Ghostery)
|
||||||
|
- Script blockers (NoScript, ScriptSafe)
|
||||||
|
|
||||||
|
Then refresh and try again.
|
||||||
|
|
||||||
|
### Option 4: Try Different Browser
|
||||||
|
|
||||||
|
Some browsers have stricter security:
|
||||||
|
- ✅ **Chrome** - Best compatibility
|
||||||
|
- ✅ **Edge** - Works well
|
||||||
|
- ⚠️ **Firefox** - May block CDN
|
||||||
|
- ❌ **Safari** - Limited WebAssembly support
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧪 How to Test If It's Working
|
||||||
|
|
||||||
|
### Test 1: Check CDN Access
|
||||||
|
```bash
|
||||||
|
# From your computer, run:
|
||||||
|
curl -I https://cdn.jsdelivr.net/npm/@xenova/transformers@2.17.2
|
||||||
|
|
||||||
|
# Should return: HTTP/2 200
|
||||||
|
# If 403 or timeout: CDN is blocked
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test 2: Browser Console
|
||||||
|
1. Open DevTools (F12)
|
||||||
|
2. Go to Console tab
|
||||||
|
3. Settings → Browser Transcription
|
||||||
|
4. Click "Pre-download model"
|
||||||
|
5. Watch for:
|
||||||
|
```
|
||||||
|
✅ [WhisperWorker] Transformers library loaded successfully
|
||||||
|
OR
|
||||||
|
❌ NetworkError: Failed to load
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test 3: Network Tab
|
||||||
|
1. Open DevTools (F12)
|
||||||
|
2. Go to Network tab
|
||||||
|
3. Click "Pre-download model"
|
||||||
|
4. Look for requests to:
|
||||||
|
- `cdn.jsdelivr.net` (should be 200 OK)
|
||||||
|
- `cdn-lfs.huggingface.co` (should be 200 OK)
|
||||||
|
5. If blocked: Status will show "failed" or "blocked"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 When to Use Each Option
|
||||||
|
|
||||||
|
| Scenario | Recommendation | Why |
|
||||||
|
|----------|---------------|-----|
|
||||||
|
| Corporate network | **Server transcription** | CDN likely blocked |
|
||||||
|
| Home network | **Browser Whisper** | Fast, free, private |
|
||||||
|
| Mobile device | **Server transcription** | Limited storage/memory |
|
||||||
|
| Offline use needed | **Browser Whisper** | Works without internet (after initial download) |
|
||||||
|
| High accuracy needed | **Server transcription** | Larger models available |
|
||||||
|
| Maximum privacy | **Browser Whisper** | Audio never leaves device |
|
||||||
|
| Can't access CDN | **Server transcription** | No choice - CDN blocked |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 Technical Details
|
||||||
|
|
||||||
|
### What Gets Downloaded (First Time Only):
|
||||||
|
|
||||||
|
**Tiny model** (~39 MB):
|
||||||
|
- onnx-runtime.wasm (~10 MB)
|
||||||
|
- whisper-tiny.en model files (~29 MB)
|
||||||
|
- Cached in browser IndexedDB (permanent)
|
||||||
|
|
||||||
|
**Base model** (~74 MB):
|
||||||
|
- Larger model, better accuracy
|
||||||
|
|
||||||
|
**Small model** (~244 MB):
|
||||||
|
- Best quality, slower processing
|
||||||
|
|
||||||
|
### Where It's Stored:
|
||||||
|
- **Location:** Browser IndexedDB
|
||||||
|
- **Persistence:** Permanent (until you clear browser data)
|
||||||
|
- **Shared:** Across all tabs/windows for this domain
|
||||||
|
- **Size:** Selected model size (39/74/244 MB)
|
||||||
|
|
||||||
|
### Performance:
|
||||||
|
- **Tiny:** 2-3 seconds per 30-second clip
|
||||||
|
- **Base:** 3-5 seconds per 30-second clip
|
||||||
|
- **Small:** 6-10 seconds per 30-second clip
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ❓ FAQ
|
||||||
|
|
||||||
|
**Q: Is Browser Whisper required?**
|
||||||
|
A: No! It's completely optional. Server transcription works great.
|
||||||
|
|
||||||
|
**Q: Why doesn't it work on my corporate network?**
|
||||||
|
A: Most corporate firewalls block CDN domains for security. Use server transcription instead.
|
||||||
|
|
||||||
|
**Q: Can I download the models manually?**
|
||||||
|
A: Not easily - they're optimized for CDN delivery. Use server transcription if CDN is blocked.
|
||||||
|
|
||||||
|
**Q: Will server transcription cost money?**
|
||||||
|
A: Depends on your provider:
|
||||||
|
- Google Vertex AI: ~$0.005 per minute
|
||||||
|
- AWS Transcribe: ~$0.024 per minute
|
||||||
|
- OpenAI: $0.006 per minute
|
||||||
|
- Very affordable for typical use
|
||||||
|
|
||||||
|
**Q: Is server transcription HIPAA-safe?**
|
||||||
|
A: Yes, if using:
|
||||||
|
- Google Vertex AI (with BAA)
|
||||||
|
- AWS Transcribe (with BAA)
|
||||||
|
- Azure OpenAI (with BAA)
|
||||||
|
|
||||||
|
OpenAI Whisper direct is NOT HIPAA-eligible.
|
||||||
|
|
||||||
|
**Q: Can I use both?**
|
||||||
|
A: Yes! Enable Browser Whisper in Settings. If it fails (CDN blocked), it automatically falls back to server transcription.
|
||||||
|
|
||||||
|
**Q: How do I know which one is being used?**
|
||||||
|
A: Check the toast notification after recording:
|
||||||
|
- "Transcribed locally" = Browser Whisper
|
||||||
|
- "Transcribed via google-gemini/aws/openai" = Server
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Recommended Setup
|
||||||
|
|
||||||
|
### For Maximum Privacy (Home Network):
|
||||||
|
1. Enable Browser Whisper
|
||||||
|
2. Choose "Tiny" model (fast, good enough for dictation)
|
||||||
|
3. Pre-download model
|
||||||
|
4. Use offline
|
||||||
|
|
||||||
|
### For Corporate/Clinical Use:
|
||||||
|
1. Keep Browser Whisper **disabled**
|
||||||
|
2. Configure server transcription:
|
||||||
|
```bash
|
||||||
|
# In .env:
|
||||||
|
TRANSCRIBE_PROVIDER=google
|
||||||
|
GOOGLE_VERTEX_PROJECT=your-project
|
||||||
|
```
|
||||||
|
3. Use with BAA for HIPAA compliance
|
||||||
|
|
||||||
|
### For Best Accuracy:
|
||||||
|
1. Use server transcription
|
||||||
|
2. Configure Google Gemini 2.0 Flash or AWS Transcribe Medical
|
||||||
|
3. Audio quality + large models = best results
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ Still Having Issues?
|
||||||
|
|
||||||
|
1. **Check console logs:** DevTools → Console → Look for `[BrowserWhisper]` errors
|
||||||
|
2. **Check network logs:** DevTools → Network → Filter by `jsdelivr` or `huggingface`
|
||||||
|
3. **Verify server transcription works:** Just disable Browser Whisper and record
|
||||||
|
4. **Contact IT:** Ask to whitelist CDN domains (if you need Browser Whisper)
|
||||||
|
|
||||||
|
**Remember:** Browser Whisper is a nice-to-have feature. Server transcription is the primary, production-ready method that works everywhere!
|
||||||
54
CONTRIBUTING.md
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
# Contributing
|
||||||
|
|
||||||
|
<!-- Pipeline verified 2026-04-15: auto-version + PAT + multi-arch docker -->
|
||||||
|
|
||||||
|
## Commit format
|
||||||
|
|
||||||
|
[Conventional Commits](https://www.conventionalcommits.org). `.github/workflows/auto-version.yml`
|
||||||
|
parses messages since the last semver tag and decides whether to bump.
|
||||||
|
|
||||||
|
| Prefix | Bump | |
|
||||||
|
|---|---|---|
|
||||||
|
| `fix:` | patch | bug fix |
|
||||||
|
| `feat:` | minor | new feature |
|
||||||
|
| `feat!:` / `fix!:` / `BREAKING CHANGE:` in body | major | breaking change |
|
||||||
|
| `docs:` `refactor:` `chore:` `test:` `style:` `ci:` `build:` | none | no release |
|
||||||
|
|
||||||
|
Append `[skip ci]` to suppress the run for that commit.
|
||||||
|
|
||||||
|
## Manual release
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scripts/release.sh 6.2.0 --push # local
|
||||||
|
```
|
||||||
|
|
||||||
|
or Actions tab → **Version bump & release** → Run workflow → pick bump type.
|
||||||
|
|
||||||
|
## What a tag push triggers
|
||||||
|
|
||||||
|
| Workflow | Output |
|
||||||
|
|---|---|
|
||||||
|
| `android-release.yml` | signed APK on GitHub release, `make_latest=true` |
|
||||||
|
| `docker-publish.yml` | `danielonyejesi/pediatric-ai-scribe-v3:{version,latest}` on Docker Hub (amd64) |
|
||||||
|
|
||||||
|
## Local dev
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d # Postgres + app
|
||||||
|
docker logs -f pediatric-ai-scribe
|
||||||
|
```
|
||||||
|
|
||||||
|
Web changes hot-reload via browser refresh (JS/CSS cached 1h — add `?v=` query
|
||||||
|
or clear cache; the build-ID server-side cache-buster appends `?v=<git SHA>`
|
||||||
|
automatically on fresh page loads).
|
||||||
|
|
||||||
|
Server code changes require `docker compose build pediatric-scribe && docker compose up -d`.
|
||||||
|
|
||||||
|
## Mobile
|
||||||
|
|
||||||
|
See `docs/mobile-build.md`.
|
||||||
|
|
||||||
|
## DB migrations
|
||||||
|
|
||||||
|
`src/db/database.js` is the baseline (idempotent CREATE-IF-NOT-EXISTS). New
|
||||||
|
changes go in `migrations/` via `node-pg-migrate`. See `docs/migrations.md`.
|
||||||
25
Dockerfile
|
|
@ -2,13 +2,36 @@ FROM node:20-alpine
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
# ffmpeg: audio conversion for AWS Transcribe (WebM → PCM)
|
||||||
|
# curl: download Whisper models for browser-based transcription
|
||||||
|
RUN apk add --no-cache ffmpeg curl
|
||||||
|
|
||||||
COPY package.json ./
|
COPY package.json ./
|
||||||
RUN npm install --omit=dev
|
# argon2 compiles native code via node-gyp — needs python3/make/g++ at build time
|
||||||
|
RUN apk add --no-cache --virtual .build-deps python3 make g++ \
|
||||||
|
&& npm install --omit=dev \
|
||||||
|
&& apk del .build-deps
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
RUN mkdir -p /app/data/logs
|
RUN mkdir -p /app/data/logs
|
||||||
|
|
||||||
|
# Download Browser Whisper (COMPLETE self-hosting - zero CDN dependencies)
|
||||||
|
# Library + Models all bundled and served from our server
|
||||||
|
RUN mkdir -p /app/public/models/Xenova/whisper-tiny.en/onnx && \
|
||||||
|
cd /app/public/models && \
|
||||||
|
echo "Downloading transformers.js library (worker-compatible build)..." && \
|
||||||
|
curl -sL -o transformers.min.js https://cdn.jsdelivr.net/npm/@xenova/transformers@2.0.0/dist/transformers.min.js && \
|
||||||
|
cd Xenova/whisper-tiny.en && \
|
||||||
|
echo "Downloading Whisper model files..." && \
|
||||||
|
curl -sL -o config.json https://huggingface.co/Xenova/whisper-tiny.en/resolve/main/config.json && \
|
||||||
|
curl -sL -o tokenizer.json https://huggingface.co/Xenova/whisper-tiny.en/resolve/main/tokenizer.json && \
|
||||||
|
curl -sL -o preprocessor_config.json https://huggingface.co/Xenova/whisper-tiny.en/resolve/main/preprocessor_config.json && \
|
||||||
|
curl -sL -o generation_config.json https://huggingface.co/Xenova/whisper-tiny.en/resolve/main/generation_config.json && \
|
||||||
|
curl -sL -o onnx/encoder_model_quantized.onnx https://huggingface.co/Xenova/whisper-tiny.en/resolve/main/onnx/encoder_model_quantized.onnx && \
|
||||||
|
curl -sL -o onnx/decoder_model_merged_quantized.onnx https://huggingface.co/Xenova/whisper-tiny.en/resolve/main/onnx/decoder_model_merged_quantized.onnx && \
|
||||||
|
echo "✅ Browser Whisper: 100% self-hosted (library: 760KB, models: 42MB)"
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s \
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s \
|
||||||
|
|
|
||||||
268
EMBEDDINGS_SETUP.md
Normal file
|
|
@ -0,0 +1,268 @@
|
||||||
|
# Embeddings & Semantic Search Setup
|
||||||
|
|
||||||
|
This guide explains how to set up and use the new vector-based semantic search for the Learning Hub.
|
||||||
|
|
||||||
|
## 🎯 What's New
|
||||||
|
|
||||||
|
- **Semantic search** - Find content by meaning, not just keywords
|
||||||
|
- **3 search modes**:
|
||||||
|
- **Keyword** (`/api/learning/search`) - Traditional text matching
|
||||||
|
- **Semantic** (`/api/learning/search/semantic`) - AI-powered vector similarity
|
||||||
|
- **Hybrid** (`/api/learning/search/hybrid`) - Combines both for best results
|
||||||
|
- **Auto-embedding** - Content is automatically vectorized when created/updated
|
||||||
|
- **HIPAA-compliant** - Uses Vertex AI embeddings (BAA available)
|
||||||
|
|
||||||
|
## 📋 Prerequisites
|
||||||
|
|
||||||
|
### 1. Install pgvector Extension
|
||||||
|
|
||||||
|
The database needs the `pgvector` extension for vector operations:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# For PostgreSQL 16 on Ubuntu/Debian
|
||||||
|
sudo apt-get install postgresql-16-pgvector
|
||||||
|
|
||||||
|
# For PostgreSQL 15
|
||||||
|
sudo apt-get install postgresql-15-pgvector
|
||||||
|
|
||||||
|
# For Docker (add to Dockerfile or docker-compose)
|
||||||
|
# The postgres:16-alpine base image doesn't include pgvector by default
|
||||||
|
# You'll need to use a custom image or install at runtime
|
||||||
|
```
|
||||||
|
|
||||||
|
**For Docker deployments**, use this postgres image instead:
|
||||||
|
```yaml
|
||||||
|
postgres:
|
||||||
|
image: pgvector/pgvector:pg16
|
||||||
|
# ... rest of your config
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Configure Embedding Provider
|
||||||
|
|
||||||
|
Add to your `.env` file:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Option 1: Vertex AI (HIPAA-eligible, recommended)
|
||||||
|
EMBEDDING_MODEL=vertex_ai/text-embedding-005
|
||||||
|
EMBEDDING_DIMENSIONS=768
|
||||||
|
VERTEX_PROJECT=your-gcp-project-id
|
||||||
|
VERTEX_LOCATION=us-central1
|
||||||
|
GOOGLE_APPLICATION_CREDENTIALS=/path/to/service-account.json
|
||||||
|
|
||||||
|
# Option 2: LiteLLM Proxy (routes to any provider)
|
||||||
|
LITELLM_API_BASE=http://localhost:4000
|
||||||
|
LITELLM_API_KEY=your-key
|
||||||
|
EMBEDDING_MODEL=text-embedding-005 # LiteLLM will route to configured provider
|
||||||
|
|
||||||
|
# Option 3: OpenAI (NOT HIPAA-eligible, fallback only)
|
||||||
|
OPENAI_API_KEY=sk-your-key
|
||||||
|
# Uses text-embedding-3-small automatically
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🚀 Available Vertex AI Embedding Models
|
||||||
|
|
||||||
|
Tested and working via LiteLLM:
|
||||||
|
|
||||||
|
| Model | Dimensions | Use Case | HIPAA |
|
||||||
|
|-------|-----------|----------|-------|
|
||||||
|
| **vertex_ai/text-embedding-005** | 768 | English + code (recommended) | ✅ Yes |
|
||||||
|
| **vertex_ai/gemini-embedding-001** | 768-3072 | Multilingual + code, best quality | ✅ Yes |
|
||||||
|
| **vertex_ai/text-multilingual-embedding-002** | 768 | Multilingual focus | ✅ Yes |
|
||||||
|
|
||||||
|
## 🔧 Setup Steps
|
||||||
|
|
||||||
|
### 1. Database Migration
|
||||||
|
|
||||||
|
The database will automatically:
|
||||||
|
- Enable the `pgvector` extension
|
||||||
|
- Add `embedding vector(768)` column to `learning_content`
|
||||||
|
- Create IVFFLAT index for fast similarity search (after 10+ embeddings)
|
||||||
|
|
||||||
|
Just restart your server after installing pgvector.
|
||||||
|
|
||||||
|
### 2. Generate Embeddings for Existing Content
|
||||||
|
|
||||||
|
Two options:
|
||||||
|
|
||||||
|
**Option A: Admin API (recommended)**
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:3000/api/admin/learning/embeddings/generate \
|
||||||
|
-H "Authorization: Bearer YOUR_JWT_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"regenerateAll": false}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Option B: Via Admin Panel**
|
||||||
|
- Go to Admin → Learning Hub → Settings
|
||||||
|
- Click "Generate Embeddings" button
|
||||||
|
- Check status at `/api/admin/learning/embeddings/status`
|
||||||
|
|
||||||
|
### 3. Verify Setup
|
||||||
|
|
||||||
|
Check embedding status:
|
||||||
|
```bash
|
||||||
|
curl http://localhost:3000/api/admin/learning/embeddings/status \
|
||||||
|
-H "Authorization: Bearer YOUR_JWT_TOKEN"
|
||||||
|
```
|
||||||
|
|
||||||
|
Response:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"enabled": true,
|
||||||
|
"total": 50,
|
||||||
|
"withEmbeddings": 50,
|
||||||
|
"missing": 0,
|
||||||
|
"model": "vertex_ai/text-embedding-005",
|
||||||
|
"dimensions": 768
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔍 Using Semantic Search
|
||||||
|
|
||||||
|
### Keyword Search (existing)
|
||||||
|
```bash
|
||||||
|
GET /api/learning/search?q=pneumonia
|
||||||
|
```
|
||||||
|
Returns exact text matches in title/subject/body.
|
||||||
|
|
||||||
|
### Semantic Search (new)
|
||||||
|
```bash
|
||||||
|
GET /api/learning/search/semantic?q=childhood breathing problems&limit=10&threshold=0.5
|
||||||
|
```
|
||||||
|
Returns content similar by **meaning** (e.g., finds "pediatric asthma" articles).
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
- `q` (required) - Search query
|
||||||
|
- `limit` (optional, default 10, max 50) - Max results
|
||||||
|
- `threshold` (optional, default 0.5) - Similarity threshold (0-1, higher = more similar)
|
||||||
|
- `contentType` (optional) - Filter by type: article, quiz, pearl, presentation
|
||||||
|
|
||||||
|
### Hybrid Search (recommended)
|
||||||
|
```bash
|
||||||
|
GET /api/learning/search/hybrid?q=fever management
|
||||||
|
```
|
||||||
|
Combines keyword + semantic for best results. Automatically deduplicates and ranks by relevance.
|
||||||
|
|
||||||
|
## 🔬 How It Works
|
||||||
|
|
||||||
|
1. **Content Creation/Update**:
|
||||||
|
- Text is extracted from `title`, `subject`, and `body` (HTML stripped)
|
||||||
|
- Sent to embedding model (Vertex AI)
|
||||||
|
- Returns 768-dimensional vector
|
||||||
|
- Stored in `learning_content.embedding` column
|
||||||
|
|
||||||
|
2. **Semantic Search**:
|
||||||
|
- Query text → embedding vector
|
||||||
|
- PostgreSQL pgvector computes cosine similarity
|
||||||
|
- Returns top N most similar documents
|
||||||
|
- Similarity score 0-1 (1 = identical, 0 = unrelated)
|
||||||
|
|
||||||
|
3. **Hybrid Search**:
|
||||||
|
- Runs both keyword + semantic searches in parallel
|
||||||
|
- Merges results (semantic first for quality)
|
||||||
|
- Deduplicates by content ID
|
||||||
|
- Sorts by relevance score
|
||||||
|
|
||||||
|
## 💰 Cost Estimate (Vertex AI)
|
||||||
|
|
||||||
|
**Titan Text Embeddings (AWS) pricing:**
|
||||||
|
- ~$0.10 per 1M tokens
|
||||||
|
- Average article: 2,000 words (~2,700 tokens) = $0.00027
|
||||||
|
- 1,000 articles: ~**$0.27 one-time**
|
||||||
|
- Search queries: ~500 tokens = $0.00005 per query
|
||||||
|
|
||||||
|
**Google Vertex AI pricing:**
|
||||||
|
- text-embedding-005: $0.025 per 1M characters
|
||||||
|
- Average article: 10,000 chars = $0.00025
|
||||||
|
- 1,000 articles: ~**$0.25 one-time**
|
||||||
|
- Search queries: ~$0.0000125 per query
|
||||||
|
|
||||||
|
## 🐛 Troubleshooting
|
||||||
|
|
||||||
|
### "pgvector extension not available"
|
||||||
|
- Install: `apt-get install postgresql-16-pgvector`
|
||||||
|
- For Docker: Use `pgvector/pgvector:pg16` image
|
||||||
|
|
||||||
|
### "Embeddings not configured"
|
||||||
|
- Verify `.env` has `VERTEX_PROJECT` or `LITELLM_API_BASE` or `OPENAI_API_KEY`
|
||||||
|
- Check service account credentials: `GOOGLE_APPLICATION_CREDENTIALS`
|
||||||
|
- Test: `curl http://localhost:3000/api/admin/learning/embeddings/status`
|
||||||
|
|
||||||
|
### "Embedding generation failed"
|
||||||
|
- Check logs for API errors
|
||||||
|
- Verify Vertex AI API is enabled in GCP
|
||||||
|
- Verify service account has `aiplatform.endpoints.predict` permission
|
||||||
|
- Check content isn't empty (skips empty bodies)
|
||||||
|
|
||||||
|
### "No results from semantic search"
|
||||||
|
- Check if embeddings exist: `/api/admin/learning/embeddings/status`
|
||||||
|
- Lower threshold: `?threshold=0.3` (default 0.5)
|
||||||
|
- Verify pgvector index exists: `\di` in psql
|
||||||
|
|
||||||
|
## 📊 Performance
|
||||||
|
|
||||||
|
- **Embedding generation**: ~500ms per article (Vertex AI)
|
||||||
|
- **Search latency**:
|
||||||
|
- Keyword: 10-50ms
|
||||||
|
- Semantic: 20-100ms (with IVFFLAT index)
|
||||||
|
- Hybrid: 30-150ms
|
||||||
|
- **Index build time**: ~1-5 seconds per 1,000 articles
|
||||||
|
|
||||||
|
## 🔐 Security & Compliance
|
||||||
|
|
||||||
|
- **HIPAA-eligible**: Vertex AI supports BAA (Business Associate Agreement)
|
||||||
|
- **Data retention**: Embeddings stored in your database only
|
||||||
|
- **No PHI**: Only article content (not patient data) is embedded
|
||||||
|
- **Encryption**: TLS in transit, at-rest encryption via PostgreSQL
|
||||||
|
|
||||||
|
## 🎓 Example Queries
|
||||||
|
|
||||||
|
**Before (keyword):**
|
||||||
|
```
|
||||||
|
Query: "fever in babies"
|
||||||
|
Results: Only articles with exact words "fever" or "babies"
|
||||||
|
```
|
||||||
|
|
||||||
|
**After (semantic):**
|
||||||
|
```
|
||||||
|
Query: "fever in babies"
|
||||||
|
Results:
|
||||||
|
- Infant hyperthermia management (similarity: 0.89)
|
||||||
|
- Pediatric fever evaluation (similarity: 0.87)
|
||||||
|
- Febrile seizures in toddlers (similarity: 0.82)
|
||||||
|
- Neonatal temperature regulation (similarity: 0.78)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Hybrid (best):**
|
||||||
|
```
|
||||||
|
Query: "asthma"
|
||||||
|
Results:
|
||||||
|
- Childhood asthma management (keyword + semantic: 1.0)
|
||||||
|
- Pediatric breathing difficulties (semantic: 0.91)
|
||||||
|
- Reactive airway disease (semantic: 0.86)
|
||||||
|
- Bronchiolitis vs asthma (keyword: 1.0)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📚 API Reference
|
||||||
|
|
||||||
|
### Admin Endpoints
|
||||||
|
|
||||||
|
- `POST /api/admin/learning/embeddings/generate` - Backfill embeddings
|
||||||
|
- `GET /api/admin/learning/embeddings/status` - Check status
|
||||||
|
- `GET /api/admin/learning/stats` - Includes embedding count
|
||||||
|
|
||||||
|
### User Endpoints
|
||||||
|
|
||||||
|
- `GET /api/learning/search` - Keyword search
|
||||||
|
- `GET /api/learning/search/semantic` - Semantic search
|
||||||
|
- `GET /api/learning/search/hybrid` - Hybrid search (recommended)
|
||||||
|
|
||||||
|
All endpoints require authentication (JWT token).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Questions?** Check logs for detailed error messages, or review the code in:
|
||||||
|
- `/src/utils/embeddings.js` - Core embedding logic
|
||||||
|
- `/src/routes/learningHub.js` - Search endpoints
|
||||||
|
- `/src/routes/learningAdmin.js` - Admin management
|
||||||
347
FEATURES_EXPLAINED.md
Normal file
|
|
@ -0,0 +1,347 @@
|
||||||
|
# Features Explained - Pediatric AI Scribe v14
|
||||||
|
|
||||||
|
## 🎙️ **Audio Backups**
|
||||||
|
|
||||||
|
### How It Works:
|
||||||
|
Audio backups happen **automatically every time you record**, regardless of transcription success/failure.
|
||||||
|
|
||||||
|
**Flow:**
|
||||||
|
1. You press "Stop" on recording
|
||||||
|
2. Audio is immediately saved **before** transcription starts
|
||||||
|
3. Server-side backup (PostgreSQL, gzip compressed) attempted first
|
||||||
|
4. If server fails → fallback to browser IndexedDB
|
||||||
|
5. After successful transcription → audio backup is deleted
|
||||||
|
6. If transcription fails → audio backup remains for retry
|
||||||
|
|
||||||
|
**Location:**
|
||||||
|
- Server: PostgreSQL `audio_backups` table (auto-deleted after 24 hours)
|
||||||
|
- Browser: IndexedDB `PedScribeAudioBackup` database (manual cleanup)
|
||||||
|
|
||||||
|
**Purpose:**
|
||||||
|
- Retry transcription if it fails
|
||||||
|
- Recover audio if browser crashes
|
||||||
|
- Audit trail (24 hour retention)
|
||||||
|
|
||||||
|
**Access:**
|
||||||
|
Settings → Audio Backups section shows:
|
||||||
|
- Date/time of recording
|
||||||
|
- Module (encounter, dictation, etc.)
|
||||||
|
- File size
|
||||||
|
- "Retry Transcription" button (if transcription failed)
|
||||||
|
- "Delete" button
|
||||||
|
|
||||||
|
**Cost:**
|
||||||
|
Server backups are compressed (gzip) to ~1/10 original size. A 2MB recording becomes ~200KB in database.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🌐 **S3 Document Storage**
|
||||||
|
|
||||||
|
### How It Works:
|
||||||
|
Upload documents (PDFs, images, Word docs, text files) to S3-compatible storage.
|
||||||
|
|
||||||
|
**Supported Providers:**
|
||||||
|
- AWS S3 (default)
|
||||||
|
- Backblaze B2
|
||||||
|
- MinIO (self-hosted)
|
||||||
|
- Any S3-compatible service
|
||||||
|
|
||||||
|
**Configuration (.env):**
|
||||||
|
```bash
|
||||||
|
# AWS S3 (uses Bedrock credentials if available)
|
||||||
|
S3_BUCKET=your-bucket-name
|
||||||
|
S3_REGION=us-east-1
|
||||||
|
S3_PREFIX=documents/ # Optional: folder prefix
|
||||||
|
|
||||||
|
# Backblaze B2
|
||||||
|
S3_BUCKET=your-bucket-name
|
||||||
|
S3_ENDPOINT=https://s3.us-west-004.backblazeb2.com
|
||||||
|
S3_REGION=us-west-004
|
||||||
|
S3_ACCESS_KEY_ID=your-b2-application-key-id
|
||||||
|
S3_SECRET_ACCESS_KEY=your-b2-application-key
|
||||||
|
|
||||||
|
# MinIO (self-hosted)
|
||||||
|
S3_BUCKET=your-bucket
|
||||||
|
S3_ENDPOINT=http://minio:9000
|
||||||
|
S3_REGION=us-east-1
|
||||||
|
S3_ACCESS_KEY_ID=minio-access-key
|
||||||
|
S3_SECRET_ACCESS_KEY=minio-secret-key
|
||||||
|
S3_FORCE_PATH_STYLE=true # Required for MinIO
|
||||||
|
```
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
- ✅ 10 MB file size limit
|
||||||
|
- ✅ AES-256 server-side encryption
|
||||||
|
- ✅ Per-user folder organization (`documents/{userId}/{uuid}/filename`)
|
||||||
|
- ✅ Metadata stored in PostgreSQL (filename, mime type, size, description)
|
||||||
|
- ✅ Presigned URLs for secure access (1 hour expiry)
|
||||||
|
|
||||||
|
**Allowed File Types:**
|
||||||
|
- PDF (`.pdf`)
|
||||||
|
- Images (`.jpg`, `.jpeg`, `.png`, `.gif`)
|
||||||
|
- Word documents (`.doc`, `.docx`)
|
||||||
|
- Text files (`.txt`, `.csv`)
|
||||||
|
|
||||||
|
**Access:**
|
||||||
|
Settings → Documents section
|
||||||
|
|
||||||
|
**Status Check:**
|
||||||
|
If S3 is not configured, the Documents section shows empty with message: "S3 not configured"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 **Learning Hub - Default Browse Path**
|
||||||
|
|
||||||
|
### What It Is:
|
||||||
|
A user preference that sets the **starting folder** when browsing Nextcloud files for AI content generation.
|
||||||
|
|
||||||
|
### When It's Used:
|
||||||
|
Only in the **Learning Hub AI Content Generator** (Admin/Moderator feature).
|
||||||
|
|
||||||
|
**Scenario:**
|
||||||
|
1. Admin/Moderator wants to create AI-generated learning content
|
||||||
|
2. They choose "Upload from Nextcloud"
|
||||||
|
3. File browser opens
|
||||||
|
4. Instead of starting at root `/`, it opens at the configured path
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```
|
||||||
|
Default path: /Medical-Resources
|
||||||
|
↓
|
||||||
|
When you click "Browse Nextcloud", it opens:
|
||||||
|
/Medical-Resources/
|
||||||
|
├── Pediatric-Guidelines/
|
||||||
|
├── Clinical-Protocols/
|
||||||
|
└── Research-Papers/
|
||||||
|
|
||||||
|
Instead of:
|
||||||
|
/
|
||||||
|
├── Personal/
|
||||||
|
├── Photos/
|
||||||
|
├── Medical-Resources/ ← you'd have to navigate here every time
|
||||||
|
└── ...
|
||||||
|
```
|
||||||
|
|
||||||
|
**Configuration:**
|
||||||
|
Settings → Nextcloud Integration → "Learning Hub — Default Browse Path"
|
||||||
|
|
||||||
|
**Examples:**
|
||||||
|
- `/Medical-Resources` - Opens in Medical Resources folder
|
||||||
|
- `/Shared/Clinical-Content` - Opens in shared clinical content
|
||||||
|
- `/` (empty) - Opens at root (default behavior)
|
||||||
|
|
||||||
|
**Who Can Use This:**
|
||||||
|
- Any authenticated user (not just moderators)
|
||||||
|
- It's a personal preference per user
|
||||||
|
- Only affects Learning Hub AI file picker
|
||||||
|
|
||||||
|
**Why This Exists:**
|
||||||
|
If you store learning resources in a specific Nextcloud folder, you don't want to navigate there every single time you generate content. Set it once, it remembers.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎤 **Browser Whisper Pre-Download**
|
||||||
|
|
||||||
|
### Issue You Reported:
|
||||||
|
"Pre-download models works, stuck at starting download"
|
||||||
|
|
||||||
|
### What's Happening:
|
||||||
|
The download **is actually working** but progress updates are slow because:
|
||||||
|
1. HuggingFace CDN serves large files (39-244 MB)
|
||||||
|
2. Progress callbacks are not granular (reported per-file, not per-chunk)
|
||||||
|
3. Initial ONNX runtime download has no progress tracking
|
||||||
|
|
||||||
|
### Fixed:
|
||||||
|
- ✅ Added console logging to track progress
|
||||||
|
- ✅ Added 30-second timeout warning (doesn't stop download)
|
||||||
|
- ✅ Better error messages
|
||||||
|
|
||||||
|
### How to Test:
|
||||||
|
1. Open browser DevTools (F12) → Console tab
|
||||||
|
2. Click "Pre-download model"
|
||||||
|
3. Watch console for progress logs:
|
||||||
|
```
|
||||||
|
[BrowserWhisper] Starting preload...
|
||||||
|
[BrowserWhisper] Progress: onnx-runtime 0%
|
||||||
|
[BrowserWhisper] Progress: model.bin 23%
|
||||||
|
[BrowserWhisper] Progress: model.bin 47%
|
||||||
|
...
|
||||||
|
[BrowserWhisper] Progress: 100%
|
||||||
|
```
|
||||||
|
|
||||||
|
### Expected Download Times:
|
||||||
|
- **Tiny** (39 MB): 5-15 seconds (fast connection)
|
||||||
|
- **Base** (74 MB): 10-30 seconds
|
||||||
|
- **Small** (244 MB): 30-90 seconds
|
||||||
|
|
||||||
|
### If Still Stuck:
|
||||||
|
**Check these:**
|
||||||
|
1. Open DevTools → Network tab
|
||||||
|
2. Filter by "HuggingFace"
|
||||||
|
3. Look for downloads from `cdn-lfs-us-1.huggingface.co`
|
||||||
|
4. Check if files are actually downloading
|
||||||
|
|
||||||
|
**Common issues:**
|
||||||
|
- Slow internet connection (244 MB takes time!)
|
||||||
|
- Corporate firewall blocking HuggingFace CDN
|
||||||
|
- Browser IndexedDB quota exceeded
|
||||||
|
|
||||||
|
**Workaround:**
|
||||||
|
Just enable it and record audio - the model will download on first use (same as pre-download, but triggered automatically).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔊 **TTS Voice Preview**
|
||||||
|
|
||||||
|
### Issue You Reported:
|
||||||
|
"Preview button next to TTS seems to do nothing"
|
||||||
|
|
||||||
|
### Fixed:
|
||||||
|
- ✅ Added error logging to console
|
||||||
|
- ✅ Better validation (checks for empty selection)
|
||||||
|
- ✅ Clear user feedback messages
|
||||||
|
|
||||||
|
### How to Use:
|
||||||
|
1. Go to Settings → Voice Preferences
|
||||||
|
2. Select a voice from "Text-to-Speech Voice" dropdown
|
||||||
|
3. Click "Preview" button
|
||||||
|
4. Wait 2-3 seconds
|
||||||
|
5. Audio should play automatically
|
||||||
|
|
||||||
|
### If Nothing Happens:
|
||||||
|
**Check browser console for errors:**
|
||||||
|
- Open DevTools (F12) → Console tab
|
||||||
|
- Click Preview
|
||||||
|
- Look for `[VoicePrefs] Preview error:` message
|
||||||
|
|
||||||
|
**Common issues:**
|
||||||
|
1. **No voice selected** → Select from dropdown first
|
||||||
|
2. **TTS not configured** → Check `.env` has `GOOGLE_VERTEX_PROJECT` or `LITELLM_API_BASE`
|
||||||
|
3. **Network error** → Check server logs for TTS API errors
|
||||||
|
4. **Browser autoplay policy** → Some browsers block autoplay, click page first
|
||||||
|
|
||||||
|
### Testing Checklist:
|
||||||
|
```bash
|
||||||
|
# 1. Check TTS is configured
|
||||||
|
curl http://localhost:3000/api/health | grep tts
|
||||||
|
|
||||||
|
# 2. Test TTS endpoint directly
|
||||||
|
curl -X POST http://localhost:3000/api/text-to-speech \
|
||||||
|
-H "Authorization: Bearer YOUR_JWT" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"text":"Test"}' \
|
||||||
|
--output test.mp3
|
||||||
|
|
||||||
|
# 3. Play the audio file
|
||||||
|
mpg123 test.mp3 # or open in browser
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 **Summary of User Settings**
|
||||||
|
|
||||||
|
### Voice Preferences
|
||||||
|
**Location:** Settings → Voice Preferences (top section)
|
||||||
|
|
||||||
|
| Setting | Options | Default | Purpose |
|
||||||
|
|---------|---------|---------|---------|
|
||||||
|
| **STT Model** | gemini-2.0-flash-exp, gemini-2.0-flash, gemini-1.5-flash, gemini-1.5-pro, whisper-1 | Server default | Controls transcription accuracy |
|
||||||
|
| **TTS Voice** | Journey-F/D, Studio-O/M, Neural2 series, alloy, echo, fable, onyx, nova, shimmer | Server default | Controls read-aloud voice |
|
||||||
|
|
||||||
|
### Browser Whisper
|
||||||
|
**Location:** Settings → Browser Transcription (Local Whisper)
|
||||||
|
|
||||||
|
| Setting | Options | Default | Purpose |
|
||||||
|
|---------|---------|---------|---------|
|
||||||
|
| **Enable** | On/Off | Off | Local transcription (HIPAA-safe) |
|
||||||
|
| **Model** | Tiny, Base, Small | Tiny | Accuracy vs speed tradeoff |
|
||||||
|
|
||||||
|
### Nextcloud
|
||||||
|
**Location:** Settings → Nextcloud Integration
|
||||||
|
|
||||||
|
| Setting | Purpose |
|
||||||
|
|---------|---------|
|
||||||
|
| **Nextcloud URL** | Your Nextcloud instance |
|
||||||
|
| **Username** | Nextcloud username |
|
||||||
|
| **App Password** | Generate in Nextcloud → Security |
|
||||||
|
| **Default Browse Path** | Starting folder for Learning Hub AI picker |
|
||||||
|
|
||||||
|
### Documents (S3)
|
||||||
|
**Location:** Settings → Documents
|
||||||
|
|
||||||
|
Shows list of uploaded documents if S3 is configured. Upload limit: 10 MB per file.
|
||||||
|
|
||||||
|
### Audio Backups
|
||||||
|
**Location:** Settings → Audio Backups
|
||||||
|
|
||||||
|
Shows last 24 hours of recordings. Can retry transcription or delete.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 **Troubleshooting Guide**
|
||||||
|
|
||||||
|
### Pre-Download Stuck
|
||||||
|
1. ✅ Open browser console (F12)
|
||||||
|
2. ✅ Look for `[BrowserWhisper] Progress:` logs
|
||||||
|
3. ✅ Check Network tab for HuggingFace downloads
|
||||||
|
4. ✅ Wait - 244 MB takes time!
|
||||||
|
5. ✅ If truly stuck (no network activity): refresh page, try again
|
||||||
|
|
||||||
|
### Preview Button Silent
|
||||||
|
1. ✅ Check voice is selected in dropdown
|
||||||
|
2. ✅ Open console for error messages
|
||||||
|
3. ✅ Test TTS endpoint directly (curl command above)
|
||||||
|
4. ✅ Check server logs for TTS provider errors
|
||||||
|
5. ✅ Verify `.env` has TTS provider configured
|
||||||
|
|
||||||
|
### S3 Not Working
|
||||||
|
1. ✅ Check `.env` has `S3_BUCKET` set
|
||||||
|
2. ✅ Verify credentials: `S3_ACCESS_KEY_ID` + `S3_SECRET_ACCESS_KEY`
|
||||||
|
3. ✅ Test bucket access from server:
|
||||||
|
```bash
|
||||||
|
aws s3 ls s3://your-bucket/ --region us-east-1
|
||||||
|
```
|
||||||
|
4. ✅ Check server logs for S3 errors when uploading
|
||||||
|
|
||||||
|
### Audio Backups Not Showing
|
||||||
|
1. ✅ Record audio first (they're created on recording, not transcription)
|
||||||
|
2. ✅ Check database: `SELECT COUNT(*) FROM audio_backups;`
|
||||||
|
3. ✅ Verify IndexedDB in browser: DevTools → Application → IndexedDB → `PedScribeAudioBackup`
|
||||||
|
4. ✅ Backups auto-delete after 24 hours
|
||||||
|
|
||||||
|
### Learning Hub Path Not Working
|
||||||
|
1. ✅ This only affects **AI content generator file picker**
|
||||||
|
2. ✅ It does NOT affect manual Nextcloud document browsing
|
||||||
|
3. ✅ Path must exist in your Nextcloud
|
||||||
|
4. ✅ Path format: `/Folder/Subfolder` (starts with `/`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 **Feature Status Matrix**
|
||||||
|
|
||||||
|
| Feature | Status | Config Required | HIPAA-Safe | Notes |
|
||||||
|
|---------|--------|-----------------|------------|-------|
|
||||||
|
| **Audio Backups** | ✅ Working | None (auto) | ✅ Yes | Server + IndexedDB |
|
||||||
|
| **S3 Documents** | ✅ Working | S3_BUCKET | ✅ Yes (AWS) | Optional feature |
|
||||||
|
| **Browser Whisper** | ✅ Working | None (optional) | ✅ Yes | Client-side only |
|
||||||
|
| **Voice Preferences** | ✅ Working | Provider config | Depends | Google/AWS = yes |
|
||||||
|
| **Learning Hub Path** | ✅ Working | Nextcloud config | ✅ Yes | User preference |
|
||||||
|
| **TTS Preview** | ✅ Fixed | TTS provider | Depends | Check logs if fails |
|
||||||
|
| **Embeddings** | ✅ Working | Vertex/LiteLLM | ✅ Yes | Requires pgvector |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 **Next Steps**
|
||||||
|
|
||||||
|
1. **Push v14 to Docker** (in progress via GitHub Actions)
|
||||||
|
2. **Test features after deployment**
|
||||||
|
3. **Check browser console for any errors**
|
||||||
|
4. **Verify TTS preview works with your provider**
|
||||||
|
5. **Test browser whisper download with different models**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Questions? Check the logs:**
|
||||||
|
- Browser: F12 → Console tab
|
||||||
|
- Server: `docker logs pediatric-ai-scribe -f`
|
||||||
|
- Database: `psql -d pedscribe -c "SELECT COUNT(*) FROM audio_backups;"`
|
||||||
188
IMPROVEMENTS.md
Normal file
|
|
@ -0,0 +1,188 @@
|
||||||
|
# Pediatric AI Scribe — Improvement Roadmap
|
||||||
|
|
||||||
|
A non-technical overview of what the app does today and how it can be taken further.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What the App Does Today
|
||||||
|
|
||||||
|
Pediatric AI Scribe is a clinical documentation tool for pediatric physicians. It listens to doctor-patient encounters (or accepts typed/pasted notes) and uses AI to generate structured medical notes — HPIs, SOAP notes, hospital courses, chart reviews, well visit and sick visit documentation.
|
||||||
|
|
||||||
|
It also includes pediatric calculators (blood pressure percentiles, BMI, growth charts, bilirubin nomograms, vital signs reference), a Learning Hub for educational content and quizzes, and a full security layer (two-factor authentication, session management, audit logging, single sign-on).
|
||||||
|
|
||||||
|
The app runs as a self-hosted web application with a mobile-friendly PWA interface.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Areas for Improvement
|
||||||
|
|
||||||
|
### 1. Visual Growth Charts
|
||||||
|
|
||||||
|
**Current state:** Growth percentiles are displayed as numbers (e.g., "75th percentile, Z-score 0.67").
|
||||||
|
|
||||||
|
**Improvement:** Plot actual WHO/CDC percentile curves (the familiar growth chart lines pediatricians use) with the patient's data point shown on the chart. This would make results immediately interpretable at a glance, matching the paper charts physicians are trained on. Support for plotting multiple visits over time would make it even more useful for tracking growth trends.
|
||||||
|
|
||||||
|
### 2. Blood Pressure Calculator Accuracy
|
||||||
|
|
||||||
|
**Current state:** The BP calculator uses simplified reference values at the 50th height percentile only.
|
||||||
|
|
||||||
|
**Improvement:** Implement the full Rosner quantile spline regression method (the same math used by the Baylor College of Medicine reference calculator). This would give exact BP percentiles adjusted for the patient's actual height, not just an approximation. The regression coefficients are publicly available and can be integrated directly.
|
||||||
|
|
||||||
|
### 3. Multi-Visit Tracking
|
||||||
|
|
||||||
|
**Current state:** Each encounter is independent. There is no way to see a patient's history across visits.
|
||||||
|
|
||||||
|
**Improvement:** Allow physicians to associate notes with a patient identifier (MRN, initials, or a pseudonym) and view previous encounters for that patient. This would enable:
|
||||||
|
- Growth tracking over time (plot multiple points on growth curves)
|
||||||
|
- Trend monitoring (weight gain/loss, blood pressure trends)
|
||||||
|
- Quick access to past notes during follow-up visits
|
||||||
|
|
||||||
|
This would need careful design around data retention and privacy since it changes the app from a transient tool to one that stores longitudinal data.
|
||||||
|
|
||||||
|
### 4. EHR Integration
|
||||||
|
|
||||||
|
**Current state:** Notes are copied manually and pasted into the EHR.
|
||||||
|
|
||||||
|
**Improvement:** Direct integration with common EHR systems:
|
||||||
|
- **FHIR API** — connect to Epic, Cerner, or other FHIR-enabled EHRs to push notes directly into the patient chart
|
||||||
|
- **HL7 messaging** — for institutions using traditional interfaces
|
||||||
|
- **Smart on FHIR** — launch the app from within the EHR as an embedded tool
|
||||||
|
|
||||||
|
This is the highest-impact improvement for adoption but also the most complex to implement (requires EHR vendor partnerships and institutional approval).
|
||||||
|
|
||||||
|
### 5. Offline Mode
|
||||||
|
|
||||||
|
**Current state:** The app requires an internet connection for AI generation and cloud-based transcription. Browser Whisper works offline for transcription only.
|
||||||
|
|
||||||
|
**Improvement:** Add a local AI model option (e.g., a small medical LLM running on the device or local server) so the entire workflow — record, transcribe, generate note — can happen without any network calls. This would be valuable for:
|
||||||
|
- Rural clinics with unreliable internet
|
||||||
|
- Maximum privacy (no data leaves the building)
|
||||||
|
- Disaster/field medicine scenarios
|
||||||
|
|
||||||
|
### 6. Specialty Expansion
|
||||||
|
|
||||||
|
**Current state:** Focused on general pediatrics with some subspecialty support in chart review.
|
||||||
|
|
||||||
|
**Improvement:** Add specialty-specific note templates and AI prompts for:
|
||||||
|
- Pediatric cardiology (echo reports, cath summaries)
|
||||||
|
- Pediatric neurology (EEG reports, seizure logs)
|
||||||
|
- Neonatology (daily progress notes, discharge summaries)
|
||||||
|
- Pediatric surgery (operative notes, pre-op assessments)
|
||||||
|
- Pediatric psychiatry (intake assessments, progress notes)
|
||||||
|
|
||||||
|
Each specialty has unique documentation requirements that could be addressed with tailored prompts and input forms.
|
||||||
|
|
||||||
|
### 7. Billing Code Suggestions
|
||||||
|
|
||||||
|
**Current state:** The well visit tab includes some billing code references.
|
||||||
|
|
||||||
|
**Improvement:** Automatically suggest ICD-10 and CPT codes based on the generated note content. After the AI generates a note, it could analyze the diagnoses, procedures, and visit complexity to suggest appropriate billing codes. This saves time on coding and reduces missed charges.
|
||||||
|
|
||||||
|
### 8. Quality Metrics Dashboard
|
||||||
|
|
||||||
|
**Current state:** Admin panel shows basic usage statistics (total API calls, users).
|
||||||
|
|
||||||
|
**Improvement:** Add a dashboard showing:
|
||||||
|
- Average note generation time by type
|
||||||
|
- Most-used AI models and their accuracy (based on how often users edit the output)
|
||||||
|
- Transcription accuracy metrics (if corrections are tracked)
|
||||||
|
- Usage patterns by time of day and day of week
|
||||||
|
- Cost tracking across AI providers
|
||||||
|
|
||||||
|
This would help administrators optimize model selection and identify training opportunities.
|
||||||
|
|
||||||
|
### 9. Patient Education Materials
|
||||||
|
|
||||||
|
**Current state:** The Learning Hub serves educational content to physicians.
|
||||||
|
|
||||||
|
**Improvement:** Add a patient-facing education module that generates age-appropriate handouts based on the diagnosis. For example, after generating a note for a child with asthma, the app could produce a parent-friendly handout explaining the diagnosis, medications, and when to seek emergency care — in the parent's preferred language.
|
||||||
|
|
||||||
|
### 10. Multi-Language Support
|
||||||
|
|
||||||
|
**Current state:** English only.
|
||||||
|
|
||||||
|
**Improvement:** Add support for:
|
||||||
|
- Generating notes in other languages (Spanish, French, Arabic, etc.)
|
||||||
|
- Transcribing encounters conducted in other languages
|
||||||
|
- Patient education materials in the family's language
|
||||||
|
- UI translation for non-English-speaking staff
|
||||||
|
|
||||||
|
Medical Spanish alone would significantly expand the app's reach in the United States.
|
||||||
|
|
||||||
|
### 11. Voice Commands During Recording
|
||||||
|
|
||||||
|
**Current state:** Recording is continuous — the physician presses start and stop.
|
||||||
|
|
||||||
|
**Improvement:** Add voice command recognition during recording:
|
||||||
|
- "New section" — marks a section break in the transcript
|
||||||
|
- "Off the record" — pauses transcription temporarily (for sidebar conversations)
|
||||||
|
- "Add diagnosis: [condition]" — tags a diagnosis without typing
|
||||||
|
- "Skip" — ignores the last segment
|
||||||
|
|
||||||
|
This would make the recording workflow more natural and reduce post-generation editing.
|
||||||
|
|
||||||
|
### 12. Collaborative Notes
|
||||||
|
|
||||||
|
**Current state:** Single-user editing. Notes are created and edited by one physician.
|
||||||
|
|
||||||
|
**Improvement:** Allow multiple team members to work on the same encounter:
|
||||||
|
- Attending reviews and co-signs a resident's note
|
||||||
|
- Nurse adds vital signs and chief complaint before the physician sees the patient
|
||||||
|
- Specialist adds their consultation note to the same encounter
|
||||||
|
|
||||||
|
This mirrors the real workflow in training institutions and group practices.
|
||||||
|
|
||||||
|
### 13. Mobile-Optimized Recording
|
||||||
|
|
||||||
|
**Current state:** Recording works on mobile but stops when the screen locks or the app is backgrounded (browser limitation).
|
||||||
|
|
||||||
|
**Improvement:** Build a native mobile wrapper (using Capacitor or React Native) that can record audio in the background even when the screen is off. This is the single biggest usability improvement for mobile users and removes the most common complaint.
|
||||||
|
|
||||||
|
### 14. Template Library
|
||||||
|
|
||||||
|
**Current state:** Physician memories and corrections provide some personalization.
|
||||||
|
|
||||||
|
**Improvement:** Add a shared template library where physicians can create, share, and browse note templates:
|
||||||
|
- "My asthma follow-up template"
|
||||||
|
- "Standard newborn discharge summary"
|
||||||
|
- "ED laceration repair template"
|
||||||
|
- Import/export templates between institutions
|
||||||
|
|
||||||
|
### 15. Audit and Compliance Reporting
|
||||||
|
|
||||||
|
**Current state:** Audit logs exist in the database but there is no reporting UI.
|
||||||
|
|
||||||
|
**Improvement:** Add an admin-facing compliance dashboard:
|
||||||
|
- Who accessed what, when (filterable by user, date, action)
|
||||||
|
- Export audit logs to CSV/PDF for compliance reviews
|
||||||
|
- Automated alerts for unusual access patterns
|
||||||
|
- HIPAA compliance checklist with green/red status indicators
|
||||||
|
- BAA tracking (which providers have signed BAAs)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Priority Recommendations
|
||||||
|
|
||||||
|
If resources are limited, focus on these high-impact improvements first:
|
||||||
|
|
||||||
|
| Priority | Improvement | Impact | Effort |
|
||||||
|
|----------|-------------|--------|--------|
|
||||||
|
| 1 | Visual growth charts | High — physicians expect visual curves | Medium |
|
||||||
|
| 2 | Accurate BP calculator | High — clinical accuracy matters | Medium |
|
||||||
|
| 3 | Billing code suggestions | High — direct revenue impact | Medium |
|
||||||
|
| 4 | Multi-language support | High — expands reach significantly | Large |
|
||||||
|
| 5 | Audit/compliance reporting | Medium — required for institutional adoption | Small |
|
||||||
|
| 6 | EHR integration (FHIR) | Very high — but requires partnerships | Very large |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What Makes This App Unique
|
||||||
|
|
||||||
|
Compared to existing medical scribes and documentation tools:
|
||||||
|
|
||||||
|
- **Pediatric-specific** — prompts, calculators, milestones, and growth charts designed for children, not adapted from adult tools
|
||||||
|
- **Self-hosted** — runs on your own infrastructure, not a SaaS that holds your data
|
||||||
|
- **Provider-agnostic** — works with any AI provider (swap between them without changing anything)
|
||||||
|
- **Privacy-first** — optional fully offline transcription, auto-expiring data, no permanent PHI storage
|
||||||
|
- **Learning system** — AI improves its output based on each physician's editing patterns
|
||||||
|
- **All-in-one** — documentation, calculators, education, and administration in a single platform
|
||||||
346
OPENID_SETUP.md
Normal file
|
|
@ -0,0 +1,346 @@
|
||||||
|
# OpenID Connect (OIDC) / PocketID Setup Guide
|
||||||
|
|
||||||
|
This guide explains how to configure Single Sign-On (SSO) authentication using OpenID Connect providers like PocketID, Keycloak, Azure AD, Okta, or Google.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The application supports OIDC authentication alongside traditional email/password login. Once configured, users can:
|
||||||
|
|
||||||
|
- Sign in with their SSO provider (e.g., PocketID)
|
||||||
|
- Automatically link existing email accounts to their SSO identity
|
||||||
|
- Admins can optionally disable local password login entirely
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
1. An OpenID Connect provider (e.g., PocketID instance)
|
||||||
|
2. Admin access to this application
|
||||||
|
3. The public URL where your app is deployed (`APP_URL` in `.env`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration Steps
|
||||||
|
|
||||||
|
### 1. Configure Your Identity Provider
|
||||||
|
|
||||||
|
First, register this application with your OIDC provider. You'll need:
|
||||||
|
|
||||||
|
**Redirect URI / Callback URL:**
|
||||||
|
```
|
||||||
|
https://your-domain.com/api/auth/oidc/callback
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace `your-domain.com` with your actual `APP_URL` value.
|
||||||
|
|
||||||
|
**Example: PocketID Setup**
|
||||||
|
|
||||||
|
1. Log into your PocketID admin panel
|
||||||
|
2. Navigate to **Applications** → **Add Application**
|
||||||
|
3. Set the callback URL: `https://your-domain.com/api/auth/oidc/callback`
|
||||||
|
4. Copy the Client ID and Client Secret
|
||||||
|
|
||||||
|
**Example: Keycloak Setup**
|
||||||
|
|
||||||
|
1. Create a new client in your Keycloak realm
|
||||||
|
2. Set **Access Type** to `confidential`
|
||||||
|
3. Add Valid Redirect URI: `https://your-domain.com/api/auth/oidc/callback`
|
||||||
|
4. Save and note the Client ID and Client Secret from the Credentials tab
|
||||||
|
|
||||||
|
### 2. Enable OIDC in Application Settings
|
||||||
|
|
||||||
|
Log into your application as an **admin** user, then:
|
||||||
|
|
||||||
|
1. Navigate to **Admin Panel** → **Settings** (or access `/admin-settings.html`)
|
||||||
|
2. Look for the **OpenID Connect (SSO)** section
|
||||||
|
3. Fill in the following fields:
|
||||||
|
|
||||||
|
| Field | Description | Example |
|
||||||
|
|-------|-------------|---------|
|
||||||
|
| **Enabled** | Toggle to enable OIDC | `true` |
|
||||||
|
| **Issuer URL** | Your provider's discovery endpoint | `https://id.example.com` or `https://keycloak.example.com/realms/myrealm` |
|
||||||
|
| **Client ID** | Application client ID from your provider | `pediatric-scribe-client` |
|
||||||
|
| **Client Secret** | Application client secret (keep confidential) | `a1b2c3d4...` |
|
||||||
|
| **Button Label** | Text shown on the SSO login button | `Sign in with PocketID` |
|
||||||
|
| **Disable Local Auth** | Hide email/password login (optional) | `false` (keep disabled initially) |
|
||||||
|
| **Allowed IPs** | Restrict SSO to specific IP ranges (optional) | Leave blank for no restriction |
|
||||||
|
|
||||||
|
4. Click **Save Settings**
|
||||||
|
|
||||||
|
### 3. Test SSO Login
|
||||||
|
|
||||||
|
1. Log out or open an incognito browser window
|
||||||
|
2. Visit the login page
|
||||||
|
3. You should see a new button: **"Sign in with [Your Provider]"**
|
||||||
|
4. Click it and authenticate with your SSO provider
|
||||||
|
5. You'll be redirected back to the application and logged in
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Linking Existing Users to SSO
|
||||||
|
|
||||||
|
When a user signs in via OIDC for the first time, the system automatically links their account based on **email address matching**:
|
||||||
|
|
||||||
|
### Scenario 1: Existing User with Matching Email
|
||||||
|
|
||||||
|
If a user already has an account with email `doctor@example.com` and signs in via SSO with the same email:
|
||||||
|
|
||||||
|
1. The system finds the existing user by email
|
||||||
|
2. Links the SSO identity (`oidc_sub`) to the existing account
|
||||||
|
3. The user is logged in
|
||||||
|
4. Future logins can use either method (email/password OR SSO)
|
||||||
|
|
||||||
|
**Database update performed:**
|
||||||
|
```sql
|
||||||
|
UPDATE users
|
||||||
|
SET oidc_sub = '<provider-unique-id>',
|
||||||
|
email_verified = true
|
||||||
|
WHERE email = 'doctor@example.com';
|
||||||
|
```
|
||||||
|
|
||||||
|
### Scenario 2: New User (No Matching Email)
|
||||||
|
|
||||||
|
If the SSO email doesn't match any existing user:
|
||||||
|
|
||||||
|
1. A new account is automatically created
|
||||||
|
2. The user is assigned the `user` role (first user becomes `admin`)
|
||||||
|
3. A random password is generated (not used for SSO logins)
|
||||||
|
4. The user is logged in
|
||||||
|
|
||||||
|
### Scenario 3: Disabled User
|
||||||
|
|
||||||
|
If an existing user is disabled (`disabled = true` in database):
|
||||||
|
|
||||||
|
- SSO login is blocked
|
||||||
|
- User sees an error message
|
||||||
|
- Admin must re-enable the account from the Admin Panel
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Manual Account Linking (CLI)
|
||||||
|
|
||||||
|
If you need to manually link an existing user to an SSO identity, use the PostgreSQL database directly:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Connect to database
|
||||||
|
docker exec -it pediatric-ai-scribe-postgres psql -U pedscribe -d pedscribe
|
||||||
|
|
||||||
|
# Link user by setting their oidc_sub
|
||||||
|
UPDATE users
|
||||||
|
SET oidc_sub = 'provider-sub-12345',
|
||||||
|
email_verified = true
|
||||||
|
WHERE email = 'doctor@example.com';
|
||||||
|
```
|
||||||
|
|
||||||
|
**Finding the `oidc_sub` value:**
|
||||||
|
|
||||||
|
The `oidc_sub` is the unique identifier from your OIDC provider (usually a UUID or numeric ID). To find it:
|
||||||
|
|
||||||
|
1. Have the user attempt SSO login once
|
||||||
|
2. Check the application logs for their `sub` claim:
|
||||||
|
```
|
||||||
|
[OIDC] User logged in: sub=abc-123-def, email=doctor@example.com
|
||||||
|
```
|
||||||
|
3. Use that `sub` value in the UPDATE statement
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
### HTTPS Required in Production
|
||||||
|
|
||||||
|
OIDC requires HTTPS for security. Ensure your `APP_URL` uses `https://`:
|
||||||
|
|
||||||
|
```env
|
||||||
|
APP_URL=https://scribe.example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
### Client Secret Protection
|
||||||
|
|
||||||
|
The client secret is stored encrypted in the database. The admin UI masks it after saving (shows `••••••••1234`).
|
||||||
|
|
||||||
|
**Never commit the client secret to Git or share it publicly.**
|
||||||
|
|
||||||
|
### IP Allowlisting (Optional)
|
||||||
|
|
||||||
|
To restrict SSO to specific networks (e.g., hospital VPN):
|
||||||
|
|
||||||
|
1. Set **Allowed IPs** in admin settings to comma-separated CIDR ranges:
|
||||||
|
```
|
||||||
|
10.0.0.0/8, 192.168.1.0/24
|
||||||
|
```
|
||||||
|
2. Users outside these ranges will see an error when attempting SSO
|
||||||
|
|
||||||
|
### Disable Local Password Login
|
||||||
|
|
||||||
|
Once SSO is working, you can optionally disable traditional email/password login:
|
||||||
|
|
||||||
|
1. In Admin Settings, enable **Disable Local Auth**
|
||||||
|
2. The login page will only show the SSO button
|
||||||
|
3. Admins can still use the CLI to reset passwords if needed
|
||||||
|
|
||||||
|
**Warning:** Only disable local auth after confirming all users can access SSO. Keep one admin password as backup.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### "SSO is not enabled" error
|
||||||
|
|
||||||
|
- Verify **Enabled** is set to `true` in admin settings
|
||||||
|
- Check application logs for OIDC configuration errors
|
||||||
|
|
||||||
|
### "Invalid state" or "Expired" error
|
||||||
|
|
||||||
|
- The OIDC flow timed out (5 minute window)
|
||||||
|
- Try logging in again
|
||||||
|
- If persistent, check server time synchronization
|
||||||
|
|
||||||
|
### "No email claim" error
|
||||||
|
|
||||||
|
Your OIDC provider didn't return an email address. Ensure:
|
||||||
|
|
||||||
|
1. The `email` scope is requested (default: `openid email profile`)
|
||||||
|
2. Your provider is configured to release email claims
|
||||||
|
3. The user's account has an email address set
|
||||||
|
|
||||||
|
### Email Mismatch
|
||||||
|
|
||||||
|
If a user has different emails in the app vs. SSO provider:
|
||||||
|
|
||||||
|
**Option 1: Update app email to match SSO**
|
||||||
|
```sql
|
||||||
|
UPDATE users SET email = 'new-email@example.com' WHERE id = 123;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Option 2: Update SSO provider email to match app**
|
||||||
|
(Provider-specific — consult your IdP documentation)
|
||||||
|
|
||||||
|
### Callback URL Not Working
|
||||||
|
|
||||||
|
Double-check the redirect URI in your OIDC provider settings matches exactly:
|
||||||
|
|
||||||
|
```
|
||||||
|
https://your-domain.com/api/auth/oidc/callback
|
||||||
|
```
|
||||||
|
|
||||||
|
Common mistakes:
|
||||||
|
- Missing `https://`
|
||||||
|
- Trailing slash (don't include it)
|
||||||
|
- Wrong domain (must match `APP_URL` in `.env`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Provider-Specific Examples
|
||||||
|
|
||||||
|
### PocketID
|
||||||
|
|
||||||
|
```
|
||||||
|
Issuer URL: https://id.pockethost.io
|
||||||
|
Client ID: (from PocketID app settings)
|
||||||
|
Client Secret: (from PocketID app settings)
|
||||||
|
Redirect URI: https://your-domain.com/api/auth/oidc/callback
|
||||||
|
```
|
||||||
|
|
||||||
|
### Keycloak
|
||||||
|
|
||||||
|
```
|
||||||
|
Issuer URL: https://keycloak.example.com/realms/medical
|
||||||
|
Client ID: pediatric-scribe
|
||||||
|
Client Secret: (from Credentials tab)
|
||||||
|
Redirect URI: https://your-domain.com/api/auth/oidc/callback
|
||||||
|
```
|
||||||
|
|
||||||
|
### Azure AD / Entra ID
|
||||||
|
|
||||||
|
```
|
||||||
|
Issuer URL: https://login.microsoftonline.com/{tenant-id}/v2.0
|
||||||
|
Client ID: (Application ID from Azure)
|
||||||
|
Client Secret: (from Certificates & secrets)
|
||||||
|
Redirect URI: https://your-domain.com/api/auth/oidc/callback
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: Azure requires app registration in Azure Portal first.
|
||||||
|
|
||||||
|
### Okta
|
||||||
|
|
||||||
|
```
|
||||||
|
Issuer URL: https://{your-okta-domain}.okta.com
|
||||||
|
Client ID: (from Okta application settings)
|
||||||
|
Client Secret: (from Okta application settings)
|
||||||
|
Redirect URI: https://your-domain.com/api/auth/oidc/callback
|
||||||
|
```
|
||||||
|
|
||||||
|
### Google (Workspace or Gmail)
|
||||||
|
|
||||||
|
```
|
||||||
|
Issuer URL: https://accounts.google.com
|
||||||
|
Client ID: (from Google Cloud Console)
|
||||||
|
Client Secret: (from Google Cloud Console)
|
||||||
|
Redirect URI: https://your-domain.com/api/auth/oidc/callback
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: Google requires OAuth consent screen configuration.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Environment Variables (Alternative to UI Config)
|
||||||
|
|
||||||
|
For deployment automation, you can set OIDC config via environment variables instead of the admin UI:
|
||||||
|
|
||||||
|
```env
|
||||||
|
# .env file
|
||||||
|
OIDC_ENABLED=true
|
||||||
|
OIDC_ISSUER=https://id.example.com
|
||||||
|
OIDC_CLIENT_ID=my-client-id
|
||||||
|
OIDC_CLIENT_SECRET=my-client-secret
|
||||||
|
OIDC_BUTTON_LABEL=Sign in with PocketID
|
||||||
|
OIDC_DISABLE_LOCAL_AUTH=false
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:** UI settings take precedence over environment variables. If set in both places, the database values are used.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## HIPAA Compliance Notes
|
||||||
|
|
||||||
|
OIDC does not transmit PHI to the identity provider. Only authentication-related data (email, name) is exchanged.
|
||||||
|
|
||||||
|
For HIPAA compliance:
|
||||||
|
- Ensure your OIDC provider has appropriate safeguards
|
||||||
|
- Use a self-hosted provider (Keycloak, PocketID) within your secure network
|
||||||
|
- Or use a HIPAA-compliant SaaS provider with a BAA
|
||||||
|
- Enable audit logging for all SSO login events (automatically logged in `audit_log` table)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Audit Logging
|
||||||
|
|
||||||
|
All SSO login events are logged in the `audit_log` table:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT * FROM audit_log WHERE action = 'login_oidc' ORDER BY created_at DESC;
|
||||||
|
```
|
||||||
|
|
||||||
|
Logged fields:
|
||||||
|
- User ID
|
||||||
|
- Action: `login_oidc`
|
||||||
|
- IP address
|
||||||
|
- Details: Issuer URL
|
||||||
|
- Timestamp
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
For issues specific to:
|
||||||
|
- **This application**: Check application logs with `docker logs pediatric-ai-scribe`
|
||||||
|
- **Your OIDC provider**: Consult provider documentation (PocketID, Keycloak, Azure, etc.)
|
||||||
|
- **Network/TLS issues**: Verify `APP_URL` matches your reverse proxy configuration
|
||||||
|
|
||||||
|
Common log locations:
|
||||||
|
```bash
|
||||||
|
# Application logs
|
||||||
|
docker logs pediatric-ai-scribe
|
||||||
|
|
||||||
|
# PostgreSQL logs
|
||||||
|
docker logs pediatric-ai-scribe-postgres
|
||||||
|
```
|
||||||
319
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
|
## Features
|
||||||
|
|
||||||
- **Live Encounter → HPI** — record a live doctor-patient conversation, AI generates a structured OLDCARTS HPI
|
### Clinical Documentation
|
||||||
- **Voice Dictation → HPI / SOAP** — dictate your narrative, AI cleans and restructures it
|
- **Live Encounter** — record doctor-patient conversations, AI generates structured OLDCARTS HPI
|
||||||
- **Hospital Course Generator** — paste progress notes, AI generates prose, day-by-day, organ-system (ICU), or psych format summaries
|
- **Voice Dictation** — dictate narrative, AI cleans and restructures
|
||||||
- **Chart Review / Precharting** — summarize outpatient, subspecialty, and ED notes into a precharting brief
|
- **Hospital Course** — paste progress notes, generates prose, day-by-day, organ-system (ICU), or psych format
|
||||||
- **SOAP Note Generator** — full SOAP or subjective-only from dictation
|
- **Chart Review / Precharting** — summarize outpatient, subspecialty, and ED notes
|
||||||
- **Well Visit / Preventive Care** — AAP 2025 Bright Futures periodicity; vaccines, screenings, billing codes; By Visit Age, Milestones, SSHADESS (12+), and Visit Note subtabs
|
- **SOAP Notes** — full SOAP or subjective-only from dictation
|
||||||
- **Sick Visit Note** — quick documentation with auto-suggested ROS and PE systems from chief complaint
|
- **Well Visit** — AAP 2025 Bright Futures periodicity with vaccines, screenings, billing codes, SSHADESS (12+), milestones
|
||||||
- **Developmental Milestones** — AAP/Nelson milestone tracker (birth–11 years) with narrative, structured list, or 3-sentence summary; copy to Visit Note
|
- **Sick Visit** — quick documentation with auto-suggested ROS and PE from chief complaint
|
||||||
- **SSHADESS Assessment** — adolescent psychosocial screening for ages 12+; auto-fills into Visit Note
|
- **Developmental Milestones** — AAP/Nelson tracker (birth-11y) with narrative/structured/summary output
|
||||||
- **Vaccine Schedule** — full AAP immunization schedule reference
|
|
||||||
- **Catch-Up Schedule** — catch-up immunization guide
|
### AI & Speech
|
||||||
- **Plain text output** — all documents generated without markdown, ready to paste into any EHR
|
- **5 AI Providers** — OpenRouter, AWS Bedrock, Azure OpenAI, Google Vertex AI, LiteLLM
|
||||||
- **Read Aloud** — browser TTS reads generated documents; ElevenLabs (Adam voice) supported
|
- **5 STT Providers** — Google Gemini, Amazon Transcribe (Medical), OpenAI Whisper, Local Whisper, LiteLLM
|
||||||
- **Copy & Export** — one-click copy or export to Nextcloud
|
- **3 TTS Providers** — Google Cloud TTS, LiteLLM (OpenAI), ElevenLabs
|
||||||
- **Refine & Shorten** — edit any document with plain-language AI instructions
|
- **Browser Whisper** — fully offline in-browser transcription via WebAssembly (HIPAA-safe)
|
||||||
- **Per-tab model selector** — choose fast vs. smart vs. reasoning models per task
|
- **Per-tab model selector** — choose fast vs. smart vs. premium models per task
|
||||||
- **Collapsible sidebar** — desktop sidebar collapses to icon rail, state persisted
|
- **Physician memory system** — Dragon-like learning from your corrections
|
||||||
- **Save & Resume** — encounters saved with unique IDs; persist across page refresh
|
|
||||||
- **Admin Panel** — user management, registration control, audit logs
|
### 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
|
- **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
|
```bash
|
||||||
git clone https://github.com/ifedan-ed/pediatric-ai-scribe-v3.git
|
|
||||||
cd pediatric-ai-scribe-v3
|
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
```
|
```
|
||||||
|
|
||||||
Edit `.env` — at minimum set:
|
Edit `.env` — at minimum set:
|
||||||
|
|
||||||
```env
|
```env
|
||||||
OPENROUTER_API_KEY=sk-or-v1-...
|
AI_PROVIDER=litellm # or openrouter, bedrock, azure, vertex
|
||||||
OPENAI_API_KEY=sk-... # for Whisper transcription
|
LITELLM_API_BASE=https://your-litellm.example.com
|
||||||
JWT_SECRET=<64-char random string>
|
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>
|
DB_PASSWORD=<strong password>
|
||||||
APP_URL=https://your-domain.com
|
APP_URL=https://your-domain.com
|
||||||
```
|
```
|
||||||
|
|
||||||
Generate a strong JWT secret:
|
|
||||||
```bash
|
|
||||||
openssl rand -hex 32
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Start
|
### 2. Start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose up -d
|
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
|
```bash
|
||||||
docker exec pediatric-ai-scribe node admin-cli.js list-users
|
docker exec pediatric-ai-scribe node admin-cli.js list-users
|
||||||
|
|
@ -74,13 +85,117 @@ 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
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Maintenance CLI
|
||||||
|
|
||||||
|
After a Postgres image upgrade (major version bump or silent base-layer change),
|
||||||
|
btree indexes on text columns can become inconsistent with the new ICU/glibc
|
||||||
|
library. The app auto-detects this at startup and reindexes on drift, but you
|
||||||
|
can also trigger it manually:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Health check — no writes
|
||||||
|
docker exec pediatric-ai-scribe npm run maint:check
|
||||||
|
|
||||||
|
# Rebuild all indexes + refresh collation + ANALYZE
|
||||||
|
docker exec pediatric-ai-scribe npm run maint:reindex
|
||||||
|
```
|
||||||
|
|
||||||
|
Run `maint:reindex` any time after:
|
||||||
|
|
||||||
|
- Upgrading the Postgres image (major or minor)
|
||||||
|
- Restoring from a dump created on a different Linux distro
|
||||||
|
- Seeing "invalid credentials" on credentials you know are correct
|
||||||
|
- Seeing `0 rows` returned from a lookup that should match
|
||||||
|
|
||||||
|
The reindex takes seconds on a small DB and a minute or two on larger ones.
|
||||||
|
Safe to run while the app is serving traffic, though queries may slow briefly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Docker Hub
|
## Docker Hub
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker pull danielonyejesi/pediatric-ai-scribe-v3:latest
|
docker pull danielonyejesi/pediatric-ai-scribe-v3:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
### Minimal docker-compose without building
|
Minimal compose without building:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
|
|
@ -95,7 +210,7 @@ services:
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:16-alpine
|
image: pgvector/pgvector:pg16
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: pedscribe
|
POSTGRES_DB: pedscribe
|
||||||
POSTGRES_USER: pedscribe
|
POSTGRES_USER: pedscribe
|
||||||
|
|
@ -114,112 +229,36 @@ 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
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Whisper Transcription
|
|
||||||
|
|
||||||
Always uses OpenAI Whisper regardless of the AI provider setting:
|
|
||||||
|
|
||||||
```env
|
|
||||||
OPENAI_API_KEY=sk-...
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 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 transcription | OpenAI key (Whisper) |
|
|
||||||
| `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
|
## HIPAA Notice
|
||||||
|
|
||||||
This application processes data through third-party AI APIs.
|
This application processes data through third-party AI APIs.
|
||||||
|
|
||||||
- ✅ All connections use HTTPS/TLS
|
- All connections use HTTPS/TLS
|
||||||
- ✅ Authentication required for all AI endpoints
|
- Authentication required for all AI endpoints
|
||||||
- ✅ 2FA available
|
- 2FA and SSO available
|
||||||
- ✅ No patient data stored on server (only audit logs)
|
- Cloudflare Turnstile bot protection
|
||||||
- ⚠️ **OpenRouter does not offer a BAA** — do not use with real PHI
|
- **AWS Bedrock**, **Azure OpenAI**, and **Google Vertex AI** offer BAAs
|
||||||
- ✅ **AWS Bedrock** and **Azure OpenAI** offer BAAs — suitable for PHI with proper configuration
|
- **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)
|
||||||
|
- [Developer Guide](docs/developer-guide.md)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -228,6 +267,6 @@ This application processes data through third-party AI APIs.
|
||||||
```bash
|
```bash
|
||||||
npm install
|
npm install
|
||||||
cp .env.example .env # edit with your keys
|
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
|
node server.js
|
||||||
```
|
```
|
||||||
|
|
|
||||||
279
TRANSCRIPTION_OPTIONS.md
Normal file
|
|
@ -0,0 +1,279 @@
|
||||||
|
# Transcription Options Guide
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Pediatric AI Scribe v2+ offers **three transcription methods**, allowing you to choose between **privacy**, **speed**, and **real-time feedback**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Comparison Table
|
||||||
|
|
||||||
|
| Feature | Browser Whisper | Server Transcription | Web Speech API |
|
||||||
|
|---------|----------------|---------------------|----------------|
|
||||||
|
| **Privacy** | ⭐⭐⭐⭐⭐ 100% offline | ⭐⭐⭐⭐ (with BAA) | ⭐ Sends to cloud |
|
||||||
|
| **Accuracy** | ⭐⭐⭐⭐⭐ Whisper | ⭐⭐⭐⭐⭐ Gemini/AWS | ⭐⭐⭐ Browser-dependent |
|
||||||
|
| **Speed** | ⭐⭐⭐ 2-10s | ⭐⭐⭐⭐⭐ ~1s | ⭐⭐⭐⭐⭐ Instant |
|
||||||
|
| **Real-time** | ❌ Batch mode | ❌ Batch mode | ✅ Live streaming |
|
||||||
|
| **HIPAA** | ✅ Yes | ✅ (Vertex/AWS) | ❌ No |
|
||||||
|
| **Cost** | Free | ~$0.005/min | Free |
|
||||||
|
| **Internet** | ❌ Not required | ✅ Required | ✅ Required |
|
||||||
|
| **Setup** | None (bundled) | API keys | None (built-in) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Option 1: Browser Whisper (Offline, Private) ⭐ RECOMMENDED
|
||||||
|
|
||||||
|
### What It Is
|
||||||
|
- Runs **OpenAI Whisper** entirely in your browser using WebAssembly
|
||||||
|
- Audio **never leaves your device** - 100% offline after initial page load
|
||||||
|
- Models bundled in Docker image (self-hosted, no CDN)
|
||||||
|
|
||||||
|
### When to Use
|
||||||
|
- ✅ Clinical documentation (HIPAA-compliant)
|
||||||
|
- ✅ Maximum privacy required
|
||||||
|
- ✅ Offline/air-gapped environments
|
||||||
|
- ✅ No API costs
|
||||||
|
- ✅ Zero vendor dependency
|
||||||
|
|
||||||
|
### How to Enable
|
||||||
|
1. Settings → Browser Transcription
|
||||||
|
2. Toggle "Enable browser transcription" ON
|
||||||
|
3. (Optional) Click "Pre-download model" if you want to cache it first
|
||||||
|
4. Start recording - transcription happens automatically after recording
|
||||||
|
|
||||||
|
### Models Available
|
||||||
|
- **Tiny** (~39MB) - Fast, good for short clips (2-3 seconds)
|
||||||
|
- **Base** (~74MB) - Balanced accuracy and speed (3-5 seconds)
|
||||||
|
- **Small** (~244MB) - Best quality, slower (6-10 seconds)
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
- Transcribes ~30-second clip in 2-10 seconds (depending on model)
|
||||||
|
- First run may be slower (model loading)
|
||||||
|
- Subsequent runs are instant (cached)
|
||||||
|
|
||||||
|
### Privacy
|
||||||
|
- ✅ Audio never transmitted
|
||||||
|
- ✅ Models run locally in WASM
|
||||||
|
- ✅ No network calls during transcription
|
||||||
|
- ✅ HIPAA-compliant
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Option 2: Server Transcription (Cloud, Fast)
|
||||||
|
|
||||||
|
### What It Is
|
||||||
|
- Sends audio to your configured AI provider
|
||||||
|
- Uses Google Gemini, AWS Transcribe, OpenAI Whisper, or LiteLLM
|
||||||
|
|
||||||
|
### When to Use
|
||||||
|
- ✅ Maximum speed (~1 second for 30-second clip)
|
||||||
|
- ✅ Best accuracy (cloud models)
|
||||||
|
- ✅ Long recordings (Browser Whisper can be slow for 5+ minutes)
|
||||||
|
- ✅ HIPAA-compliant with BAA providers
|
||||||
|
|
||||||
|
### HIPAA-Eligible Providers
|
||||||
|
- **Google Vertex AI** (with BAA) ✅
|
||||||
|
- **AWS Transcribe** (with BAA) ✅
|
||||||
|
- **Azure OpenAI** (with BAA) ✅
|
||||||
|
- **OpenAI Whisper Direct** ❌ Not HIPAA-eligible
|
||||||
|
|
||||||
|
### How to Enable
|
||||||
|
- Configured via environment variables (`.env`)
|
||||||
|
- No user action needed - just works if API keys present
|
||||||
|
- Falls back automatically if Browser Whisper fails
|
||||||
|
|
||||||
|
### Cost
|
||||||
|
- Google Gemini: ~$0.005/minute
|
||||||
|
- AWS Transcribe: ~$0.024/minute
|
||||||
|
- OpenAI: $0.006/minute
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Option 3: Web Speech API (Real-Time, Experimental) ⚠️
|
||||||
|
|
||||||
|
### What It Is
|
||||||
|
- Uses your browser's built-in speech recognition
|
||||||
|
- Shows transcription **in real-time** as you speak (streaming)
|
||||||
|
- Chrome/Edge → Google Cloud Speech
|
||||||
|
- Safari → Apple Speech Recognition
|
||||||
|
|
||||||
|
### ⚠️ PRIVACY WARNING
|
||||||
|
- **Audio IS sent to cloud servers** (Google, Apple, etc.)
|
||||||
|
- **NOT HIPAA-compliant**
|
||||||
|
- Only use for non-clinical, personal use
|
||||||
|
|
||||||
|
### When to Use
|
||||||
|
- ✅ Personal notes (non-clinical)
|
||||||
|
- ✅ Want real-time feedback while speaking
|
||||||
|
- ✅ Demonstration/testing
|
||||||
|
- ❌ **NEVER for patient data**
|
||||||
|
|
||||||
|
### How to Enable
|
||||||
|
1. Settings → Real-Time Streaming Transcription
|
||||||
|
2. Read privacy warning carefully
|
||||||
|
3. Toggle "Enable real-time streaming" ON
|
||||||
|
4. Confirm warning dialog
|
||||||
|
5. Grants microphone permission
|
||||||
|
6. Start recording - see words appear live
|
||||||
|
|
||||||
|
### Limitations
|
||||||
|
- Not available in all browsers (requires Web Speech API)
|
||||||
|
- Accuracy varies by browser
|
||||||
|
- Requires internet connection
|
||||||
|
- May have usage limits
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Choosing the Right Option
|
||||||
|
|
||||||
|
### For Clinical Use (HIPAA Required)
|
||||||
|
**Use:** Browser Whisper (offline) OR Server (Vertex AI/AWS with BAA)
|
||||||
|
- Browser Whisper: Maximum privacy, no costs
|
||||||
|
- Server: Faster, better for long recordings
|
||||||
|
|
||||||
|
### For Personal Use (Non-HIPAA)
|
||||||
|
**Use:** Any option
|
||||||
|
- Browser Whisper: Best balance of privacy and accuracy
|
||||||
|
- Server: Fastest
|
||||||
|
- Web Speech: Real-time feedback
|
||||||
|
|
||||||
|
### Decision Tree
|
||||||
|
|
||||||
|
```
|
||||||
|
Is this clinical/patient data?
|
||||||
|
├─ YES → Use Browser Whisper or Server (Vertex/AWS)
|
||||||
|
│ ├─ Need offline? → Browser Whisper
|
||||||
|
│ ├─ Need speed? → Server (Vertex AI)
|
||||||
|
│ └─ Want free? → Browser Whisper
|
||||||
|
│
|
||||||
|
└─ NO → Any option
|
||||||
|
├─ Want real-time? → Web Speech API
|
||||||
|
├─ Want privacy? → Browser Whisper
|
||||||
|
└─ Want speed? → Server
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Browser Whisper
|
||||||
|
```bash
|
||||||
|
# No configuration needed - bundled in Docker image
|
||||||
|
# Models at: /app/public/models/Xenova/whisper-tiny.en/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Server Transcription
|
||||||
|
```bash
|
||||||
|
# .env file
|
||||||
|
TRANSCRIBE_PROVIDER=google # google, aws, openai, litellm
|
||||||
|
|
||||||
|
# Google Vertex AI
|
||||||
|
GOOGLE_VERTEX_PROJECT=your-project-id
|
||||||
|
GOOGLE_APPLICATION_CREDENTIALS=/path/to/key.json
|
||||||
|
|
||||||
|
# AWS Transcribe
|
||||||
|
AWS_BEDROCK_REGION=us-east-1
|
||||||
|
AWS_ACCESS_KEY_ID=your-key
|
||||||
|
AWS_SECRET_ACCESS_KEY=your-secret
|
||||||
|
|
||||||
|
# OpenAI
|
||||||
|
OPENAI_API_KEY=sk-...
|
||||||
|
|
||||||
|
# LiteLLM (proxy)
|
||||||
|
LITELLM_API_BASE=http://localhost:4000
|
||||||
|
LITELLM_API_KEY=optional
|
||||||
|
```
|
||||||
|
|
||||||
|
### Web Speech API
|
||||||
|
```bash
|
||||||
|
# No configuration - uses browser built-in
|
||||||
|
# Privacy warning shown in Settings UI
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## FAQ
|
||||||
|
|
||||||
|
### Q: Which is most accurate?
|
||||||
|
**A:** Browser Whisper and Server (Gemini/Whisper) are equally accurate. Web Speech is slightly less accurate.
|
||||||
|
|
||||||
|
### Q: Which is fastest?
|
||||||
|
**A:** Server transcription (~1s) > Web Speech (real-time) > Browser Whisper (2-10s)
|
||||||
|
|
||||||
|
### Q: Which is most private?
|
||||||
|
**A:** Browser Whisper (100% offline) > Server (with BAA) > Web Speech (not private)
|
||||||
|
|
||||||
|
### Q: Can I use multiple at once?
|
||||||
|
**A:** No. Priority: Web Speech > Browser Whisper > Server (whichever is enabled first)
|
||||||
|
|
||||||
|
### Q: What if transcription fails?
|
||||||
|
**A:** Automatic fallback chain:
|
||||||
|
1. Browser Whisper (if enabled)
|
||||||
|
2. Falls back to Server (if configured)
|
||||||
|
3. Falls back to live transcript (if available)
|
||||||
|
|
||||||
|
### Q: Is Browser Whisper really offline?
|
||||||
|
**A:** Yes! Models are bundled in the Docker image. After the page loads once, transcription works with zero network access.
|
||||||
|
|
||||||
|
### Q: Does Web Speech work offline?
|
||||||
|
**A:** No. It requires internet to send audio to cloud servers.
|
||||||
|
|
||||||
|
### Q: Can I train/customize the models?
|
||||||
|
**A:** No. Browser Whisper uses pre-trained models. Server transcription uses cloud models. No custom training available.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Browser Whisper stuck at "Initializing"
|
||||||
|
- **Cause:** Models not loaded or network blocked during initial download
|
||||||
|
- **Fix:** See BROWSER_WHISPER_TROUBLESHOOTING.md
|
||||||
|
|
||||||
|
### Server transcription returns "No provider"
|
||||||
|
- **Cause:** API keys not configured
|
||||||
|
- **Fix:** Set environment variables in `.env`
|
||||||
|
|
||||||
|
### Web Speech says "Not supported"
|
||||||
|
- **Cause:** Browser doesn't support Web Speech API
|
||||||
|
- **Fix:** Use Chrome, Edge, or Safari
|
||||||
|
|
||||||
|
### Transcription is slow
|
||||||
|
- **Browser Whisper:** Try switching to "Tiny" model
|
||||||
|
- **Server:** Check API provider status
|
||||||
|
- **Web Speech:** Check internet connection
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### Clinical Documentation
|
||||||
|
1. Use Browser Whisper for all patient data
|
||||||
|
2. Enable audio backups (automatic in v2)
|
||||||
|
3. Keep recordings under 5 minutes for faster processing
|
||||||
|
4. Use "Tiny" model for quick notes, "Base" for detailed documentation
|
||||||
|
|
||||||
|
### Personal Use
|
||||||
|
1. Web Speech for quick, informal notes
|
||||||
|
2. Browser Whisper for anything you want private
|
||||||
|
3. Server for long recordings
|
||||||
|
|
||||||
|
### Performance Optimization
|
||||||
|
1. Pre-download Browser Whisper model before first use
|
||||||
|
2. Use shorter clips (30-60 seconds) for fastest results
|
||||||
|
3. Clear browser cache if models seem corrupted
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
| Need | Recommendation |
|
||||||
|
|------|---------------|
|
||||||
|
| Clinical/HIPAA | Browser Whisper (offline) |
|
||||||
|
| Fast transcription | Server (Vertex AI) |
|
||||||
|
| Real-time feedback | Web Speech (non-clinical only) |
|
||||||
|
| Maximum privacy | Browser Whisper |
|
||||||
|
| Zero cost | Browser Whisper |
|
||||||
|
| Long recordings | Server (faster for 5+ min clips) |
|
||||||
|
| Offline use | Browser Whisper |
|
||||||
|
|
||||||
|
**Default recommendation:** Browser Whisper for 95% of use cases. It's private, accurate, free, and offline. Only use alternatives when you have specific needs for speed or real-time feedback.
|
||||||
44
android/app/build.gradle
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
plugins {
|
||||||
|
id 'com.android.application'
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace 'com.pediatricscribe.twa'
|
||||||
|
compileSdk 34
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId "com.pediatricscribe.twa"
|
||||||
|
minSdk 24
|
||||||
|
targetSdk 34
|
||||||
|
versionCode 1
|
||||||
|
versionName "1.0.0"
|
||||||
|
|
||||||
|
// TWA host URL — default: peds.danvics.com (change if self-hosting elsewhere)
|
||||||
|
def twaHost = project.hasProperty('TWA_HOST') ? project.property('TWA_HOST') : "peds.danvics.com"
|
||||||
|
def twaUrl = "https://${twaHost}"
|
||||||
|
manifestPlaceholders = [
|
||||||
|
hostName: twaHost,
|
||||||
|
defaultUrl: twaUrl,
|
||||||
|
launcherName: "PedScribe",
|
||||||
|
assetStatements: "[{ \"relation\": [\"delegate_permission/common.handle_all_urls\"], \"target\": { \"namespace\": \"web\", \"site\": \"${twaUrl}\" } }]"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
minifyEnabled true
|
||||||
|
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility JavaVersion.VERSION_17
|
||||||
|
targetCompatibility JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation 'androidx.appcompat:appcompat:1.6.1'
|
||||||
|
implementation 'androidx.browser:browser:1.7.0'
|
||||||
|
implementation 'com.google.androidbrowserhelper:androidbrowserhelper:2.5.0'
|
||||||
|
}
|
||||||
73
android/app/src/main/AndroidManifest.xml
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||||
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:allowBackup="false"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:label="${launcherName}"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:theme="@style/AppTheme">
|
||||||
|
|
||||||
|
<meta-data
|
||||||
|
android:name="asset_statements"
|
||||||
|
android:value='${assetStatements}' />
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name="com.google.androidbrowserhelper.trusted.LauncherActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:label="${launcherName}">
|
||||||
|
|
||||||
|
<meta-data
|
||||||
|
android:name="android.support.customtabs.trusted.DEFAULT_URL"
|
||||||
|
android:value="${defaultUrl}" />
|
||||||
|
|
||||||
|
<meta-data
|
||||||
|
android:name="android.support.customtabs.trusted.STATUS_BAR_COLOR"
|
||||||
|
android:resource="@color/colorStatusBar" />
|
||||||
|
|
||||||
|
<meta-data
|
||||||
|
android:name="android.support.customtabs.trusted.NAVIGATION_BAR_COLOR"
|
||||||
|
android:resource="@color/colorNavigationBar" />
|
||||||
|
|
||||||
|
<meta-data
|
||||||
|
android:name="android.support.customtabs.trusted.SPLASH_IMAGE_DRAWABLE"
|
||||||
|
android:resource="@drawable/splash" />
|
||||||
|
|
||||||
|
<meta-data
|
||||||
|
android:name="android.support.customtabs.trusted.SPLASH_SCREEN_BACKGROUND_COLOR"
|
||||||
|
android:resource="@color/colorSplashBackground" />
|
||||||
|
|
||||||
|
<meta-data
|
||||||
|
android:name="android.support.customtabs.trusted.SCREEN_ORIENTATION"
|
||||||
|
android:value="default" />
|
||||||
|
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
|
||||||
|
<intent-filter android:autoVerify="true">
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data android:scheme="https" android:host="${hostName}" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
<!-- Foreground service for background audio recording -->
|
||||||
|
<service
|
||||||
|
android:name="com.pediatricscribe.twa.AudioRecordingService"
|
||||||
|
android:foregroundServiceType="microphone"
|
||||||
|
android:exported="false" />
|
||||||
|
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
|
|
@ -0,0 +1,102 @@
|
||||||
|
package com.pediatricscribe.twa;
|
||||||
|
|
||||||
|
import android.app.Notification;
|
||||||
|
import android.app.NotificationChannel;
|
||||||
|
import android.app.NotificationManager;
|
||||||
|
import android.app.PendingIntent;
|
||||||
|
import android.app.Service;
|
||||||
|
import android.content.Intent;
|
||||||
|
import android.os.Build;
|
||||||
|
import android.os.IBinder;
|
||||||
|
import android.os.PowerManager;
|
||||||
|
|
||||||
|
import androidx.core.app.NotificationCompat;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Foreground service that keeps the app alive during audio recording.
|
||||||
|
* Acquires a partial wake lock to prevent CPU sleep during recording.
|
||||||
|
* The TWA web app sends a message to start/stop this service when recording.
|
||||||
|
*/
|
||||||
|
public class AudioRecordingService extends Service {
|
||||||
|
|
||||||
|
private static final String CHANNEL_ID = "recording_channel";
|
||||||
|
private static final int NOTIFICATION_ID = 1;
|
||||||
|
private static final String WAKE_LOCK_TAG = "PedScribe:AudioRecording";
|
||||||
|
|
||||||
|
public static final String ACTION_STOP = "com.pediatricscribe.twa.STOP_RECORDING";
|
||||||
|
|
||||||
|
private PowerManager.WakeLock wakeLock;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onCreate() {
|
||||||
|
super.onCreate();
|
||||||
|
createNotificationChannel();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int onStartCommand(Intent intent, int flags, int startId) {
|
||||||
|
if (intent != null && ACTION_STOP.equals(intent.getAction())) {
|
||||||
|
stopSelf();
|
||||||
|
return START_NOT_STICKY;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Acquire wake lock to keep CPU active during recording
|
||||||
|
PowerManager pm = (PowerManager) getSystemService(POWER_SERVICE);
|
||||||
|
if (pm != null) {
|
||||||
|
wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, WAKE_LOCK_TAG);
|
||||||
|
wakeLock.acquire(60 * 60 * 1000L); // 1 hour max
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop action in notification
|
||||||
|
Intent stopIntent = new Intent(this, AudioRecordingService.class);
|
||||||
|
stopIntent.setAction(ACTION_STOP);
|
||||||
|
PendingIntent stopPending = PendingIntent.getService(
|
||||||
|
this, 0, stopIntent,
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE
|
||||||
|
);
|
||||||
|
|
||||||
|
Notification notification = new NotificationCompat.Builder(this, CHANNEL_ID)
|
||||||
|
.setContentTitle("Pediatric AI Scribe")
|
||||||
|
.setContentText("Recording in progress...")
|
||||||
|
.setSmallIcon(android.R.drawable.ic_btn_speak_now)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||||
|
.setOngoing(true)
|
||||||
|
.setCategory(NotificationCompat.CATEGORY_SERVICE)
|
||||||
|
.addAction(android.R.drawable.ic_media_pause, "Stop Recording", stopPending)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
startForeground(NOTIFICATION_ID, notification);
|
||||||
|
return START_STICKY;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public IBinder onBind(Intent intent) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onDestroy() {
|
||||||
|
if (wakeLock != null && wakeLock.isHeld()) {
|
||||||
|
wakeLock.release();
|
||||||
|
wakeLock = null;
|
||||||
|
}
|
||||||
|
stopForeground(true);
|
||||||
|
super.onDestroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void createNotificationChannel() {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
NotificationChannel channel = new NotificationChannel(
|
||||||
|
CHANNEL_ID,
|
||||||
|
"Recording",
|
||||||
|
NotificationManager.IMPORTANCE_LOW
|
||||||
|
);
|
||||||
|
channel.setDescription("Shows when audio recording is active");
|
||||||
|
channel.setShowBadge(false);
|
||||||
|
NotificationManager manager = getSystemService(NotificationManager.class);
|
||||||
|
if (manager != null) {
|
||||||
|
manager.createNotificationChannel(channel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
15
android/app/src/main/res/drawable/ic_launcher.xml
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
android:viewportHeight="108">
|
||||||
|
<group android:translateX="22" android:translateY="22">
|
||||||
|
<path
|
||||||
|
android:fillColor="#2563EB"
|
||||||
|
android:pathData="M32,0C49.67,0 64,14.33 64,32C64,49.67 49.67,64 32,64C14.33,64 0,49.67 0,32C0,14.33 14.33,0 32,0Z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:pathData="M32,12C32,12 22,20 22,30C22,35.52 26.48,40 32,40C37.52,40 42,35.52 42,30C42,20 32,12 32,12ZM32,52C32,52 28,48 28,46C28,43.79 29.79,42 32,42C34.21,42 36,43.79 36,46C36,48 32,52 32,52Z" />
|
||||||
|
</group>
|
||||||
|
</vector>
|
||||||
BIN
android/app/src/main/res/drawable/splash.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
BIN
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 6.8 KiB |
8
android/app/src/main/res/values/colors.xml
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="colorPrimary">#2563EB</color>
|
||||||
|
<color name="colorPrimaryDark">#1E40AF</color>
|
||||||
|
<color name="colorStatusBar">#2563EB</color>
|
||||||
|
<color name="colorNavigationBar">#1E40AF</color>
|
||||||
|
<color name="colorSplashBackground">#FFFFFF</color>
|
||||||
|
</resources>
|
||||||
4
android/app/src/main/res/values/strings.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">Pediatric AI Scribe</string>
|
||||||
|
</resources>
|
||||||
10
android/app/src/main/res/values/styles.xml
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<style name="AppTheme" parent="Theme.AppCompat.Light.NoActionBar">
|
||||||
|
<item name="android:windowBackground">@color/colorSplashBackground</item>
|
||||||
|
<item name="colorPrimary">@color/colorPrimary</item>
|
||||||
|
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
|
||||||
|
<item name="android:statusBarColor">@color/colorStatusBar</item>
|
||||||
|
<item name="android:navigationBarColor">@color/colorNavigationBar</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
16
android/build.gradle
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
buildscript {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
dependencies {
|
||||||
|
classpath 'com.android.tools.build:gradle:8.2.0'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
allprojects {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
3
android/gradle.properties
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
android.useAndroidX=true
|
||||||
|
android.enableJetifier=true
|
||||||
|
org.gradle.jvmargs=-Xmx2048m
|
||||||
7
android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip
|
||||||
|
networkTimeout=10000
|
||||||
|
validateDistributionUrl=true
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
15
android/gradlew
vendored
Executable file
|
|
@ -0,0 +1,15 @@
|
||||||
|
#!/bin/sh
|
||||||
|
# Gradle wrapper stub - download if not present
|
||||||
|
GRADLE_VERSION="8.5"
|
||||||
|
GRADLE_DIR="$HOME/.gradle/wrapper/dists/gradle-${GRADLE_VERSION}-bin"
|
||||||
|
|
||||||
|
if [ ! -f "gradle/wrapper/gradle-wrapper.jar" ]; then
|
||||||
|
echo "Downloading Gradle wrapper..."
|
||||||
|
mkdir -p gradle/wrapper
|
||||||
|
curl -sL "https://services.gradle.org/distributions/gradle-${GRADLE_VERSION}-bin.zip" -o /tmp/gradle.zip
|
||||||
|
unzip -q /tmp/gradle.zip -d /tmp
|
||||||
|
cp /tmp/gradle-${GRADLE_VERSION}/lib/gradle-wrapper-*.jar gradle/wrapper/gradle-wrapper.jar 2>/dev/null || true
|
||||||
|
rm -rf /tmp/gradle.zip /tmp/gradle-${GRADLE_VERSION}
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec java -jar gradle/wrapper/gradle-wrapper.jar "$@"
|
||||||
2
android/settings.gradle
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
rootProject.name = 'PediatricAIScribe'
|
||||||
|
include ':app'
|
||||||
52
docker-compose.monitoring.yml
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
## Monitoring stack — Loki + Grafana
|
||||||
|
## Usage: docker compose -f docker-compose.yml -f docker-compose.monitoring.yml up -d
|
||||||
|
##
|
||||||
|
## Grafana: http://localhost:3003 (admin/admin on first login)
|
||||||
|
## Loki: http://localhost:3100 (internal, used by Grafana)
|
||||||
|
##
|
||||||
|
## The app sends logs to Loki via HTTP at http://loki:3100/loki/api/v1/push
|
||||||
|
|
||||||
|
services:
|
||||||
|
loki:
|
||||||
|
image: grafana/loki:3.4.2
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:3101:3100"
|
||||||
|
command: -config.file=/etc/loki/loki-config.yaml
|
||||||
|
volumes:
|
||||||
|
- loki-data:/loki
|
||||||
|
- ./monitoring/loki-config.yaml:/etc/loki/loki-config.yaml:ro
|
||||||
|
restart: unless-stopped
|
||||||
|
container_name: pedscribe-loki
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget --spider -q http://localhost:3100/ready"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
|
grafana:
|
||||||
|
image: grafana/grafana:11.6.0
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:3003:3000"
|
||||||
|
environment:
|
||||||
|
- GF_SECURITY_ADMIN_PASSWORD=pedscribe
|
||||||
|
- GF_USERS_ALLOW_SIGN_UP=false
|
||||||
|
- GF_AUTH_ANONYMOUS_ENABLED=false
|
||||||
|
volumes:
|
||||||
|
- grafana-data:/var/lib/grafana
|
||||||
|
- ./monitoring/grafana-datasource.yaml:/etc/grafana/provisioning/datasources/loki.yaml:ro
|
||||||
|
- ./monitoring/grafana-dashboards.yaml:/etc/grafana/provisioning/dashboards/dashboards.yaml:ro
|
||||||
|
- ./monitoring/dashboards:/var/lib/grafana/dashboards:ro
|
||||||
|
depends_on:
|
||||||
|
loki:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: unless-stopped
|
||||||
|
container_name: pedscribe-grafana
|
||||||
|
|
||||||
|
# Override the main app to add Loki env
|
||||||
|
pediatric-scribe:
|
||||||
|
environment:
|
||||||
|
- LOKI_URL=http://loki:3100
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
loki-data:
|
||||||
|
grafana-data:
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
services:
|
services:
|
||||||
pediatric-scribe:
|
pediatric-scribe:
|
||||||
image: danielonyejesi/pediatric-ai-scribe-v3:v3.1
|
build: .
|
||||||
|
image: ped-ai-local:latest
|
||||||
ports:
|
ports:
|
||||||
- "3552:3000"
|
- "127.0.0.1:3552:3000"
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
volumes:
|
volumes:
|
||||||
|
|
@ -20,7 +21,10 @@ services:
|
||||||
start_period: 20s
|
start_period: 20s
|
||||||
|
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:16-alpine
|
# Tag-pinned. If a newer pg16 image ships a different ICU library, the
|
||||||
|
# startup drift check in src/db/database.js auto-REINDEXes and
|
||||||
|
# refreshes the collation version. For stricter control, pin by digest.
|
||||||
|
image: pgvector/pgvector:pg16
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: pedscribe
|
POSTGRES_DB: pedscribe
|
||||||
POSTGRES_USER: pedscribe
|
POSTGRES_USER: pedscribe
|
||||||
|
|
|
||||||
139
docs/ai-providers.md
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
# 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 manage models from **Admin Panel > Models**:
|
||||||
|
|
||||||
|
- **Enable/Disable** -- toggle visibility of any model for users (`PUT /api/admin/config/models/toggle`). Disabled models are stored in the `models.disabled` setting as a JSON array of model IDs.
|
||||||
|
- **Set Default** -- choose which model is pre-selected for new users (`PUT /api/admin/config/models/default`). Stored in `models.default` setting.
|
||||||
|
- **Add Custom Models** -- manually add any model not in the built-in list (`POST /api/admin/config/models/custom`). Each custom model has:
|
||||||
|
- `id` -- the model identifier as the provider expects it (e.g., `openai-gpt-4.1-mini` for LiteLLM, `anthropic.agent-config-3-haiku` for Bedrock)
|
||||||
|
- `name` -- display name shown to users
|
||||||
|
- `cost` -- cost string shown in the UI (e.g., `~$0.002`, `FREE`)
|
||||||
|
- `category` -- one of `free`, `fast`, `smart`, `premium` (determines grouping in dropdown)
|
||||||
|
- **Delete Custom Models** -- remove a manually added model (`DELETE /api/admin/config/models/custom/:modelId`)
|
||||||
|
- **Clear All** -- remove all custom models and reset the disabled list (`POST /api/admin/config/models/clear`)
|
||||||
|
|
||||||
|
Custom models are stored in the `models.custom` setting as a JSON array in the `app_settings` table.
|
||||||
|
|
||||||
|
### Model Discovery
|
||||||
|
|
||||||
|
The **Discover** button (`GET /api/admin/config/models/discover`) queries the active provider's API:
|
||||||
|
|
||||||
|
- **LiteLLM**: calls `/v1/models` on the LiteLLM proxy
|
||||||
|
- **OpenRouter**: calls `https://openrouter.ai/api/v1/models` (includes pricing data)
|
||||||
|
- **Bedrock**: uses `ListFoundationModelsCommand`
|
||||||
|
|
||||||
|
Discovered models can be added individually via `POST /api/admin/config/models/add-discovered`, which merges them into the custom models list.
|
||||||
|
|
||||||
|
For **LiteLLM** specifically, since models are manually configured in the LiteLLM proxy, the model IDs returned by discovery are the exact names to use -- no provider prefix is added.
|
||||||
|
|
||||||
|
### Frontend Display
|
||||||
|
|
||||||
|
The model selector dropdown (present on every tab) groups models by category:
|
||||||
|
|
||||||
|
- Free -- no-cost models
|
||||||
|
- Fast & Cheap -- low-latency, low-cost
|
||||||
|
- Smart -- balanced capability
|
||||||
|
- Premium -- highest quality
|
||||||
|
|
||||||
|
Each model shows its display name and cost string. The dropdown is populated from `GET /api/models` which merges built-in models, custom models, and respects the enabled/disabled list.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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
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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
||||||
467
docs/developer-guide.md
Normal file
|
|
@ -0,0 +1,467 @@
|
||||||
|
# Developer Guide
|
||||||
|
|
||||||
|
This guide explains how the codebase works so any developer can understand, modify, and extend the Pediatric AI Scribe platform.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
server.js -- Express app entry point, middleware stack, route registration
|
||||||
|
src/
|
||||||
|
db/database.js -- PostgreSQL pool, schema init, query helpers, auto-cleanup
|
||||||
|
middleware/
|
||||||
|
auth.js -- JWT/cookie auth, admin/moderator role checks
|
||||||
|
logging.js -- Request logging middleware
|
||||||
|
utils/
|
||||||
|
ai.js -- Multi-provider AI client (callAI), model discovery
|
||||||
|
models.js -- Built-in model definitions per provider
|
||||||
|
prompts.js -- All AI system prompts (overridable via DB)
|
||||||
|
config.js -- DB-backed settings with 2-minute cache
|
||||||
|
logger.js -- Audit, API, access logging to DB + files
|
||||||
|
embeddings.js -- Vector embedding generation (Vertex/LiteLLM/OpenAI)
|
||||||
|
transcribeAWS.js -- Amazon Transcribe client
|
||||||
|
transcribeGoogle.js -- Google Gemini STT
|
||||||
|
transcribeLocal.js -- Local whisper.cpp / faster-whisper
|
||||||
|
ttsGoogle.js -- Google Cloud TTS
|
||||||
|
routes/ -- 27 route files (see below)
|
||||||
|
public/
|
||||||
|
index.html -- Main SPA shell, auth forms, script tags
|
||||||
|
sw.js -- Service worker (cache shell, network-first API)
|
||||||
|
manifest.json -- PWA manifest
|
||||||
|
components/ -- HTML fragments loaded into tabs
|
||||||
|
js/ -- 20+ vanilla JS modules
|
||||||
|
css/styles.css -- All styles in one file
|
||||||
|
icons/ -- PWA icons
|
||||||
|
models/ -- Self-hosted Whisper WASM model files
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How the Frontend Works
|
||||||
|
|
||||||
|
### SPA Architecture
|
||||||
|
|
||||||
|
This is a **vanilla JavaScript SPA** -- no React, Vue, or framework. The approach:
|
||||||
|
|
||||||
|
1. `index.html` is the only HTML page. It contains two top-level divs:
|
||||||
|
- `#auth-screen` -- login/register/forgot forms (hidden when authenticated)
|
||||||
|
- `#main-app` -- the actual application (hidden until authenticated)
|
||||||
|
|
||||||
|
2. **Tabs** are managed by `app.js`. The sidebar has tab buttons. Clicking a tab calls `activateTab(tabName)` which:
|
||||||
|
- Fetches `/components/{tabName}.html` via `loadComponent()`
|
||||||
|
- Injects the HTML into `.app-body`
|
||||||
|
- Dispatches a `CustomEvent('tabChanged', { detail: { tab: tabName } })`
|
||||||
|
- Other modules listen for this event to initialize their UI
|
||||||
|
|
||||||
|
3. **Module communication** uses `window` globals and `CustomEvent`:
|
||||||
|
- Functions exposed on `window` (e.g., `window.saveAudioBackup`, `window.getAuthHeaders`, `window.showToast`)
|
||||||
|
- Events dispatched on `document` (e.g., `recording-started`, `recording-stopped`, `tabChanged`)
|
||||||
|
|
||||||
|
4. **Script loading**: all JS files use `defer` attribute, loaded in dependency order defined in `index.html` (lines 302-328). `audioBackup.js` before `app.js` before `auth.js` etc.
|
||||||
|
|
||||||
|
### Auth Flow
|
||||||
|
|
||||||
|
`auth.js` runs on DOMContentLoaded:
|
||||||
|
1. Checks for SSO redirect (`?sso=ok` URL param)
|
||||||
|
2. Tries to restore session from localStorage token or cookie
|
||||||
|
3. Calls `/api/auth/me` to validate
|
||||||
|
4. If valid: hides auth screen, shows main app, loads default tab
|
||||||
|
5. If invalid: shows auth screen
|
||||||
|
|
||||||
|
Token is stored in both `localStorage` (for Bearer header) and `ped_auth` cookie (for SSO/cookie-based auth). The auth middleware accepts either.
|
||||||
|
|
||||||
|
### Component Lifecycle
|
||||||
|
|
||||||
|
When a tab is activated:
|
||||||
|
1. HTML is fetched and injected into `.app-body`
|
||||||
|
2. The `tabChanged` event fires
|
||||||
|
3. Each module has a listener that initializes when its tab is active:
|
||||||
|
```javascript
|
||||||
|
document.addEventListener('tabChanged', function(e) {
|
||||||
|
if (e.detail.tab === 'settings') {
|
||||||
|
loadMemories();
|
||||||
|
renderAudioBackups();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
4. Modules query the DOM for elements inside the injected component HTML
|
||||||
|
|
||||||
|
### Common Patterns
|
||||||
|
|
||||||
|
**API calls**: Always use `getAuthHeaders()` for JSON requests, or manually add Bearer token for FormData uploads. Include `credentials: 'same-origin'` when cookie auth may be needed.
|
||||||
|
|
||||||
|
**Toast notifications**: `showToast(message, type)` where type is `success`, `error`, `info`, or `warning`.
|
||||||
|
|
||||||
|
**Loading overlay**: `showLoading(message)` and `hideLoading()`.
|
||||||
|
|
||||||
|
**Model selection**: `getSelectedModel()` returns the currently selected model ID from the tab's dropdown.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How the Backend Works
|
||||||
|
|
||||||
|
### Middleware Stack (server.js)
|
||||||
|
|
||||||
|
Requests flow through this chain in order:
|
||||||
|
|
||||||
|
```
|
||||||
|
Request
|
||||||
|
-> Helmet (CSP headers)
|
||||||
|
-> CORS (restrict to APP_URL)
|
||||||
|
-> cookieParser
|
||||||
|
-> express.json (10MB limit)
|
||||||
|
-> Rate limiters (per-endpoint)
|
||||||
|
-> Static file serving (public/)
|
||||||
|
-> Route handlers
|
||||||
|
-> 404 fallback (serves index.html for SPA routes)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database Layer (src/db/database.js)
|
||||||
|
|
||||||
|
The database module provides:
|
||||||
|
|
||||||
|
- `db.get(sql, params)` -- single row (returns object or null)
|
||||||
|
- `db.all(sql, params)` -- multiple rows (returns array)
|
||||||
|
- `db.run(sql, params)` -- INSERT/UPDATE/DELETE (returns `{ lastInsertRowid, changes }`)
|
||||||
|
- `db.query(sql, params)` -- raw pg query
|
||||||
|
- `db.getSetting(key)` -- read from app_settings
|
||||||
|
- `db.setSetting(key, value)` -- write to app_settings
|
||||||
|
|
||||||
|
SQL uses `?` placeholders which are auto-converted to PostgreSQL `$1, $2, ...` by `convertPlaceholders()`. You can also use `$N` directly.
|
||||||
|
|
||||||
|
For INSERT statements, `RETURNING id` is auto-appended if not already present.
|
||||||
|
|
||||||
|
**Schema migration**: all tables use `CREATE TABLE IF NOT EXISTS` and `ALTER TABLE ADD COLUMN IF NOT EXISTS`. Migrations run on every startup -- no separate migration tool needed. Just add new columns/tables to `initDatabase()`.
|
||||||
|
|
||||||
|
### Authentication Middleware (src/middleware/auth.js)
|
||||||
|
|
||||||
|
Three middleware functions:
|
||||||
|
- `authMiddleware` -- verifies JWT from Bearer header or `ped_auth` cookie. If Bearer header is present but empty, falls through to cookie. Sets `req.user`.
|
||||||
|
- `adminMiddleware` -- requires `req.user.role === 'admin'` (use after authMiddleware)
|
||||||
|
- `moderatorMiddleware` -- requires admin or moderator role
|
||||||
|
|
||||||
|
### AI Integration (src/utils/ai.js)
|
||||||
|
|
||||||
|
The `callAI(messages, options)` function is the single entry point for all AI calls:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
var result = await callAI([
|
||||||
|
{ role: 'system', content: systemPrompt },
|
||||||
|
{ role: 'user', content: userContent }
|
||||||
|
], { model: selectedModel });
|
||||||
|
// result = { text: "...", usage: { input, output } }
|
||||||
|
```
|
||||||
|
|
||||||
|
Internally, `callAI` routes to the active provider:
|
||||||
|
- **Bedrock**: uses `InvokeModelCommand` with Converse API
|
||||||
|
- **Azure/OpenRouter/LiteLLM**: uses OpenAI SDK `chat.completions.create()`
|
||||||
|
- **Vertex**: uses `@google-cloud/vertexai` GenerativeModel
|
||||||
|
|
||||||
|
Provider is selected once at startup. The `model` param in options overrides the default.
|
||||||
|
|
||||||
|
### Settings System (src/utils/config.js)
|
||||||
|
|
||||||
|
Database-backed configuration with in-memory caching:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
var config = require('../utils/config');
|
||||||
|
var value = await config.get('feature.read_aloud', 'true'); // key, default
|
||||||
|
await config.set('registration_enabled', 'false');
|
||||||
|
```
|
||||||
|
|
||||||
|
Cache TTL is 2 minutes. Settings are stored in the `app_settings` table. Environment variables take precedence for provider credentials, but most app settings are DB-backed.
|
||||||
|
|
||||||
|
### Prompt System (src/utils/prompts.js)
|
||||||
|
|
||||||
|
All AI prompts are defined as a `PROMPTS` object:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
module.exports = {
|
||||||
|
hpiEncounter: "You are a pediatric physician...",
|
||||||
|
soapFull: "Generate a complete SOAP note...",
|
||||||
|
// ... etc
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
On startup, DB overrides are loaded from `app_settings` where `key LIKE 'prompt.%'`. Admin can edit prompts from the Admin Panel without restarting.
|
||||||
|
|
||||||
|
### Logging (src/utils/logger.js)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
var logger = require('../utils/logger');
|
||||||
|
logger.audit(userId, 'action_name', 'details', req, { category: 'auth' });
|
||||||
|
logger.apiCall(userId, endpoint, { model, tokens_input, tokens_output, duration_ms });
|
||||||
|
logger.access(userId, 'login', req, true);
|
||||||
|
```
|
||||||
|
|
||||||
|
All log entries go to both the database and daily log files at `/data/logs/YYYY-MM-DD.log`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Adding a New Feature
|
||||||
|
|
||||||
|
### Adding a New AI Endpoint
|
||||||
|
|
||||||
|
1. Create a route file in `src/routes/`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
var express = require('express');
|
||||||
|
var router = express.Router();
|
||||||
|
var { callAI } = require('../utils/ai');
|
||||||
|
var { authMiddleware } = require('../middleware/auth');
|
||||||
|
var PROMPTS = require('../utils/prompts');
|
||||||
|
|
||||||
|
router.post('/my-feature', authMiddleware, async function(req, res) {
|
||||||
|
try {
|
||||||
|
var { transcript, model } = req.body;
|
||||||
|
var result = await callAI([
|
||||||
|
{ role: 'system', content: PROMPTS.myFeature },
|
||||||
|
{ role: 'user', content: transcript }
|
||||||
|
], { model });
|
||||||
|
res.json({ success: true, text: result.text });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Register it in `server.js`:
|
||||||
|
```javascript
|
||||||
|
app.use('/api', require('./src/routes/myFeature'));
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Add the prompt to `src/utils/prompts.js`:
|
||||||
|
```javascript
|
||||||
|
myFeature: "You are a pediatric physician. Generate..."
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Create a frontend component in `public/components/myfeature.html`
|
||||||
|
|
||||||
|
5. Add a tab button in `public/index.html` sidebar
|
||||||
|
|
||||||
|
6. Create `public/js/myFeature.js` with a `tabChanged` listener
|
||||||
|
|
||||||
|
### Adding a New Database Table
|
||||||
|
|
||||||
|
Add the `CREATE TABLE IF NOT EXISTS` statement inside `initDatabase()` in `src/db/database.js`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
try { await client.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS my_table (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
data TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_my_table_user ON my_table(user_id);
|
||||||
|
`); } catch(e) {}
|
||||||
|
```
|
||||||
|
|
||||||
|
No migration files needed. The `IF NOT EXISTS` pattern is idempotent.
|
||||||
|
|
||||||
|
### Adding a New Setting
|
||||||
|
|
||||||
|
1. Add the default in the `defaults` array in `initDatabase()`:
|
||||||
|
```javascript
|
||||||
|
['my_setting.key', 'default_value'],
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Read it in route handlers:
|
||||||
|
```javascript
|
||||||
|
var value = await db.getSetting('my_setting.key');
|
||||||
|
```
|
||||||
|
|
||||||
|
3. If it should be admin-editable, ensure the admin config route handles it (the generic `POST /api/admin/config` already saves any key-value pair).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Design Decisions
|
||||||
|
|
||||||
|
### Why Vanilla JS (No Framework)
|
||||||
|
|
||||||
|
The frontend uses plain JavaScript instead of React/Vue because:
|
||||||
|
- Simpler deployment (no build step, no bundler)
|
||||||
|
- Components are HTML fragments loaded via fetch
|
||||||
|
- State is managed via DOM elements and window globals
|
||||||
|
- Works with aggressive CSP (no eval needed for templates)
|
||||||
|
- Easy to modify any part without understanding a framework's lifecycle
|
||||||
|
|
||||||
|
### Why PostgreSQL Placeholders Are Auto-Converted
|
||||||
|
|
||||||
|
The codebase was originally SQLite, then migrated to PostgreSQL. The `convertPlaceholders()` function in `database.js` converts `?` to `$1, $2, ...` so existing queries work unchanged. New code can use either style.
|
||||||
|
|
||||||
|
### Why Prompts Are DB-Overridable
|
||||||
|
|
||||||
|
Clinicians have specific documentation preferences. Making prompts editable from the admin panel means the team can tune AI output without redeploying. The `prompt.*` keys in `app_settings` override the hardcoded defaults in `prompts.js`.
|
||||||
|
|
||||||
|
### Why Audio Backups Use PostgreSQL (Not Filesystem)
|
||||||
|
|
||||||
|
Audio is stored as gzip-compressed BYTEA in PostgreSQL because:
|
||||||
|
- Works in containerized environments without persistent volumes for temp files
|
||||||
|
- Auto-expires via SQL (`expires_at` column + hourly cleanup)
|
||||||
|
- Per-user access control is handled by the same auth system
|
||||||
|
- No orphaned files if the container restarts
|
||||||
|
|
||||||
|
### Why Corrections Are Low-Priority Style Hints
|
||||||
|
|
||||||
|
The physician memory/correction system injects past edits into AI prompts. Originally these were labeled "APPLY these preferences" which caused smaller models to hallucinate content from the correction examples instead of the current transcript. The injection was changed to `[STYLE HINTS (low priority)]` with truncated 200-char snippets to prevent this.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## AI Learning System (Correction Tracker)
|
||||||
|
|
||||||
|
The app learns from physician edits over time, similar to Dragon Medical's adaptive learning. Here is how it works:
|
||||||
|
|
||||||
|
### Flow
|
||||||
|
|
||||||
|
1. **Track**: When AI generates a note, `trackAIOutput(elementId, originalText)` stores the original AI output in memory (`correctionTracker.js`).
|
||||||
|
2. **Edit**: The physician edits the generated note directly in the contenteditable output area.
|
||||||
|
3. **Save**: When the physician clicks Save, `saveCorrection(elementId, section)` compares the current text against the stored original.
|
||||||
|
4. **Store**: If there is a meaningful difference (more than 2 words or 20 characters changed), the before/after diff is sent to `POST /api/memories/correction` and stored in the `user_memories` table with category `correction_{section}`.
|
||||||
|
5. **Apply**: On future generations, the last 10 corrections per category are fetched via `GET /api/memories/context` and injected into the AI prompt as low-priority style hints.
|
||||||
|
|
||||||
|
### Which tabs support it
|
||||||
|
|
||||||
|
| Tab | trackAIOutput | saveCorrection (on Save) |
|
||||||
|
|-----|---------------|--------------------------|
|
||||||
|
| Live Encounter | Yes (`enc-hpi-text`) | Yes |
|
||||||
|
| SOAP | Yes (`soap-text`) | Yes |
|
||||||
|
| Dictation | Yes (`dict-hpi-text`) | Yes |
|
||||||
|
| Sick Visit | Yes (`sick-note-text`) | Yes |
|
||||||
|
| Well Visit | Yes (`wv-note-text`) | Yes |
|
||||||
|
| Hospital Course | No (output varies by format) | Yes (if tracked) |
|
||||||
|
| Chart Review | No (output varies by input) | Yes (if tracked) |
|
||||||
|
|
||||||
|
### Important notes
|
||||||
|
|
||||||
|
- Corrections are only captured when the user clicks **Save**. Editing without saving does not trigger learning.
|
||||||
|
- The system keeps a maximum of 20 corrections per category, auto-deleting the oldest.
|
||||||
|
- Corrections are injected as `[STYLE HINTS (low priority)]` with 200-character snippets to avoid confusing smaller AI models.
|
||||||
|
- Users can view and delete their corrections in Settings > AI Corrections.
|
||||||
|
|
||||||
|
### Why Auth Middleware Checks Cookie After Bearer
|
||||||
|
|
||||||
|
The auth middleware first checks the `Authorization: Bearer` header, then falls back to the `ped_auth` cookie. If a Bearer header is present but the token is empty (which happens with SSO-only users who have no localStorage token), the middleware now correctly treats it as absent and falls through to the cookie. This was a bug fix -- previously, an empty Bearer token would block cookie auth entirely.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Route File Reference
|
||||||
|
|
||||||
|
| File | Mount Point | Auth | Purpose |
|
||||||
|
|------|------------|------|---------|
|
||||||
|
| `auth.js` | `/api/auth` | Public | Registration, login, 2FA, email verification, password reset |
|
||||||
|
| `oidc.js` | `/api/auth` | Public | OpenID Connect SSO flow |
|
||||||
|
| `hpi.js` | `/api` | Auth | HPI generation (encounter + dictation) |
|
||||||
|
| `soap.js` | `/api` | Auth | SOAP note generation |
|
||||||
|
| `chartReview.js` | `/api` | Auth | Chart review / precharting |
|
||||||
|
| `hospitalCourse.js` | `/api` | Auth | Hospital course generation |
|
||||||
|
| `wellVisit.js` | `/api` | Auth | Well visit + SSHADESS |
|
||||||
|
| `sickVisit.js` | `/api` | Auth | Sick visit documentation |
|
||||||
|
| `milestones.js` | `/api` | Auth | Developmental milestone narratives |
|
||||||
|
| `refine.js` | `/api` | Auth | Refine, shorten, clarify documents |
|
||||||
|
| `transcribe.js` | `/api` | Auth | Speech-to-text (5 providers) |
|
||||||
|
| `tts.js` | `/api` | Auth | Text-to-speech (3 providers) |
|
||||||
|
| `encounters.js` | `/api` | Auth | Save/load/delete encounters |
|
||||||
|
| `memories.js` | `/api` | Auth | Physician templates + corrections |
|
||||||
|
| `audioBackups.js` | `/api` | Auth | Audio backup storage |
|
||||||
|
| `documents.js` | `/api` | Auth | S3 document management |
|
||||||
|
| `userPreferences.js` | `/api` | Auth | STT/TTS preferences |
|
||||||
|
| `nextcloud.js` | `/api` | Auth | WebDAV integration |
|
||||||
|
| `logs.js` | `/api` | Auth | Usage and audit logs |
|
||||||
|
| `admin.js` | `/api/admin` | Admin | User management |
|
||||||
|
| `adminConfig.js` | `/api/admin` | Admin | Settings, prompts, models, SMTP, OIDC |
|
||||||
|
| `adminMilestones.js` | `/api/admin` | Admin | Milestone data management |
|
||||||
|
| `learningHub.js` | `/api/learning` | Auth | Learning content delivery + quizzes |
|
||||||
|
| `learningAdmin.js` | `/api/admin/learning` | Moderator | Learning CMS CRUD |
|
||||||
|
| `learningAI.js` | `/api/admin/learning` | Moderator | AI content generation, PPTX, slides |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Frontend JS File Reference
|
||||||
|
|
||||||
|
| File | Loads After | Purpose |
|
||||||
|
|------|-------------|---------|
|
||||||
|
| `app.js` | audioBackup, correctionTracker | Tab navigation, model selector, AudioRecorder, transcription |
|
||||||
|
| `auth.js` | app.js | Login, register, SSO, session management, Turnstile |
|
||||||
|
| `liveEncounter.js` | auth.js | Recording UI, speech recognition, live transcript |
|
||||||
|
| `soap.js` | auth.js | SOAP note tab |
|
||||||
|
| `hospitalCourse.js` | auth.js | Hospital course tab |
|
||||||
|
| `chartReview.js` | auth.js | Chart review tab |
|
||||||
|
| `wellVisit.js` | auth.js | Well visit tab |
|
||||||
|
| `sickVisit.js` | auth.js | Sick visit tab |
|
||||||
|
| `encounters.js` | auth.js | Save/load/resume encounters |
|
||||||
|
| `milestones.js` | milestonesData.js | Milestone selection and generation |
|
||||||
|
| `shadess.js` | auth.js | SSHADESS adolescent assessment |
|
||||||
|
| `learningHub.js` | auth.js | Learning Hub + CMS (1843 lines) |
|
||||||
|
| `memories.js` | auth.js | Physician templates + corrections UI |
|
||||||
|
| `documents.js` | auth.js | S3 document upload/download |
|
||||||
|
| `admin.js` | auth.js | Admin panel (users, settings, prompts, models) |
|
||||||
|
| `audioBackup.js` | (early) | Audio backup save/list/retry/delete |
|
||||||
|
| `correctionTracker.js` | (early) | Track AI output edits for learning |
|
||||||
|
| `browserWhisper.js` | (early) | In-browser Whisper via WebAssembly |
|
||||||
|
| `speechRecognition.js` | (early) | Web Speech API wrapper |
|
||||||
|
| `voicePreferences.js` | auth.js | STT/TTS model/voice selection |
|
||||||
|
| `nextcloud.js` | auth.js | Nextcloud connection and export |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing Locally
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start just the database
|
||||||
|
docker compose up -d postgres
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Copy and configure env
|
||||||
|
cp .env.example .env
|
||||||
|
# Edit .env with your provider keys
|
||||||
|
|
||||||
|
# Start the app
|
||||||
|
node server.js
|
||||||
|
```
|
||||||
|
|
||||||
|
The app runs on `http://localhost:3000`. Without `APP_URL` set, CORS allows all origins (development mode).
|
||||||
|
|
||||||
|
## Common Tasks
|
||||||
|
|
||||||
|
### Change the default AI temperature
|
||||||
|
|
||||||
|
Edit the `callAI` function in `src/utils/ai.js`. The default temperature is `0.3` for most providers.
|
||||||
|
|
||||||
|
### Add a new AI prompt
|
||||||
|
|
||||||
|
1. Add the prompt text to `src/utils/prompts.js`
|
||||||
|
2. Use it in your route: `var PROMPTS = require('../utils/prompts'); ... PROMPTS.myPrompt`
|
||||||
|
3. It becomes admin-editable automatically via `prompt.myPrompt` in the DB
|
||||||
|
|
||||||
|
### Override a prompt without code changes
|
||||||
|
|
||||||
|
In the admin panel, go to Settings > Prompts. Edit any prompt. The override is stored in `app_settings` with key `prompt.{name}` and takes effect immediately (no restart needed).
|
||||||
|
|
||||||
|
### Add a model to the dropdown
|
||||||
|
|
||||||
|
From the admin panel, go to Models > Add Custom Model. Enter:
|
||||||
|
- **Model ID**: the exact string the provider expects (e.g., `gemini-2.5-flash` for LiteLLM)
|
||||||
|
- **Display Name**: what users see
|
||||||
|
- **Cost**: price string (e.g., `~$0.001`)
|
||||||
|
- **Category**: determines dropdown group (free/fast/smart/premium)
|
||||||
|
|
||||||
|
The model appears immediately for all users.
|
||||||
|
|
||||||
|
### Debug an AI call
|
||||||
|
|
||||||
|
Check `docker compose logs -f pediatric-scribe` for lines like:
|
||||||
|
```
|
||||||
|
[AI] bedrock/anthropic.agent-config-3-haiku... 1247 tokens in 2.3s
|
||||||
|
```
|
||||||
|
|
||||||
|
Or query the `api_log` table for detailed metrics:
|
||||||
|
```sql
|
||||||
|
SELECT endpoint, model_used, tokens_input, tokens_output, duration_ms, cost_estimate
|
||||||
|
FROM api_log ORDER BY timestamp DESC LIMIT 20;
|
||||||
|
```
|
||||||
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. |
|
||||||
115
docs/migrations.md
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
# Database Migrations
|
||||||
|
|
||||||
|
The app uses [node-pg-migrate](https://github.com/salsita/node-pg-migrate)
|
||||||
|
for versioned, reversible schema changes.
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
Boot sequence:
|
||||||
|
|
||||||
|
1. **Baseline init** — `src/db/database.js` runs `CREATE TABLE IF NOT EXISTS`
|
||||||
|
and `ALTER TABLE ADD COLUMN IF NOT EXISTS` for the existing schema. This
|
||||||
|
is the implicit baseline — everything that was in place before
|
||||||
|
migrations were introduced. Idempotent on every boot.
|
||||||
|
2. **Migrations** — `src/db/migrate.js` runs every file in `/app/migrations/`
|
||||||
|
that hasn't already been recorded in the `pgmigrations` table, in
|
||||||
|
filename order. Each applied migration is inserted into `pgmigrations`
|
||||||
|
so it only runs once.
|
||||||
|
|
||||||
|
New schema changes should go in versioned migration files, not in the
|
||||||
|
inline `database.js` init.
|
||||||
|
|
||||||
|
## Creating a migration
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec -w /app pediatric-ai-scribe npm run migrate:new -- add_avatar_url
|
||||||
|
```
|
||||||
|
|
||||||
|
Creates a file like `migrations/1744601234567_add_avatar_url.js` with
|
||||||
|
empty `up()` and `down()` functions. Edit it:
|
||||||
|
|
||||||
|
```js
|
||||||
|
exports.up = (pgm) => {
|
||||||
|
pgm.addColumn('users', {
|
||||||
|
avatar_url: { type: 'text', notNull: false }
|
||||||
|
});
|
||||||
|
pgm.createIndex('users', 'avatar_url');
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = (pgm) => {
|
||||||
|
pgm.dropIndex('users', 'avatar_url');
|
||||||
|
pgm.dropColumn('users', 'avatar_url');
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Full API: https://salsita.github.io/node-pg-migrate/
|
||||||
|
|
||||||
|
## Running migrations
|
||||||
|
|
||||||
|
Migrations apply automatically on app boot. To run them manually (e.g.
|
||||||
|
before a restart):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec -w /app pediatric-ai-scribe npm run migrate:up
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rolling back
|
||||||
|
|
||||||
|
Roll back the most recent migration:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec -w /app pediatric-ai-scribe npm run migrate:down
|
||||||
|
```
|
||||||
|
|
||||||
|
This calls the file's `down()`. If `down()` is empty or missing, the
|
||||||
|
rollback is a no-op but the migration is removed from `pgmigrations`
|
||||||
|
— meaning the next `up` will reapply it.
|
||||||
|
|
||||||
|
## Viewing state
|
||||||
|
|
||||||
|
Which migrations have been applied:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec -w /app pediatric-ai-scribe npm run migrate:status
|
||||||
|
```
|
||||||
|
|
||||||
|
Or directly:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec pedscribe-db psql -U pedscribe -d pedscribe \
|
||||||
|
-c "SELECT id, name, run_on FROM pgmigrations ORDER BY id;"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Raw SQL migrations
|
||||||
|
|
||||||
|
If pgm's JS helpers are limiting, drop to SQL:
|
||||||
|
|
||||||
|
```js
|
||||||
|
exports.up = (pgm) => {
|
||||||
|
pgm.sql(`
|
||||||
|
CREATE INDEX CONCURRENTLY idx_audit_log_action
|
||||||
|
ON audit_log (action)
|
||||||
|
WHERE action IN ('login', 'login_failed', 'session_idle_timeout');
|
||||||
|
`);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: `CREATE INDEX CONCURRENTLY` cannot run inside a transaction. For
|
||||||
|
that you need `exports.disableTransaction = true;` in the migration file.
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
- One logical change per file. Don't bundle unrelated alters.
|
||||||
|
- Always write `down()` unless rollback is fundamentally impossible
|
||||||
|
(e.g., dropping a column that had unique data).
|
||||||
|
- Name files by what the change does (`add_foo`, `backfill_bar`), not
|
||||||
|
the ticket number.
|
||||||
|
- Migrations run in filename order — the timestamp prefix ensures order
|
||||||
|
across checkouts from different devs.
|
||||||
|
- Never edit an already-applied migration. Write a new one to fix it.
|
||||||
|
|
||||||
|
## Relationship to the inline init
|
||||||
|
|
||||||
|
`src/db/database.js` still runs on every boot and handles the pre-migration
|
||||||
|
baseline. Do not add new schema changes there — use migrations. The inline
|
||||||
|
init will gradually shrink as old CREATE TABLE statements age out.
|
||||||
109
docs/mobile-build.md
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
# Mobile build & release
|
||||||
|
|
||||||
|
Capacitor 6 wrapper. Android only today; iOS project exists but requires macOS
|
||||||
|
+ Xcode to produce an `.ipa`.
|
||||||
|
|
||||||
|
## One-time setup
|
||||||
|
|
||||||
|
### Keystore
|
||||||
|
|
||||||
|
```bash
|
||||||
|
keytool -genkeypair -v -keystore ~/pedscribe-release.jks \
|
||||||
|
-keyalg RSA -keysize 2048 -validity 10000 -alias pedscribe
|
||||||
|
```
|
||||||
|
|
||||||
|
Store the password in a password manager. Back up the `.jks` file off the
|
||||||
|
machine. Losing it = can't sign updates; Play Store requires signature
|
||||||
|
continuity (unless you're on Play App Signing).
|
||||||
|
|
||||||
|
### Android Studio (optional, IDE workflow only)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export CAPACITOR_ANDROID_STUDIO_PATH="/snap/android-studio/current/bin/studio.sh"
|
||||||
|
npx cap open android
|
||||||
|
```
|
||||||
|
|
||||||
|
## CI build (preferred)
|
||||||
|
|
||||||
|
Tag-triggered. Push any `vX.Y.Z` tag → `.github/workflows/android-release.yml`
|
||||||
|
builds a signed APK on a GitHub runner and attaches it to the matching release.
|
||||||
|
|
||||||
|
Required repo secrets (set once, via Settings → Secrets and variables → Actions
|
||||||
|
or `gh secret set`):
|
||||||
|
|
||||||
|
- `ANDROID_KEYSTORE_BASE64` — `base64 -w0 ~/pedscribe-release.jks`
|
||||||
|
- `ANDROID_KEYSTORE_PASSWORD`
|
||||||
|
- `ANDROID_KEY_ALIAS` — `pedscribe`
|
||||||
|
- `ANDROID_KEY_PASSWORD`
|
||||||
|
|
||||||
|
Tag a release:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# conventional-commits prefix auto-tags (see CONTRIBUTING.md)
|
||||||
|
git commit -m "feat: ..." && git push # auto-version workflow bumps minor
|
||||||
|
git commit -m "fix: ..." && git push # auto-version workflow bumps patch
|
||||||
|
|
||||||
|
# or force an exact version
|
||||||
|
scripts/release.sh 6.2.0 --push
|
||||||
|
```
|
||||||
|
|
||||||
|
APK lands at the GitHub release; `/releases/latest` link in the login page
|
||||||
|
resolves to it automatically. Obtanium subscribers (`github.com/<owner>/<repo>`)
|
||||||
|
pick up the update on next poll.
|
||||||
|
|
||||||
|
## Local build (fallback / debugging)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd mobile
|
||||||
|
npm install
|
||||||
|
npx cap sync android
|
||||||
|
cd android
|
||||||
|
./gradlew assembleRelease \
|
||||||
|
-Pandroid.injected.signing.store.file=$HOME/pedscribe-release.jks \
|
||||||
|
-Pandroid.injected.signing.store.password='<pass>' \
|
||||||
|
-Pandroid.injected.signing.key.alias=pedscribe \
|
||||||
|
-Pandroid.injected.signing.key.password='<pass>'
|
||||||
|
```
|
||||||
|
|
||||||
|
Output: `android/app/build/outputs/apk/release/app-release.apk`
|
||||||
|
For Play Store, swap `assembleRelease` → `bundleRelease`; output: `.aab` under
|
||||||
|
`bundle/release/`.
|
||||||
|
|
||||||
|
### Single-quote the password
|
||||||
|
|
||||||
|
Keystore passwords with shell metacharacters (`)`, `$`, `!`, space, etc.) must
|
||||||
|
be single-quoted. Backslash line continuations get eaten by some terminal
|
||||||
|
paste handlers — prefer one-line commands.
|
||||||
|
|
||||||
|
## Reinstall on device
|
||||||
|
|
||||||
|
```bash
|
||||||
|
adb install -r android/app/build/outputs/apk/release/app-release.apk
|
||||||
|
```
|
||||||
|
|
||||||
|
`-r` keeps app data (saved server URL, auth token in Keystore, IndexedDB).
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- **JDK 17 only.** Newer JDK (21/25) breaks Android Gradle Plugin. Set
|
||||||
|
`org.gradle.java.home=/usr/lib/jvm/java-17-openjdk-amd64` in `~/.gradle/gradle.properties`
|
||||||
|
if the system default is different.
|
||||||
|
- **QEMU multi-arch Docker builds fail** with SIGILL on native modules (argon2).
|
||||||
|
Docker Hub workflow is x86-only. Use a native ARM runner if you need ARM64.
|
||||||
|
- **`npx cap` must run inside `mobile/`**, not repo root.
|
||||||
|
- **Foreground recording on Android 14+** requires `foregroundServiceType="microphone"`
|
||||||
|
in `AndroidManifest.xml` plus the 3-arg `startForeground(id, notif, TYPE_MICROPHONE)`.
|
||||||
|
Already applied.
|
||||||
|
- **Mic "denied" after permission grant** — WebView intercepts the prompt.
|
||||||
|
Fix: long-press app icon → App info → Permissions → Microphone → Allow.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
| Path | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `mobile/capacitor.config.json` | app ID, name, WebView config, plugin opts |
|
||||||
|
| `mobile/src/` | launcher HTML (server URL entry) |
|
||||||
|
| `mobile/android/app/src/main/java/com/pedshub/scribe/MainActivity.java` | JS bridge + WebView mic permission |
|
||||||
|
| `mobile/android/app/src/main/java/com/pedshub/scribe/AudioRecordingService.java` | foreground service for background recording |
|
||||||
|
| `mobile/android/app/src/main/AndroidManifest.xml` | permissions, intents, backup rules |
|
||||||
|
| `.github/workflows/android-release.yml` | CI build |
|
||||||
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.
|
||||||
29
migrations/1744600000000_example-no-op.js
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
/**
|
||||||
|
* Example migration — demonstrates the shape.
|
||||||
|
* This one is a NO-OP so the tooling can boot cleanly without
|
||||||
|
* interfering with the existing baseline in src/db/database.js.
|
||||||
|
*
|
||||||
|
* For a real change, replace the body with:
|
||||||
|
* exports.up = (pgm) => {
|
||||||
|
* pgm.addColumn('users', {
|
||||||
|
* avatar_url: { type: 'text' }
|
||||||
|
* });
|
||||||
|
* };
|
||||||
|
* exports.down = (pgm) => {
|
||||||
|
* pgm.dropColumn('users', 'avatar_url');
|
||||||
|
* };
|
||||||
|
*
|
||||||
|
* Full API: https://salsita.github.io/node-pg-migrate/
|
||||||
|
*/
|
||||||
|
|
||||||
|
exports.up = async () => {
|
||||||
|
// intentionally empty
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = async () => {
|
||||||
|
// intentionally empty
|
||||||
|
};
|
||||||
|
|
||||||
|
// Tell node-pg-migrate this migration doesn't need a transaction —
|
||||||
|
// lets future migrations that need CREATE INDEX CONCURRENTLY etc run.
|
||||||
|
exports.shorthands = undefined;
|
||||||
16
migrations/1744650000000_add-encounter-version.js
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
/**
|
||||||
|
* Adds a `version` column to saved_encounters for optimistic locking.
|
||||||
|
* Concurrent edits previously clobbered each other silently (last
|
||||||
|
* write wins). The route compares the caller's known version against
|
||||||
|
* the row's current version and rejects with 409 when they diverge.
|
||||||
|
*/
|
||||||
|
|
||||||
|
exports.up = (pgm) => {
|
||||||
|
pgm.addColumn('saved_encounters', {
|
||||||
|
version: { type: 'integer', notNull: true, default: 1 }
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = (pgm) => {
|
||||||
|
pgm.dropColumn('saved_encounters', 'version');
|
||||||
|
};
|
||||||
36
mobile/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
# Node / npm — keep package-lock.json for reproducible CI builds,
|
||||||
|
# ignore only the installed tree.
|
||||||
|
node_modules/
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# Capacitor generated files (rewritten by `npx cap sync`)
|
||||||
|
# Keep the *project* (mobile/android/, mobile/ios/) but not the
|
||||||
|
# per-sync mirrors.
|
||||||
|
android/app/src/main/assets/public/
|
||||||
|
android/app/src/main/assets/capacitor.config.json
|
||||||
|
android/app/src/main/assets/capacitor.plugins.json
|
||||||
|
android/app/capacitor.build.gradle
|
||||||
|
android/capacitor.settings.gradle
|
||||||
|
android/capacitor-cordova-android-plugins/
|
||||||
|
|
||||||
|
ios/App/App/public/
|
||||||
|
ios/App/capacitor-cordova-ios-plugins/
|
||||||
|
ios/App/Pods/
|
||||||
|
ios/App/Podfile.lock
|
||||||
|
|
||||||
|
# Android build outputs & local state
|
||||||
|
android/.gradle/
|
||||||
|
android/build/
|
||||||
|
android/app/build/
|
||||||
|
android/app/release/
|
||||||
|
android/local.properties
|
||||||
|
android/app/release/output-metadata.json
|
||||||
|
android/.idea/
|
||||||
|
*.apk
|
||||||
|
*.aab
|
||||||
|
*.jks
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
.DS_Store
|
||||||
152
mobile/README.md
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
# PedScribe Mobile App
|
||||||
|
|
||||||
|
Native mobile wrapper for Pediatric AI Scribe using Capacitor. Provides background audio recording, push notifications, haptic feedback, deep linking, and share intent support on both iOS and Android.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Background recording that survives screen lock (foreground service on Android, background audio on iOS)
|
||||||
|
- Configurable server URL (supports self-hosted instances)
|
||||||
|
- Haptic feedback on recording start/stop
|
||||||
|
- Keep screen awake during recording
|
||||||
|
- Deep linking (pedscribe:// and https://app.pedshub.com)
|
||||||
|
- Share intent (receive text/PDFs from other apps)
|
||||||
|
- Push notification support
|
||||||
|
- App Store and Play Store ready
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Node.js 18+
|
||||||
|
- Android Studio (for Android builds): `sudo snap install android-studio --classic`
|
||||||
|
- Xcode 15+ (for iOS builds, macOS only)
|
||||||
|
- Apple Developer account ($99/yr for App Store)
|
||||||
|
- Google Play Developer account ($25 one-time)
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd mobile
|
||||||
|
npm install
|
||||||
|
npx cap sync
|
||||||
|
```
|
||||||
|
|
||||||
|
## Build Android
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Open in Android Studio
|
||||||
|
npx cap open android
|
||||||
|
|
||||||
|
# Build menu: Build > Generate Signed Bundle / APK > APK
|
||||||
|
# Sign with your keystore (create one on first build)
|
||||||
|
# APK output: android/app/build/outputs/apk/release/
|
||||||
|
|
||||||
|
# Or build from command line:
|
||||||
|
cd android && ./gradlew assembleRelease
|
||||||
|
```
|
||||||
|
|
||||||
|
## Build iOS (macOS only)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Open in Xcode
|
||||||
|
npx cap open ios
|
||||||
|
|
||||||
|
# In Xcode:
|
||||||
|
# 1. Select your team/signing certificate
|
||||||
|
# 2. Product > Archive
|
||||||
|
# 3. Distribute App > App Store Connect
|
||||||
|
```
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
1. App launches with a local launcher page
|
||||||
|
2. First launch: user enters their PedScribe server URL (default: app.pedshub.com)
|
||||||
|
3. URL is saved locally for future launches
|
||||||
|
4. App navigates to the remote web app inside a native WebView
|
||||||
|
5. Native plugins provide background recording, haptics, and push notifications
|
||||||
|
|
||||||
|
### Background Recording
|
||||||
|
|
||||||
|
**Android:** `AudioRecordingService` is a foreground service that:
|
||||||
|
- Acquires a partial wake lock (CPU stays active, screen can sleep)
|
||||||
|
- Shows a persistent notification ("Recording in progress...")
|
||||||
|
- Includes a "Stop Recording" quick action in the notification
|
||||||
|
- Maximum 1-hour wake lock duration
|
||||||
|
|
||||||
|
**iOS:** Uses `UIBackgroundModes: audio` in Info.plist, which tells iOS to keep the app alive for audio capture when backgrounded or screen-locked.
|
||||||
|
|
||||||
|
### Deep Linking
|
||||||
|
|
||||||
|
- `pedscribe://` custom URL scheme opens the app directly
|
||||||
|
- `https://app.pedshub.com` links open in the app instead of the browser (Android App Links)
|
||||||
|
|
||||||
|
### Share Intent (Android)
|
||||||
|
|
||||||
|
Other apps can share text or PDFs directly into PedScribe:
|
||||||
|
- Share a lab result from your email into the Chart Review tab
|
||||||
|
- Share a referral note into the Hospital Course tab
|
||||||
|
|
||||||
|
## Capacitor Plugins Included
|
||||||
|
|
||||||
|
| Plugin | Purpose |
|
||||||
|
|--------|---------|
|
||||||
|
| @capacitor/app | App lifecycle management |
|
||||||
|
| @capacitor/haptics | Vibration feedback on recording start/stop |
|
||||||
|
| @capacitor/keyboard | Keyboard management for WebView |
|
||||||
|
| @capacitor/push-notifications | Push notification support |
|
||||||
|
| @capacitor/screen-orientation | Screen orientation control |
|
||||||
|
| @capacitor/share | Native share dialog |
|
||||||
|
| @capacitor/splash-screen | Launch splash screen |
|
||||||
|
| @capacitor/status-bar | Status bar styling |
|
||||||
|
|
||||||
|
## App Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
mobile/
|
||||||
|
capacitor.config.json # Capacitor configuration
|
||||||
|
package.json # Dependencies
|
||||||
|
src/
|
||||||
|
index.html # Launcher page (server URL config)
|
||||||
|
launcher.js # Auto-redirect + native feature init
|
||||||
|
launcher.css # Launcher styles
|
||||||
|
android/ # Android native project
|
||||||
|
app/src/main/
|
||||||
|
java/com/pedshub/scribe/
|
||||||
|
MainActivity.java
|
||||||
|
AudioRecordingService.java
|
||||||
|
AndroidManifest.xml # Permissions, deep links, share intent
|
||||||
|
ios/ # iOS native project
|
||||||
|
App/App/
|
||||||
|
Info.plist # Background audio, microphone, deep links
|
||||||
|
```
|
||||||
|
|
||||||
|
## Updating the Web App
|
||||||
|
|
||||||
|
The mobile app wraps the remote web app — updating the server automatically updates all mobile clients. No app store update needed for web changes.
|
||||||
|
|
||||||
|
To update native features (plugins, permissions, splash screen):
|
||||||
|
```bash
|
||||||
|
cd mobile
|
||||||
|
npm install
|
||||||
|
npx cap sync
|
||||||
|
# Then rebuild in Android Studio / Xcode
|
||||||
|
```
|
||||||
|
|
||||||
|
## Generating App Icons
|
||||||
|
|
||||||
|
Replace the default Capacitor icons with PedScribe branding:
|
||||||
|
|
||||||
|
1. Create a 1024x1024 PNG icon
|
||||||
|
2. Install the assets tool: `npm install -D @capacitor/assets`
|
||||||
|
3. Place your icon as `assets/icon-only.png` and `assets/splash.png`
|
||||||
|
4. Run: `npx capacitor-assets generate`
|
||||||
|
|
||||||
|
This generates all required sizes for both platforms.
|
||||||
|
|
||||||
|
## App Store Listing Suggestions
|
||||||
|
|
||||||
|
**Title:** PedScribe - Pediatric AI Scribe
|
||||||
|
**Subtitle:** Voice-to-Note Clinical Documentation
|
||||||
|
**Category:** Medical
|
||||||
|
**Keywords:** pediatric, scribe, medical, documentation, HPI, SOAP, clinical, AI, voice
|
||||||
|
|
||||||
|
**Description:**
|
||||||
|
PedScribe is an AI-powered clinical documentation tool for pediatric physicians. Record patient encounters, and the AI generates structured medical notes — HPIs, SOAP notes, hospital courses, chart reviews, and more. Includes pediatric calculators, developmental milestone tracking, and a learning hub with quizzes. Self-hosted for maximum privacy with HIPAA-compliant AI providers.
|
||||||
101
mobile/android/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
# Using Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore
|
||||||
|
|
||||||
|
# Built application files
|
||||||
|
*.apk
|
||||||
|
*.aar
|
||||||
|
*.ap_
|
||||||
|
*.aab
|
||||||
|
|
||||||
|
# Files for the ART/Dalvik VM
|
||||||
|
*.dex
|
||||||
|
|
||||||
|
# Java class files
|
||||||
|
*.class
|
||||||
|
|
||||||
|
# Generated files
|
||||||
|
bin/
|
||||||
|
gen/
|
||||||
|
out/
|
||||||
|
# Uncomment the following line in case you need and you don't have the release build type files in your app
|
||||||
|
# release/
|
||||||
|
|
||||||
|
# Gradle files
|
||||||
|
.gradle/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# Local configuration file (sdk path, etc)
|
||||||
|
local.properties
|
||||||
|
|
||||||
|
# Proguard folder generated by Eclipse
|
||||||
|
proguard/
|
||||||
|
|
||||||
|
# Log Files
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Android Studio Navigation editor temp files
|
||||||
|
.navigation/
|
||||||
|
|
||||||
|
# Android Studio captures folder
|
||||||
|
captures/
|
||||||
|
|
||||||
|
# IntelliJ
|
||||||
|
*.iml
|
||||||
|
.idea/workspace.xml
|
||||||
|
.idea/tasks.xml
|
||||||
|
.idea/gradle.xml
|
||||||
|
.idea/assetWizardSettings.xml
|
||||||
|
.idea/dictionaries
|
||||||
|
.idea/libraries
|
||||||
|
# Android Studio 3 in .gitignore file.
|
||||||
|
.idea/caches
|
||||||
|
.idea/modules.xml
|
||||||
|
# Comment next line if keeping position of elements in Navigation Editor is relevant for you
|
||||||
|
.idea/navEditor.xml
|
||||||
|
|
||||||
|
# Keystore files
|
||||||
|
# Uncomment the following lines if you do not want to check your keystore files in.
|
||||||
|
#*.jks
|
||||||
|
#*.keystore
|
||||||
|
|
||||||
|
# External native build folder generated in Android Studio 2.2 and later
|
||||||
|
.externalNativeBuild
|
||||||
|
.cxx/
|
||||||
|
|
||||||
|
# Google Services (e.g. APIs or Firebase)
|
||||||
|
# google-services.json
|
||||||
|
|
||||||
|
# Freeline
|
||||||
|
freeline.py
|
||||||
|
freeline/
|
||||||
|
freeline_project_description.json
|
||||||
|
|
||||||
|
# fastlane
|
||||||
|
fastlane/report.xml
|
||||||
|
fastlane/Preview.html
|
||||||
|
fastlane/screenshots
|
||||||
|
fastlane/test_output
|
||||||
|
fastlane/readme.md
|
||||||
|
|
||||||
|
# Version control
|
||||||
|
vcs.xml
|
||||||
|
|
||||||
|
# lint
|
||||||
|
lint/intermediates/
|
||||||
|
lint/generated/
|
||||||
|
lint/outputs/
|
||||||
|
lint/tmp/
|
||||||
|
# lint/reports/
|
||||||
|
|
||||||
|
# Android Profiling
|
||||||
|
*.hprof
|
||||||
|
|
||||||
|
# Cordova plugins for Capacitor
|
||||||
|
capacitor-cordova-android-plugins
|
||||||
|
|
||||||
|
# Copied web assets
|
||||||
|
app/src/main/assets/public
|
||||||
|
|
||||||
|
# Generated Config files
|
||||||
|
app/src/main/assets/capacitor.config.json
|
||||||
|
app/src/main/assets/capacitor.plugins.json
|
||||||
|
app/src/main/res/xml/config.xml
|
||||||
2
mobile/android/app/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
/build/*
|
||||||
|
!/build/.npmkeep
|
||||||
57
mobile/android/app/build.gradle
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
apply plugin: 'com.android.application'
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace "com.pedshub.scribe"
|
||||||
|
compileSdk rootProject.ext.compileSdkVersion
|
||||||
|
defaultConfig {
|
||||||
|
applicationId "com.pedshub.scribe"
|
||||||
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
|
// Version values below are overwritten by scripts/release.sh from
|
||||||
|
// the root package.json. versionCode auto-increments per release.
|
||||||
|
versionCode 602001
|
||||||
|
versionName "6.2.1"
|
||||||
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
aaptOptions {
|
||||||
|
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||||
|
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
|
||||||
|
ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
minifyEnabled false
|
||||||
|
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
repositories {
|
||||||
|
flatDir{
|
||||||
|
dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation fileTree(include: ['*.jar'], dir: 'libs')
|
||||||
|
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
|
||||||
|
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
|
||||||
|
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
|
||||||
|
implementation project(':capacitor-android')
|
||||||
|
testImplementation "junit:junit:$junitVersion"
|
||||||
|
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
|
||||||
|
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
|
||||||
|
implementation project(':capacitor-cordova-android-plugins')
|
||||||
|
implementation "androidx.biometric:biometric:1.2.0-alpha05"
|
||||||
|
}
|
||||||
|
|
||||||
|
apply from: 'capacitor.build.gradle'
|
||||||
|
|
||||||
|
try {
|
||||||
|
def servicesJSON = file('google-services.json')
|
||||||
|
if (servicesJSON.text) {
|
||||||
|
apply plugin: 'com.google.gms.google-services'
|
||||||
|
}
|
||||||
|
} catch(Exception e) {
|
||||||
|
logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work")
|
||||||
|
}
|
||||||
0
mobile/android/app/build/.npmkeep
Normal file
21
mobile/android/app/proguard-rules.pro
vendored
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
# Add project specific ProGuard rules here.
|
||||||
|
# You can control the set of applied configuration files using the
|
||||||
|
# proguardFiles setting in build.gradle.
|
||||||
|
#
|
||||||
|
# For more details, see
|
||||||
|
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||||
|
|
||||||
|
# If your project uses WebView with JS, uncomment the following
|
||||||
|
# and specify the fully qualified class name to the JavaScript interface
|
||||||
|
# class:
|
||||||
|
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||||
|
# public *;
|
||||||
|
#}
|
||||||
|
|
||||||
|
# Uncomment this to preserve the line number information for
|
||||||
|
# debugging stack traces.
|
||||||
|
#-keepattributes SourceFile,LineNumberTable
|
||||||
|
|
||||||
|
# If you keep the line number information, uncomment this to
|
||||||
|
# hide the original source file name.
|
||||||
|
#-renamesourcefileattribute SourceFile
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
package com.getcapacitor.myapp;
|
||||||
|
|
||||||
|
import static org.junit.Assert.*;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||||
|
import androidx.test.platform.app.InstrumentationRegistry;
|
||||||
|
import org.junit.Test;
|
||||||
|
import org.junit.runner.RunWith;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Instrumented test, which will execute on an Android device.
|
||||||
|
*
|
||||||
|
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
|
||||||
|
*/
|
||||||
|
@RunWith(AndroidJUnit4.class)
|
||||||
|
public class ExampleInstrumentedTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void useAppContext() throws Exception {
|
||||||
|
// Context of the app under test.
|
||||||
|
Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
|
||||||
|
|
||||||
|
assertEquals("com.getcapacitor.app", appContext.getPackageName());
|
||||||
|
}
|
||||||
|
}
|
||||||
79
mobile/android/app/src/main/AndroidManifest.xml
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:allowBackup="false"
|
||||||
|
android:fullBackupContent="false"
|
||||||
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:theme="@style/AppTheme">
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode"
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:label="@string/title_activity_main"
|
||||||
|
android:theme="@style/AppTheme.NoActionBarLaunch"
|
||||||
|
android:launchMode="singleTask"
|
||||||
|
android:exported="true">
|
||||||
|
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
|
||||||
|
<!-- Deep linking: pedscribe:// and https://app.pedshub.com -->
|
||||||
|
<intent-filter android:autoVerify="true">
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data android:scheme="pedscribe" />
|
||||||
|
</intent-filter>
|
||||||
|
<intent-filter android:autoVerify="true">
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data android:scheme="https" android:host="app.pedshub.com" />
|
||||||
|
</intent-filter>
|
||||||
|
|
||||||
|
<!-- Share intent: receive text/files from other apps -->
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.SEND" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<data android:mimeType="text/plain" />
|
||||||
|
</intent-filter>
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.SEND" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<data android:mimeType="application/pdf" />
|
||||||
|
</intent-filter>
|
||||||
|
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name=".AudioRecordingService"
|
||||||
|
android:foregroundServiceType="microphone"
|
||||||
|
android:exported="false" />
|
||||||
|
|
||||||
|
<provider
|
||||||
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
android:authorities="${applicationId}.fileprovider"
|
||||||
|
android:exported="false"
|
||||||
|
android:grantUriPermissions="true">
|
||||||
|
<meta-data
|
||||||
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
|
android:resource="@xml/file_paths"></meta-data>
|
||||||
|
</provider>
|
||||||
|
</application>
|
||||||
|
|
||||||
|
<!-- Permissions -->
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
|
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||||
|
</manifest>
|
||||||
0
mobile/android/app/src/main/assets/public/cordova.js
vendored
Normal file
0
mobile/android/app/src/main/assets/public/cordova_plugins.js
vendored
Normal file
59
mobile/android/app/src/main/assets/public/index.html
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, user-scalable=no">
|
||||||
|
<title>PedScribe</title>
|
||||||
|
<link rel="stylesheet" href="launcher.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="launcher">
|
||||||
|
<!-- Auto-redirect screen (shown when server URL is saved) -->
|
||||||
|
<div id="connecting-screen" style="display:none;">
|
||||||
|
<div class="logo-icon">
|
||||||
|
<svg viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<circle cx="24" cy="24" r="22" fill="white" fill-opacity="0.15"/>
|
||||||
|
<path d="M24 12c-2.2 0-4 1.8-4 4v8c0 2.2 1.8 4 4 4s4-1.8 4-4V16c0-2.2-1.8-4-4-4z" fill="white"/>
|
||||||
|
<path d="M32 22v2c0 4.4-3.6 8-8 8s-8-3.6-8-8v-2h-2v2c0 5.1 3.8 9.3 8.7 9.9V36H20v2h8v-2h-2.7v-2.1c4.9-.6 8.7-4.8 8.7-9.9v-2h-2z" fill="white"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h1>PedScribe</h1>
|
||||||
|
<p class="subtitle">Connecting...</p>
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<button id="btn-change-server" class="btn-link">Change Server</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- Server URL setup screen -->
|
||||||
|
<div id="setup-screen">
|
||||||
|
<div class="logo-icon">
|
||||||
|
<svg viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<circle cx="24" cy="24" r="22" fill="white" fill-opacity="0.15"/>
|
||||||
|
<path d="M24 12c-2.2 0-4 1.8-4 4v8c0 2.2 1.8 4 4 4s4-1.8 4-4V16c0-2.2-1.8-4-4-4z" fill="white"/>
|
||||||
|
<path d="M32 22v2c0 4.4-3.6 8-8 8s-8-3.6-8-8v-2h-2v2c0 5.1 3.8 9.3 8.7 9.9V36H20v2h8v-2h-2.7v-2.1c4.9-.6 8.7-4.8 8.7-9.9v-2h-2z" fill="white"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h1>PedScribe</h1>
|
||||||
|
<p class="subtitle">AI-Powered Pediatric Clinical Documentation</p>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Server URL</label>
|
||||||
|
<input type="url" id="server-url" placeholder="https://app.pedshub.com" autocapitalize="none" autocorrect="off" spellcheck="false">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button id="btn-connect" class="btn-primary">
|
||||||
|
Connect
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p class="hint">Enter the URL of your Pediatric AI Scribe server. If you don't have one, use the default.</p>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<p>Pediatric AI Scribe by PedsHub</p>
|
||||||
|
<p>Committed to healthcare equity</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="launcher.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
134
mobile/android/app/src/main/assets/public/launcher.css
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
background: linear-gradient(135deg, #1e3a5f 0%, #2563eb 50%, #1d4ed8 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: white;
|
||||||
|
padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);
|
||||||
|
}
|
||||||
|
|
||||||
|
.launcher {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
padding: 40px 24px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-icon {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
margin: 0 auto 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-icon svg { width: 100%; height: 100%; }
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.5px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
font-size: 14px;
|
||||||
|
opacity: 0.7;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
text-align: left;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
opacity: 0.8;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border: 2px solid rgba(255,255,255,0.3);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(255,255,255,0.15);
|
||||||
|
color: white;
|
||||||
|
font-size: 16px;
|
||||||
|
font-family: inherit;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input::placeholder { color: rgba(255,255,255,0.4); }
|
||||||
|
.form-group input:focus { border-color: rgba(255,255,255,0.7); background: rgba(255,255,255,0.2); }
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
width: 100%;
|
||||||
|
padding: 14px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: white;
|
||||||
|
color: #1d4ed8;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.1s, opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:active { transform: scale(0.98); }
|
||||||
|
.btn-primary:disabled { opacity: 0.5; }
|
||||||
|
|
||||||
|
.btn-link {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: rgba(255,255,255,0.6);
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-top: 16px;
|
||||||
|
font-family: inherit;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
margin-top: 20px;
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.5;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
margin-top: 40px;
|
||||||
|
font-size: 11px;
|
||||||
|
opacity: 0.3;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border: 3px solid rgba(255,255,255,0.2);
|
||||||
|
border-top-color: white;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
margin: 20px auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
/* Error state */
|
||||||
|
.error-msg {
|
||||||
|
background: rgba(239,68,68,0.2);
|
||||||
|
border: 1px solid rgba(239,68,68,0.4);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-top: 12px;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
70
mobile/android/app/src/main/assets/public/launcher.js
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
// PedScribe Mobile Launcher
|
||||||
|
// Handles configurable server URL and auto-redirect
|
||||||
|
|
||||||
|
(function() {
|
||||||
|
var STORAGE_KEY = 'pedscribe_server_url';
|
||||||
|
var DEFAULT_URL = 'https://app.pedshub.com';
|
||||||
|
|
||||||
|
var setupScreen = document.getElementById('setup-screen');
|
||||||
|
var connectingScreen = document.getElementById('connecting-screen');
|
||||||
|
var urlInput = document.getElementById('server-url');
|
||||||
|
var connectBtn = document.getElementById('btn-connect');
|
||||||
|
var changeBtn = document.getElementById('btn-change-server');
|
||||||
|
|
||||||
|
var savedUrl = localStorage.getItem(STORAGE_KEY);
|
||||||
|
|
||||||
|
if (savedUrl) {
|
||||||
|
showConnecting(savedUrl);
|
||||||
|
} else {
|
||||||
|
urlInput.value = DEFAULT_URL;
|
||||||
|
showScreen('setup');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect button
|
||||||
|
connectBtn.addEventListener('click', function() {
|
||||||
|
var url = (urlInput.value || DEFAULT_URL).trim().replace(/\/+$/, '');
|
||||||
|
if (!url.startsWith('http')) url = 'https://' + url;
|
||||||
|
|
||||||
|
connectBtn.disabled = true;
|
||||||
|
connectBtn.textContent = 'Connecting...';
|
||||||
|
haptic();
|
||||||
|
|
||||||
|
localStorage.setItem(STORAGE_KEY, url);
|
||||||
|
navigateToServer(url);
|
||||||
|
});
|
||||||
|
|
||||||
|
urlInput.addEventListener('keydown', function(e) {
|
||||||
|
if (e.key === 'Enter') connectBtn.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Change server
|
||||||
|
changeBtn.addEventListener('click', function() {
|
||||||
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
|
urlInput.value = savedUrl || DEFAULT_URL;
|
||||||
|
showScreen('setup');
|
||||||
|
urlInput.focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Screen management
|
||||||
|
function showScreen(which) {
|
||||||
|
setupScreen.style.display = which === 'setup' ? '' : 'none';
|
||||||
|
connectingScreen.style.display = which === 'connecting' ? '' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function showConnecting(url) {
|
||||||
|
showScreen('connecting');
|
||||||
|
setTimeout(function() { navigateToServer(url); }, 800);
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigateToServer(url) {
|
||||||
|
window.location.href = url;
|
||||||
|
}
|
||||||
|
|
||||||
|
function haptic() {
|
||||||
|
try {
|
||||||
|
if (window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Haptics) {
|
||||||
|
window.Capacitor.Plugins.Haptics.impact({ style: 'medium' });
|
||||||
|
}
|
||||||
|
} catch(e) {}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
@ -0,0 +1,113 @@
|
||||||
|
package com.pedshub.scribe;
|
||||||
|
|
||||||
|
import android.app.Notification;
|
||||||
|
import android.app.NotificationChannel;
|
||||||
|
import android.app.NotificationManager;
|
||||||
|
import android.app.PendingIntent;
|
||||||
|
import android.app.Service;
|
||||||
|
import android.content.Intent;
|
||||||
|
import android.content.pm.ServiceInfo;
|
||||||
|
import android.os.Build;
|
||||||
|
import android.os.IBinder;
|
||||||
|
import android.os.PowerManager;
|
||||||
|
|
||||||
|
import androidx.core.app.NotificationCompat;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Foreground service that keeps the app alive during audio recording.
|
||||||
|
* Acquires a partial wake lock to prevent CPU sleep during recording.
|
||||||
|
* The Capacitor web app sends a message to start/stop this service when recording.
|
||||||
|
*/
|
||||||
|
public class AudioRecordingService extends Service {
|
||||||
|
|
||||||
|
private static final String CHANNEL_ID = "recording_channel";
|
||||||
|
private static final int NOTIFICATION_ID = 1;
|
||||||
|
private static final String WAKE_LOCK_TAG = "PedScribe:AudioRecording";
|
||||||
|
|
||||||
|
public static final String ACTION_STOP = "com.pedshub.scribe.STOP_RECORDING";
|
||||||
|
|
||||||
|
private PowerManager.WakeLock wakeLock;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onCreate() {
|
||||||
|
super.onCreate();
|
||||||
|
createNotificationChannel();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int onStartCommand(Intent intent, int flags, int startId) {
|
||||||
|
if (intent != null && ACTION_STOP.equals(intent.getAction())) {
|
||||||
|
stopSelf();
|
||||||
|
return START_NOT_STICKY;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Acquire wake lock to keep CPU active during recording.
|
||||||
|
// 8h cap is a safety net — onDestroy() releases early when recording
|
||||||
|
// stops. The cap prevents a runaway lock if the service leaks.
|
||||||
|
PowerManager pm = (PowerManager) getSystemService(POWER_SERVICE);
|
||||||
|
if (pm != null) {
|
||||||
|
wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, WAKE_LOCK_TAG);
|
||||||
|
wakeLock.acquire(8 * 60 * 60 * 1000L);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop action in notification
|
||||||
|
Intent stopIntent = new Intent(this, AudioRecordingService.class);
|
||||||
|
stopIntent.setAction(ACTION_STOP);
|
||||||
|
PendingIntent stopPending = PendingIntent.getService(
|
||||||
|
this, 0, stopIntent,
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE
|
||||||
|
);
|
||||||
|
|
||||||
|
Notification notification = new NotificationCompat.Builder(this, CHANNEL_ID)
|
||||||
|
.setContentTitle("Pediatric AI Scribe")
|
||||||
|
.setContentText("Recording in progress...")
|
||||||
|
.setSmallIcon(android.R.drawable.ic_btn_speak_now)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||||
|
.setOngoing(true)
|
||||||
|
.setCategory(NotificationCompat.CATEGORY_SERVICE)
|
||||||
|
.addAction(android.R.drawable.ic_media_pause, "Stop Recording", stopPending)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// Android 14 (SDK 34) requires the 3-arg form with an explicit
|
||||||
|
// foregroundServiceType matching the manifest declaration, else
|
||||||
|
// the service is killed with MissingForegroundServiceTypeException.
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||||
|
startForeground(NOTIFICATION_ID, notification,
|
||||||
|
ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE);
|
||||||
|
} else {
|
||||||
|
startForeground(NOTIFICATION_ID, notification);
|
||||||
|
}
|
||||||
|
return START_STICKY;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public IBinder onBind(Intent intent) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onDestroy() {
|
||||||
|
if (wakeLock != null && wakeLock.isHeld()) {
|
||||||
|
wakeLock.release();
|
||||||
|
wakeLock = null;
|
||||||
|
}
|
||||||
|
stopForeground(STOP_FOREGROUND_REMOVE);
|
||||||
|
super.onDestroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void createNotificationChannel() {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
NotificationChannel channel = new NotificationChannel(
|
||||||
|
CHANNEL_ID,
|
||||||
|
"Recording",
|
||||||
|
NotificationManager.IMPORTANCE_LOW
|
||||||
|
);
|
||||||
|
channel.setDescription("Shows when audio recording is active");
|
||||||
|
channel.setShowBadge(false);
|
||||||
|
NotificationManager manager = getSystemService(NotificationManager.class);
|
||||||
|
if (manager != null) {
|
||||||
|
manager.createNotificationChannel(channel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,103 @@
|
||||||
|
package com.pedshub.scribe;
|
||||||
|
|
||||||
|
import android.Manifest;
|
||||||
|
import android.content.Intent;
|
||||||
|
import android.content.pm.PackageManager;
|
||||||
|
import android.os.Bundle;
|
||||||
|
import android.webkit.PermissionRequest;
|
||||||
|
import android.webkit.WebChromeClient;
|
||||||
|
import android.webkit.WebView;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
import androidx.core.app.ActivityCompat;
|
||||||
|
import androidx.core.content.ContextCompat;
|
||||||
|
|
||||||
|
import com.getcapacitor.BridgeActivity;
|
||||||
|
|
||||||
|
public class MainActivity extends BridgeActivity {
|
||||||
|
|
||||||
|
private static final int MIC_PERMISSION_CODE = 1001;
|
||||||
|
private PermissionRequest pendingPermissionRequest;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onCreate(Bundle savedInstanceState) {
|
||||||
|
super.onCreate(savedInstanceState);
|
||||||
|
|
||||||
|
// Request mic permission upfront
|
||||||
|
if (ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO)
|
||||||
|
!= PackageManager.PERMISSION_GRANTED) {
|
||||||
|
ActivityCompat.requestPermissions(this,
|
||||||
|
new String[]{ Manifest.permission.RECORD_AUDIO }, MIC_PERMISSION_CODE);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup WebView mic permission granting
|
||||||
|
setupWebViewPermissions();
|
||||||
|
|
||||||
|
// Register JS interface for foreground service control
|
||||||
|
setupRecordingBridge();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── WebView Microphone Permission ──────────────────────────
|
||||||
|
|
||||||
|
private void setupWebViewPermissions() {
|
||||||
|
WebView webView = this.bridge.getWebView();
|
||||||
|
final MainActivity activity = this;
|
||||||
|
|
||||||
|
webView.setWebChromeClient(new WebChromeClient() {
|
||||||
|
@Override
|
||||||
|
public void onPermissionRequest(final PermissionRequest request) {
|
||||||
|
if (ContextCompat.checkSelfPermission(activity, Manifest.permission.RECORD_AUDIO)
|
||||||
|
== PackageManager.PERMISSION_GRANTED) {
|
||||||
|
activity.runOnUiThread(() -> request.grant(request.getResources()));
|
||||||
|
} else {
|
||||||
|
pendingPermissionRequest = request;
|
||||||
|
ActivityCompat.requestPermissions(activity,
|
||||||
|
new String[]{ Manifest.permission.RECORD_AUDIO }, MIC_PERMISSION_CODE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
|
||||||
|
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
|
||||||
|
|
||||||
|
if (requestCode == MIC_PERMISSION_CODE && pendingPermissionRequest != null) {
|
||||||
|
if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
|
||||||
|
final PermissionRequest req = pendingPermissionRequest;
|
||||||
|
runOnUiThread(() -> req.grant(req.getResources()));
|
||||||
|
} else {
|
||||||
|
pendingPermissionRequest.deny();
|
||||||
|
}
|
||||||
|
pendingPermissionRequest = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Background Recording Service Bridge ───────────────────
|
||||||
|
|
||||||
|
private void setupRecordingBridge() {
|
||||||
|
WebView webView = this.bridge.getWebView();
|
||||||
|
webView.addJavascriptInterface(new RecordingBridge(this), "NativeRecording");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class RecordingBridge {
|
||||||
|
private final MainActivity activity;
|
||||||
|
|
||||||
|
RecordingBridge(MainActivity activity) {
|
||||||
|
this.activity = activity;
|
||||||
|
}
|
||||||
|
|
||||||
|
@android.webkit.JavascriptInterface
|
||||||
|
public void startForegroundService() {
|
||||||
|
Intent intent = new Intent(activity, AudioRecordingService.class);
|
||||||
|
ContextCompat.startForegroundService(activity, intent);
|
||||||
|
}
|
||||||
|
|
||||||
|
@android.webkit.JavascriptInterface
|
||||||
|
public void stopForegroundService() {
|
||||||
|
Intent intent = new Intent(activity, AudioRecordingService.class);
|
||||||
|
intent.setAction(AudioRecordingService.ACTION_STOP);
|
||||||
|
activity.startService(intent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
mobile/android/app/src/main/res/drawable-land-hdpi/splash.png
Normal file
|
After Width: | Height: | Size: 7.5 KiB |
BIN
mobile/android/app/src/main/res/drawable-land-mdpi/splash.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
mobile/android/app/src/main/res/drawable-land-xhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 9 KiB |
BIN
mobile/android/app/src/main/res/drawable-land-xxhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
mobile/android/app/src/main/res/drawable-land-xxxhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
mobile/android/app/src/main/res/drawable-port-hdpi/splash.png
Normal file
|
After Width: | Height: | Size: 7.7 KiB |
BIN
mobile/android/app/src/main/res/drawable-port-mdpi/splash.png
Normal file
|
After Width: | Height: | Size: 4 KiB |
BIN
mobile/android/app/src/main/res/drawable-port-xhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 9.6 KiB |
BIN
mobile/android/app/src/main/res/drawable-port-xxhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
mobile/android/app/src/main/res/drawable-port-xxxhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
|
|
@ -0,0 +1,34 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:aapt="http://schemas.android.com/aapt"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportHeight="108"
|
||||||
|
android:viewportWidth="108">
|
||||||
|
<path
|
||||||
|
android:fillType="evenOdd"
|
||||||
|
android:pathData="M32,64C32,64 38.39,52.99 44.13,50.95C51.37,48.37 70.14,49.57 70.14,49.57L108.26,87.69L108,109.01L75.97,107.97L32,64Z"
|
||||||
|
android:strokeColor="#00000000"
|
||||||
|
android:strokeWidth="1">
|
||||||
|
<aapt:attr name="android:fillColor">
|
||||||
|
<gradient
|
||||||
|
android:endX="78.5885"
|
||||||
|
android:endY="90.9159"
|
||||||
|
android:startX="48.7653"
|
||||||
|
android:startY="61.0927"
|
||||||
|
android:type="linear">
|
||||||
|
<item
|
||||||
|
android:color="#44000000"
|
||||||
|
android:offset="0.0" />
|
||||||
|
<item
|
||||||
|
android:color="#00000000"
|
||||||
|
android:offset="1.0" />
|
||||||
|
</gradient>
|
||||||
|
</aapt:attr>
|
||||||
|
</path>
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:fillType="nonZero"
|
||||||
|
android:pathData="M66.94,46.02L66.94,46.02C72.44,50.07 76,56.61 76,64L32,64C32,56.61 35.56,50.11 40.98,46.06L36.18,41.19C35.45,40.45 35.45,39.3 36.18,38.56C36.91,37.81 38.05,37.81 38.78,38.56L44.25,44.05C47.18,42.57 50.48,41.71 54,41.71C57.48,41.71 60.78,42.57 63.68,44.05L69.11,38.56C69.84,37.81 70.98,37.81 71.71,38.56C72.44,39.3 72.44,40.45 71.71,41.19L66.94,46.02ZM62.94,56.92C64.08,56.92 65,56.01 65,54.88C65,53.76 64.08,52.85 62.94,52.85C61.8,52.85 60.88,53.76 60.88,54.88C60.88,56.01 61.8,56.92 62.94,56.92ZM45.06,56.92C46.2,56.92 47.13,56.01 47.13,54.88C47.13,53.76 46.2,52.85 45.06,52.85C43.92,52.85 43,53.76 43,54.88C43,56.01 43.92,56.92 45.06,56.92Z"
|
||||||
|
android:strokeColor="#00000000"
|
||||||
|
android:strokeWidth="1" />
|
||||||
|
</vector>
|
||||||
|
|
@ -0,0 +1,170 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportHeight="108"
|
||||||
|
android:viewportWidth="108">
|
||||||
|
<path
|
||||||
|
android:fillColor="#26A69A"
|
||||||
|
android:pathData="M0,0h108v108h-108z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M9,0L9,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,0L19,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M29,0L29,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M39,0L39,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M49,0L49,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M59,0L59,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M69,0L69,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M79,0L79,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M89,0L89,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M99,0L99,108"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,9L108,9"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,19L108,19"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,29L108,29"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,39L108,39"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,49L108,49"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,59L108,59"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,69L108,69"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,79L108,79"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,89L108,89"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,99L108,99"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,29L89,29"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,39L89,39"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,49L89,49"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,59L89,59"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,69L89,69"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,79L89,79"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M29,19L29,89"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M39,19L39,89"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M49,19L49,89"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M59,19L59,89"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M69,19L69,89"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M79,19L79,89"
|
||||||
|
android:strokeColor="#33FFFFFF"
|
||||||
|
android:strokeWidth="0.8" />
|
||||||
|
</vector>
|
||||||
BIN
mobile/android/app/src/main/res/drawable/splash.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
12
mobile/android/app/src/main/res/layout/activity_main.xml
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
tools:context=".MainActivity">
|
||||||
|
|
||||||
|
<WebView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent" />
|
||||||
|
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/ic_launcher_background"/>
|
||||||
|
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||||
|
</adaptive-icon>
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/ic_launcher_background"/>
|
||||||
|
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||||
|
</adaptive-icon>
|
||||||
BIN
mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
BIN
mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
BIN
mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
BIN
mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 10 KiB |
BIN
mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 9.2 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 16 KiB |