Compare commits
83 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
104 changed files with 9739 additions and 436 deletions
106
.env.example
106
.env.example
|
|
@ -20,11 +20,71 @@ 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=
|
||||||
|
|
||||||
|
|
@ -32,7 +92,6 @@ ELEVENLABS_API_KEY=
|
||||||
PORT=3000
|
PORT=3000
|
||||||
APP_URL=https://your-domain.com
|
APP_URL=https://your-domain.com
|
||||||
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
|
|
||||||
|
|
||||||
# Email (for verification & password reset)
|
# Email (for verification & password reset)
|
||||||
SMTP_HOST=smtp.gmail.com
|
SMTP_HOST=smtp.gmail.com
|
||||||
|
|
@ -44,6 +103,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
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|
|
||||||
103
.github/workflows/build-apk.yml
vendored
Normal file
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
|
||||||
58
.github/workflows/docker-publish.yml
vendored
Normal file
58
.github/workflows/docker-publish.yml
vendored
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
name: Build & Push Docker Image
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags: ['v*']
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
tag:
|
||||||
|
description: 'Docker image tag (e.g. v8, latest)'
|
||||||
|
required: false
|
||||||
|
default: 'latest'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Docker 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: Extract version tag
|
||||||
|
id: meta
|
||||||
|
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: Build and Push
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
danielonyejesi/pediatric-ai-scribe-v3:${{ steps.meta.outputs.tag }}
|
||||||
|
danielonyejesi/pediatric-ai-scribe-v3:latest
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
|
||||||
|
- name: Summary
|
||||||
|
run: |
|
||||||
|
echo "### Docker image published" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "- \`danielonyejesi/pediatric-ai-scribe-v3:${{ steps.meta.outputs.tag }}\`" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "- \`danielonyejesi/pediatric-ai-scribe-v3:latest\`" >> $GITHUB_STEP_SUMMARY
|
||||||
13
.gitignore
vendored
13
.gitignore
vendored
|
|
@ -15,3 +15,16 @@ 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/
|
||||||
|
|
|
||||||
174
BROWSER_WHISPER_SETUP.md
Normal file
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
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!
|
||||||
20
Dockerfile
20
Dockerfile
|
|
@ -2,6 +2,10 @@ 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
|
RUN npm install --omit=dev
|
||||||
|
|
||||||
|
|
@ -9,6 +13,22 @@ 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
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
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;"`
|
||||||
346
OPENID_SETUP.md
Normal file
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
|
||||||
|
```
|
||||||
76
README.md
76
README.md
|
|
@ -157,16 +157,83 @@ AZURE_OPENAI_API_VERSION=2024-02-01
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Whisper Transcription
|
## Transcription (Speech-to-Text)
|
||||||
|
|
||||||
Always uses OpenAI Whisper regardless of the AI provider setting:
|
Two providers supported. The app auto-selects AWS Transcribe when `AWS_BEDROCK_REGION` is set, otherwise falls back to OpenAI Whisper.
|
||||||
|
|
||||||
|
### Amazon Transcribe (recommended for clinical use — HIPAA eligible)
|
||||||
|
|
||||||
|
Uses your existing Bedrock AWS credentials. No S3 bucket required — audio streams directly to AWS.
|
||||||
|
|
||||||
|
**Requires `ffmpeg` installed on the server** (handles audio format conversion from browser WebM to PCM). The Docker image includes ffmpeg automatically.
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Auto-enabled when AWS_BEDROCK_REGION is set.
|
||||||
|
# To force it explicitly:
|
||||||
|
TRANSCRIBE_PROVIDER=aws
|
||||||
|
|
||||||
|
# Amazon Transcribe Medical — trained on clinical speech.
|
||||||
|
# Knows drug names, diagnoses, procedures. Recommended.
|
||||||
|
AWS_TRANSCRIBE_MEDICAL=true
|
||||||
|
AWS_TRANSCRIBE_SPECIALTY=PRIMARYCARE
|
||||||
|
# Other specialty options: CARDIOLOGY, NEUROLOGY, ONCOLOGY, RADIOLOGY, UROLOGY
|
||||||
|
```
|
||||||
|
|
||||||
|
Install ffmpeg on non-Docker servers:
|
||||||
|
```bash
|
||||||
|
# Ubuntu/Debian
|
||||||
|
sudo apt-get install -y ffmpeg
|
||||||
|
|
||||||
|
# Amazon Linux / RHEL
|
||||||
|
sudo yum install -y ffmpeg
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
brew install ffmpeg
|
||||||
|
```
|
||||||
|
|
||||||
|
### OpenAI Whisper (default fallback)
|
||||||
|
|
||||||
```env
|
```env
|
||||||
OPENAI_API_KEY=sk-...
|
OPENAI_API_KEY=sk-...
|
||||||
|
# Optionally force Whisper even when AWS is configured:
|
||||||
|
# TRANSCRIBE_PROVIDER=openai
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## OpenID Connect / SSO (optional — PocketID, Keycloak, Azure AD, etc.)
|
||||||
|
|
||||||
|
Enable Single Sign-On authentication with any OpenID Connect provider. Users can log in with their SSO provider, and existing accounts are automatically linked by email.
|
||||||
|
|
||||||
|
**Supported providers:** PocketID, Keycloak, Azure AD / Entra ID, Okta, Google Workspace, and any OIDC-compliant provider.
|
||||||
|
|
||||||
|
### Quick Setup
|
||||||
|
|
||||||
|
1. Register this app with your OIDC provider using callback URL:
|
||||||
|
```
|
||||||
|
https://your-domain.com/api/auth/oidc/callback
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Log into the app as admin and navigate to **Admin Panel** → **Settings**
|
||||||
|
|
||||||
|
3. Configure OIDC settings:
|
||||||
|
- Enable OIDC
|
||||||
|
- Set Issuer URL (e.g., `https://id.example.com`)
|
||||||
|
- Enter Client ID and Client Secret from your provider
|
||||||
|
- Customize the button label (e.g., "Sign in with PocketID")
|
||||||
|
|
||||||
|
4. Save and test the login
|
||||||
|
|
||||||
|
**Detailed setup guide with examples for all providers:** [OPENID_SETUP.md](OPENID_SETUP.md)
|
||||||
|
|
||||||
|
### Linking Existing Users
|
||||||
|
|
||||||
|
When a user signs in via SSO, the system automatically links their account if the email matches an existing user. No manual intervention needed.
|
||||||
|
|
||||||
|
For advanced scenarios (manual linking, CLI commands, troubleshooting), see [OPENID_SETUP.md](OPENID_SETUP.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Email (optional — for verification & password reset)
|
## Email (optional — for verification & password reset)
|
||||||
|
|
||||||
Without SMTP configured, email verification is skipped and users are auto-verified on registration.
|
Without SMTP configured, email verification is skipped and users are auto-verified on registration.
|
||||||
|
|
@ -193,7 +260,10 @@ SMTP_FROM=noreply@yourdomain.com
|
||||||
| `AZURE_OPENAI_ENDPOINT` | If using Azure | Azure OpenAI endpoint URL |
|
| `AZURE_OPENAI_ENDPOINT` | If using Azure | Azure OpenAI endpoint URL |
|
||||||
| `AZURE_OPENAI_API_KEY` | If using Azure | Azure API key |
|
| `AZURE_OPENAI_API_KEY` | If using Azure | Azure API key |
|
||||||
| `AZURE_DEPLOYMENT_NAME` | If using Azure | Deployment name, e.g. `gpt-4o-mini` |
|
| `AZURE_DEPLOYMENT_NAME` | If using Azure | Deployment name, e.g. `gpt-4o-mini` |
|
||||||
| `OPENAI_API_KEY` | For transcription | OpenAI key (Whisper) |
|
| `OPENAI_API_KEY` | For Whisper transcription | OpenAI key (Whisper fallback) |
|
||||||
|
| `TRANSCRIBE_PROVIDER` | No | `aws` or `openai` — auto-detected from AWS config |
|
||||||
|
| `AWS_TRANSCRIBE_MEDICAL` | No | `true` to use Transcribe Medical (clinical accuracy) |
|
||||||
|
| `AWS_TRANSCRIBE_SPECIALTY` | No | `PRIMARYCARE` (default), `CARDIOLOGY`, `NEUROLOGY`, etc. |
|
||||||
| `ELEVENLABS_API_KEY` | No | ElevenLabs TTS (optional) |
|
| `ELEVENLABS_API_KEY` | No | ElevenLabs TTS (optional) |
|
||||||
| `JWT_SECRET` | **Yes** | Random 64-char string — keep secret |
|
| `JWT_SECRET` | **Yes** | Random 64-char string — keep secret |
|
||||||
| `DATABASE_URL` | No | PostgreSQL URL (auto-set by docker-compose) |
|
| `DATABASE_URL` | No | PostgreSQL URL (auto-set by docker-compose) |
|
||||||
|
|
|
||||||
279
TRANSCRIPTION_OPTIONS.md
Normal file
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
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
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
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
BIN
android/app/src/main/res/drawable/splash.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.3 KiB |
BIN
android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
BIN
android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.1 KiB |
BIN
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
BIN
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.8 KiB |
8
android/app/src/main/res/values/colors.xml
Normal file
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
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
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
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
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
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
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
2
android/settings.gradle
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
rootProject.name = 'PediatricAIScribe'
|
||||||
|
include ':app'
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
services:
|
services:
|
||||||
pediatric-scribe:
|
pediatric-scribe:
|
||||||
image: danielonyejesi/pediatric-ai-scribe-v3:v3.1
|
image: danielonyejesi/pediatric-ai-scribe-v3:v6
|
||||||
ports:
|
ports:
|
||||||
- "3552:3000"
|
- "3552:3000"
|
||||||
env_file:
|
env_file:
|
||||||
|
|
|
||||||
1164
package-lock.json
generated
1164
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "pediatric-ai-scribe",
|
"name": "pediatric-ai-scribe",
|
||||||
"version": "3.1.0",
|
"version": "6.0.0",
|
||||||
"description": "AI-powered pediatric clinical documentation platform",
|
"description": "AI-powered pediatric clinical documentation platform",
|
||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
@ -28,6 +28,7 @@
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^1.4.5-lts.1",
|
||||||
"nodemailer": "^6.9.16",
|
"nodemailer": "^6.9.16",
|
||||||
"openai": "^4.73.0",
|
"openai": "^4.73.0",
|
||||||
|
"openid-client": "^6.8.2",
|
||||||
"pdf-parse": "^1.1.1",
|
"pdf-parse": "^1.1.1",
|
||||||
"pg": "^8.13.0",
|
"pg": "^8.13.0",
|
||||||
"pptxgenjs": "^4.0.1",
|
"pptxgenjs": "^4.0.1",
|
||||||
|
|
@ -35,6 +36,10 @@
|
||||||
"speakeasy": "^2.0.0"
|
"speakeasy": "^2.0.0"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@aws-sdk/client-bedrock-runtime": "^3.700.0"
|
"@aws-sdk/client-bedrock-runtime": "^3.700.0",
|
||||||
|
"@aws-sdk/client-transcribe-streaming": "^3.1017.0",
|
||||||
|
"@aws-sdk/client-s3": "^3.700.0",
|
||||||
|
"@aws-sdk/s3-request-presigner": "^3.700.0",
|
||||||
|
"@google-cloud/vertexai": "^1.9.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
12
public/.well-known/assetlinks.json
Normal file
12
public/.well-known/assetlinks.json
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"relation": ["delegate_permission/common.handle_all_urls"],
|
||||||
|
"target": {
|
||||||
|
"namespace": "android_app",
|
||||||
|
"package_name": "com.pediatricscribe.twa",
|
||||||
|
"sha256_cert_fingerprints": [
|
||||||
|
"69:6E:08:31:4B:41:D2:89:25:DE:E6:28:33:63:FF:C3:E8:34:7A:0B:78:77:3C:73:4F:6A:49:D0:5C:54:79:34"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
@ -202,6 +202,133 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ── AI Model Management ─────────────────────────────────── -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3><i class="fas fa-microchip"></i> AI Model Management</h3>
|
||||||
|
<span id="admin-model-provider-badge" style="font-size:11px;padding:2px 8px;border-radius:10px;background:var(--g100);color:var(--g600);">Loading...</span>
|
||||||
|
</div>
|
||||||
|
<div style="padding:16px;display:flex;flex-direction:column;gap:14px;">
|
||||||
|
|
||||||
|
<!-- Default Model -->
|
||||||
|
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;">
|
||||||
|
<label style="font-size:13px;font-weight:600;min-width:120px;">Default Model:</label>
|
||||||
|
<select id="admin-default-model" style="font-size:13px;padding:5px 8px;border:1px solid var(--g300);border-radius:6px;flex:1;max-width:400px;"></select>
|
||||||
|
<button id="btn-save-default-model" class="btn-sm btn-primary">Set Default</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Built-in Models Toggle -->
|
||||||
|
<div>
|
||||||
|
<label style="font-size:12px;font-weight:600;color:var(--g600);display:block;margin-bottom:8px;">Built-in Models (toggle to enable/disable for users)</label>
|
||||||
|
<div id="admin-builtin-models" style="display:flex;flex-direction:column;gap:4px;max-height:300px;overflow-y:auto;padding-right:4px;">
|
||||||
|
<p style="color:var(--g400);font-size:13px;">Loading...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Discover Models from API -->
|
||||||
|
<div style="border-top:1px solid var(--g100);padding-top:14px;">
|
||||||
|
<label style="font-size:12px;font-weight:600;color:var(--g600);display:block;margin-bottom:8px;">Discover Models from Provider API</label>
|
||||||
|
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;">
|
||||||
|
<input type="text" id="admin-model-search" placeholder="Search models (e.g. gemini, vendor-model, gpt)" style="font-size:13px;padding:6px 10px;border:1px solid var(--g300);border-radius:6px;flex:1;min-width:200px;">
|
||||||
|
<button id="btn-discover-models" class="btn-sm btn-primary"><i class="fas fa-magnifying-glass"></i> Search API</button>
|
||||||
|
</div>
|
||||||
|
<div id="admin-discovered-models" style="margin-top:10px;display:flex;flex-direction:column;gap:4px;max-height:400px;overflow-y:auto;">
|
||||||
|
</div>
|
||||||
|
<p id="admin-discover-hint" style="font-size:12px;color:var(--g500);margin:8px 0 0;">Click "Search API" to query your configured provider for all available models. Use the search box to filter results.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Custom Model (manual) -->
|
||||||
|
<div style="border-top:1px solid var(--g100);padding-top:14px;">
|
||||||
|
<label style="font-size:12px;font-weight:600;color:var(--g600);display:block;margin-bottom:8px;">Add Custom Model (manual)</label>
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;">
|
||||||
|
<input type="text" id="admin-custom-model-id" placeholder="Model ID (e.g. openai/gpt-4.1)" style="font-size:13px;padding:6px 10px;border:1px solid var(--g300);border-radius:6px;">
|
||||||
|
<input type="text" id="admin-custom-model-name" placeholder="Display name" style="font-size:13px;padding:6px 10px;border:1px solid var(--g300);border-radius:6px;">
|
||||||
|
<input type="text" id="admin-custom-model-cost" placeholder="Cost (e.g. ~$0.01)" style="font-size:13px;padding:6px 10px;border:1px solid var(--g300);border-radius:6px;">
|
||||||
|
<select id="admin-custom-model-cat" style="font-size:13px;padding:6px 10px;border:1px solid var(--g300);border-radius:6px;">
|
||||||
|
<option value="fast">Fast & Cheap</option>
|
||||||
|
<option value="smart" selected>Smart</option>
|
||||||
|
<option value="premium">Premium</option>
|
||||||
|
<option value="free">Free</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:8px;">
|
||||||
|
<button id="btn-add-custom-model" class="btn-sm btn-primary"><i class="fas fa-plus"></i> Add Model</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Custom Models List -->
|
||||||
|
<div id="admin-custom-models-list" style="display:flex;flex-direction:column;gap:4px;">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── CMS: Developmental Milestones ──────────────────────────── -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3><i class="fas fa-baby"></i> Developmental Milestones</h3>
|
||||||
|
<button id="btn-refresh-milestones" class="btn-sm btn-ghost"><i class="fas fa-rotate"></i> Refresh</button>
|
||||||
|
</div>
|
||||||
|
<div style="padding:16px;display:flex;flex-direction:column;gap:16px;">
|
||||||
|
<!-- Bulk Import Notice -->
|
||||||
|
<div id="ms-empty-notice" style="display:none;background:var(--blue-bg);border-left:3px solid var(--blue);padding:12px;border-radius:6px;">
|
||||||
|
<p style="margin:0 0 8px;font-weight:600;color:var(--blue);"><i class="fas fa-info-circle"></i> No milestones in database</p>
|
||||||
|
<p style="margin:0 0 12px;font-size:13px;color:var(--g600);">Import the default developmental milestones data (birth to 11 years) to enable admin editing.</p>
|
||||||
|
<button id="btn-bulk-import-milestones" class="btn-sm btn-primary"><i class="fas fa-download"></i> Import Default Milestones Data</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filters -->
|
||||||
|
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;">
|
||||||
|
<label style="font-size:13px;font-weight:600;">Age Group:</label>
|
||||||
|
<select id="ms-filter-age" style="font-size:13px;padding:4px 8px;border:1px solid var(--g300);border-radius:6px;min-width:200px;">
|
||||||
|
<option value="">All Age Groups</option>
|
||||||
|
</select>
|
||||||
|
<button id="btn-add-milestone" class="btn-sm btn-primary"><i class="fas fa-plus"></i> Add Milestone</button>
|
||||||
|
<button id="btn-reimport-milestones" class="btn-sm btn-ghost" title="Re-import all static data (clears existing)"><i class="fas fa-sync"></i> Re-import All</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Milestones List -->
|
||||||
|
<div id="milestones-list" style="display:flex;flex-direction:column;gap:8px;max-height:600px;overflow-y:auto;"></div>
|
||||||
|
|
||||||
|
<!-- Add/Edit Modal -->
|
||||||
|
<div id="milestone-modal" class="modal-overlay" style="display:none;">
|
||||||
|
<div class="modal-content" style="max-width:600px;">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3 id="milestone-modal-title"><i class="fas fa-edit"></i> Edit Milestone</h3>
|
||||||
|
<button class="modal-close" id="btn-close-milestone-modal">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<input type="hidden" id="ms-edit-id">
|
||||||
|
<div style="display:flex;flex-direction:column;gap:12px;">
|
||||||
|
<div>
|
||||||
|
<label style="font-size:12px;font-weight:600;color:var(--g600);display:block;margin-bottom:4px;">Age Group</label>
|
||||||
|
<input type="text" id="ms-edit-age" list="ms-age-list" style="width:100%;font-size:13px;padding:7px;border:1px solid var(--g300);border-radius:6px;box-sizing:border-box;" placeholder="e.g., 2 months">
|
||||||
|
<datalist id="ms-age-list"></datalist>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style="font-size:12px;font-weight:600;color:var(--g600);display:block;margin-bottom:4px;">Domain</label>
|
||||||
|
<input type="text" id="ms-edit-domain" list="ms-domain-list" style="width:100%;font-size:13px;padding:7px;border:1px solid var(--g300);border-radius:6px;box-sizing:border-box;" placeholder="e.g., Gross Motor">
|
||||||
|
<datalist id="ms-domain-list"></datalist>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style="font-size:12px;font-weight:600;color:var(--g600);display:block;margin-bottom:4px;">Milestone Description</label>
|
||||||
|
<textarea id="ms-edit-text" rows="3" style="width:100%;font-size:13px;padding:8px;border:1px solid var(--g300);border-radius:6px;resize:vertical;box-sizing:border-box;" placeholder="e.g., Lifts head when prone (45 degrees)"></textarea>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style="font-size:12px;font-weight:600;color:var(--g600);display:block;margin-bottom:4px;">Sort Order</label>
|
||||||
|
<input type="number" id="ms-edit-sort" value="0" style="width:100%;font-size:13px;padding:7px;border:1px solid var(--g300);border-radius:6px;box-sizing:border-box;" placeholder="0">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button id="btn-save-milestone" class="btn-sm btn-primary">Save</button>
|
||||||
|
<button id="btn-cancel-milestone-modal" class="btn-sm btn-ghost">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- ── CMS: Reset to Defaults ─────────────────────────────────── -->
|
<!-- ── CMS: Reset to Defaults ─────────────────────────────────── -->
|
||||||
<div class="card" style="border:1px solid var(--red-light);">
|
<div class="card" style="border:1px solid var(--red-light);">
|
||||||
<div class="card-header"><h3 style="color:var(--red);"><i class="fas fa-triangle-exclamation"></i> Reset Settings</h3></div>
|
<div class="card-header"><h3 style="color:var(--red);"><i class="fas fa-triangle-exclamation"></i> Reset Settings</h3></div>
|
||||||
|
|
|
||||||
|
|
@ -80,7 +80,7 @@
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header"><h3><i class="fas fa-comment-dots"></i> Instructions (optional)</h3></div>
|
<div class="card-header"><h3><i class="fas fa-comment-dots"></i> Instructions (optional)</h3></div>
|
||||||
<input type="text" id="cr-instructions" class="full-input" placeholder="e.g., 'Focus on thyroid management', 'Include medication changes'">
|
<textarea id="cr-instructions" class="full-input" rows="3" placeholder="e.g., 'Focus on thyroid management', 'Include medication changes'"></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button id="cr-generate-btn" class="btn-generate btn-generate-green"><i class="fas fa-wand-magic-sparkles"></i> Generate Chart Review</button>
|
<button id="cr-generate-btn" class="btn-generate btn-generate-green"><i class="fas fa-wand-magic-sparkles"></i> Generate Chart Review</button>
|
||||||
|
|
@ -97,7 +97,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div id="cr-review-text" class="output-text" contenteditable="true"></div>
|
<div id="cr-review-text" class="output-text" contenteditable="true"></div>
|
||||||
<div class="refine-bar">
|
<div class="refine-bar">
|
||||||
<input type="text" id="cr-refine-input" class="refine-input" placeholder="Tell AI to modify...">
|
<textarea id="cr-refine-input" class="refine-input" rows="2" placeholder="Tell AI to modify..."></textarea>
|
||||||
<button class="btn-sm btn-primary" id="cr-refine-btn"><i class="fas fa-edit"></i> Refine</button>
|
<button class="btn-sm btn-primary" id="cr-refine-btn"><i class="fas fa-edit"></i> Refine</button>
|
||||||
<button class="btn-sm btn-ghost" id="cr-shorten-btn"><i class="fas fa-compress"></i> Shorter</button>
|
<button class="btn-sm btn-ghost" id="cr-shorten-btn"><i class="fas fa-compress"></i> Shorter</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -153,10 +153,11 @@
|
||||||
<div class="lh-ai-tabpanel hidden" id="lh-ai-tp-upload">
|
<div class="lh-ai-tabpanel hidden" id="lh-ai-tp-upload">
|
||||||
<label class="lh-ai-dropzone" id="lh-ai-dropzone">
|
<label class="lh-ai-dropzone" id="lh-ai-dropzone">
|
||||||
<i class="fas fa-cloud-upload-alt" style="font-size:28px;color:var(--blue);margin-bottom:8px;display:block;"></i>
|
<i class="fas fa-cloud-upload-alt" style="font-size:28px;color:var(--blue);margin-bottom:8px;display:block;"></i>
|
||||||
<span id="lh-ai-file-label">Drop a file here or click to browse</span>
|
<span id="lh-ai-file-label">Drop files here or click to browse</span>
|
||||||
<small style="color:var(--g400);display:block;margin-top:4px;">PDF, TXT, MD, HTML — max 20 MB</small>
|
<small style="color:var(--g400);display:block;margin-top:4px;">PDF, TXT, MD, HTML — max 100 MB each, up to 10 files</small>
|
||||||
<input type="file" id="lh-ai-file" accept=".pdf,.txt,.md,.html,.htm,.csv,.json" style="display:none;">
|
<input type="file" id="lh-ai-file" accept=".pdf,.txt,.md,.html,.htm,.csv,.json" multiple style="display:none;">
|
||||||
</label>
|
</label>
|
||||||
|
<div id="lh-ai-files-list" style="margin-top:8px;display:none;"></div>
|
||||||
<div class="form-group" style="margin:8px 0 0;">
|
<div class="form-group" style="margin:8px 0 0;">
|
||||||
<label style="font-size:12px;font-weight:600;color:var(--g600);">Topic / context <span style="color:var(--g400);font-weight:400;">(optional — helps AI focus)</span></label>
|
<label style="font-size:12px;font-weight:600;color:var(--g600);">Topic / context <span style="color:var(--g400);font-weight:400;">(optional — helps AI focus)</span></label>
|
||||||
<input type="text" id="lh-ai-upload-context" class="cms-input-sm" style="width:100%;" placeholder="e.g., Pediatric asthma management, focus on treatment ladder">
|
<input type="text" id="lh-ai-upload-context" class="cms-input-sm" style="width:100%;" placeholder="e.g., Pediatric asthma management, focus on treatment ladder">
|
||||||
|
|
|
||||||
|
|
@ -76,7 +76,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div id="dict-hpi-text" class="output-text" contenteditable="true"></div>
|
<div id="dict-hpi-text" class="output-text" contenteditable="true"></div>
|
||||||
<div class="refine-bar">
|
<div class="refine-bar">
|
||||||
<input type="text" id="dict-refine-input" class="refine-input" placeholder="Tell AI to modify...">
|
<textarea id="dict-refine-input" class="refine-input" rows="2" placeholder="Tell AI to modify..."></textarea>
|
||||||
<button class="btn-sm btn-primary" id="dict-refine-btn"><i class="fas fa-edit"></i> Refine</button>
|
<button class="btn-sm btn-primary" id="dict-refine-btn"><i class="fas fa-edit"></i> Refine</button>
|
||||||
<button class="btn-sm btn-ghost" id="dict-shorten-btn"><i class="fas fa-compress"></i> Shorter</button>
|
<button class="btn-sm btn-ghost" id="dict-shorten-btn"><i class="fas fa-compress"></i> Shorter</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -50,13 +50,14 @@
|
||||||
<div class="record-controls">
|
<div class="record-controls">
|
||||||
<button id="enc-record-btn" class="record-btn"><i class="fas fa-microphone"></i><span>Start Recording</span></button>
|
<button id="enc-record-btn" class="record-btn"><i class="fas fa-microphone"></i><span>Start Recording</span></button>
|
||||||
<button id="enc-pause-btn" class="btn-sm btn-ghost hidden" style="margin-left:8px;"><i class="fas fa-pause"></i> Pause</button>
|
<button id="enc-pause-btn" class="btn-sm btn-ghost hidden" style="margin-left:8px;"><i class="fas fa-pause"></i> Pause</button>
|
||||||
|
<button id="enc-stop-btn" class="btn-sm hidden" style="margin-left:8px;background:var(--red);color:white;border:none;border-radius:8px;padding:7px 16px;cursor:pointer;font-size:13px;"><i class="fas fa-stop"></i> Stop</button>
|
||||||
<div id="enc-recording-indicator" class="recording-indicator hidden"><div class="pulse-dot"></div><span>Recording... <span id="enc-timer">00:00</span></span></div>
|
<div id="enc-recording-indicator" class="recording-indicator hidden"><div class="pulse-dot"></div><span>Recording... <span id="enc-timer">00:00</span></span></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header"><h3><i class="fas fa-file-lines"></i> Transcript</h3><button id="enc-clear" class="btn-sm btn-ghost"><i class="fas fa-eraser"></i> Clear</button></div>
|
<div class="card-header"><h3><i class="fas fa-file-lines"></i> Transcript</h3><button id="enc-clear" class="btn-sm btn-ghost"><i class="fas fa-eraser"></i> Clear</button></div>
|
||||||
<div id="enc-transcript" class="editable-box" contenteditable="true" data-placeholder="Transcript appears here or type/paste directly..."></div>
|
<div id="enc-transcript" class="editable-box editable-box-large" contenteditable="true" data-placeholder="Transcript appears here or type/paste directly..."></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button id="enc-generate-btn" class="btn-generate"><i class="fas fa-wand-magic-sparkles"></i> Generate HPI</button>
|
<button id="enc-generate-btn" class="btn-generate"><i class="fas fa-wand-magic-sparkles"></i> Generate HPI</button>
|
||||||
|
|
@ -71,9 +72,9 @@
|
||||||
<button class="btn-sm btn-ghost" data-action="nc-export" data-target="enc-hpi-text" data-label="hpi-encounter"><i class="fas fa-cloud-arrow-up"></i></button>
|
<button class="btn-sm btn-ghost" data-action="nc-export" data-target="enc-hpi-text" data-label="hpi-encounter"><i class="fas fa-cloud-arrow-up"></i></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="enc-hpi-text" class="output-text" contenteditable="true"></div>
|
<div id="enc-hpi-text" class="output-text output-text-large" contenteditable="true"></div>
|
||||||
<div class="refine-bar">
|
<div class="refine-bar">
|
||||||
<input type="text" id="enc-refine-input" class="refine-input" placeholder="Tell AI to modify (e.g., 'make it shorter', 'add that patient has asthma history')">
|
<textarea id="enc-refine-input" class="refine-input" rows="2" placeholder="Tell AI to modify (e.g., 'make it shorter', 'add that patient has asthma history')"></textarea>
|
||||||
<button class="btn-sm btn-primary" id="enc-refine-btn"><i class="fas fa-edit"></i> Refine</button>
|
<button class="btn-sm btn-primary" id="enc-refine-btn"><i class="fas fa-edit"></i> Refine</button>
|
||||||
<button class="btn-sm btn-ghost" id="enc-shorten-btn"><i class="fas fa-compress"></i> Shorter</button>
|
<button class="btn-sm btn-ghost" id="enc-shorten-btn"><i class="fas fa-compress"></i> Shorter</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -124,7 +124,7 @@
|
||||||
<!-- AI Instructions -->
|
<!-- AI Instructions -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header"><h3><i class="fas fa-comment-dots"></i> Additional Instructions (optional)</h3></div>
|
<div class="card-header"><h3><i class="fas fa-comment-dots"></i> Additional Instructions (optional)</h3></div>
|
||||||
<input type="text" id="hc-instructions" class="full-input" placeholder="e.g., 'Focus on respiratory course', 'Patient was transferred from outside hospital'">
|
<textarea id="hc-instructions" class="full-input" rows="3" placeholder="e.g., 'Focus on respiratory course', 'Patient was transferred from outside hospital'"></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="action-row">
|
<div class="action-row">
|
||||||
|
|
@ -145,7 +145,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div id="hc-course-text" class="output-text" contenteditable="true"></div>
|
<div id="hc-course-text" class="output-text" contenteditable="true"></div>
|
||||||
<div class="refine-bar">
|
<div class="refine-bar">
|
||||||
<input type="text" id="hc-refine-input" class="refine-input" placeholder="Tell AI to modify or add discharge day info...">
|
<textarea id="hc-refine-input" class="refine-input" rows="2" placeholder="Tell AI to modify or add discharge day info..."></textarea>
|
||||||
<button class="btn-sm btn-primary" id="hc-refine-btn"><i class="fas fa-edit"></i> Refine</button>
|
<button class="btn-sm btn-primary" id="hc-refine-btn"><i class="fas fa-edit"></i> Refine</button>
|
||||||
<button class="btn-sm btn-ghost" id="hc-shorten-btn"><i class="fas fa-compress"></i> Shorter</button>
|
<button class="btn-sm btn-ghost" id="hc-shorten-btn"><i class="fas fa-compress"></i> Shorter</button>
|
||||||
<button class="btn-sm btn-warning" id="hc-clarify-btn"><i class="fas fa-circle-question"></i> What's Missing?</button>
|
<button class="btn-sm btn-warning" id="hc-clarify-btn"><i class="fas fa-circle-question"></i> What's Missing?</button>
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,86 @@
|
||||||
|
|
||||||
<div class="settings-page">
|
<div class="settings-page">
|
||||||
|
|
||||||
|
<!-- Voice Preferences (STT & TTS) -->
|
||||||
|
<div class="settings-section card" id="voice-preferences-section">
|
||||||
|
<h3><i class="fas fa-microphone-lines"></i> Voice Preferences</h3>
|
||||||
|
<p style="font-size:13px;color:var(--g600);">Customize your speech-to-text model and text-to-speech voice. These settings apply to all your recording and read-aloud features.</p>
|
||||||
|
|
||||||
|
<div style="margin-bottom:16px;">
|
||||||
|
<label style="display:block;font-size:13px;font-weight:600;color:var(--g700);margin-bottom:6px;">Speech-to-Text Model (Transcription)</label>
|
||||||
|
<p style="font-size:12px;color:var(--g500);margin:4px 0 8px;">Choose the AI model for transcribing your audio recordings. More accurate models may be slower.</p>
|
||||||
|
<select id="stt-model-select" style="font-size:13px;padding:6px 10px;border:1px solid var(--g300);border-radius:6px;width:100%;max-width:400px;">
|
||||||
|
<option value="">Server default</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-bottom:16px;">
|
||||||
|
<label style="display:block;font-size:13px;font-weight:600;color:var(--g700);margin-bottom:6px;">Text-to-Speech Voice (Read Aloud)</label>
|
||||||
|
<p style="font-size:12px;color:var(--g500);margin:4px 0 8px;">Choose the voice for the "Read Aloud" feature. Preview available after selection.</p>
|
||||||
|
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
|
||||||
|
<select id="tts-voice-select" style="font-size:13px;padding:6px 10px;border:1px solid var(--g300);border-radius:6px;flex:1;max-width:400px;">
|
||||||
|
<option value="">Server default</option>
|
||||||
|
</select>
|
||||||
|
<button id="btn-preview-voice" class="btn-sm btn-ghost"><i class="fas fa-play"></i> Preview</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top:12px;">
|
||||||
|
<button id="btn-save-voice-prefs" class="btn-sm btn-primary"><i class="fas fa-save"></i> Save Voice Preferences</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Browser Whisper -->
|
||||||
|
<div class="settings-section card" id="browser-whisper-section">
|
||||||
|
<h3><i class="fas fa-microchip"></i> Browser Transcription (Local Whisper)</h3>
|
||||||
|
<p style="font-size:13px;color:var(--g600);">Transcribes audio entirely in your browser — no audio sent to any server. Powered by OpenAI Whisper running in WebAssembly. Model is downloaded once and cached locally.</p>
|
||||||
|
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:12px;">
|
||||||
|
<label style="font-size:13px;font-weight:600;">Enable browser transcription:</label>
|
||||||
|
<label style="display:flex;align-items:center;gap:6px;cursor:pointer;">
|
||||||
|
<input type="checkbox" id="browser-whisper-enabled" style="accent-color:var(--blue);width:16px;height:16px;">
|
||||||
|
<span style="font-size:13px;" id="browser-whisper-status">Off</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div id="browser-whisper-model-row" style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:12px;">
|
||||||
|
<label style="font-size:13px;font-weight:600;">Model:</label>
|
||||||
|
<select id="browser-whisper-model" style="font-size:13px;padding:5px 8px;border:1px solid var(--g300);border-radius:6px;">
|
||||||
|
<option value="Xenova/whisper-tiny.en">Tiny (~39MB) — fastest, ~2-3s</option>
|
||||||
|
<option value="Xenova/whisper-base.en">Base (~74MB) — balanced, ~3-5s</option>
|
||||||
|
<option value="Xenova/whisper-small.en">Small (~244MB) — best quality, ~6-10s</option>
|
||||||
|
</select>
|
||||||
|
<button id="btn-whisper-preload" class="btn-sm btn-ghost"><i class="fas fa-download"></i> Pre-download model</button>
|
||||||
|
</div>
|
||||||
|
<div id="browser-whisper-progress" style="display:none;font-size:12px;color:var(--g500);margin-top:4px;">
|
||||||
|
<i class="fas fa-spinner fa-spin"></i> <span id="browser-whisper-progress-text">Loading...</span>
|
||||||
|
</div>
|
||||||
|
<p style="font-size:12px;color:var(--g400);margin:8px 0 0;"><i class="fas fa-info-circle"></i> When enabled, overrides server transcription. Falls back to server if browser transcription fails.</p>
|
||||||
|
<p style="font-size:11px;color:var(--orange);margin:4px 0 0;display:none;" id="browser-whisper-csp-warning"><i class="fas fa-exclamation-triangle"></i> <strong>Network/Firewall Issue:</strong> If model download fails, check that <code>cdn.jsdelivr.net</code> and <code>huggingface.co</code> are not blocked. Server transcription will be used as fallback.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Web Speech Recognition (Real-time Streaming) -->
|
||||||
|
<div class="settings-section card" id="web-speech-section" style="border-left:3px solid var(--orange);">
|
||||||
|
<h3><i class="fas fa-wave-square"></i> Real-Time Streaming Transcription</h3>
|
||||||
|
<div style="background:var(--orange-light);padding:12px;border-radius:6px;margin-bottom:12px;">
|
||||||
|
<p style="font-size:13px;color:var(--orange-dark);margin:0;"><i class="fas fa-exclamation-triangle"></i> <strong>Privacy Warning:</strong> Uses your browser's built-in speech recognition, which <strong>may send audio to cloud servers</strong> (Chrome/Edge send to Google). Only enable if you accept this trade-off for real-time transcription.</p>
|
||||||
|
</div>
|
||||||
|
<p style="font-size:13px;color:var(--g600);">See words appear as you speak (streaming). Overrides browser and server transcription when enabled. <strong>Not HIPAA-compliant</strong> in most browsers.</p>
|
||||||
|
|
||||||
|
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:12px;">
|
||||||
|
<label style="font-size:13px;font-weight:600;">Enable real-time streaming:</label>
|
||||||
|
<label style="display:flex;align-items:center;gap:6px;cursor:pointer;">
|
||||||
|
<input type="checkbox" id="web-speech-enabled" style="accent-color:var(--orange);width:16px;height:16px;">
|
||||||
|
<span style="font-size:13px;" id="web-speech-status">Off</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="web-speech-privacy-info" style="font-size:12px;color:var(--g500);padding:10px;background:var(--g50);border-radius:6px;margin-top:8px;">
|
||||||
|
<p style="margin:0 0 4px;font-weight:600;">Current Browser:</p>
|
||||||
|
<p style="margin:0;" id="web-speech-browser-info">Detecting...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style="font-size:11px;color:var(--g400);margin:8px 0 0;"><i class="fas fa-info-circle"></i> <strong>Trade-off:</strong> Immediate transcription vs. privacy. For maximum privacy, use Browser Whisper (offline batch mode) or Server transcription with HIPAA-eligible provider.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 2FA -->
|
<!-- 2FA -->
|
||||||
<div class="settings-section card">
|
<div class="settings-section card">
|
||||||
<h3><i class="fas fa-shield-halved"></i> Two-Factor Authentication</h3>
|
<h3><i class="fas fa-shield-halved"></i> Two-Factor Authentication</h3>
|
||||||
|
|
@ -64,6 +144,10 @@
|
||||||
<option value="encounter_format">Encounter Note Format</option>
|
<option value="encounter_format">Encounter Note Format</option>
|
||||||
<option value="family_history">Family History Format</option>
|
<option value="family_history">Family History Format</option>
|
||||||
<option value="assessment_plan">Assessment & Plan Format</option>
|
<option value="assessment_plan">Assessment & Plan Format</option>
|
||||||
|
<option value="template_soap">SOAP Note Template</option>
|
||||||
|
<option value="template_hpi">HPI Template</option>
|
||||||
|
<option value="template_wellvisit">Well Visit Template</option>
|
||||||
|
<option value="template_sickvisit">Sick Visit Template</option>
|
||||||
<option value="custom">Custom</option>
|
<option value="custom">Custom</option>
|
||||||
</select>
|
</select>
|
||||||
<input type="text" id="mem-name" style="font-size:13px;padding:5px 8px;border:1px solid var(--g300);border-radius:6px;flex:1;min-width:150px;" placeholder="Template name (e.g. Normal PE)">
|
<input type="text" id="mem-name" style="font-size:13px;padding:5px 8px;border:1px solid var(--g300);border-radius:6px;flex:1;min-width:150px;" placeholder="Template name (e.g. Normal PE)">
|
||||||
|
|
@ -77,6 +161,40 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Documents (S3) -->
|
||||||
|
<div class="settings-section card" id="documents-section">
|
||||||
|
<h3><i class="fas fa-file-arrow-up"></i> Documents</h3>
|
||||||
|
<p style="font-size:13px;color:var(--g600);">Upload and manage documents via S3 storage (PDF, images, Word docs, text files). Max 10 MB per file.</p>
|
||||||
|
<div id="doc-upload-area" style="margin-bottom:12px;">
|
||||||
|
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
|
||||||
|
<input type="file" id="doc-file-input" accept=".pdf,.jpg,.jpeg,.png,.gif,.doc,.docx,.txt,.csv" style="font-size:13px;">
|
||||||
|
<input type="text" id="doc-description" placeholder="Description (optional)" style="font-size:13px;padding:5px 8px;border:1px solid var(--g300);border-radius:6px;flex:1;min-width:150px;">
|
||||||
|
<button id="btn-doc-upload" class="btn-sm btn-primary"><i class="fas fa-upload"></i> Upload</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="documents-list" style="display:flex;flex-direction:column;gap:6px;">
|
||||||
|
<p style="color:var(--g400);font-size:13px;">Loading...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- AI Corrections (Dragon-like memory) -->
|
||||||
|
<div class="settings-section card">
|
||||||
|
<h3><i class="fas fa-brain"></i> AI Learning (Corrections)</h3>
|
||||||
|
<p style="font-size:13px;color:var(--g600);">The AI automatically learns from your edits. When you modify AI-generated text and save, corrections are stored here and applied to future notes. Latest 20 per section.</p>
|
||||||
|
<div id="corrections-list" style="display:flex;flex-direction:column;gap:6px;">
|
||||||
|
<p style="color:var(--g400);font-size:13px;">Loading corrections...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Audio Backups -->
|
||||||
|
<div class="settings-section card">
|
||||||
|
<h3><i class="fas fa-microphone-lines"></i> Audio Backups</h3>
|
||||||
|
<p style="font-size:13px;color:var(--g600);">Recordings are automatically backed up locally and kept for 24 hours. Retry transcription if it failed.</p>
|
||||||
|
<div id="audio-backups-list" style="display:flex;flex-direction:column;gap:6px;">
|
||||||
|
<p style="color:var(--g400);font-size:13px;">Loading...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Saved Encounters -->
|
<!-- Saved Encounters -->
|
||||||
<div class="settings-section card">
|
<div class="settings-section card">
|
||||||
<h3><i class="fas fa-floppy-disk"></i> Saved Encounters</h3>
|
<h3><i class="fas fa-floppy-disk"></i> Saved Encounters</h3>
|
||||||
|
|
|
||||||
|
|
@ -99,7 +99,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div id="sick-note-text" class="output-text" contenteditable="true"></div>
|
<div id="sick-note-text" class="output-text" contenteditable="true"></div>
|
||||||
<div class="refine-bar">
|
<div class="refine-bar">
|
||||||
<input type="text" id="sick-refine-input" class="refine-input" placeholder="Tell AI to modify (e.g., 'add that patient received amoxicillin')">
|
<textarea id="sick-refine-input" class="refine-input" rows="2" placeholder="Tell AI to modify (e.g., 'add that patient received amoxicillin')"></textarea>
|
||||||
<button class="btn-sm btn-primary" id="sick-refine-btn"><i class="fas fa-edit"></i> Refine</button>
|
<button class="btn-sm btn-primary" id="sick-refine-btn"><i class="fas fa-edit"></i> Refine</button>
|
||||||
<button class="btn-sm btn-ghost" id="sick-shorten-btn"><i class="fas fa-compress"></i> Shorter</button>
|
<button class="btn-sm btn-ghost" id="sick-shorten-btn"><i class="fas fa-compress"></i> Shorter</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,26 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Save/Load bar -->
|
||||||
|
<div class="save-bar-wrap">
|
||||||
|
<div class="save-bar" id="soap-save-bar">
|
||||||
|
<div style="display:flex;align-items:center;gap:8px;flex:1;">
|
||||||
|
<i class="fas fa-tag" style="color:var(--g400);font-size:13px;"></i>
|
||||||
|
<input type="text" id="soap-label" class="save-label-input" placeholder="Patient label (e.g. John D, Visit #42)">
|
||||||
|
</div>
|
||||||
|
<button id="btn-soap-save" class="btn-sm btn-ghost"><i class="fas fa-floppy-disk"></i> Save</button>
|
||||||
|
<button id="btn-soap-load" class="btn-sm btn-ghost"><i class="fas fa-folder-open"></i> Load</button>
|
||||||
|
<button id="btn-soap-new" class="btn-sm btn-ghost" title="Clear all and start new patient" style="color:var(--red);"><i class="fas fa-rotate-left"></i> New</button>
|
||||||
|
</div>
|
||||||
|
<div id="soap-load-popover" class="enc-load-popover hidden">
|
||||||
|
<div class="enc-load-popover-inner">
|
||||||
|
<input type="text" id="soap-load-search" class="enc-load-search" placeholder="Search saved encounters...">
|
||||||
|
<button class="enc-pop-close btn-sm btn-ghost"><i class="fas fa-times"></i></button>
|
||||||
|
</div>
|
||||||
|
<div id="soap-pop-list" class="enc-pop-list"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="record-controls">
|
<div class="record-controls">
|
||||||
<button id="soap-record-btn" class="record-btn btn-teal"><i class="fas fa-microphone"></i><span>Dictate</span></button>
|
<button id="soap-record-btn" class="record-btn btn-teal"><i class="fas fa-microphone"></i><span>Dictate</span></button>
|
||||||
|
|
@ -33,7 +53,7 @@
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header"><h3><i class="fas fa-comment-dots"></i> Instructions (optional)</h3></div>
|
<div class="card-header"><h3><i class="fas fa-comment-dots"></i> Instructions (optional)</h3></div>
|
||||||
<input type="text" id="soap-instructions" class="full-input" placeholder="e.g., 'Include assessment for otitis media', 'Add anticipatory guidance'">
|
<textarea id="soap-instructions" class="full-input" rows="3" placeholder="e.g., 'Include assessment for otitis media', 'Add anticipatory guidance', 'Always include return precautions'"></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button id="soap-generate-btn" class="btn-generate btn-generate-teal"><i class="fas fa-wand-magic-sparkles"></i> Generate SOAP Note</button>
|
<button id="soap-generate-btn" class="btn-generate btn-generate-teal"><i class="fas fa-wand-magic-sparkles"></i> Generate SOAP Note</button>
|
||||||
|
|
|
||||||
|
|
@ -297,7 +297,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div id="wv-note-text" class="output-text" contenteditable="true"></div>
|
<div id="wv-note-text" class="output-text" contenteditable="true"></div>
|
||||||
<div class="refine-bar">
|
<div class="refine-bar">
|
||||||
<input type="text" id="wv-refine-input" class="refine-input" placeholder="Tell AI to modify (e.g., 'add that patient has asthma', 'expand the plan section')">
|
<textarea id="wv-refine-input" class="refine-input" rows="2" placeholder="Tell AI to modify (e.g., 'add that patient has asthma', 'expand the plan section')"></textarea>
|
||||||
<button class="btn-sm btn-primary" id="wv-refine-btn"><i class="fas fa-edit"></i> Refine</button>
|
<button class="btn-sm btn-primary" id="wv-refine-btn"><i class="fas fa-edit"></i> Refine</button>
|
||||||
<button class="btn-sm btn-ghost" id="wv-shorten-btn"><i class="fas fa-compress"></i> Shorter</button>
|
<button class="btn-sm btn-ghost" id="wv-shorten-btn"><i class="fas fa-compress"></i> Shorter</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -153,11 +153,14 @@ body{font-family:'Inter',system-ui,sans-serif;background:var(--g50);color:var(--
|
||||||
.output-header{background:var(--green-light);border-bottom-color:var(--green);}
|
.output-header{background:var(--green-light);border-bottom-color:var(--green);}
|
||||||
.output-actions{display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
|
.output-actions{display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
|
||||||
.output-text{padding:20px 24px;font-size:13px;line-height:1.9;outline:none;min-height:480px;white-space:pre-wrap;overflow-y:auto;}
|
.output-text{padding:20px 24px;font-size:13px;line-height:1.9;outline:none;min-height:480px;white-space:pre-wrap;overflow-y:auto;}
|
||||||
|
.output-text-large{min-height:600px;}
|
||||||
|
.editable-box-large{min-height:400px;}
|
||||||
.model-tag{font-size:10px;padding:2px 7px;background:var(--g200);border-radius:4px;color:var(--g600);}
|
.model-tag{font-size:10px;padding:2px 7px;background:var(--g200);border-radius:4px;color:var(--g600);}
|
||||||
|
|
||||||
/* REFINE BAR */
|
/* REFINE BAR */
|
||||||
.refine-bar{display:flex;gap:6px;padding:10px 16px;border-top:1px solid var(--g200);background:var(--g50);align-items:center;flex-wrap:wrap;}
|
.refine-bar{display:flex;gap:6px;padding:10px 16px;border-top:1px solid var(--g200);background:var(--g50);align-items:flex-start;flex-wrap:wrap;}
|
||||||
.refine-input{flex:1;min-width:200px;padding:7px 12px;border:1.5px solid var(--g300);border-radius:6px;font-size:13px;font-family:inherit;}
|
.refine-input{flex:1;min-width:200px;padding:7px 12px;border:1.5px solid var(--g300);border-radius:6px;font-size:13px;font-family:inherit;resize:vertical;}
|
||||||
|
textarea.full-input{resize:vertical;}
|
||||||
.refine-input:focus{outline:none;border-color:var(--blue);box-shadow:0 0 0 3px var(--blue-light);}
|
.refine-input:focus{outline:none;border-color:var(--blue);box-shadow:0 0 0 3px var(--blue-light);}
|
||||||
|
|
||||||
/* CLARIFY BOX */
|
/* CLARIFY BOX */
|
||||||
|
|
@ -268,6 +271,14 @@ body{font-family:'Inter',system-ui,sans-serif;background:var(--g50);color:var(--
|
||||||
.auth-box{padding:24px;}
|
.auth-box{padding:24px;}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* TOGGLE SWITCH */
|
||||||
|
.toggle-switch{position:relative;display:inline-block;width:36px;height:20px;vertical-align:middle;}
|
||||||
|
.toggle-switch input{opacity:0;width:0;height:0;}
|
||||||
|
.toggle-slider{position:absolute;cursor:pointer;top:0;left:0;right:0;bottom:0;background:var(--g300);border-radius:20px;transition:.2s;}
|
||||||
|
.toggle-slider::before{content:'';position:absolute;height:16px;width:16px;left:2px;bottom:2px;background:white;border-radius:50%;transition:.2s;}
|
||||||
|
.toggle-switch input:checked+.toggle-slider{background:var(--blue);}
|
||||||
|
.toggle-switch input:checked+.toggle-slider::before{transform:translateX(16px);}
|
||||||
|
|
||||||
.editable-box::-webkit-scrollbar,.output-text::-webkit-scrollbar{width:5px;}
|
.editable-box::-webkit-scrollbar,.output-text::-webkit-scrollbar{width:5px;}
|
||||||
.editable-box::-webkit-scrollbar-thumb,.output-text::-webkit-scrollbar-thumb{background:var(--g300);border-radius:3px;}
|
.editable-box::-webkit-scrollbar-thumb,.output-text::-webkit-scrollbar-thumb{background:var(--g300);border-radius:3px;}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,14 @@
|
||||||
<label>2FA Code</label>
|
<label>2FA Code</label>
|
||||||
<input type="text" id="login-totp" placeholder="6-digit code" maxlength="6">
|
<input type="text" id="login-totp" placeholder="6-digit code" maxlength="6">
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn-auth">Sign In</button>
|
<button type="submit" class="btn-auth" id="btn-local-login">Sign In</button>
|
||||||
|
<div id="sso-divider" class="hidden" style="display:none;text-align:center;margin:16px 0 12px;position:relative;">
|
||||||
|
<span style="background:white;padding:0 12px;color:#9ca3af;font-size:12px;position:relative;z-index:1;">or</span>
|
||||||
|
<hr style="border:none;border-top:1px solid #e5e7eb;position:absolute;top:50%;left:0;right:0;margin:0;">
|
||||||
|
</div>
|
||||||
|
<a href="/api/auth/oidc" id="btn-sso" class="btn-auth" style="display:none;text-align:center;text-decoration:none;background:linear-gradient(135deg,#0f172a,#334155);margin-top:0;">
|
||||||
|
<i class="fas fa-shield-halved"></i> <span id="sso-label">Sign in with SSO</span>
|
||||||
|
</a>
|
||||||
<div id="resend-verify-box" class="hidden" style="margin:12px 0;padding:12px;background:#fef3c7;border-radius:8px;text-align:center;">
|
<div id="resend-verify-box" class="hidden" style="margin:12px 0;padding:12px;background:#fef3c7;border-radius:8px;text-align:center;">
|
||||||
<p style="margin:0 0 8px;font-size:13px;color:#92400e;">Your email is not verified yet.</p>
|
<p style="margin:0 0 8px;font-size:13px;color:#92400e;">Your email is not verified yet.</p>
|
||||||
<a href="#" id="resend-verify-link" style="font-size:13px;font-weight:600;color:#2563eb;">Resend verification link</a>
|
<a href="#" id="resend-verify-link" style="font-size:13px;font-weight:600;color:#2563eb;">Resend verification link</a>
|
||||||
|
|
@ -293,6 +300,12 @@
|
||||||
<script src="/vendor/tiptap.bundle.js"></script>
|
<script src="/vendor/tiptap.bundle.js"></script>
|
||||||
<script defer src="/js/milestonesData.js"></script>
|
<script defer src="/js/milestonesData.js"></script>
|
||||||
<script defer src="/js/pediatricScheduleData.js"></script>
|
<script defer src="/js/pediatricScheduleData.js"></script>
|
||||||
|
<script defer src="/js/audioBackup.js"></script>
|
||||||
|
<script defer src="/js/correctionTracker.js"></script>
|
||||||
|
<script defer src="/js/browserWhisper.js"></script>
|
||||||
|
<script defer src="/js/speechRecognition.js"></script>
|
||||||
|
<script defer src="/js/transcriptionSettings.js"></script>
|
||||||
|
<script defer src="/js/voicePreferences.js"></script>
|
||||||
<script defer src="/js/app.js"></script>
|
<script defer src="/js/app.js"></script>
|
||||||
<script defer src="/js/auth.js"></script>
|
<script defer src="/js/auth.js"></script>
|
||||||
<script defer src="/js/liveEncounter.js"></script>
|
<script defer src="/js/liveEncounter.js"></script>
|
||||||
|
|
@ -307,8 +320,10 @@
|
||||||
<script defer src="/js/sickVisit.js"></script>
|
<script defer src="/js/sickVisit.js"></script>
|
||||||
<script defer src="/js/encounters.js"></script>
|
<script defer src="/js/encounters.js"></script>
|
||||||
<script defer src="/js/memories.js"></script>
|
<script defer src="/js/memories.js"></script>
|
||||||
|
<script defer src="/js/documents.js"></script>
|
||||||
<script defer src="/js/learningHub.js"></script>
|
<script defer src="/js/learningHub.js"></script>
|
||||||
<script defer src="/js/admin.js"></script>
|
<script defer src="/js/admin.js"></script>
|
||||||
|
<script defer src="/js/adminMilestones.js"></script>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -573,3 +573,310 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// ADMIN MODEL MANAGEMENT — Discover, search, enable/disable, custom models
|
||||||
|
// ============================================================
|
||||||
|
(function() {
|
||||||
|
|
||||||
|
document.addEventListener('tabChanged', function(e) {
|
||||||
|
if (e.detail && e.detail.tab === 'admin') {
|
||||||
|
loadAdminModels();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('click', function(e) {
|
||||||
|
if (e.target.closest('#btn-discover-models')) discoverModels();
|
||||||
|
if (e.target.closest('#btn-add-custom-model')) addCustomModel();
|
||||||
|
if (e.target.closest('#btn-save-default-model')) saveDefaultModel();
|
||||||
|
if (e.target.closest('#btn-clear-all-models')) clearAllModels();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Allow Enter key to trigger search
|
||||||
|
document.addEventListener('keydown', function(e) {
|
||||||
|
if (e.target.id === 'admin-model-search' && e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
discoverModels();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function esc(str) {
|
||||||
|
if (!str) return '';
|
||||||
|
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadAdminModels() {
|
||||||
|
fetch('/api/admin/config/models', { headers: getAuthHeaders() })
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(data) {
|
||||||
|
if (!data.success) return;
|
||||||
|
|
||||||
|
// Provider badge
|
||||||
|
var badge = document.getElementById('admin-model-provider-badge');
|
||||||
|
if (badge) badge.textContent = (data.provider || 'unknown').toUpperCase();
|
||||||
|
|
||||||
|
// Default model selector — built-ins + custom models
|
||||||
|
var defaultSel = document.getElementById('admin-default-model');
|
||||||
|
if (defaultSel) {
|
||||||
|
defaultSel.innerHTML = '';
|
||||||
|
data.models.forEach(function(m) {
|
||||||
|
var opt = document.createElement('option');
|
||||||
|
opt.value = m.id;
|
||||||
|
opt.textContent = m.name + (m.enabled === false ? ' (disabled)' : '');
|
||||||
|
defaultSel.appendChild(opt);
|
||||||
|
});
|
||||||
|
(data.custom || []).forEach(function(m) {
|
||||||
|
var opt = document.createElement('option');
|
||||||
|
opt.value = m.id;
|
||||||
|
opt.textContent = m.name;
|
||||||
|
defaultSel.appendChild(opt);
|
||||||
|
});
|
||||||
|
// Pre-select the saved default
|
||||||
|
if (data.defaultModel) defaultSel.value = data.defaultModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Built-in models with toggle
|
||||||
|
var container = document.getElementById('admin-builtin-models');
|
||||||
|
if (container) {
|
||||||
|
if (data.litellmHint) {
|
||||||
|
container.innerHTML = '<div style="padding:10px 12px;background:var(--g50);border-radius:6px;font-size:13px;color:var(--g600);">' +
|
||||||
|
'<p style="margin:0 0 8px;"><i class="fas fa-info-circle" style="color:var(--blue);"></i> <strong>LiteLLM mode:</strong> No built-in models. ' +
|
||||||
|
'Use <strong>Search API</strong> below to discover models from your proxy, then add them.</p>' +
|
||||||
|
'<button id="btn-clear-all-models" class="btn-sm" style="background:var(--red-light);color:var(--red);border:none;border-radius:6px;padding:4px 12px;font-size:12px;cursor:pointer;">' +
|
||||||
|
'<i class="fas fa-trash"></i> Clear all added models</button></div>';
|
||||||
|
} else if (data.models.length === 0) {
|
||||||
|
container.innerHTML = '<p style="color:var(--g400);font-size:13px;">No built-in models for this provider.</p>';
|
||||||
|
} else {
|
||||||
|
container.innerHTML = data.models.map(function(m) {
|
||||||
|
var checked = m.enabled !== false ? 'checked' : '';
|
||||||
|
return '<label style="display:flex;align-items:center;gap:8px;padding:6px 8px;border-radius:6px;background:var(--g50);cursor:pointer;font-size:13px;">' +
|
||||||
|
'<input type="checkbox" class="admin-model-toggle" data-model-id="' + esc(m.id) + '" ' + checked + ' style="accent-color:var(--blue);">' +
|
||||||
|
'<span style="flex:1;"><strong>' + esc(m.name) + '</strong> <span style="color:var(--g500);font-size:11px;">(' + esc(m.id) + ')</span></span>' +
|
||||||
|
'<span style="font-size:11px;padding:1px 6px;border-radius:4px;background:var(--g100);color:var(--g600);">' + esc(m.tag || '') + '</span>' +
|
||||||
|
'<span style="font-size:11px;color:var(--g500);">' + esc(m.cost || '') + '</span>' +
|
||||||
|
'</label>';
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
container.querySelectorAll('.admin-model-toggle').forEach(function(cb) {
|
||||||
|
cb.addEventListener('change', function() {
|
||||||
|
toggleModel(cb.dataset.modelId, cb.checked);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom models list
|
||||||
|
renderCustomModels(data.custom || []);
|
||||||
|
})
|
||||||
|
.catch(function(err) { console.error('[AdminModels] Load failed:', err); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleModel(modelId, enabled) {
|
||||||
|
fetch('/api/admin/config/models/toggle', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
body: JSON.stringify({ modelId: modelId, enabled: enabled })
|
||||||
|
})
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(data) {
|
||||||
|
if (data.success) {
|
||||||
|
showToast(modelId + ' ' + (enabled ? 'enabled' : 'disabled'), 'success');
|
||||||
|
} else {
|
||||||
|
showToast(data.error || 'Failed', 'error');
|
||||||
|
// Revert checkbox
|
||||||
|
var cb = document.querySelector('.admin-model-toggle[data-model-id="' + modelId + '"]');
|
||||||
|
if (cb) cb.checked = !enabled;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function() { showToast('Request failed', 'error'); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function discoverModels() {
|
||||||
|
var search = (document.getElementById('admin-model-search') || {}).value || '';
|
||||||
|
var container = document.getElementById('admin-discovered-models');
|
||||||
|
var hint = document.getElementById('admin-discover-hint');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
container.innerHTML = '<p style="color:var(--g400);font-size:13px;"><i class="fas fa-spinner fa-spin"></i> Querying provider API...</p>';
|
||||||
|
if (hint) hint.style.display = 'none';
|
||||||
|
|
||||||
|
fetch('/api/admin/config/models/discover?q=' + encodeURIComponent(search), { headers: getAuthHeaders() })
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(data) {
|
||||||
|
if (!data.success) {
|
||||||
|
container.innerHTML = '<p style="color:var(--red);font-size:13px;">Error: ' + esc(data.error || 'Unknown error') + '</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!data.models || data.models.length === 0) {
|
||||||
|
container.innerHTML = '<p style="color:var(--g400);font-size:13px;">No models found' + (search ? ' matching "' + esc(search) + '"' : '') + '. Try a different search term.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = '<p style="font-size:12px;color:var(--g500);margin:0 0 6px;">Found ' + data.count + ' models. Click + to add to your model list.</p>' +
|
||||||
|
data.models.slice(0, 100).map(function(m) {
|
||||||
|
return '<div style="display:flex;align-items:center;gap:8px;padding:5px 8px;border-radius:6px;background:var(--g50);font-size:13px;">' +
|
||||||
|
'<button class="btn-sm btn-primary admin-add-discovered" data-mid="' + esc(m.id) + '" data-mname="' + esc(m.name) + '" data-mcost="' + esc(m.cost) + '" data-mcat="' + esc(m.category) + '" style="padding:2px 8px;font-size:11px;min-width:28px;">+</button>' +
|
||||||
|
'<span style="flex:1;"><strong>' + esc(m.name) + '</strong> <span style="color:var(--g500);font-size:11px;">(' + esc(m.id) + ')</span></span>' +
|
||||||
|
'<span style="font-size:11px;color:var(--g500);">' + esc(m.cost || '') + '</span>' +
|
||||||
|
'<span style="font-size:10px;padding:1px 5px;border-radius:3px;background:var(--g100);color:var(--g600);">' + esc(m.category || '') + '</span>' +
|
||||||
|
'</div>';
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
container.querySelectorAll('.admin-add-discovered').forEach(function(btn) {
|
||||||
|
btn.addEventListener('click', function() {
|
||||||
|
addDiscoveredModel(btn.dataset.mid, btn.dataset.mname, btn.dataset.mcost, btn.dataset.mcat, btn);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(function(err) {
|
||||||
|
container.innerHTML = '<p style="color:var(--red);font-size:13px;">Request failed: ' + esc(err.message) + '</p>';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function addDiscoveredModel(id, name, cost, category, btn) {
|
||||||
|
fetch('/api/admin/config/models/add-discovered', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
body: JSON.stringify({ id: id, name: name, cost: cost, category: category })
|
||||||
|
})
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(data) {
|
||||||
|
if (data.success) {
|
||||||
|
showToast('Added: ' + name + ' — now select it as default and click Set Default', 'success');
|
||||||
|
if (btn) { btn.textContent = 'Added'; btn.disabled = true; btn.style.background = 'var(--green)'; }
|
||||||
|
// Refresh model lists, then auto-select the newly added model
|
||||||
|
fetch('/api/admin/config/models', { headers: getAuthHeaders() })
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(refreshed) {
|
||||||
|
if (!refreshed.success) return;
|
||||||
|
var defaultSel = document.getElementById('admin-default-model');
|
||||||
|
if (defaultSel) {
|
||||||
|
defaultSel.innerHTML = '';
|
||||||
|
refreshed.models.forEach(function(m) {
|
||||||
|
var opt = document.createElement('option');
|
||||||
|
opt.value = m.id;
|
||||||
|
opt.textContent = m.name + (m.enabled === false ? ' (disabled)' : '');
|
||||||
|
defaultSel.appendChild(opt);
|
||||||
|
});
|
||||||
|
(refreshed.custom || []).forEach(function(m) {
|
||||||
|
var opt = document.createElement('option');
|
||||||
|
opt.value = m.id;
|
||||||
|
opt.textContent = m.name;
|
||||||
|
defaultSel.appendChild(opt);
|
||||||
|
});
|
||||||
|
// Auto-select the model just added
|
||||||
|
defaultSel.value = id;
|
||||||
|
}
|
||||||
|
renderCustomModels(refreshed.custom || []);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
showToast(data.error || 'Failed', 'error');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function() { showToast('Request failed', 'error'); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function addCustomModel() {
|
||||||
|
var id = (document.getElementById('admin-custom-model-id') || {}).value || '';
|
||||||
|
var name = (document.getElementById('admin-custom-model-name') || {}).value || '';
|
||||||
|
var cost = (document.getElementById('admin-custom-model-cost') || {}).value || '';
|
||||||
|
var cat = (document.getElementById('admin-custom-model-cat') || {}).value || 'smart';
|
||||||
|
|
||||||
|
if (!id.trim() || !name.trim()) { showToast('Model ID and name required', 'error'); return; }
|
||||||
|
|
||||||
|
fetch('/api/admin/config/models/custom', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
body: JSON.stringify({ id: id.trim(), name: name.trim(), cost: cost.trim(), category: cat })
|
||||||
|
})
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(data) {
|
||||||
|
if (data.success) {
|
||||||
|
showToast('Custom model added: ' + name, 'success');
|
||||||
|
// Clear inputs
|
||||||
|
var el = document.getElementById('admin-custom-model-id'); if (el) el.value = '';
|
||||||
|
el = document.getElementById('admin-custom-model-name'); if (el) el.value = '';
|
||||||
|
el = document.getElementById('admin-custom-model-cost'); if (el) el.value = '';
|
||||||
|
loadAdminModels();
|
||||||
|
} else {
|
||||||
|
showToast(data.error || 'Failed', 'error');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function() { showToast('Request failed', 'error'); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCustomModels(custom) {
|
||||||
|
var container = document.getElementById('admin-custom-models-list');
|
||||||
|
if (!container) return;
|
||||||
|
if (!custom || custom.length === 0) {
|
||||||
|
container.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = '<label style="font-size:12px;font-weight:600;color:var(--g600);display:block;margin-bottom:4px;">Custom / Discovered Models</label>' +
|
||||||
|
custom.map(function(m) {
|
||||||
|
return '<div style="display:flex;align-items:center;gap:8px;padding:5px 8px;border-radius:6px;background:var(--g50);font-size:13px;">' +
|
||||||
|
'<span style="flex:1;"><strong>' + esc(m.name) + '</strong> <span style="color:var(--g500);font-size:11px;">(' + esc(m.id) + ')</span></span>' +
|
||||||
|
'<span style="font-size:11px;padding:1px 6px;border-radius:4px;background:var(--blue-light, #e0f0ff);color:var(--blue);">' + esc(m.tag || 'CUSTOM') + '</span>' +
|
||||||
|
'<span style="font-size:11px;color:var(--g500);">' + esc(m.cost || '') + '</span>' +
|
||||||
|
'<button class="btn-sm admin-delete-custom" data-mid="' + esc(m.id) + '" style="padding:2px 8px;font-size:11px;background:var(--red-light);color:var(--red);border:none;border-radius:4px;cursor:pointer;">Remove</button>' +
|
||||||
|
'</div>';
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
container.querySelectorAll('.admin-delete-custom').forEach(function(btn) {
|
||||||
|
btn.addEventListener('click', function() {
|
||||||
|
deleteCustomModel(btn.dataset.mid);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteCustomModel(modelId) {
|
||||||
|
fetch('/api/admin/config/models/custom/' + encodeURIComponent(modelId), {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: getAuthHeaders()
|
||||||
|
})
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(data) {
|
||||||
|
if (data.success) {
|
||||||
|
showToast('Removed: ' + modelId, 'info');
|
||||||
|
loadAdminModels();
|
||||||
|
} else {
|
||||||
|
showToast(data.error || 'Failed', 'error');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function() { showToast('Request failed', 'error'); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearAllModels() {
|
||||||
|
if (!confirm('Remove all added models? You will need to re-add them via Search API.')) return;
|
||||||
|
Promise.all([
|
||||||
|
fetch('/api/admin/config/models/clear-all', { method: 'POST', headers: getAuthHeaders() })
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
]).then(function(results) {
|
||||||
|
if (results[0].success) {
|
||||||
|
showToast('All models cleared', 'info');
|
||||||
|
loadAdminModels();
|
||||||
|
} else {
|
||||||
|
showToast(results[0].error || 'Failed', 'error');
|
||||||
|
}
|
||||||
|
}).catch(function() { showToast('Request failed', 'error'); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveDefaultModel() {
|
||||||
|
var sel = document.getElementById('admin-default-model');
|
||||||
|
if (!sel || !sel.value) { showToast('Select a model', 'error'); return; }
|
||||||
|
|
||||||
|
fetch('/api/admin/config/models/default', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
body: JSON.stringify({ modelId: sel.value })
|
||||||
|
})
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(data) {
|
||||||
|
if (data.success) showToast('Default model set: ' + sel.value, 'success');
|
||||||
|
else showToast(data.error || 'Failed', 'error');
|
||||||
|
})
|
||||||
|
.catch(function() { showToast('Request failed', 'error'); });
|
||||||
|
}
|
||||||
|
|
||||||
|
})();
|
||||||
|
|
|
||||||
326
public/js/adminMilestones.js
Normal file
326
public/js/adminMilestones.js
Normal file
|
|
@ -0,0 +1,326 @@
|
||||||
|
// ============================================================
|
||||||
|
// ADMIN: DEVELOPMENTAL MILESTONES MANAGEMENT
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
(function() {
|
||||||
|
var _inited = false;
|
||||||
|
|
||||||
|
document.addEventListener('tabChanged', function(e) {
|
||||||
|
if (e.detail.tab !== 'admin' || _inited) return;
|
||||||
|
_inited = true;
|
||||||
|
initMilestonesAdmin();
|
||||||
|
});
|
||||||
|
|
||||||
|
var milestones = [];
|
||||||
|
var ageGroups = [];
|
||||||
|
var domains = [];
|
||||||
|
var editingId = null;
|
||||||
|
|
||||||
|
function initMilestonesAdmin() {
|
||||||
|
console.log('[AdminMilestones] Initializing...');
|
||||||
|
|
||||||
|
// Buttons
|
||||||
|
document.getElementById('btn-refresh-milestones')?.addEventListener('click', loadMilestones);
|
||||||
|
document.getElementById('btn-add-milestone')?.addEventListener('click', function() {
|
||||||
|
editingId = null;
|
||||||
|
openMilestoneModal();
|
||||||
|
});
|
||||||
|
document.getElementById('btn-close-milestone-modal')?.addEventListener('click', closeMilestoneModal);
|
||||||
|
document.getElementById('btn-cancel-milestone-modal')?.addEventListener('click', closeMilestoneModal);
|
||||||
|
document.getElementById('btn-save-milestone')?.addEventListener('click', saveMilestone);
|
||||||
|
document.getElementById('btn-bulk-import-milestones')?.addEventListener('click', bulkImportMilestones);
|
||||||
|
document.getElementById('btn-reimport-milestones')?.addEventListener('click', function() {
|
||||||
|
if (confirm('This will DELETE all existing milestones and re-import from static data. Continue?')) {
|
||||||
|
bulkImportMilestones(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filter
|
||||||
|
document.getElementById('ms-filter-age')?.addEventListener('change', renderMilestones);
|
||||||
|
|
||||||
|
// Load data
|
||||||
|
loadMetadata();
|
||||||
|
loadMilestones();
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadMetadata() {
|
||||||
|
fetch('/api/admin/milestones/meta', {
|
||||||
|
headers: getAuthHeaders()
|
||||||
|
})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
if (!data.success) return;
|
||||||
|
ageGroups = data.age_groups || [];
|
||||||
|
domains = data.domains || [];
|
||||||
|
|
||||||
|
// Populate filter dropdown
|
||||||
|
var filterSelect = document.getElementById('ms-filter-age');
|
||||||
|
if (filterSelect) {
|
||||||
|
filterSelect.innerHTML = '<option value="">All Age Groups</option>';
|
||||||
|
ageGroups.forEach(function(age) {
|
||||||
|
var opt = document.createElement('option');
|
||||||
|
opt.value = age;
|
||||||
|
opt.textContent = age;
|
||||||
|
filterSelect.appendChild(opt);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Populate datalists for modal
|
||||||
|
var ageDatalist = document.getElementById('ms-age-list');
|
||||||
|
if (ageDatalist) {
|
||||||
|
ageDatalist.innerHTML = '';
|
||||||
|
ageGroups.forEach(function(age) {
|
||||||
|
var opt = document.createElement('option');
|
||||||
|
opt.value = age;
|
||||||
|
ageDatalist.appendChild(opt);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var domainDatalist = document.getElementById('ms-domain-list');
|
||||||
|
if (domainDatalist) {
|
||||||
|
domainDatalist.innerHTML = '';
|
||||||
|
domains.forEach(function(domain) {
|
||||||
|
var opt = document.createElement('option');
|
||||||
|
opt.value = domain;
|
||||||
|
domainDatalist.appendChild(opt);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(err => console.error('[AdminMilestones] Failed to load metadata:', err));
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadMilestones() {
|
||||||
|
var filterAge = document.getElementById('ms-filter-age')?.value;
|
||||||
|
var url = '/api/admin/milestones' + (filterAge ? '?age_group=' + encodeURIComponent(filterAge) : '');
|
||||||
|
|
||||||
|
fetch(url, {
|
||||||
|
headers: getAuthHeaders()
|
||||||
|
})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
if (!data.success) {
|
||||||
|
showToast(data.error || 'Failed to load milestones', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
milestones = data.milestones || [];
|
||||||
|
|
||||||
|
// Show/hide empty notice
|
||||||
|
var emptyNotice = document.getElementById('ms-empty-notice');
|
||||||
|
if (emptyNotice) {
|
||||||
|
emptyNotice.style.display = (milestones.length === 0 && !filterAge) ? 'block' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
renderMilestones();
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error('[AdminMilestones] Failed to load:', err);
|
||||||
|
showToast('Failed to load milestones: ' + err.message, 'error');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMilestones() {
|
||||||
|
var list = document.getElementById('milestones-list');
|
||||||
|
if (!list) return;
|
||||||
|
|
||||||
|
var filterAge = document.getElementById('ms-filter-age')?.value;
|
||||||
|
var filtered = filterAge ? milestones.filter(m => m.age_group === filterAge) : milestones;
|
||||||
|
|
||||||
|
if (filtered.length === 0) {
|
||||||
|
list.innerHTML = '<p style="text-align:center;color:var(--g400);padding:40px;">No milestones found. Click "Add Milestone" to create one.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group by age_group and domain
|
||||||
|
var grouped = {};
|
||||||
|
filtered.forEach(function(m) {
|
||||||
|
if (!grouped[m.age_group]) grouped[m.age_group] = {};
|
||||||
|
if (!grouped[m.age_group][m.domain]) grouped[m.age_group][m.domain] = [];
|
||||||
|
grouped[m.age_group][m.domain].push(m);
|
||||||
|
});
|
||||||
|
|
||||||
|
var html = '';
|
||||||
|
Object.keys(grouped).forEach(function(age) {
|
||||||
|
html += '<div style="border:1px solid var(--g200);border-radius:8px;padding:12px;background:var(--g50);">';
|
||||||
|
html += '<h4 style="margin:0 0 12px 0;font-size:15px;color:var(--g700);">' + age + '</h4>';
|
||||||
|
|
||||||
|
Object.keys(grouped[age]).forEach(function(domain) {
|
||||||
|
html += '<div style="margin-bottom:10px;">';
|
||||||
|
html += '<div style="font-size:13px;font-weight:600;color:var(--g600);margin-bottom:6px;">' + domain + '</div>';
|
||||||
|
|
||||||
|
grouped[age][domain].forEach(function(m) {
|
||||||
|
html += '<div style="display:flex;align-items:center;gap:8px;padding:6px;border:1px solid var(--g200);border-radius:6px;background:white;margin-bottom:4px;">';
|
||||||
|
html += '<span style="flex:1;font-size:13px;color:var(--g700);">' + m.milestone_text + '</span>';
|
||||||
|
html += '<button class="btn-sm btn-ghost" style="padding:4px 8px;font-size:11px;" onclick="editMilestone(' + m.id + ')"><i class="fas fa-edit"></i></button>';
|
||||||
|
html += '<button class="btn-sm" style="padding:4px 8px;font-size:11px;background:var(--red);color:white;border:none;" onclick="deleteMilestone(' + m.id + ')"><i class="fas fa-trash"></i></button>';
|
||||||
|
html += '</div>';
|
||||||
|
});
|
||||||
|
|
||||||
|
html += '</div>';
|
||||||
|
});
|
||||||
|
|
||||||
|
html += '</div>';
|
||||||
|
});
|
||||||
|
|
||||||
|
list.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openMilestoneModal(milestone) {
|
||||||
|
var modal = document.getElementById('milestone-modal');
|
||||||
|
var title = document.getElementById('milestone-modal-title');
|
||||||
|
|
||||||
|
if (!modal) return;
|
||||||
|
|
||||||
|
if (milestone) {
|
||||||
|
title.innerHTML = '<i class="fas fa-edit"></i> Edit Milestone';
|
||||||
|
document.getElementById('ms-edit-id').value = milestone.id;
|
||||||
|
document.getElementById('ms-edit-age').value = milestone.age_group;
|
||||||
|
document.getElementById('ms-edit-domain').value = milestone.domain;
|
||||||
|
document.getElementById('ms-edit-text').value = milestone.milestone_text;
|
||||||
|
document.getElementById('ms-edit-sort').value = milestone.sort_order;
|
||||||
|
} else {
|
||||||
|
title.innerHTML = '<i class="fas fa-plus"></i> Add Milestone';
|
||||||
|
document.getElementById('ms-edit-id').value = '';
|
||||||
|
document.getElementById('ms-edit-age').value = '';
|
||||||
|
document.getElementById('ms-edit-domain').value = '';
|
||||||
|
document.getElementById('ms-edit-text').value = '';
|
||||||
|
document.getElementById('ms-edit-sort').value = '0';
|
||||||
|
}
|
||||||
|
|
||||||
|
modal.style.display = 'flex';
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeMilestoneModal() {
|
||||||
|
var modal = document.getElementById('milestone-modal');
|
||||||
|
if (modal) modal.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveMilestone() {
|
||||||
|
var id = document.getElementById('ms-edit-id').value;
|
||||||
|
var age_group = document.getElementById('ms-edit-age').value.trim();
|
||||||
|
var domain = document.getElementById('ms-edit-domain').value.trim();
|
||||||
|
var milestone_text = document.getElementById('ms-edit-text').value.trim();
|
||||||
|
var sort_order = parseInt(document.getElementById('ms-edit-sort').value) || 0;
|
||||||
|
|
||||||
|
if (!age_group || !domain || !milestone_text) {
|
||||||
|
showToast('Please fill in all fields', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var method = id ? 'PUT' : 'POST';
|
||||||
|
var url = id ? '/api/admin/milestones/' + id : '/api/admin/milestones';
|
||||||
|
|
||||||
|
fetch(url, {
|
||||||
|
method: method,
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
body: JSON.stringify({ age_group, domain, milestone_text, sort_order })
|
||||||
|
})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
if (!data.success) {
|
||||||
|
showToast(data.error || 'Failed to save', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showToast(id ? 'Milestone updated' : 'Milestone added', 'success');
|
||||||
|
closeMilestoneModal();
|
||||||
|
loadMetadata();
|
||||||
|
loadMilestones();
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error('[AdminMilestones] Save error:', err);
|
||||||
|
showToast('Failed to save: ' + err.message, 'error');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
window.editMilestone = function(id) {
|
||||||
|
var milestone = milestones.find(m => m.id === id);
|
||||||
|
if (!milestone) return;
|
||||||
|
editingId = id;
|
||||||
|
openMilestoneModal(milestone);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.deleteMilestone = function(id) {
|
||||||
|
if (!confirm('Delete this milestone? This action cannot be undone.')) return;
|
||||||
|
|
||||||
|
fetch('/api/admin/milestones/' + id, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: getAuthHeaders()
|
||||||
|
})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
if (!data.success) {
|
||||||
|
showToast(data.error || 'Failed to delete', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showToast('Milestone deleted', 'success');
|
||||||
|
loadMetadata();
|
||||||
|
loadMilestones();
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error('[AdminMilestones] Delete error:', err);
|
||||||
|
showToast('Failed to delete: ' + err.message, 'error');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
function bulkImportMilestones(clearExisting) {
|
||||||
|
if (!window.MILESTONES_DATA_STATIC) {
|
||||||
|
showToast('Static milestones data not available', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var btn = clearExisting ? document.getElementById('btn-reimport-milestones') : document.getElementById('btn-bulk-import-milestones');
|
||||||
|
if (btn) {
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Importing...';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert static data to array format for bulk import
|
||||||
|
var milestonesToImport = [];
|
||||||
|
var sortOrder = 0;
|
||||||
|
|
||||||
|
Object.keys(window.MILESTONES_DATA_STATIC).forEach(function(ageGroup) {
|
||||||
|
Object.keys(window.MILESTONES_DATA_STATIC[ageGroup]).forEach(function(domain) {
|
||||||
|
var items = window.MILESTONES_DATA_STATIC[ageGroup][domain];
|
||||||
|
items.forEach(function(text) {
|
||||||
|
milestonesToImport.push({
|
||||||
|
age_group: ageGroup,
|
||||||
|
domain: domain,
|
||||||
|
milestone_text: text,
|
||||||
|
sort_order: sortOrder++
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Import directly (backend will handle existing data)
|
||||||
|
fetch('/api/admin/milestones/bulk-import', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
body: JSON.stringify({ milestones: milestonesToImport, clearExisting: clearExisting })
|
||||||
|
})
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(data) {
|
||||||
|
if (btn) {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = clearExisting ? '<i class="fas fa-sync"></i> Re-import All' : '<i class="fas fa-download"></i> Import Default Milestones Data';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
showToast(data.error || 'Import failed', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
showToast('Successfully imported ' + data.imported + ' milestones', 'success');
|
||||||
|
loadMetadata();
|
||||||
|
loadMilestones();
|
||||||
|
})
|
||||||
|
.catch(function(err) {
|
||||||
|
if (btn) {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = clearExisting ? '<i class="fas fa-sync"></i> Re-import All' : '<i class="fas fa-download"></i> Import Default Milestones Data';
|
||||||
|
}
|
||||||
|
console.error('[AdminMilestones] Import error:', err);
|
||||||
|
showToast('Import failed: ' + err.message, 'error');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
})();
|
||||||
224
public/js/app.js
224
public/js/app.js
|
|
@ -155,9 +155,111 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||||
if (typeof loadNextcloudStatus === 'function') loadNextcloudStatus();
|
if (typeof loadNextcloudStatus === 'function') loadNextcloudStatus();
|
||||||
if (typeof loadMemories === 'function') loadMemories();
|
if (typeof loadMemories === 'function') loadMemories();
|
||||||
if (typeof loadSavedEncountersList === 'function') loadSavedEncountersList();
|
if (typeof loadSavedEncountersList === 'function') loadSavedEncountersList();
|
||||||
|
if (typeof renderAudioBackups === 'function') renderAudioBackups();
|
||||||
|
if (typeof loadDocuments === 'function') loadDocuments();
|
||||||
|
initBrowserWhisperSettings();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Browser Whisper settings UI ───────────────────────────
|
||||||
|
function initBrowserWhisperSettings() {
|
||||||
|
var chk = document.getElementById('browser-whisper-enabled');
|
||||||
|
var sel = document.getElementById('browser-whisper-model');
|
||||||
|
var pre = document.getElementById('btn-whisper-preload');
|
||||||
|
var stat = document.getElementById('browser-whisper-status');
|
||||||
|
var prog = document.getElementById('browser-whisper-progress');
|
||||||
|
var pt = document.getElementById('browser-whisper-progress-text');
|
||||||
|
var sec = document.getElementById('browser-whisper-section');
|
||||||
|
if (!chk) return;
|
||||||
|
|
||||||
|
var supported = typeof BrowserWhisper !== 'undefined' && BrowserWhisper.isSupported();
|
||||||
|
if (!supported) {
|
||||||
|
if (sec) sec.innerHTML += '<p style="color:var(--red);font-size:12px;margin:8px 0 0;">Not supported in this browser. Use Chrome or Edge.</p>';
|
||||||
|
if (chk) chk.disabled = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore saved state
|
||||||
|
chk.checked = BrowserWhisper.isEnabled();
|
||||||
|
sel.value = BrowserWhisper.getModel();
|
||||||
|
stat.textContent = chk.checked ? 'On — audio stays on device' : 'Off';
|
||||||
|
|
||||||
|
chk.addEventListener('change', function() {
|
||||||
|
BrowserWhisper.setEnabled(chk.checked);
|
||||||
|
stat.textContent = chk.checked ? 'On — audio stays on device' : 'Off';
|
||||||
|
if (chk.checked) {
|
||||||
|
BrowserWhisper.preload(function(file, pct) {
|
||||||
|
if (!prog || !pt) return;
|
||||||
|
if (pct >= 100) { prog.style.display = 'none'; return; }
|
||||||
|
prog.style.display = 'block';
|
||||||
|
pt.textContent = file + (pct > 0 ? ' ' + pct + '%' : '');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
sel.addEventListener('change', function() {
|
||||||
|
BrowserWhisper.setModel(sel.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (pre) {
|
||||||
|
pre.addEventListener('click', function(e) {
|
||||||
|
console.log('[BrowserWhisper] Pre-download button clicked!');
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!prog || !pt) {
|
||||||
|
console.error('[BrowserWhisper] Progress elements not found');
|
||||||
|
showToast('UI elements missing - check page load', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!BrowserWhisper || !BrowserWhisper.isSupported()) {
|
||||||
|
console.error('[BrowserWhisper] Not supported');
|
||||||
|
showToast('Browser Whisper not supported in this browser', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[BrowserWhisper] Starting preload...');
|
||||||
|
prog.style.display = 'block';
|
||||||
|
pt.textContent = 'Initializing...';
|
||||||
|
|
||||||
|
BrowserWhisper.setEnabled(true);
|
||||||
|
chk.checked = true;
|
||||||
|
stat.textContent = 'On — audio stays on device';
|
||||||
|
|
||||||
|
// Set timeout in case it gets stuck
|
||||||
|
var timeout = setTimeout(function() {
|
||||||
|
console.warn('[BrowserWhisper] 30s elapsed - still downloading, check Network tab');
|
||||||
|
showToast('Download in progress - check browser console', 'info');
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
|
try {
|
||||||
|
BrowserWhisper.preload(function(file, pct) {
|
||||||
|
console.log('[BrowserWhisper] Progress:', file, pct + '%');
|
||||||
|
if (pct >= 100) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
prog.style.display = 'none';
|
||||||
|
showToast('Whisper model ready!', 'success');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
prog.style.display = 'block';
|
||||||
|
pt.textContent = file + (pct > 0 ? ' ' + pct + '%' : '');
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
console.error('[BrowserWhisper] Preload error:', err);
|
||||||
|
prog.style.display = 'none';
|
||||||
|
pt.textContent = '';
|
||||||
|
|
||||||
|
// Show CSP/network warning
|
||||||
|
var cspWarning = document.getElementById('browser-whisper-csp-warning');
|
||||||
|
if (cspWarning) cspWarning.style.display = 'block';
|
||||||
|
|
||||||
|
showToast('Download blocked by network/firewall. Server transcription will be used.', 'warning');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- MODEL SELECTOR ---
|
// --- MODEL SELECTOR ---
|
||||||
var modelSelect = document.getElementById('global-model-select');
|
var modelSelect = document.getElementById('global-model-select');
|
||||||
var costBadge = document.getElementById('model-cost-badge');
|
var costBadge = document.getElementById('model-cost-badge');
|
||||||
|
|
@ -194,14 +296,21 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Determine default model (admin override or first model)
|
||||||
|
var defaultModelId = data.defaultModel || (window._currentModels.length > 0 ? window._currentModels[0].id : '');
|
||||||
|
|
||||||
if (modelSelect && window._currentModels.length > 0) {
|
if (modelSelect && window._currentModels.length > 0) {
|
||||||
window._buildModelOptions(modelSelect);
|
window._buildModelOptions(modelSelect);
|
||||||
if (costBadge) costBadge.textContent = window._currentProvider.toUpperCase() + ' | ' + window._currentModels[0].cost;
|
// Select the admin-configured default
|
||||||
|
if (defaultModelId) modelSelect.value = defaultModelId;
|
||||||
|
var defaultM = window._currentModels.find(function(x) { return x.id === modelSelect.value; });
|
||||||
|
if (costBadge) costBadge.textContent = window._currentProvider.toUpperCase() + ' | ' + (defaultM ? defaultM.cost : '');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Populate all per-tab model selectors already in DOM
|
// Populate all per-tab model selectors already in DOM
|
||||||
document.querySelectorAll('.tab-model-select').forEach(function(sel) {
|
document.querySelectorAll('.tab-model-select').forEach(function(sel) {
|
||||||
window._buildModelOptions(sel);
|
window._buildModelOptions(sel);
|
||||||
|
if (defaultModelId) sel.value = defaultModelId;
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch(function(err) { console.warn('Models load failed:', err); });
|
.catch(function(err) { console.warn('Models load failed:', err); });
|
||||||
|
|
@ -380,20 +489,21 @@ function speakText(elementId) {
|
||||||
})
|
})
|
||||||
.then(function(r) {
|
.then(function(r) {
|
||||||
if (!r.ok) throw new Error('TTS request failed (' + r.status + ')');
|
if (!r.ok) throw new Error('TTS request failed (' + r.status + ')');
|
||||||
return r.blob();
|
var ttsProvider = r.headers.get('X-TTS-Provider') || 'server';
|
||||||
|
return r.blob().then(function(blob) { return { blob: blob, provider: ttsProvider }; });
|
||||||
})
|
})
|
||||||
.then(function(blob) {
|
.then(function(result) {
|
||||||
var url = URL.createObjectURL(blob);
|
var url = URL.createObjectURL(result.blob);
|
||||||
currentAudio = new Audio(url);
|
currentAudio = new Audio(url);
|
||||||
currentAudio.onended = function() { URL.revokeObjectURL(url); stopReading(); };
|
currentAudio.onended = function() { URL.revokeObjectURL(url); stopReading(); };
|
||||||
currentAudio.onerror = function() { URL.revokeObjectURL(url); stopReading(); showToast('Audio playback error', 'error'); };
|
currentAudio.onerror = function() { URL.revokeObjectURL(url); stopReading(); showToast('Audio playback error', 'error'); };
|
||||||
currentAudio.play();
|
currentAudio.play();
|
||||||
if (btn) { btn.classList.add('btn-reading'); btn.innerHTML = '<i class="fas fa-stop"></i> Stop'; }
|
if (btn) { btn.classList.add('btn-reading'); btn.innerHTML = '<i class="fas fa-stop"></i> Stop'; }
|
||||||
showToast('Reading aloud (Adam/ElevenLabs)', 'info');
|
showToast('Reading aloud (' + result.provider + ')', 'info');
|
||||||
})
|
})
|
||||||
.catch(function(err) {
|
.catch(function(err) {
|
||||||
stopReading();
|
stopReading();
|
||||||
// Fallback to browser TTS if ElevenLabs fails
|
// Fallback to browser TTS if server TTS fails
|
||||||
if ('speechSynthesis' in window) {
|
if ('speechSynthesis' in window) {
|
||||||
var utter = new SpeechSynthesisUtterance(text);
|
var utter = new SpeechSynthesisUtterance(text);
|
||||||
utter.rate = 0.9;
|
utter.rate = 0.9;
|
||||||
|
|
@ -401,7 +511,7 @@ function speakText(elementId) {
|
||||||
currentlyReadingId = elementId;
|
currentlyReadingId = elementId;
|
||||||
if (btn) { btn.classList.add('btn-reading'); btn.innerHTML = '<i class="fas fa-stop"></i> Stop'; }
|
if (btn) { btn.classList.add('btn-reading'); btn.innerHTML = '<i class="fas fa-stop"></i> Stop'; }
|
||||||
window.speechSynthesis.speak(utter);
|
window.speechSynthesis.speak(utter);
|
||||||
showToast('ElevenLabs unavailable — using browser voice', 'info');
|
showToast('TTS unavailable — using browser voice', 'info');
|
||||||
} else {
|
} else {
|
||||||
showToast('Read aloud failed: ' + err.message, 'error');
|
showToast('Read aloud failed: ' + err.message, 'error');
|
||||||
}
|
}
|
||||||
|
|
@ -425,8 +535,12 @@ function findReadButton(elementId) {
|
||||||
if (!card) return null;
|
if (!card) return null;
|
||||||
var buttons = card.querySelectorAll('button');
|
var buttons = card.querySelectorAll('button');
|
||||||
for (var i = 0; i < buttons.length; i++) {
|
for (var i = 0; i < buttons.length; i++) {
|
||||||
var oc = buttons[i].getAttribute('onclick') || '';
|
var btn = buttons[i];
|
||||||
if (oc.indexOf('speakText') !== -1 && oc.indexOf(elementId) !== -1) return buttons[i];
|
// data-action="speak" buttons (current approach)
|
||||||
|
if (btn.getAttribute('data-action') === 'speak' && btn.getAttribute('data-target') === elementId) return btn;
|
||||||
|
// legacy onclick fallback
|
||||||
|
var oc = btn.getAttribute('onclick') || '';
|
||||||
|
if (oc.indexOf('speakText') !== -1 && oc.indexOf(elementId) !== -1) return btn;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
@ -445,11 +559,12 @@ function createTimer(el) {
|
||||||
function AudioRecorder() { this.mediaRecorder = null; this.chunks = []; this.stream = null; }
|
function AudioRecorder() { this.mediaRecorder = null; this.chunks = []; this.stream = null; }
|
||||||
AudioRecorder.prototype.start = function() {
|
AudioRecorder.prototype.start = function() {
|
||||||
var self = this; self.chunks = [];
|
var self = this; self.chunks = [];
|
||||||
return navigator.mediaDevices.getUserMedia({ audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true } })
|
return navigator.mediaDevices.getUserMedia({ audio: { channelCount: 1, sampleRate: 16000, echoCancellation: true, noiseSuppression: true } })
|
||||||
.then(function(stream) {
|
.then(function(stream) {
|
||||||
self.stream = stream;
|
self.stream = stream;
|
||||||
var mime = MediaRecorder.isTypeSupported('audio/webm;codecs=opus') ? 'audio/webm;codecs=opus' : 'audio/webm';
|
var mime = MediaRecorder.isTypeSupported('audio/webm;codecs=opus') ? 'audio/webm;codecs=opus' : 'audio/webm';
|
||||||
self.mediaRecorder = new MediaRecorder(stream, { mimeType: mime });
|
// 32kbps Opus is excellent for speech — small files, fast upload, great quality
|
||||||
|
self.mediaRecorder = new MediaRecorder(stream, { mimeType: mime, audioBitsPerSecond: 32000 });
|
||||||
self.mediaRecorder.ondataavailable = function(e) { if (e.data.size > 0) self.chunks.push(e.data); };
|
self.mediaRecorder.ondataavailable = function(e) { if (e.data.size > 0) self.chunks.push(e.data); };
|
||||||
self.mediaRecorder.start(1000);
|
self.mediaRecorder.start(1000);
|
||||||
});
|
});
|
||||||
|
|
@ -461,20 +576,82 @@ AudioRecorder.prototype.stop = function() {
|
||||||
self.mediaRecorder.onstop = function() {
|
self.mediaRecorder.onstop = function() {
|
||||||
var blob = new Blob(self.chunks, { type: self.mediaRecorder.mimeType });
|
var blob = new Blob(self.chunks, { type: self.mediaRecorder.mimeType });
|
||||||
if (self.stream) self.stream.getTracks().forEach(function(t) { t.stop(); });
|
if (self.stream) self.stream.getTracks().forEach(function(t) { t.stop(); });
|
||||||
|
// Auto-save to IndexedDB backup before transcription
|
||||||
|
if (blob.size > 0 && typeof saveAudioBackup === 'function') {
|
||||||
|
var module = self._module || 'unknown';
|
||||||
|
saveAudioBackup(blob, module).catch(function() {});
|
||||||
|
}
|
||||||
resolve(blob);
|
resolve(blob);
|
||||||
};
|
};
|
||||||
self.mediaRecorder.stop();
|
self.mediaRecorder.stop();
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Check if server-side transcription (Whisper/AWS) is available
|
||||||
|
window._transcribeAvailable = null; // null = not checked yet, true/false after check
|
||||||
|
function checkTranscribeStatus() {
|
||||||
|
var token = window.AUTH_TOKEN || localStorage.getItem('ped_scribe_token') || '';
|
||||||
|
if (!token) return;
|
||||||
|
fetch('/api/transcribe/status', {
|
||||||
|
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' }
|
||||||
|
})
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(data) {
|
||||||
|
window._transcribeAvailable = !!data.available;
|
||||||
|
window._transcribeProvider = data.provider || 'none';
|
||||||
|
if (!data.available) {
|
||||||
|
console.log('[Transcribe] No server transcription configured — using browser speech recognition only');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function() { window._transcribeAvailable = false; });
|
||||||
|
}
|
||||||
|
|
||||||
function transcribeAudio(blob) {
|
function transcribeAudio(blob) {
|
||||||
|
// Browser Whisper — local, zero network, HIPAA-safe
|
||||||
|
if (typeof BrowserWhisper !== 'undefined' && BrowserWhisper.isEnabled()) {
|
||||||
|
var startTime = Date.now();
|
||||||
|
return BrowserWhisper.transcribe(blob)
|
||||||
|
.then(function(text) {
|
||||||
|
var elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
||||||
|
showToast('Transcribed locally (' + elapsed + 's)', 'success');
|
||||||
|
if (window._lastAudioBackupId && typeof deleteAudioBackup === 'function') {
|
||||||
|
deleteAudioBackup(window._lastAudioBackupId);
|
||||||
|
window._lastAudioBackupId = null;
|
||||||
|
}
|
||||||
|
return { success: true, text: text, provider: 'browser-whisper' };
|
||||||
|
})
|
||||||
|
.catch(function(err) {
|
||||||
|
console.warn('[BrowserWhisper] Failed:', err.message, '— falling back to server');
|
||||||
|
return _serverTranscribe(blob);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return _serverTranscribe(blob);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _serverTranscribe(blob) {
|
||||||
|
// If no server transcription is configured, skip upload entirely
|
||||||
|
if (window._transcribeAvailable === false) {
|
||||||
|
return Promise.resolve({ success: false, noProvider: true, error: 'No transcription API configured — using live transcript' });
|
||||||
|
}
|
||||||
|
|
||||||
|
var startTime = Date.now();
|
||||||
var formData = new FormData();
|
var formData = new FormData();
|
||||||
formData.append('audio', blob, 'audio.webm');
|
formData.append('audio', blob, 'audio.webm');
|
||||||
return fetch('/api/transcribe', {
|
return fetch('/api/transcribe', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Authorization': 'Bearer ' + (window.AUTH_TOKEN || localStorage.getItem('ped_scribe_token') || '') },
|
headers: { 'Authorization': 'Bearer ' + (window.AUTH_TOKEN || localStorage.getItem('ped_scribe_token') || '') },
|
||||||
body: formData
|
body: formData
|
||||||
}).then(function(r) { return r.json(); });
|
}).then(function(r) { return r.json(); }).then(function(data) {
|
||||||
|
var elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
||||||
|
if (data.success && data.provider) {
|
||||||
|
showToast('Transcribed via ' + data.provider + ' (' + elapsed + 's)', 'info');
|
||||||
|
}
|
||||||
|
if (data.success && window._lastAudioBackupId) {
|
||||||
|
if (typeof deleteAudioBackup === 'function') deleteAudioBackup(window._lastAudioBackupId);
|
||||||
|
window._lastAudioBackupId = null;
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function refineDocument(outputElementId, inputElementId) {
|
function refineDocument(outputElementId, inputElementId) {
|
||||||
|
|
@ -542,9 +719,32 @@ function createSpeechRecognition() {
|
||||||
rec.continuous = true;
|
rec.continuous = true;
|
||||||
rec.interimResults = true;
|
rec.interimResults = true;
|
||||||
rec.lang = 'en-US';
|
rec.lang = 'en-US';
|
||||||
|
rec.maxAlternatives = 1;
|
||||||
return rec;
|
return rec;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Deduplicate speech recognition finals — Chrome can repeat text across restarts
|
||||||
|
function deduplicateFinal(newText, existingText) {
|
||||||
|
if (!newText || !existingText) return newText;
|
||||||
|
var trimmed = newText.trim();
|
||||||
|
if (!trimmed) return '';
|
||||||
|
// Check if the new text is already at the end of existing text
|
||||||
|
if (existingText.trimEnd().endsWith(trimmed)) return '';
|
||||||
|
// Check for partial overlap (last sentence repeated)
|
||||||
|
var words = trimmed.split(/\s+/);
|
||||||
|
if (words.length >= 3) {
|
||||||
|
var tail = existingText.trimEnd().split(/\s+/).slice(-words.length).join(' ');
|
||||||
|
if (tail === trimmed) return '';
|
||||||
|
// Check if first half of new text overlaps with end of existing
|
||||||
|
var half = Math.ceil(words.length / 2);
|
||||||
|
var firstHalf = words.slice(0, half).join(' ');
|
||||||
|
if (existingText.trimEnd().endsWith(firstHalf)) {
|
||||||
|
return words.slice(half).join(' ') + ' ';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return newText;
|
||||||
|
}
|
||||||
|
|
||||||
// PWA Service Worker
|
// PWA Service Worker
|
||||||
if ('serviceWorker' in navigator) {
|
if ('serviceWorker' in navigator) {
|
||||||
navigator.serviceWorker.register('/sw.js').catch(function() {});
|
navigator.serviceWorker.register('/sw.js').catch(function() {});
|
||||||
|
|
|
||||||
293
public/js/audioBackup.js
Normal file
293
public/js/audioBackup.js
Normal file
|
|
@ -0,0 +1,293 @@
|
||||||
|
// ============================================================
|
||||||
|
// AUDIO BACKUP — Server-side audio backup with local IndexedDB fallback
|
||||||
|
// Saves audio to server (gzip compressed in PostgreSQL), auto-deletes 24h.
|
||||||
|
// Falls back to IndexedDB if server save fails.
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
(function() {
|
||||||
|
var DB_NAME = 'PedScribeAudioBackup';
|
||||||
|
var STORE_NAME = 'recordings';
|
||||||
|
var DB_VERSION = 1;
|
||||||
|
var MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
var _db = null;
|
||||||
|
|
||||||
|
function openDB() {
|
||||||
|
if (_db) return Promise.resolve(_db);
|
||||||
|
return new Promise(function(resolve, reject) {
|
||||||
|
var request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||||
|
request.onupgradeneeded = function(e) {
|
||||||
|
var db = e.target.result;
|
||||||
|
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||||
|
var store = db.createObjectStore(STORE_NAME, { keyPath: 'id', autoIncrement: true });
|
||||||
|
store.createIndex('timestamp', 'timestamp', { unique: false });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
request.onsuccess = function(e) { _db = e.target.result; resolve(_db); };
|
||||||
|
request.onerror = function() { reject(new Error('IndexedDB open failed')); };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save audio — tries server first, falls back to IndexedDB
|
||||||
|
window.saveAudioBackup = function(blob, module) {
|
||||||
|
// Try server save first
|
||||||
|
return saveToServer(blob, module).then(function(serverId) {
|
||||||
|
if (serverId) {
|
||||||
|
window._lastAudioBackupId = 'server_' + serverId;
|
||||||
|
return serverId;
|
||||||
|
}
|
||||||
|
// Fallback to IndexedDB
|
||||||
|
return saveToIndexedDB(blob, module);
|
||||||
|
}).catch(function() {
|
||||||
|
return saveToIndexedDB(blob, module);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
function saveToServer(blob, module) {
|
||||||
|
var token = window.AUTH_TOKEN || localStorage.getItem('ped_scribe_token') || '';
|
||||||
|
if (!token) return Promise.resolve(null);
|
||||||
|
|
||||||
|
var formData = new FormData();
|
||||||
|
formData.append('audio', blob, 'audio.webm');
|
||||||
|
formData.append('module', module || 'encounter');
|
||||||
|
|
||||||
|
return fetch('/api/audio-backups', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Authorization': 'Bearer ' + token },
|
||||||
|
body: formData
|
||||||
|
})
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(data) {
|
||||||
|
if (data.success) return data.id;
|
||||||
|
return null;
|
||||||
|
})
|
||||||
|
.catch(function() { return null; });
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveToIndexedDB(blob, module) {
|
||||||
|
return openDB().then(function(db) {
|
||||||
|
return new Promise(function(resolve, reject) {
|
||||||
|
var tx = db.transaction(STORE_NAME, 'readwrite');
|
||||||
|
var store = tx.objectStore(STORE_NAME);
|
||||||
|
var record = {
|
||||||
|
blob: blob,
|
||||||
|
module: module || 'unknown',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
size: blob.size,
|
||||||
|
mimeType: blob.type
|
||||||
|
};
|
||||||
|
var req = store.add(record);
|
||||||
|
req.onsuccess = function() { resolve(req.result); };
|
||||||
|
req.onerror = function() { reject(new Error('Failed to save audio backup')); };
|
||||||
|
});
|
||||||
|
}).then(function(id) {
|
||||||
|
window._lastAudioBackupId = 'local_' + id;
|
||||||
|
cleanupOldLocalBackups();
|
||||||
|
return id;
|
||||||
|
}).catch(function(err) {
|
||||||
|
console.warn('[AudioBackup] Save failed:', err.message);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete a specific backup (server or local)
|
||||||
|
window.deleteAudioBackup = function(id) {
|
||||||
|
if (typeof id === 'string' && id.startsWith('server_')) {
|
||||||
|
var serverId = id.replace('server_', '');
|
||||||
|
return fetch('/api/audio-backups/' + serverId, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: getAuthHeaders()
|
||||||
|
}).then(function() {}).catch(function() {});
|
||||||
|
}
|
||||||
|
// Local IndexedDB delete
|
||||||
|
var localId = typeof id === 'string' ? parseInt(id.replace('local_', '')) : id;
|
||||||
|
return openDB().then(function(db) {
|
||||||
|
return new Promise(function(resolve) {
|
||||||
|
var tx = db.transaction(STORE_NAME, 'readwrite');
|
||||||
|
tx.objectStore(STORE_NAME).delete(localId);
|
||||||
|
tx.oncomplete = function() { resolve(); };
|
||||||
|
tx.onerror = function() { resolve(); };
|
||||||
|
});
|
||||||
|
}).catch(function() {});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get all backups (merged: server + local)
|
||||||
|
window.getAudioBackups = function() {
|
||||||
|
var serverPromise = fetchServerBackups();
|
||||||
|
var localPromise = getLocalBackups();
|
||||||
|
|
||||||
|
return Promise.all([serverPromise, localPromise]).then(function(results) {
|
||||||
|
var server = results[0].map(function(b) {
|
||||||
|
return {
|
||||||
|
id: 'server_' + b.id,
|
||||||
|
module: b.module,
|
||||||
|
timestamp: new Date(b.created_at).getTime(),
|
||||||
|
size: b.size_bytes,
|
||||||
|
compressedSize: b.compressed_bytes,
|
||||||
|
source: 'server',
|
||||||
|
expiresAt: b.expires_at
|
||||||
|
};
|
||||||
|
});
|
||||||
|
var local = results[1].map(function(b) {
|
||||||
|
return {
|
||||||
|
id: 'local_' + b.id,
|
||||||
|
module: b.module,
|
||||||
|
timestamp: b.timestamp,
|
||||||
|
size: b.size,
|
||||||
|
source: 'local'
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return server.concat(local).sort(function(a, b) { return b.timestamp - a.timestamp; });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
function fetchServerBackups() {
|
||||||
|
var token = window.AUTH_TOKEN || localStorage.getItem('ped_scribe_token') || '';
|
||||||
|
if (!token) return Promise.resolve([]);
|
||||||
|
return fetch('/api/audio-backups', {
|
||||||
|
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' }
|
||||||
|
})
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(data) { return data.success ? (data.backups || []) : []; })
|
||||||
|
.catch(function() { return []; });
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLocalBackups() {
|
||||||
|
return openDB().then(function(db) {
|
||||||
|
return new Promise(function(resolve) {
|
||||||
|
var tx = db.transaction(STORE_NAME, 'readonly');
|
||||||
|
var req = tx.objectStore(STORE_NAME).getAll();
|
||||||
|
req.onsuccess = function() {
|
||||||
|
var records = (req.result || []).filter(function(r) {
|
||||||
|
return (Date.now() - r.timestamp) < MAX_AGE_MS;
|
||||||
|
});
|
||||||
|
resolve(records);
|
||||||
|
};
|
||||||
|
req.onerror = function() { resolve([]); };
|
||||||
|
});
|
||||||
|
}).catch(function() { return []; });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retry transcription from backup
|
||||||
|
window.retryAudioBackup = function(id) {
|
||||||
|
if (typeof id === 'string' && id.startsWith('server_')) {
|
||||||
|
var serverId = id.replace('server_', '');
|
||||||
|
showLoading('Downloading and re-transcribing...');
|
||||||
|
var token = window.AUTH_TOKEN || localStorage.getItem('ped_scribe_token') || '';
|
||||||
|
return fetch('/api/audio-backups/' + serverId + '/audio', {
|
||||||
|
headers: { 'Authorization': 'Bearer ' + token }
|
||||||
|
})
|
||||||
|
.then(function(r) { if (!r.ok) throw new Error('Download failed'); return r.blob(); })
|
||||||
|
.then(function(blob) {
|
||||||
|
window._lastAudioBackupId = id;
|
||||||
|
return transcribeAudio(blob).then(function(data) {
|
||||||
|
hideLoading();
|
||||||
|
if (data.success) {
|
||||||
|
showToast('Backup transcribed!', 'success');
|
||||||
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||||
|
navigator.clipboard.writeText(data.text);
|
||||||
|
showToast('Transcript copied to clipboard', 'info');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
showToast('Retry failed: ' + (data.error || 'unknown'), 'error');
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(function(err) { hideLoading(); showToast('Retry failed: ' + err.message, 'error'); });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Local IndexedDB retry
|
||||||
|
var localId = typeof id === 'string' ? parseInt(id.replace('local_', '')) : id;
|
||||||
|
return openDB().then(function(db) {
|
||||||
|
return new Promise(function(resolve, reject) {
|
||||||
|
var tx = db.transaction(STORE_NAME, 'readonly');
|
||||||
|
var req = tx.objectStore(STORE_NAME).get(localId);
|
||||||
|
req.onsuccess = function() {
|
||||||
|
if (!req.result) { reject(new Error('Backup not found')); return; }
|
||||||
|
resolve(req.result);
|
||||||
|
};
|
||||||
|
req.onerror = function() { reject(new Error('Failed to read backup')); };
|
||||||
|
});
|
||||||
|
}).then(function(record) {
|
||||||
|
showLoading('Re-transcribing audio backup...');
|
||||||
|
window._lastAudioBackupId = id;
|
||||||
|
return transcribeAudio(record.blob).then(function(data) {
|
||||||
|
hideLoading();
|
||||||
|
if (data.success) {
|
||||||
|
showToast('Backup transcribed!', 'success');
|
||||||
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||||
|
navigator.clipboard.writeText(data.text);
|
||||||
|
showToast('Transcript copied to clipboard', 'info');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
showToast('Retry failed: ' + (data.error || 'unknown'), 'error');
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
function cleanupOldLocalBackups() {
|
||||||
|
openDB().then(function(db) {
|
||||||
|
var tx = db.transaction(STORE_NAME, 'readwrite');
|
||||||
|
var store = tx.objectStore(STORE_NAME);
|
||||||
|
var index = store.index('timestamp');
|
||||||
|
var cutoff = Date.now() - MAX_AGE_MS;
|
||||||
|
var range = IDBKeyRange.upperBound(cutoff);
|
||||||
|
var req = index.openCursor(range);
|
||||||
|
req.onsuccess = function(e) {
|
||||||
|
var cursor = e.target.result;
|
||||||
|
if (cursor) { cursor.delete(); cursor.continue(); }
|
||||||
|
};
|
||||||
|
}).catch(function() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render audio backups list in settings
|
||||||
|
window.renderAudioBackups = function() {
|
||||||
|
var container = document.getElementById('audio-backups-list');
|
||||||
|
if (!container) return;
|
||||||
|
getAudioBackups().then(function(backups) {
|
||||||
|
if (backups.length === 0) {
|
||||||
|
container.innerHTML = '<p style="color:var(--g400);font-size:13px;">No audio backups. Recordings are saved automatically to the server and kept for 24 hours.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
container.innerHTML = backups.map(function(b) {
|
||||||
|
var date = new Date(b.timestamp);
|
||||||
|
var sizeKb = Math.round(b.size / 1024);
|
||||||
|
var age = Math.round((Date.now() - b.timestamp) / 60000);
|
||||||
|
var ageStr = age < 60 ? age + 'm ago' : Math.round(age / 60) + 'h ago';
|
||||||
|
var sourceTag = b.source === 'server'
|
||||||
|
? '<span style="font-size:10px;padding:1px 5px;border-radius:3px;background:var(--blue-light);color:var(--blue);">server</span>'
|
||||||
|
: '<span style="font-size:10px;padding:1px 5px;border-radius:3px;background:var(--g100);color:var(--g500);">local</span>';
|
||||||
|
var compInfo = b.compressedSize ? ' (' + Math.round(b.compressedSize / 1024) + ' KB compressed)' : '';
|
||||||
|
return '<div class="saved-enc-item" style="padding:8px 12px;">' +
|
||||||
|
'<div style="flex:1;">' +
|
||||||
|
'<div style="font-weight:600;font-size:13px;display:flex;align-items:center;gap:6px;">' + esc(b.module) + ' recording ' + sourceTag + '</div>' +
|
||||||
|
'<div style="font-size:11px;color:var(--g500);">' + date.toLocaleString() + ' · ' + sizeKb + ' KB' + compInfo + ' · ' + ageStr + '</div>' +
|
||||||
|
'</div>' +
|
||||||
|
'<button class="btn-sm btn-primary audio-backup-retry" data-id="' + b.id + '"><i class="fas fa-rotate-right"></i> Retry</button>' +
|
||||||
|
'<button class="btn-sm btn-ghost audio-backup-delete" data-id="' + b.id + '" style="color:var(--red);"><i class="fas fa-trash"></i></button>' +
|
||||||
|
'</div>';
|
||||||
|
}).join('');
|
||||||
|
container.querySelectorAll('.audio-backup-retry').forEach(function(btn) {
|
||||||
|
btn.addEventListener('click', function() { retryAudioBackup(btn.dataset.id); });
|
||||||
|
});
|
||||||
|
container.querySelectorAll('.audio-backup-delete').forEach(function(btn) {
|
||||||
|
btn.addEventListener('click', function() {
|
||||||
|
deleteAudioBackup(btn.dataset.id).then(function() {
|
||||||
|
showToast('Backup deleted', 'info');
|
||||||
|
renderAudioBackups();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
function esc(str) {
|
||||||
|
if (!str) return '';
|
||||||
|
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanupOldLocalBackups();
|
||||||
|
})();
|
||||||
|
|
@ -15,11 +15,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||||
var TOKEN_KEY = 'ped_scribe_token';
|
var TOKEN_KEY = 'ped_scribe_token';
|
||||||
var USER_KEY = 'ped_scribe_user';
|
var USER_KEY = 'ped_scribe_user';
|
||||||
|
|
||||||
console.log('[Auth] Initializing...');
|
// Auth module initialized
|
||||||
console.log('[Auth] authScreen:', !!authScreen);
|
|
||||||
console.log('[Auth] mainApp:', !!mainApp);
|
|
||||||
console.log('[Auth] loginForm:', !!loginForm);
|
|
||||||
console.log('[Auth] registerForm:', !!registerForm);
|
|
||||||
|
|
||||||
// Make sure forms are visible/hidden correctly on load
|
// Make sure forms are visible/hidden correctly on load
|
||||||
showLoginForm();
|
showLoginForm();
|
||||||
|
|
@ -29,9 +25,58 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||||
if (authScreen) authScreen.style.display = 'flex';
|
if (authScreen) authScreen.style.display = 'flex';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Check for SSO redirect (token is in httpOnly cookie) ──
|
||||||
|
var urlParams = new URLSearchParams(window.location.search);
|
||||||
|
var ssoOk = urlParams.get('sso');
|
||||||
|
var ssoError = urlParams.get('error');
|
||||||
|
if (ssoOk === 'ok') {
|
||||||
|
history.replaceState(null, '', window.location.pathname);
|
||||||
|
// Token is in httpOnly cookie — verify via /me endpoint (cookie sent automatically)
|
||||||
|
fetch('/api/auth/me', { credentials: 'same-origin' })
|
||||||
|
.then(function(r) { if (r.ok) return r.json(); throw new Error('invalid'); })
|
||||||
|
.then(function(data) {
|
||||||
|
if (data && data.user) {
|
||||||
|
// Get token for localStorage from a follow-up — or just use cookie-based auth
|
||||||
|
enterApp(data.user, '');
|
||||||
|
showToast('Welcome, ' + data.user.name + '!', 'success');
|
||||||
|
} else { showAuthScreen(); clearSession(); }
|
||||||
|
})
|
||||||
|
.catch(function() { showAuthScreen(); clearSession(); });
|
||||||
|
} else if (ssoError) {
|
||||||
|
history.replaceState(null, '', window.location.pathname);
|
||||||
|
var errorMsgs = { invalid_state: 'SSO session expired', expired: 'SSO session expired', no_email: 'Your identity provider did not return an email', disabled: 'Account disabled', sso_failed: 'SSO login failed' };
|
||||||
|
showAuthScreen();
|
||||||
|
setTimeout(function() { showToast(errorMsgs[ssoError] || 'SSO error', 'error'); }, 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Check OIDC status to show/hide SSO button ──
|
||||||
|
fetch('/api/auth/oidc-status')
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(data) {
|
||||||
|
if (data.oidcEnabled) {
|
||||||
|
var ssoBtn = document.getElementById('btn-sso');
|
||||||
|
var ssoDivider = document.getElementById('sso-divider');
|
||||||
|
var ssoLabel = document.getElementById('sso-label');
|
||||||
|
if (ssoBtn) ssoBtn.style.display = 'block';
|
||||||
|
if (ssoDivider) ssoDivider.style.display = 'block';
|
||||||
|
if (ssoLabel && data.buttonLabel) ssoLabel.textContent = data.buttonLabel;
|
||||||
|
if (data.disableLocalAuth) {
|
||||||
|
// Hide local login form fields, only show SSO
|
||||||
|
var localFields = document.querySelectorAll('#login-form .form-group, #btn-local-login, #show-register, #show-forgot');
|
||||||
|
localFields.forEach(function(el) { el.style.display = 'none'; });
|
||||||
|
if (ssoDivider) ssoDivider.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function() {});
|
||||||
|
|
||||||
var savedToken = localStorage.getItem(TOKEN_KEY);
|
var savedToken = localStorage.getItem(TOKEN_KEY);
|
||||||
if (savedToken) {
|
if (ssoOk) {
|
||||||
// Token exists — verify it silently; auth screen stays hidden during check
|
// SSO handled above — do nothing here
|
||||||
|
} else if (ssoError) {
|
||||||
|
// SSO error handled above — auth screen already shown
|
||||||
|
} else if (savedToken) {
|
||||||
|
// Existing token — verify silently; auth screen stays hidden during check
|
||||||
fetch('/api/auth/me', {
|
fetch('/api/auth/me', {
|
||||||
headers: { 'Authorization': 'Bearer ' + savedToken }
|
headers: { 'Authorization': 'Bearer ' + savedToken }
|
||||||
})
|
})
|
||||||
|
|
@ -118,6 +163,9 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
|
||||||
// Load announcement banner if function is available
|
// Load announcement banner if function is available
|
||||||
if (typeof loadAnnouncement === 'function') loadAnnouncement();
|
if (typeof loadAnnouncement === 'function') loadAnnouncement();
|
||||||
|
|
||||||
|
// Check if server-side transcription is configured
|
||||||
|
if (typeof checkTranscribeStatus === 'function') checkTranscribeStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
function exitApp() {
|
function exitApp() {
|
||||||
|
|
@ -263,7 +311,6 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[Auth] Attempting login for:', email);
|
|
||||||
showLoading('Signing in...');
|
showLoading('Signing in...');
|
||||||
|
|
||||||
var body = { email: email, password: password };
|
var body = { email: email, password: password };
|
||||||
|
|
@ -277,7 +324,6 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||||
.then(function(r) { return r.json(); })
|
.then(function(r) { return r.json(); })
|
||||||
.then(function(data) {
|
.then(function(data) {
|
||||||
hideLoading();
|
hideLoading();
|
||||||
console.log('[Auth] Login response:', JSON.stringify(data).substring(0, 200));
|
|
||||||
|
|
||||||
if (data.requires2FA) {
|
if (data.requires2FA) {
|
||||||
var grp = document.getElementById('totp-group');
|
var grp = document.getElementById('totp-group');
|
||||||
|
|
@ -361,7 +407,6 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[Auth] Registering:', email);
|
|
||||||
showLoading('Creating account...');
|
showLoading('Creating account...');
|
||||||
|
|
||||||
fetch('/api/auth/register', {
|
fetch('/api/auth/register', {
|
||||||
|
|
@ -372,7 +417,6 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||||
.then(function(r) { return r.json(); })
|
.then(function(r) { return r.json(); })
|
||||||
.then(function(data) {
|
.then(function(data) {
|
||||||
hideLoading();
|
hideLoading();
|
||||||
console.log('[Auth] Register response:', JSON.stringify(data).substring(0, 200));
|
|
||||||
|
|
||||||
if (data.success && data.token && data.user) {
|
if (data.success && data.token && data.user) {
|
||||||
enterApp(data.user, data.token);
|
enterApp(data.user, data.token);
|
||||||
|
|
|
||||||
155
public/js/browserWhisper.js
Normal file
155
public/js/browserWhisper.js
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
// ============================================================
|
||||||
|
// BROWSER WHISPER — client-side transcription, zero server calls
|
||||||
|
// Uses @xenova/transformers running Whisper in WebAssembly.
|
||||||
|
// Audio is processed entirely in-browser, never transmitted.
|
||||||
|
//
|
||||||
|
// Models (cached in IndexedDB after first download):
|
||||||
|
// Xenova/whisper-tiny.en — ~39MB — fastest, ~2-3s/clip
|
||||||
|
// Xenova/whisper-base.en — ~74MB — balanced, ~3-5s/clip
|
||||||
|
// Xenova/whisper-small.en — ~244MB — best quality, ~6-10s/clip
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
(function() {
|
||||||
|
|
||||||
|
var STORAGE_ENABLED = 'ped_browser_whisper';
|
||||||
|
var STORAGE_MODEL = 'ped_whisper_model';
|
||||||
|
var DEFAULT_MODEL = 'Xenova/whisper-tiny.en';
|
||||||
|
|
||||||
|
var _worker = null;
|
||||||
|
var _ready = false;
|
||||||
|
var _loading = false;
|
||||||
|
var _modelLoaded = null;
|
||||||
|
var _pending = null; // { resolve, reject }
|
||||||
|
|
||||||
|
window.BrowserWhisper = {
|
||||||
|
|
||||||
|
isSupported: function() {
|
||||||
|
return typeof Worker !== 'undefined' &&
|
||||||
|
typeof AudioContext !== 'undefined' &&
|
||||||
|
typeof WebAssembly !== 'undefined';
|
||||||
|
},
|
||||||
|
|
||||||
|
isEnabled: function() {
|
||||||
|
try { return localStorage.getItem(STORAGE_ENABLED) === '1'; } catch(e) { return false; }
|
||||||
|
},
|
||||||
|
|
||||||
|
setEnabled: function(val) {
|
||||||
|
try { localStorage.setItem(STORAGE_ENABLED, val ? '1' : '0'); } catch(e) {}
|
||||||
|
},
|
||||||
|
|
||||||
|
getModel: function() {
|
||||||
|
try { return localStorage.getItem(STORAGE_MODEL) || DEFAULT_MODEL; } catch(e) { return DEFAULT_MODEL; }
|
||||||
|
},
|
||||||
|
|
||||||
|
setModel: function(m) {
|
||||||
|
try { localStorage.setItem(STORAGE_MODEL, m); } catch(e) {}
|
||||||
|
// Force reload next time
|
||||||
|
_ready = false;
|
||||||
|
_modelLoaded = null;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Pre-warm: load model in background before first recording
|
||||||
|
preload: function(onProgress) {
|
||||||
|
if (!this.isSupported() || !this.isEnabled()) return;
|
||||||
|
_initWorker(this.getModel(), onProgress || function() {});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Transcribe a Blob (WebM, WAV, etc.)
|
||||||
|
transcribe: function(blob) {
|
||||||
|
var self = this;
|
||||||
|
if (!this.isSupported()) return Promise.reject(new Error('WebAssembly not supported'));
|
||||||
|
if (!this.isEnabled()) return Promise.reject(new Error('Browser Whisper not enabled'));
|
||||||
|
|
||||||
|
return _blobToFloat32(blob).then(function(float32) {
|
||||||
|
return new Promise(function(resolve, reject) {
|
||||||
|
var model = self.getModel();
|
||||||
|
|
||||||
|
// If worker is ready with same model, send immediately
|
||||||
|
if (_ready && _modelLoaded === model) {
|
||||||
|
_pending = { resolve: resolve, reject: reject };
|
||||||
|
_worker.postMessage({ type: 'transcribe', audio: float32, model: model }, [float32.buffer]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Need to (re)load model first
|
||||||
|
_pending = { resolve: resolve, reject: reject };
|
||||||
|
_initWorker(model, function() {}, function() {
|
||||||
|
_worker.postMessage({ type: 'transcribe', audio: float32, model: model }, [float32.buffer]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Internal ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
function _initWorker(model, onProgress, onReady) {
|
||||||
|
if (_loading && _modelLoaded === model) return; // already loading same model
|
||||||
|
|
||||||
|
if (_worker) { _worker.terminate(); _worker = null; }
|
||||||
|
_ready = false;
|
||||||
|
_loading = true;
|
||||||
|
_modelLoaded = model;
|
||||||
|
|
||||||
|
_worker = new Worker('/js/whisperWorker.js');
|
||||||
|
|
||||||
|
_worker.addEventListener('message', function(e) {
|
||||||
|
var d = e.data;
|
||||||
|
|
||||||
|
if (d.type === 'loading') {
|
||||||
|
if (onProgress) onProgress('Downloading Whisper model (' + d.model.split('/').pop() + ')...', 0);
|
||||||
|
}
|
||||||
|
if (d.type === 'progress') {
|
||||||
|
if (onProgress) onProgress(d.file.split('/').pop() || 'model', d.progress);
|
||||||
|
}
|
||||||
|
if (d.type === 'ready') {
|
||||||
|
_ready = true;
|
||||||
|
_loading = false;
|
||||||
|
if (onProgress) onProgress('', 100);
|
||||||
|
if (onReady) onReady();
|
||||||
|
}
|
||||||
|
if (d.type === 'result') {
|
||||||
|
if (_pending) { _pending.resolve(d.text); _pending = null; }
|
||||||
|
}
|
||||||
|
if (d.type === 'error') {
|
||||||
|
_loading = false;
|
||||||
|
_ready = false;
|
||||||
|
console.error('[BrowserWhisper] Worker error:', d.message);
|
||||||
|
// Show user-friendly message
|
||||||
|
if (typeof showToast === 'function') {
|
||||||
|
showToast('Browser Whisper blocked by network/firewall. Using server transcription.', 'warning');
|
||||||
|
}
|
||||||
|
if (_pending) { _pending.reject(new Error(d.message)); _pending = null; }
|
||||||
|
if (onProgress) onProgress('', 0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
_worker.addEventListener('error', function(err) {
|
||||||
|
_loading = false;
|
||||||
|
_ready = false;
|
||||||
|
console.error('[BrowserWhisper] Worker crashed:', err);
|
||||||
|
if (typeof showToast === 'function') {
|
||||||
|
showToast('Browser Whisper unavailable. Using server transcription.', 'warning');
|
||||||
|
}
|
||||||
|
if (_pending) { _pending.reject(err); _pending = null; }
|
||||||
|
if (onProgress) onProgress('', 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
_worker.postMessage({ type: 'load', model: model });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert audio Blob → Float32Array at 16kHz mono (what Whisper expects)
|
||||||
|
function _blobToFloat32(blob) {
|
||||||
|
return blob.arrayBuffer().then(function(buf) {
|
||||||
|
var ctx = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: 16000 });
|
||||||
|
return ctx.decodeAudioData(buf).then(function(audioBuffer) {
|
||||||
|
ctx.close();
|
||||||
|
return audioBuffer.getChannelData(0); // mono
|
||||||
|
}).catch(function(err) {
|
||||||
|
ctx.close();
|
||||||
|
throw new Error('Audio decode failed: ' + err.message);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
})();
|
||||||
|
|
@ -123,6 +123,17 @@
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Warn if payload is very large (>8MB = approaching 10MB server limit)
|
||||||
|
var payloadEstimate = JSON.stringify({ visits: visits, subspecialty: subspecialty, edVisits: edVisits, labs: labs }).length;
|
||||||
|
if (payloadEstimate > 8 * 1024 * 1024) {
|
||||||
|
showToast('Notes are very large (' + Math.round(payloadEstimate / 1024 / 1024) + 'MB). Consider trimming some notes.', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var totalNotes = visits.length + subspecialty.length + edVisits.length;
|
||||||
|
if (totalNotes > 30) {
|
||||||
|
showToast('Processing ' + totalNotes + ' notes — this may take a moment...', 'info');
|
||||||
|
}
|
||||||
|
|
||||||
showLoading('Generating chart review...');
|
showLoading('Generating chart review...');
|
||||||
|
|
||||||
fetch('/api/generate-chart-review', {
|
fetch('/api/generate-chart-review', {
|
||||||
|
|
|
||||||
86
public/js/correctionTracker.js
Normal file
86
public/js/correctionTracker.js
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
// ============================================================
|
||||||
|
// CORRECTION TRACKER — Dragon-like AI learning from user edits
|
||||||
|
// Tracks when users edit AI-generated output and saves corrections
|
||||||
|
// so the AI can learn user preferences over time.
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
(function() {
|
||||||
|
|
||||||
|
// Store original AI outputs per element
|
||||||
|
var _originals = {};
|
||||||
|
|
||||||
|
// Track an output element: store original text when AI generates it
|
||||||
|
window.trackAIOutput = function(elementId, originalText) {
|
||||||
|
if (!elementId || !originalText) return;
|
||||||
|
_originals[elementId] = originalText.trim();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Save correction when user is done editing (call on save or blur)
|
||||||
|
window.saveCorrection = function(elementId, section) {
|
||||||
|
var original = _originals[elementId];
|
||||||
|
if (!original) return;
|
||||||
|
var el = document.getElementById(elementId);
|
||||||
|
if (!el) return;
|
||||||
|
var current = (el.innerText || el.textContent || '').trim();
|
||||||
|
if (!current || current === original) return;
|
||||||
|
|
||||||
|
// Only save if there's a meaningful difference (not just whitespace)
|
||||||
|
var origWords = original.split(/\s+/).length;
|
||||||
|
var currWords = current.split(/\s+/).length;
|
||||||
|
var wordDiff = Math.abs(origWords - currWords);
|
||||||
|
// Require at least some meaningful change
|
||||||
|
if (wordDiff < 2 && original.length > 100) {
|
||||||
|
// Check character-level difference
|
||||||
|
var charDiff = Math.abs(original.length - current.length);
|
||||||
|
if (charDiff < 20) return; // too minor
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the most significant changed section (not full text)
|
||||||
|
var origSnippet = extractDiffSnippet(original, current);
|
||||||
|
var corrSnippet = extractDiffSnippet(current, original);
|
||||||
|
|
||||||
|
if (!origSnippet || !corrSnippet) {
|
||||||
|
origSnippet = original.substring(0, 500);
|
||||||
|
corrSnippet = current.substring(0, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch('/api/memories/correction', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
body: JSON.stringify({
|
||||||
|
section: section || 'encounter',
|
||||||
|
original_snippet: origSnippet,
|
||||||
|
corrected_snippet: corrSnippet
|
||||||
|
})
|
||||||
|
}).then(function(r) { return r.json(); })
|
||||||
|
.then(function(data) {
|
||||||
|
if (data.success && !data.skipped) {
|
||||||
|
console.log('[CorrectionTracker] Saved correction for', section);
|
||||||
|
}
|
||||||
|
}).catch(function() {});
|
||||||
|
|
||||||
|
// Clear so we don't re-save
|
||||||
|
delete _originals[elementId];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Extract the most changed portion between two texts
|
||||||
|
function extractDiffSnippet(text1, text2) {
|
||||||
|
var lines1 = text1.split('\n');
|
||||||
|
var lines2 = text2.split('\n');
|
||||||
|
var changed = [];
|
||||||
|
var maxLen = Math.max(lines1.length, lines2.length);
|
||||||
|
|
||||||
|
for (var i = 0; i < maxLen; i++) {
|
||||||
|
var l1 = (lines1[i] || '').trim();
|
||||||
|
var l2 = (lines2[i] || '').trim();
|
||||||
|
if (l1 !== l2 && l1) {
|
||||||
|
changed.push(l1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changed.length === 0) return null;
|
||||||
|
return changed.slice(0, 10).join('\n').substring(0, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Correction tracker loaded');
|
||||||
|
})();
|
||||||
123
public/js/documents.js
Normal file
123
public/js/documents.js
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
// ============================================================
|
||||||
|
// DOCUMENTS.JS — S3 document upload & management UI
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
(function() {
|
||||||
|
|
||||||
|
function loadDocuments() {
|
||||||
|
var container = document.getElementById('documents-list');
|
||||||
|
if (!container) return;
|
||||||
|
fetch('/api/documents', { headers: getAuthHeaders() })
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(data) {
|
||||||
|
if (!data.s3_configured) {
|
||||||
|
container.innerHTML = '<p style="color:var(--g400);font-size:13px;">S3 storage not configured. Set S3_BUCKET in server environment.</p>';
|
||||||
|
var uploadArea = document.getElementById('doc-upload-area');
|
||||||
|
if (uploadArea) uploadArea.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var docs = data.documents || [];
|
||||||
|
if (docs.length === 0) {
|
||||||
|
container.innerHTML = '<p style="color:var(--g400);font-size:13px;">No documents uploaded yet.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
container.innerHTML = docs.map(function(doc) {
|
||||||
|
var sizeStr = doc.size_bytes < 1024 ? doc.size_bytes + ' B' :
|
||||||
|
doc.size_bytes < 1048576 ? Math.round(doc.size_bytes / 1024) + ' KB' :
|
||||||
|
(doc.size_bytes / 1048576).toFixed(1) + ' MB';
|
||||||
|
var date = doc.created_at ? new Date(doc.created_at).toLocaleDateString() : '';
|
||||||
|
return '<div class="saved-enc-item" style="padding:8px 12px;">' +
|
||||||
|
'<div style="flex:1;min-width:0;">' +
|
||||||
|
'<div style="font-weight:600;font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">' +
|
||||||
|
'<i class="fas fa-file" style="margin-right:4px;color:var(--g400);"></i>' + esc(doc.filename) +
|
||||||
|
'</div>' +
|
||||||
|
'<div style="font-size:11px;color:var(--g500);">' + sizeStr + ' · ' + date +
|
||||||
|
(doc.description ? ' · ' + esc(doc.description) : '') +
|
||||||
|
'</div>' +
|
||||||
|
'</div>' +
|
||||||
|
'<button class="btn-sm btn-primary doc-download-btn" data-id="' + doc.id + '"><i class="fas fa-download"></i></button>' +
|
||||||
|
'<button class="btn-sm btn-ghost doc-delete-btn" data-id="' + doc.id + '" style="color:var(--red);"><i class="fas fa-trash"></i></button>' +
|
||||||
|
'</div>';
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
container.querySelectorAll('.doc-download-btn').forEach(function(btn) {
|
||||||
|
btn.addEventListener('click', function() { downloadDocument(btn.dataset.id); });
|
||||||
|
});
|
||||||
|
container.querySelectorAll('.doc-delete-btn').forEach(function(btn) {
|
||||||
|
btn.addEventListener('click', function() { deleteDocument(btn.dataset.id); });
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(function() {
|
||||||
|
container.innerHTML = '<p style="color:var(--g400);font-size:13px;">Failed to load documents.</p>';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function uploadDocument() {
|
||||||
|
var fileInput = document.getElementById('doc-file-input');
|
||||||
|
var descInput = document.getElementById('doc-description');
|
||||||
|
if (!fileInput || !fileInput.files[0]) { showToast('Select a file first', 'error'); return; }
|
||||||
|
|
||||||
|
var formData = new FormData();
|
||||||
|
formData.append('file', fileInput.files[0]);
|
||||||
|
formData.append('description', descInput ? descInput.value : '');
|
||||||
|
|
||||||
|
showLoading('Uploading document...');
|
||||||
|
fetch('/api/documents/upload', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Authorization': 'Bearer ' + (window.AUTH_TOKEN || localStorage.getItem('ped_scribe_token') || '') },
|
||||||
|
body: formData
|
||||||
|
})
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(data) {
|
||||||
|
hideLoading();
|
||||||
|
if (data.success) {
|
||||||
|
showToast('Document uploaded: ' + data.filename, 'success');
|
||||||
|
fileInput.value = '';
|
||||||
|
if (descInput) descInput.value = '';
|
||||||
|
loadDocuments();
|
||||||
|
} else {
|
||||||
|
showToast(data.error || 'Upload failed', 'error');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function(err) { hideLoading(); showToast('Upload failed: ' + err.message, 'error'); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadDocument(id) {
|
||||||
|
fetch('/api/documents/' + id + '/download', { headers: getAuthHeaders() })
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(data) {
|
||||||
|
if (data.success && data.url) {
|
||||||
|
window.open(data.url, '_blank');
|
||||||
|
} else {
|
||||||
|
showToast(data.error || 'Download failed', 'error');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function() { showToast('Download failed', 'error'); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteDocument(id) {
|
||||||
|
if (!confirm('Delete this document permanently?')) return;
|
||||||
|
fetch('/api/documents/' + id, { method: 'DELETE', headers: getAuthHeaders() })
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(data) {
|
||||||
|
if (data.success) { showToast('Document deleted', 'info'); loadDocuments(); }
|
||||||
|
else showToast(data.error || 'Delete failed', 'error');
|
||||||
|
})
|
||||||
|
.catch(function() { showToast('Delete failed', 'error'); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function esc(str) {
|
||||||
|
if (!str) return '';
|
||||||
|
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expose for settings tab load
|
||||||
|
window.loadDocuments = loadDocuments;
|
||||||
|
|
||||||
|
// Wire upload button
|
||||||
|
document.addEventListener('click', function(e) {
|
||||||
|
if (e.target.closest('#btn-doc-upload')) uploadDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Documents module loaded');
|
||||||
|
})();
|
||||||
|
|
@ -64,9 +64,38 @@
|
||||||
var _loadCallback = null; // set by each module to handle loading a saved encounter
|
var _loadCallback = null; // set by each module to handle loading a saved encounter
|
||||||
var _savingInProgress = {}; // prevent duplicate saves on double-click
|
var _savingInProgress = {}; // prevent duplicate saves on double-click
|
||||||
|
|
||||||
|
// Generate a UUID v4 for idempotency keys
|
||||||
|
function generateUUID() {
|
||||||
|
if (crypto && crypto.randomUUID) return crypto.randomUUID();
|
||||||
|
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
|
||||||
|
var r = Math.random() * 16 | 0;
|
||||||
|
return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get or create idempotency key for a tab type
|
||||||
|
function getIdempotencyKey(type) {
|
||||||
|
var key = '_idempKey_' + type;
|
||||||
|
if (!window[key]) {
|
||||||
|
try { window[key] = sessionStorage.getItem(key); } catch(e) {}
|
||||||
|
if (!window[key]) {
|
||||||
|
window[key] = generateUUID();
|
||||||
|
try { sessionStorage.setItem(key, window[key]); } catch(e) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return window[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset idempotency key (on New Patient)
|
||||||
|
function resetIdempotencyKey(type) {
|
||||||
|
var key = '_idempKey_' + type;
|
||||||
|
window[key] = null;
|
||||||
|
try { sessionStorage.removeItem(key); } catch(e) {}
|
||||||
|
}
|
||||||
|
|
||||||
// Restore saved encounter IDs from sessionStorage (survive page refresh, cleared on tab close)
|
// Restore saved encounter IDs from sessionStorage (survive page refresh, cleared on tab close)
|
||||||
(function() {
|
(function() {
|
||||||
['encounter','dictation','hospital','chart','wellvisit','sickvisit'].forEach(function(t) {
|
['encounter','dictation','hospital','chart','wellvisit','sickvisit','soap'].forEach(function(t) {
|
||||||
try {
|
try {
|
||||||
var id = sessionStorage.getItem('_savedEncId_' + t);
|
var id = sessionStorage.getItem('_savedEncId_' + t);
|
||||||
if (id) window['_savedEncId_' + t] = id;
|
if (id) window['_savedEncId_' + t] = id;
|
||||||
|
|
@ -119,7 +148,7 @@
|
||||||
_savedEncounters = data.encounters || [];
|
_savedEncounters = data.encounters || [];
|
||||||
renderSavedList();
|
renderSavedList();
|
||||||
// Refresh any open popovers
|
// Refresh any open popovers
|
||||||
['enc', 'dict', 'wv', 'sick', 'hosp', 'chart'].forEach(function(pfx) {
|
['enc', 'dict', 'wv', 'sick', 'hosp', 'chart', 'soap'].forEach(function(pfx) {
|
||||||
var pop = document.getElementById(pfx + '-load-popover');
|
var pop = document.getElementById(pfx + '-load-popover');
|
||||||
if (pop && !pop.classList.contains('hidden')) {
|
if (pop && !pop.classList.contains('hidden')) {
|
||||||
var search = pop.querySelector('.enc-load-search');
|
var search = pop.querySelector('.enc-load-search');
|
||||||
|
|
@ -209,7 +238,7 @@
|
||||||
if (!popover) return;
|
if (!popover) return;
|
||||||
var isHidden = popover.classList.contains('hidden');
|
var isHidden = popover.classList.contains('hidden');
|
||||||
// Close all popovers first
|
// Close all popovers first
|
||||||
['enc', 'dict', 'wv', 'sick', 'hosp', 'chart'].forEach(function(p) {
|
['enc', 'dict', 'wv', 'sick', 'hosp', 'chart', 'soap'].forEach(function(p) {
|
||||||
var pop = document.getElementById(p + '-load-popover');
|
var pop = document.getElementById(p + '-load-popover');
|
||||||
if (pop) pop.classList.add('hidden');
|
if (pop) pop.classList.add('hidden');
|
||||||
});
|
});
|
||||||
|
|
@ -247,7 +276,7 @@
|
||||||
if (!data.success) { showToast(data.error || 'Failed', 'error'); return; }
|
if (!data.success) { showToast(data.error || 'Failed', 'error'); return; }
|
||||||
var enc = data.encounter;
|
var enc = data.encounter;
|
||||||
// Close all popovers
|
// Close all popovers
|
||||||
['enc', 'dict', 'wv', 'sick', 'hosp', 'chart'].forEach(function(p) {
|
['enc', 'dict', 'wv', 'sick', 'hosp', 'chart', 'soap'].forEach(function(p) {
|
||||||
var pop = document.getElementById(p + '-load-popover');
|
var pop = document.getElementById(p + '-load-popover');
|
||||||
if (pop) pop.classList.add('hidden');
|
if (pop) pop.classList.add('hidden');
|
||||||
});
|
});
|
||||||
|
|
@ -272,7 +301,8 @@
|
||||||
var noteIdMap = {
|
var noteIdMap = {
|
||||||
encounter: 'enc-hpi-text', dictation: 'dict-hpi-text',
|
encounter: 'enc-hpi-text', dictation: 'dict-hpi-text',
|
||||||
hospital: 'hc-course-text', chart: 'cr-review-text',
|
hospital: 'hc-course-text', chart: 'cr-review-text',
|
||||||
wellvisit: 'wv-note-text', sickvisit: 'sick-note-text'
|
wellvisit: 'wv-note-text', sickvisit: 'sick-note-text',
|
||||||
|
soap: 'soap-text'
|
||||||
};
|
};
|
||||||
|
|
||||||
// Fill in data
|
// Fill in data
|
||||||
|
|
@ -349,6 +379,7 @@
|
||||||
if (e.target.closest('#btn-hosp-save')) saveFromTab('hospital', 'hosp');
|
if (e.target.closest('#btn-hosp-save')) saveFromTab('hospital', 'hosp');
|
||||||
if (e.target.closest('#btn-chart-save')) saveFromTab('chart', 'chart');
|
if (e.target.closest('#btn-chart-save')) saveFromTab('chart', 'chart');
|
||||||
if (e.target.closest('#btn-wv-save')) saveFromTab('wellvisit', 'wv');
|
if (e.target.closest('#btn-wv-save')) saveFromTab('wellvisit', 'wv');
|
||||||
|
if (e.target.closest('#btn-soap-save')) saveFromTab('soap', 'soap');
|
||||||
// Load buttons — open inline popovers
|
// Load buttons — open inline popovers
|
||||||
if (e.target.closest('#btn-enc-load')) openLoadPopover('enc');
|
if (e.target.closest('#btn-enc-load')) openLoadPopover('enc');
|
||||||
if (e.target.closest('#btn-dict-load')) openLoadPopover('dict');
|
if (e.target.closest('#btn-dict-load')) openLoadPopover('dict');
|
||||||
|
|
@ -356,6 +387,7 @@
|
||||||
if (e.target.closest('#btn-sick-load')) openLoadPopover('sick');
|
if (e.target.closest('#btn-sick-load')) openLoadPopover('sick');
|
||||||
if (e.target.closest('#btn-hosp-load')) openLoadPopover('hosp');
|
if (e.target.closest('#btn-hosp-load')) openLoadPopover('hosp');
|
||||||
if (e.target.closest('#btn-chart-load')) openLoadPopover('chart');
|
if (e.target.closest('#btn-chart-load')) openLoadPopover('chart');
|
||||||
|
if (e.target.closest('#btn-soap-load')) openLoadPopover('soap');
|
||||||
// New Patient / clear tab buttons
|
// New Patient / clear tab buttons
|
||||||
if (e.target.closest('#btn-enc-new')) clearTab('encounter');
|
if (e.target.closest('#btn-enc-new')) clearTab('encounter');
|
||||||
if (e.target.closest('#btn-dict-new')) clearTab('dictation');
|
if (e.target.closest('#btn-dict-new')) clearTab('dictation');
|
||||||
|
|
@ -363,11 +395,12 @@
|
||||||
if (e.target.closest('#btn-chart-new')) clearTab('chart');
|
if (e.target.closest('#btn-chart-new')) clearTab('chart');
|
||||||
if (e.target.closest('#btn-wv-new')) clearTab('wellvisit');
|
if (e.target.closest('#btn-wv-new')) clearTab('wellvisit');
|
||||||
if (e.target.closest('#btn-sick-new')) clearTab('sickvisit');
|
if (e.target.closest('#btn-sick-new')) clearTab('sickvisit');
|
||||||
|
if (e.target.closest('#btn-soap-new')) clearTab('soap');
|
||||||
// Close popovers when clicking outside
|
// Close popovers when clicking outside
|
||||||
var loadBtnIds = ['#btn-enc-load','#btn-dict-load','#btn-wv-load','#btn-sick-load','#btn-hosp-load','#btn-chart-load'];
|
var loadBtnIds = ['#btn-enc-load','#btn-dict-load','#btn-wv-load','#btn-sick-load','#btn-hosp-load','#btn-chart-load','#btn-soap-load'];
|
||||||
var clickedLoadBtn = loadBtnIds.some(function(id) { return e.target.closest(id); });
|
var clickedLoadBtn = loadBtnIds.some(function(id) { return e.target.closest(id); });
|
||||||
if (!clickedLoadBtn) {
|
if (!clickedLoadBtn) {
|
||||||
['enc', 'dict', 'wv', 'sick', 'hosp', 'chart'].forEach(function(p) {
|
['enc', 'dict', 'wv', 'sick', 'hosp', 'chart', 'soap'].forEach(function(p) {
|
||||||
var pop = document.getElementById(p + '-load-popover');
|
var pop = document.getElementById(p + '-load-popover');
|
||||||
if (pop) pop.classList.add('hidden');
|
if (pop) pop.classList.add('hidden');
|
||||||
});
|
});
|
||||||
|
|
@ -386,33 +419,42 @@
|
||||||
var noteIdMap = {
|
var noteIdMap = {
|
||||||
encounter: 'enc-hpi-text', dictation: 'dict-hpi-text',
|
encounter: 'enc-hpi-text', dictation: 'dict-hpi-text',
|
||||||
hospital: 'hc-course-text', chart: 'cr-review-text',
|
hospital: 'hc-course-text', chart: 'cr-review-text',
|
||||||
wellvisit: 'wv-note-text'
|
wellvisit: 'wv-note-text', soap: 'soap-text'
|
||||||
};
|
};
|
||||||
var noteEl = document.getElementById(noteIdMap[type] || (prefix + '-hpi-text'));
|
var noteEl = document.getElementById(noteIdMap[type] || (prefix + '-hpi-text'));
|
||||||
|
|
||||||
|
// Save any corrections (Dragon-like learning) before saving encounter
|
||||||
|
var noteElId = noteIdMap[type] || (prefix + '-hpi-text');
|
||||||
|
if (typeof saveCorrection === 'function') {
|
||||||
|
saveCorrection(noteElId, type);
|
||||||
|
}
|
||||||
|
|
||||||
window.saveEncounter({
|
window.saveEncounter({
|
||||||
id: savedId,
|
id: savedId,
|
||||||
label: label,
|
label: label,
|
||||||
enc_type: type,
|
enc_type: type,
|
||||||
transcript: transcriptEl ? (transcriptEl.innerText || transcriptEl.textContent || '') : '',
|
transcript: transcriptEl ? (transcriptEl.innerText || transcriptEl.textContent || '') : '',
|
||||||
generated_note: noteEl ? (noteEl.innerText || noteEl.textContent || '') : '',
|
generated_note: noteEl ? (noteEl.innerText || noteEl.textContent || '') : '',
|
||||||
|
idempotency_key: getIdempotencyKey(type),
|
||||||
onSaved: function(newId) { window['_savedEncId_' + type] = newId; }
|
onSaved: function(newId) { window['_savedEncId_' + type] = newId; }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── New Patient / Clear tab ────────────────────────────────────────────────
|
// ── New Patient / Clear tab ────────────────────────────────────────────────
|
||||||
function clearTab(type) {
|
function clearTab(type) {
|
||||||
var pfxMap = { encounter:'enc', dictation:'dict', hospital:'hosp', chart:'chart', wellvisit:'wv', sickvisit:'sick' };
|
var pfxMap = { encounter:'enc', dictation:'dict', hospital:'hosp', chart:'chart', wellvisit:'wv', sickvisit:'sick', soap:'soap' };
|
||||||
var pfx = pfxMap[type] || type;
|
var pfx = pfxMap[type] || type;
|
||||||
var noteElMap = {
|
var noteElMap = {
|
||||||
encounter:'enc-hpi-text', dictation:'dict-hpi-text',
|
encounter:'enc-hpi-text', dictation:'dict-hpi-text',
|
||||||
hospital:'hc-course-text', chart:'cr-review-text',
|
hospital:'hc-course-text', chart:'cr-review-text',
|
||||||
wellvisit:'wv-note-text', sickvisit:'sick-note-text'
|
wellvisit:'wv-note-text', sickvisit:'sick-note-text',
|
||||||
|
soap:'soap-text'
|
||||||
};
|
};
|
||||||
var outputElMap = {
|
var outputElMap = {
|
||||||
encounter:'enc-output', dictation:'dict-output',
|
encounter:'enc-output', dictation:'dict-output',
|
||||||
hospital:'hc-output', chart:'cr-output',
|
hospital:'hc-output', chart:'cr-output',
|
||||||
wellvisit:'wv-note-output', sickvisit:'sick-note-output'
|
wellvisit:'wv-note-output', sickvisit:'sick-note-output',
|
||||||
|
soap:'soap-output'
|
||||||
};
|
};
|
||||||
// Clear label
|
// Clear label
|
||||||
var lbl = document.getElementById(pfx + '-label');
|
var lbl = document.getElementById(pfx + '-label');
|
||||||
|
|
@ -426,9 +468,21 @@
|
||||||
// Hide output card
|
// Hide output card
|
||||||
var out = document.getElementById(outputElMap[type]);
|
var out = document.getElementById(outputElMap[type]);
|
||||||
if (out) out.classList.add('hidden');
|
if (out) out.classList.add('hidden');
|
||||||
// Reset saved ID (memory + sessionStorage)
|
// Clear refine input (textarea or input) for every tab
|
||||||
|
var refineEl = document.getElementById(pfx + '-refine-input');
|
||||||
|
if (refineEl) refineEl.value = '';
|
||||||
|
// Clear instructions textarea (SOAP, hospital, etc.)
|
||||||
|
var instrEl = document.getElementById(pfx + '-instructions');
|
||||||
|
if (instrEl) instrEl.value = '';
|
||||||
|
// Clear demographic fields
|
||||||
|
var ageEl = document.getElementById(pfx + '-age');
|
||||||
|
if (ageEl) ageEl.value = '';
|
||||||
|
var genderEl = document.getElementById(pfx + '-gender');
|
||||||
|
if (genderEl) genderEl.value = '';
|
||||||
|
// Reset saved ID and idempotency key (memory + sessionStorage)
|
||||||
window['_savedEncId_' + type] = null;
|
window['_savedEncId_' + type] = null;
|
||||||
try { sessionStorage.removeItem('_savedEncId_' + type); } catch(e) {}
|
try { sessionStorage.removeItem('_savedEncId_' + type); } catch(e) {}
|
||||||
|
resetIdempotencyKey(type);
|
||||||
// Chart review has additional fields/cards to clear
|
// Chart review has additional fields/cards to clear
|
||||||
if (type === 'chart' && typeof window.resetChartReview === 'function') {
|
if (type === 'chart' && typeof window.resetChartReview === 'function') {
|
||||||
window.resetChartReview();
|
window.resetChartReview();
|
||||||
|
|
|
||||||
|
|
@ -134,23 +134,28 @@
|
||||||
|
|
||||||
showLoading('Generating hospital course...');
|
showLoading('Generating hospital course...');
|
||||||
|
|
||||||
fetch('/api/generate-hospital-course', {
|
var memoriesPromise = (typeof getUserMemoryContext === 'function') ? getUserMemoryContext() : Promise.resolve('');
|
||||||
method: 'POST',
|
|
||||||
headers: getAuthHeaders(),
|
memoriesPromise.then(function(memCtx) {
|
||||||
body: JSON.stringify({
|
return fetch('/api/generate-hospital-course', {
|
||||||
notes: notes,
|
method: 'POST',
|
||||||
edNote: edNote,
|
headers: getAuthHeaders(),
|
||||||
hAndP: hAndP,
|
body: JSON.stringify({
|
||||||
labs: labs,
|
notes: notes,
|
||||||
patientAge: document.getElementById('hc-age').value,
|
edNote: edNote,
|
||||||
patientGender: document.getElementById('hc-gender').value,
|
hAndP: hAndP,
|
||||||
pmh: document.getElementById('hc-pmh').value,
|
labs: labs,
|
||||||
setting: document.getElementById('hc-setting').value,
|
patientAge: document.getElementById('hc-age').value,
|
||||||
los: document.getElementById('hc-los').value,
|
patientGender: document.getElementById('hc-gender').value,
|
||||||
formatPreference: document.getElementById('hc-format').value,
|
pmh: document.getElementById('hc-pmh').value,
|
||||||
additionalInstructions: document.getElementById('hc-instructions').value,
|
setting: document.getElementById('hc-setting').value,
|
||||||
model: getSelectedModel()
|
los: document.getElementById('hc-los').value,
|
||||||
})
|
formatPreference: document.getElementById('hc-format').value,
|
||||||
|
additionalInstructions: document.getElementById('hc-instructions').value,
|
||||||
|
physicianMemories: memCtx || null,
|
||||||
|
model: getSelectedModel()
|
||||||
|
})
|
||||||
|
});
|
||||||
})
|
})
|
||||||
.then(function(r) { return r.json(); })
|
.then(function(r) { return r.json(); })
|
||||||
.then(function(data) {
|
.then(function(data) {
|
||||||
|
|
|
||||||
|
|
@ -547,11 +547,32 @@
|
||||||
function wireCmsFileInput() {
|
function wireCmsFileInput() {
|
||||||
var fileInput = document.getElementById('lh-ai-file');
|
var fileInput = document.getElementById('lh-ai-file');
|
||||||
var fileLabel = document.getElementById('lh-ai-file-label');
|
var fileLabel = document.getElementById('lh-ai-file-label');
|
||||||
|
var filesList = document.getElementById('lh-ai-files-list');
|
||||||
var dropzone = document.getElementById('lh-ai-dropzone');
|
var dropzone = document.getElementById('lh-ai-dropzone');
|
||||||
|
|
||||||
|
function updateFilesList() {
|
||||||
|
if (!fileInput || !fileLabel || !filesList) return;
|
||||||
|
var files = fileInput.files;
|
||||||
|
if (files.length === 0) {
|
||||||
|
fileLabel.textContent = 'Drop files here or click to browse';
|
||||||
|
filesList.style.display = 'none';
|
||||||
|
filesList.innerHTML = '';
|
||||||
|
} else if (files.length === 1) {
|
||||||
|
fileLabel.textContent = files[0].name;
|
||||||
|
filesList.style.display = 'none';
|
||||||
|
filesList.innerHTML = '';
|
||||||
|
} else {
|
||||||
|
fileLabel.textContent = files.length + ' files selected';
|
||||||
|
filesList.style.display = 'block';
|
||||||
|
filesList.innerHTML = '<ul style="list-style:none;padding:0;margin:0;font-size:0.9em;">' +
|
||||||
|
Array.from(files).map(function(f) {
|
||||||
|
return '<li style="padding:2px 0;"><i class="fas fa-file-pdf" style="color:var(--blue);margin-right:4px;"></i>' + f.name + '</li>';
|
||||||
|
}).join('') + '</ul>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (fileInput) {
|
if (fileInput) {
|
||||||
fileInput.addEventListener('change', function() {
|
fileInput.addEventListener('change', updateFilesList);
|
||||||
if (fileInput.files[0] && fileLabel) fileLabel.textContent = fileInput.files[0].name;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
if (dropzone) {
|
if (dropzone) {
|
||||||
dropzone.addEventListener('dragover', function(e) { e.preventDefault(); dropzone.style.borderColor = 'var(--blue)'; });
|
dropzone.addEventListener('dragover', function(e) { e.preventDefault(); dropzone.style.borderColor = 'var(--blue)'; });
|
||||||
|
|
@ -560,14 +581,16 @@
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
dropzone.style.borderColor = '';
|
dropzone.style.borderColor = '';
|
||||||
var files = e.dataTransfer.files;
|
var files = e.dataTransfer.files;
|
||||||
if (files[0] && fileInput && fileLabel) {
|
if (files.length > 0 && fileInput) {
|
||||||
// Transfer to file input via DataTransfer
|
// Transfer to file input via DataTransfer
|
||||||
try {
|
try {
|
||||||
var dt = new DataTransfer();
|
var dt = new DataTransfer();
|
||||||
dt.items.add(files[0]);
|
for (var i = 0; i < files.length && i < 10; i++) {
|
||||||
|
dt.items.add(files[i]);
|
||||||
|
}
|
||||||
fileInput.files = dt.files;
|
fileInput.files = dt.files;
|
||||||
|
updateFilesList();
|
||||||
} catch(ex) {}
|
} catch(ex) {}
|
||||||
fileLabel.textContent = files[0].name;
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -799,8 +822,10 @@
|
||||||
formData.append('topic', topic);
|
formData.append('topic', topic);
|
||||||
} else if (tabName === 'upload') {
|
} else if (tabName === 'upload') {
|
||||||
var fileInput = document.getElementById('lh-ai-file');
|
var fileInput = document.getElementById('lh-ai-file');
|
||||||
if (!fileInput || !fileInput.files[0]) { showToast('Select a file to upload', 'error'); return; }
|
if (!fileInput || fileInput.files.length === 0) { showToast('Select at least one file to upload', 'error'); return; }
|
||||||
formData.append('file', fileInput.files[0]);
|
for (var i = 0; i < fileInput.files.length; i++) {
|
||||||
|
formData.append('files', fileInput.files[i]);
|
||||||
|
}
|
||||||
var uploadCtx = document.getElementById('lh-ai-upload-context');
|
var uploadCtx = document.getElementById('lh-ai-upload-context');
|
||||||
if (uploadCtx && uploadCtx.value.trim()) formData.append('topic', uploadCtx.value.trim());
|
if (uploadCtx && uploadCtx.value.trim()) formData.append('topic', uploadCtx.value.trim());
|
||||||
} else if (tabName === 'webdav') {
|
} else if (tabName === 'webdav') {
|
||||||
|
|
|
||||||
|
|
@ -14,35 +14,55 @@
|
||||||
var hpiText = document.getElementById('enc-hpi-text');
|
var hpiText = document.getElementById('enc-hpi-text');
|
||||||
var modelTag = document.getElementById('enc-model-tag');
|
var modelTag = document.getElementById('enc-model-tag');
|
||||||
|
|
||||||
|
var stopBtn = document.getElementById('enc-stop-btn');
|
||||||
var recorder = new AudioRecorder();
|
var recorder = new AudioRecorder();
|
||||||
|
recorder._module = 'encounter';
|
||||||
var timer = createTimer(timerEl);
|
var timer = createTimer(timerEl);
|
||||||
var isRecording = false;
|
var isRecording = false;
|
||||||
var isPaused = false;
|
var isPaused = false;
|
||||||
var recognition = createSpeechRecognition();
|
var recognition = createSpeechRecognition();
|
||||||
var finalText = '';
|
var finalText = '';
|
||||||
|
var sessionFinals = '';
|
||||||
|
|
||||||
|
function escHtml(s) { return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||||||
|
|
||||||
if (recognition) {
|
if (recognition) {
|
||||||
recognition.onresult = function(e) {
|
recognition.onresult = function(e) {
|
||||||
var interim = '';
|
var interim = '';
|
||||||
for (var i = e.resultIndex; i < e.results.length; i++) {
|
for (var i = e.resultIndex; i < e.results.length; i++) {
|
||||||
if (e.results[i].isFinal) finalText += e.results[i][0].transcript + ' ';
|
if (e.results[i].isFinal) {
|
||||||
else interim = e.results[i][0].transcript;
|
var chunk = e.results[i][0].transcript + ' ';
|
||||||
|
var deduped = deduplicateFinal(chunk, finalText + sessionFinals);
|
||||||
|
sessionFinals += deduped;
|
||||||
|
} else {
|
||||||
|
interim = e.results[i][0].transcript;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
transcript.innerHTML = finalText + (interim ? '<span style="color:#9ca3af;">' + interim + '</span>' : '');
|
var combined = finalText + sessionFinals;
|
||||||
|
transcript.innerHTML = escHtml(combined) + (interim ? '<span style="color:#9ca3af;">' + escHtml(interim) + '</span>' : '');
|
||||||
|
};
|
||||||
|
recognition.onend = function() {
|
||||||
|
finalText += sessionFinals;
|
||||||
|
sessionFinals = '';
|
||||||
|
if (isRecording && !isPaused) try { recognition.start(); } catch(e) {}
|
||||||
|
};
|
||||||
|
recognition.onerror = function(e) {
|
||||||
|
if (e.error === 'no-speech' || e.error === 'aborted') return;
|
||||||
|
console.warn('[SpeechRecognition] error:', e.error);
|
||||||
};
|
};
|
||||||
recognition.onend = function() { if (isRecording && !isPaused) try { recognition.start(); } catch(e) {} };
|
|
||||||
recognition.onerror = function() {};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
recordBtn.addEventListener('click', function() {
|
recordBtn.addEventListener('click', function() {
|
||||||
if (!isRecording) {
|
if (!isRecording) {
|
||||||
finalText = '';
|
finalText = '';
|
||||||
|
sessionFinals = '';
|
||||||
recorder.start().then(function() {
|
recorder.start().then(function() {
|
||||||
isRecording = true;
|
isRecording = true;
|
||||||
isPaused = false;
|
isPaused = false;
|
||||||
recordBtn.classList.add('recording');
|
recordBtn.classList.add('recording');
|
||||||
recordBtn.querySelector('span').textContent = 'Stop';
|
recordBtn.querySelector('span').textContent = 'Stop';
|
||||||
if (pauseBtn) pauseBtn.classList.remove('hidden');
|
if (pauseBtn) pauseBtn.classList.remove('hidden');
|
||||||
|
if (stopBtn) stopBtn.classList.remove('hidden');
|
||||||
indicator.classList.remove('hidden');
|
indicator.classList.remove('hidden');
|
||||||
timer.start();
|
timer.start();
|
||||||
if (recognition) try { recognition.start(); } catch(e) {}
|
if (recognition) try { recognition.start(); } catch(e) {}
|
||||||
|
|
@ -56,22 +76,45 @@
|
||||||
recordBtn.classList.remove('recording');
|
recordBtn.classList.remove('recording');
|
||||||
recordBtn.querySelector('span').textContent = 'Start Recording';
|
recordBtn.querySelector('span').textContent = 'Start Recording';
|
||||||
if (pauseBtn) { pauseBtn.classList.add('hidden'); pauseBtn.innerHTML = '<i class="fas fa-pause"></i> Pause'; }
|
if (pauseBtn) { pauseBtn.classList.add('hidden'); pauseBtn.innerHTML = '<i class="fas fa-pause"></i> Pause'; }
|
||||||
|
if (stopBtn) stopBtn.classList.add('hidden');
|
||||||
indicator.classList.add('hidden');
|
indicator.classList.add('hidden');
|
||||||
if (recognition) try { recognition.stop(); } catch(e) {}
|
if (recognition) try { recognition.stop(); } catch(e) {}
|
||||||
document.dispatchEvent(new CustomEvent('recording-stopped', { detail: { module: 'enc' } }));
|
document.dispatchEvent(new CustomEvent('recording-stopped', { detail: { module: 'enc' } }));
|
||||||
|
|
||||||
showLoading('Transcribing...');
|
// If no server transcription API, use live transcript directly (no upload)
|
||||||
recorder.stop().then(function(blob) {
|
var liveText = (finalText + sessionFinals).trim();
|
||||||
if (!blob || blob.size === 0) { hideLoading(); if (finalText.trim()) transcript.textContent = finalText.trim(); return; }
|
if (window._transcribeAvailable === false) {
|
||||||
return transcribeAudio(blob).then(function(data) {
|
recorder.stop().then(function() {});
|
||||||
hideLoading();
|
if (liveText) transcript.textContent = liveText;
|
||||||
if (data.success) { transcript.textContent = data.text; showToast('Transcribed ' + dur + 's', 'success'); }
|
showToast('Using browser speech recognition (no transcription API configured)', 'info');
|
||||||
else { if (finalText.trim()) transcript.textContent = finalText.trim(); showToast(data.error || 'Failed', 'error'); }
|
} else {
|
||||||
});
|
showLoading('Transcribing...');
|
||||||
}).catch(function(err) { hideLoading(); if (finalText.trim()) transcript.textContent = finalText.trim(); showToast(err.message, 'error'); });
|
recorder.stop().then(function(blob) {
|
||||||
|
if (!blob || blob.size === 0) { hideLoading(); if (liveText) transcript.textContent = liveText; return; }
|
||||||
|
if (blob.size > 24 * 1024 * 1024) {
|
||||||
|
hideLoading();
|
||||||
|
transcript.textContent = liveText;
|
||||||
|
showToast('Recording too large for AI transcription — using live transcript', 'info');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return transcribeAudio(blob).then(function(data) {
|
||||||
|
hideLoading();
|
||||||
|
if (data.success) { transcript.textContent = data.text; showToast('Transcribed ' + dur + 's', 'success'); }
|
||||||
|
else if (data.noProvider) { if (liveText) transcript.textContent = liveText; showToast('Using live transcript', 'info'); }
|
||||||
|
else { if (liveText) transcript.textContent = liveText; showToast(data.error || 'Failed', 'error'); }
|
||||||
|
});
|
||||||
|
}).catch(function(err) { hideLoading(); if (liveText) transcript.textContent = liveText; showToast(err.message, 'error'); });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Dedicated stop button (always visible during recording)
|
||||||
|
if (stopBtn) {
|
||||||
|
stopBtn.addEventListener('click', function() {
|
||||||
|
if (isRecording) recordBtn.click();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Pause / Resume
|
// Pause / Resume
|
||||||
if (pauseBtn) {
|
if (pauseBtn) {
|
||||||
pauseBtn.addEventListener('click', function() {
|
pauseBtn.addEventListener('click', function() {
|
||||||
|
|
@ -107,6 +150,8 @@
|
||||||
window._savedEncId_encounter = null;
|
window._savedEncId_encounter = null;
|
||||||
var labelEl = document.getElementById('enc-label');
|
var labelEl = document.getElementById('enc-label');
|
||||||
if (labelEl) labelEl.value = '';
|
if (labelEl) labelEl.value = '';
|
||||||
|
var refineEl = document.getElementById('enc-refine-input');
|
||||||
|
if (refineEl) refineEl.value = '';
|
||||||
});
|
});
|
||||||
|
|
||||||
generateBtn.addEventListener('click', function() {
|
generateBtn.addEventListener('click', function() {
|
||||||
|
|
@ -114,22 +159,28 @@
|
||||||
if (!text) { showToast('No transcript', 'error'); return; }
|
if (!text) { showToast('No transcript', 'error'); return; }
|
||||||
showLoading('Generating HPI...');
|
showLoading('Generating HPI...');
|
||||||
|
|
||||||
fetch('/api/generate-hpi-encounter', {
|
var memoriesPromise = (typeof getUserMemoryContext === 'function') ? getUserMemoryContext() : Promise.resolve('');
|
||||||
method: 'POST',
|
|
||||||
headers: getAuthHeaders(),
|
memoriesPromise.then(function(memCtx) {
|
||||||
body: JSON.stringify({
|
return fetch('/api/generate-hpi-encounter', {
|
||||||
transcript: text,
|
method: 'POST',
|
||||||
patientAge: document.getElementById('enc-age').value,
|
headers: getAuthHeaders(),
|
||||||
patientGender: document.getElementById('enc-gender').value,
|
body: JSON.stringify({
|
||||||
setting: document.getElementById('enc-setting').value,
|
transcript: text,
|
||||||
model: getSelectedModel()
|
patientAge: document.getElementById('enc-age').value,
|
||||||
})
|
patientGender: document.getElementById('enc-gender').value,
|
||||||
|
setting: document.getElementById('enc-setting').value,
|
||||||
|
physicianMemories: memCtx || null,
|
||||||
|
model: getSelectedModel()
|
||||||
|
})
|
||||||
|
});
|
||||||
})
|
})
|
||||||
.then(function(r) { return r.json(); })
|
.then(function(r) { return r.json(); })
|
||||||
.then(function(data) {
|
.then(function(data) {
|
||||||
hideLoading();
|
hideLoading();
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
setOutputText(hpiText, data.hpi);
|
setOutputText(hpiText, data.hpi);
|
||||||
|
if (typeof trackAIOutput === 'function') trackAIOutput('enc-hpi-text', data.hpi);
|
||||||
modelTag.textContent = (data.model || '').split('/').pop();
|
modelTag.textContent = (data.model || '').split('/').pop();
|
||||||
outputCard.classList.remove('hidden');
|
outputCard.classList.remove('hidden');
|
||||||
outputCard.scrollIntoView({ behavior: 'smooth' });
|
outputCard.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
|
|
||||||
|
|
@ -13,9 +13,22 @@
|
||||||
encounter_format: 'Encounter Format',
|
encounter_format: 'Encounter Format',
|
||||||
family_history: 'Family History',
|
family_history: 'Family History',
|
||||||
assessment_plan: 'Assessment & Plan',
|
assessment_plan: 'Assessment & Plan',
|
||||||
|
template_soap: 'SOAP Template',
|
||||||
|
template_hpi: 'HPI Template',
|
||||||
|
template_wellvisit: 'Well Visit Template',
|
||||||
|
template_sickvisit: 'Sick Visit Template',
|
||||||
custom: 'Custom'
|
custom: 'Custom'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Correction categories (hidden from manual editing, managed by correction tracker)
|
||||||
|
var CORRECTION_LABELS = {
|
||||||
|
correction_soap: 'SOAP Correction',
|
||||||
|
correction_hpi: 'HPI Correction',
|
||||||
|
correction_encounter: 'Encounter Correction',
|
||||||
|
correction_wellvisit: 'Well Visit Correction',
|
||||||
|
correction_sickvisit: 'Sick Visit Correction'
|
||||||
|
};
|
||||||
|
|
||||||
// ── Load memories ────────────────────────────────────────────────────────
|
// ── Load memories ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function loadMemories() {
|
function loadMemories() {
|
||||||
|
|
@ -32,11 +45,16 @@
|
||||||
function renderMemoryList() {
|
function renderMemoryList() {
|
||||||
var container = document.getElementById('mem-list');
|
var container = document.getElementById('mem-list');
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
if (_memories.length === 0) {
|
// Separate templates from corrections
|
||||||
|
var templates = _memories.filter(function(m) { return !m.category.startsWith('correction_'); });
|
||||||
|
var corrections = _memories.filter(function(m) { return m.category.startsWith('correction_'); });
|
||||||
|
// Render corrections list
|
||||||
|
renderCorrectionsList(corrections);
|
||||||
|
if (templates.length === 0) {
|
||||||
container.innerHTML = '<p style="color:var(--g400);font-size:13px;">No templates saved yet. Add one above.</p>';
|
container.innerHTML = '<p style="color:var(--g400);font-size:13px;">No templates saved yet. Add one above.</p>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
container.innerHTML = _memories.map(function(m) {
|
container.innerHTML = templates.map(function(m) {
|
||||||
var catLabel = CATEGORY_LABELS[m.category] || m.category;
|
var catLabel = CATEGORY_LABELS[m.category] || m.category;
|
||||||
var preview = (m.content || '').substring(0, 100).replace(/\n/g, ' ');
|
var preview = (m.content || '').substring(0, 100).replace(/\n/g, ' ');
|
||||||
return '<div class="mem-item" data-id="' + m.id + '">' +
|
return '<div class="mem-item" data-id="' + m.id + '">' +
|
||||||
|
|
@ -60,6 +78,75 @@
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderCorrectionsList(corrections) {
|
||||||
|
var container = document.getElementById('corrections-list');
|
||||||
|
if (!container) return;
|
||||||
|
if (corrections.length === 0) {
|
||||||
|
container.innerHTML = '<p style="color:var(--g400);font-size:13px;">No corrections yet. Edit AI-generated notes and save to start learning.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
container.innerHTML = corrections.map(function(m) {
|
||||||
|
var catLabel = CORRECTION_LABELS[m.category] || m.category;
|
||||||
|
var preview = (m.name || '').replace(/\n/g, ' ');
|
||||||
|
// Parse original and corrected from content
|
||||||
|
var parts = parseCorrection(m.content || '');
|
||||||
|
var date = m.created_at ? new Date(m.created_at).toLocaleDateString() : '';
|
||||||
|
return '<div class="mem-item" style="flex-direction:column;align-items:stretch;cursor:pointer;" data-id="' + m.id + '">' +
|
||||||
|
'<div style="display:flex;align-items:center;gap:8px;" class="correction-header" data-toggle="' + m.id + '">' +
|
||||||
|
'<i class="fas fa-chevron-right correction-arrow" id="arrow-' + m.id + '" style="font-size:10px;color:var(--g400);transition:transform 0.2s;"></i>' +
|
||||||
|
'<span class="mem-item-cat">' + esc(catLabel) + '</span>' +
|
||||||
|
'<span style="flex:1;font-size:12px;color:var(--g600);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">' + esc(preview) + '</span>' +
|
||||||
|
'<span style="font-size:11px;color:var(--g400);flex-shrink:0;">' + date + '</span>' +
|
||||||
|
'<button class="btn-sm btn-ghost correction-delete-btn" data-id="' + m.id + '" style="color:var(--red);flex-shrink:0;" title="Delete"><i class="fas fa-trash"></i></button>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="correction-detail hidden" id="detail-' + m.id + '" style="margin-top:8px;padding:8px 12px;background:var(--g50);border-radius:6px;font-size:12px;line-height:1.6;">' +
|
||||||
|
'<div style="margin-bottom:8px;">' +
|
||||||
|
'<div style="font-weight:600;color:var(--red);font-size:11px;text-transform:uppercase;margin-bottom:2px;">Original (AI generated):</div>' +
|
||||||
|
'<div style="color:var(--g600);white-space:pre-wrap;font-family:inherit;">' + esc(parts.original) + '</div>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div>' +
|
||||||
|
'<div style="font-weight:600;color:var(--green);font-size:11px;text-transform:uppercase;margin-bottom:2px;">Corrected to:</div>' +
|
||||||
|
'<div style="color:var(--g800);white-space:pre-wrap;font-family:inherit;">' + esc(parts.corrected) + '</div>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>';
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
// Toggle expand/collapse
|
||||||
|
container.querySelectorAll('.correction-header').forEach(function(header) {
|
||||||
|
header.addEventListener('click', function(e) {
|
||||||
|
if (e.target.closest('.correction-delete-btn')) return;
|
||||||
|
var id = header.dataset.toggle;
|
||||||
|
var detail = document.getElementById('detail-' + id);
|
||||||
|
var arrow = document.getElementById('arrow-' + id);
|
||||||
|
if (detail) {
|
||||||
|
detail.classList.toggle('hidden');
|
||||||
|
if (arrow) arrow.style.transform = detail.classList.contains('hidden') ? '' : 'rotate(90deg)';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
container.querySelectorAll('.correction-delete-btn').forEach(function(btn) {
|
||||||
|
btn.addEventListener('click', function(e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
deleteMemory(parseInt(btn.dataset.id));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCorrection(content) {
|
||||||
|
var original = '';
|
||||||
|
var corrected = '';
|
||||||
|
var idx = content.indexOf('\nCORRECTED TO: ');
|
||||||
|
if (idx !== -1) {
|
||||||
|
original = content.substring(0, idx).replace(/^ORIGINAL:\s*/i, '');
|
||||||
|
corrected = content.substring(idx + '\nCORRECTED TO: '.length);
|
||||||
|
} else {
|
||||||
|
original = content;
|
||||||
|
}
|
||||||
|
return { original: original.trim(), corrected: corrected.trim() };
|
||||||
|
}
|
||||||
|
|
||||||
// ── Save memory ──────────────────────────────────────────────────────────
|
// ── Save memory ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function saveMemory() {
|
function saveMemory() {
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,14 @@
|
||||||
(function() {
|
(function() {
|
||||||
var _inited = false;
|
var _inited = false;
|
||||||
|
var MILESTONES_DATA = {}; // Will be loaded from API
|
||||||
|
|
||||||
document.addEventListener('tabChanged', function(e) {
|
document.addEventListener('tabChanged', function(e) {
|
||||||
if (e.detail.tab !== 'wellvisit' || _inited) return;
|
if (e.detail.tab !== 'wellvisit' || _inited) return;
|
||||||
_inited = true;
|
_inited = true;
|
||||||
|
initMilestones();
|
||||||
|
});
|
||||||
|
|
||||||
|
function initMilestones() {
|
||||||
var ageSelect = document.getElementById('ms-age-group');
|
var ageSelect = document.getElementById('ms-age-group');
|
||||||
var checklist = document.getElementById('milestone-checklist');
|
var checklist = document.getElementById('milestone-checklist');
|
||||||
var actionsBar = document.getElementById('milestone-actions');
|
var actionsBar = document.getElementById('milestone-actions');
|
||||||
|
|
@ -16,6 +22,51 @@
|
||||||
|
|
||||||
var state = {};
|
var state = {};
|
||||||
|
|
||||||
|
// Load milestones from API (database), fallback to static data if empty
|
||||||
|
fetch('/api/milestones-data', {
|
||||||
|
headers: getAuthHeaders()
|
||||||
|
})
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(data) {
|
||||||
|
if (data.success && data.milestones && Object.keys(data.milestones).length > 0) {
|
||||||
|
// Database has milestones - use them
|
||||||
|
MILESTONES_DATA = data.milestones;
|
||||||
|
populateAgeGroups();
|
||||||
|
} else {
|
||||||
|
// Database empty - fallback to static data
|
||||||
|
console.log('[Milestones] Database empty, using static data');
|
||||||
|
if (typeof window.MILESTONES_DATA_STATIC !== 'undefined') {
|
||||||
|
MILESTONES_DATA = window.MILESTONES_DATA_STATIC;
|
||||||
|
populateAgeGroups();
|
||||||
|
} else {
|
||||||
|
showToast('Milestones data not available', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function(err) {
|
||||||
|
console.error('[Milestones] Load error, falling back to static:', err);
|
||||||
|
// Fallback to static data on API error
|
||||||
|
if (typeof window.MILESTONES_DATA_STATIC !== 'undefined') {
|
||||||
|
MILESTONES_DATA = window.MILESTONES_DATA_STATIC;
|
||||||
|
populateAgeGroups();
|
||||||
|
} else {
|
||||||
|
showToast('Failed to load milestones: ' + err.message, 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function populateAgeGroups() {
|
||||||
|
// Populate age group dropdown
|
||||||
|
if (ageSelect && Object.keys(MILESTONES_DATA).length > 0) {
|
||||||
|
ageSelect.innerHTML = '<option value="">Select age group</option>';
|
||||||
|
Object.keys(MILESTONES_DATA).forEach(function(age) {
|
||||||
|
var opt = document.createElement('option');
|
||||||
|
opt.value = age;
|
||||||
|
opt.textContent = age;
|
||||||
|
ageSelect.appendChild(opt);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ageSelect.addEventListener('change', function() {
|
ageSelect.addEventListener('change', function() {
|
||||||
var age = ageSelect.value;
|
var age = ageSelect.value;
|
||||||
state = {};
|
state = {};
|
||||||
|
|
@ -185,5 +236,5 @@
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('✅ Milestones module loaded');
|
console.log('✅ Milestones module loaded');
|
||||||
});
|
} // end initMilestones
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,8 @@
|
||||||
// THIS MODULE IS COMPLETELY SEPARATE FROM HPI
|
// THIS MODULE IS COMPLETELY SEPARATE FROM HPI
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
var MILESTONES_DATA = {
|
// Static fallback data - used when database is empty
|
||||||
|
var MILESTONES_DATA_STATIC = {
|
||||||
|
|
||||||
"Newborn / 1 month": {
|
"Newborn / 1 month": {
|
||||||
"Gross Motor": [
|
"Gross Motor": [
|
||||||
|
|
@ -713,4 +714,6 @@ var DOMAIN_CONFIG = {
|
||||||
"Cognitive": { icon: "🧠", css: "domain-cognitive" }
|
"Cognitive": { icon: "🧠", css: "domain-cognitive" }
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log('✅ Milestones data loaded:', Object.keys(MILESTONES_DATA).length, 'age groups');
|
// Expose as window global for fallback usage
|
||||||
|
window.MILESTONES_DATA_STATIC = MILESTONES_DATA_STATIC;
|
||||||
|
console.log('✅ Static milestones data loaded:', Object.keys(MILESTONES_DATA_STATIC).length, 'age groups');
|
||||||
|
|
|
||||||
|
|
@ -14,26 +14,41 @@
|
||||||
var modelTag = document.getElementById('soap-model-tag');
|
var modelTag = document.getElementById('soap-model-tag');
|
||||||
|
|
||||||
var recorder = new AudioRecorder();
|
var recorder = new AudioRecorder();
|
||||||
|
recorder._module = 'soap';
|
||||||
var timer = createTimer(timerEl);
|
var timer = createTimer(timerEl);
|
||||||
var isRecording = false;
|
var isRecording = false;
|
||||||
var recognition = createSpeechRecognition();
|
var recognition = createSpeechRecognition();
|
||||||
var finalText = '';
|
var finalText = '';
|
||||||
|
var sessionFinals = '';
|
||||||
|
|
||||||
|
function escHtml(s) { return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||||||
|
|
||||||
if (recognition) {
|
if (recognition) {
|
||||||
recognition.onresult = function(e) {
|
recognition.onresult = function(e) {
|
||||||
var interim = '';
|
var interim = '';
|
||||||
for (var i = e.resultIndex; i < e.results.length; i++) {
|
for (var i = e.resultIndex; i < e.results.length; i++) {
|
||||||
if (e.results[i].isFinal) finalText += e.results[i][0].transcript + ' ';
|
if (e.results[i].isFinal) {
|
||||||
else interim = e.results[i][0].transcript;
|
var chunk = e.results[i][0].transcript + ' ';
|
||||||
|
var deduped = deduplicateFinal(chunk, finalText + sessionFinals);
|
||||||
|
sessionFinals += deduped;
|
||||||
|
} else {
|
||||||
|
interim = e.results[i][0].transcript;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
transcript.innerHTML = finalText + (interim ? '<span style="color:#9ca3af;">' + interim + '</span>' : '');
|
var combined = finalText + sessionFinals;
|
||||||
|
transcript.innerHTML = escHtml(combined) + (interim ? '<span style="color:#9ca3af;">' + escHtml(interim) + '</span>' : '');
|
||||||
|
};
|
||||||
|
recognition.onend = function() {
|
||||||
|
finalText += sessionFinals;
|
||||||
|
sessionFinals = '';
|
||||||
|
if (isRecording) try { recognition.start(); } catch(e) {}
|
||||||
};
|
};
|
||||||
recognition.onend = function() { if (isRecording) try { recognition.start(); } catch(e) {} };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
recordBtn.addEventListener('click', function() {
|
recordBtn.addEventListener('click', function() {
|
||||||
if (!isRecording) {
|
if (!isRecording) {
|
||||||
finalText = '';
|
finalText = '';
|
||||||
|
sessionFinals = '';
|
||||||
recorder.start().then(function() {
|
recorder.start().then(function() {
|
||||||
isRecording = true;
|
isRecording = true;
|
||||||
recordBtn.classList.add('recording');
|
recordBtn.classList.add('recording');
|
||||||
|
|
@ -50,19 +65,40 @@
|
||||||
indicator.classList.add('hidden');
|
indicator.classList.add('hidden');
|
||||||
if (recognition) try { recognition.stop(); } catch(e) {}
|
if (recognition) try { recognition.stop(); } catch(e) {}
|
||||||
|
|
||||||
showLoading('Transcribing...');
|
var liveText = (finalText + sessionFinals).trim();
|
||||||
recorder.stop().then(function(blob) {
|
if (window._transcribeAvailable === false) {
|
||||||
if (!blob) { hideLoading(); if (finalText.trim()) transcript.textContent = finalText.trim(); return; }
|
recorder.stop().then(function() {});
|
||||||
return transcribeAudio(blob).then(function(data) {
|
if (liveText) transcript.textContent = liveText;
|
||||||
hideLoading();
|
showToast('Using browser speech recognition', 'info');
|
||||||
if (data.success) transcript.textContent = data.text;
|
} else {
|
||||||
else if (finalText.trim()) transcript.textContent = finalText.trim();
|
showLoading('Transcribing...');
|
||||||
});
|
recorder.stop().then(function(blob) {
|
||||||
}).catch(function() { hideLoading(); if (finalText.trim()) transcript.textContent = finalText.trim(); });
|
if (!blob) { hideLoading(); if (liveText) transcript.textContent = liveText; return; }
|
||||||
|
if (blob.size > 24 * 1024 * 1024) {
|
||||||
|
hideLoading();
|
||||||
|
transcript.textContent = liveText;
|
||||||
|
showToast('Recording too large for AI transcription — using live transcript', 'info');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return transcribeAudio(blob).then(function(data) {
|
||||||
|
hideLoading();
|
||||||
|
if (data.success) transcript.textContent = data.text;
|
||||||
|
else if (data.noProvider) { if (liveText) transcript.textContent = liveText; showToast('Using live transcript', 'info'); }
|
||||||
|
else { if (liveText) transcript.textContent = liveText; showToast(data.error || 'Transcription failed — using live transcript', 'error'); }
|
||||||
|
});
|
||||||
|
}).catch(function(err) { hideLoading(); if (liveText) transcript.textContent = liveText; showToast('Transcription error: ' + (err.message || 'unknown'), 'error'); });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
clearBtn.addEventListener('click', function() { transcript.textContent = ''; finalText = ''; outputCard.classList.add('hidden'); });
|
clearBtn.addEventListener('click', function() {
|
||||||
|
transcript.textContent = ''; finalText = ''; outputCard.classList.add('hidden');
|
||||||
|
var instrEl = document.getElementById('soap-instructions');
|
||||||
|
if (instrEl) instrEl.value = '';
|
||||||
|
window._savedEncId_soap = null;
|
||||||
|
var labelEl = document.getElementById('soap-label');
|
||||||
|
if (labelEl) labelEl.value = '';
|
||||||
|
});
|
||||||
|
|
||||||
generateBtn.addEventListener('click', function() {
|
generateBtn.addEventListener('click', function() {
|
||||||
var text = transcript.innerText.trim();
|
var text = transcript.innerText.trim();
|
||||||
|
|
@ -70,23 +106,29 @@
|
||||||
|
|
||||||
showLoading('Generating SOAP note...');
|
showLoading('Generating SOAP note...');
|
||||||
|
|
||||||
fetch('/api/generate-soap', {
|
var memoriesPromise = (typeof getUserMemoryContext === 'function') ? getUserMemoryContext() : Promise.resolve('');
|
||||||
method: 'POST',
|
|
||||||
headers: getAuthHeaders(),
|
memoriesPromise.then(function(memCtx) {
|
||||||
body: JSON.stringify({
|
return fetch('/api/generate-soap', {
|
||||||
transcript: text,
|
method: 'POST',
|
||||||
patientAge: document.getElementById('soap-age').value,
|
headers: getAuthHeaders(),
|
||||||
patientGender: document.getElementById('soap-gender').value,
|
body: JSON.stringify({
|
||||||
type: document.getElementById('soap-type').value,
|
transcript: text,
|
||||||
additionalInstructions: document.getElementById('soap-instructions').value,
|
patientAge: document.getElementById('soap-age').value,
|
||||||
model: getSelectedModel()
|
patientGender: document.getElementById('soap-gender').value,
|
||||||
})
|
type: document.getElementById('soap-type').value,
|
||||||
|
additionalInstructions: document.getElementById('soap-instructions').value,
|
||||||
|
physicianMemories: memCtx || null,
|
||||||
|
model: getSelectedModel()
|
||||||
|
})
|
||||||
|
});
|
||||||
})
|
})
|
||||||
.then(function(r) { return r.json(); })
|
.then(function(r) { return r.json(); })
|
||||||
.then(function(data) {
|
.then(function(data) {
|
||||||
hideLoading();
|
hideLoading();
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
setOutputText(soapText, data.soap);
|
setOutputText(soapText, data.soap);
|
||||||
|
if (typeof trackAIOutput === 'function') trackAIOutput('soap-text', data.soap);
|
||||||
modelTag.textContent = (data.model || '').split('/').pop();
|
modelTag.textContent = (data.model || '').split('/').pop();
|
||||||
outputCard.classList.remove('hidden');
|
outputCard.classList.remove('hidden');
|
||||||
outputCard.scrollIntoView({ behavior: 'smooth' });
|
outputCard.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
|
@ -99,6 +141,23 @@
|
||||||
document.getElementById('soap-refine-btn').addEventListener('click', function() { refineDocument('soap-text', 'soap-refine-input'); });
|
document.getElementById('soap-refine-btn').addEventListener('click', function() { refineDocument('soap-text', 'soap-refine-input'); });
|
||||||
document.getElementById('soap-shorten-btn').addEventListener('click', function() { shortenDocument('soap-text'); });
|
document.getElementById('soap-shorten-btn').addEventListener('click', function() { shortenDocument('soap-text'); });
|
||||||
|
|
||||||
|
// Register load handler for resuming saved SOAP notes
|
||||||
|
if (typeof registerEncounterLoadHandler === 'function') {
|
||||||
|
registerEncounterLoadHandler('soap', function(enc) {
|
||||||
|
if (enc.transcript) transcript.textContent = enc.transcript;
|
||||||
|
if (enc.generated_note) {
|
||||||
|
setOutputText(soapText, enc.generated_note);
|
||||||
|
outputCard.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
var pd = JSON.parse(enc.partial_data || '{}');
|
||||||
|
if (pd.age) document.getElementById('soap-age').value = pd.age;
|
||||||
|
if (pd.gender) document.getElementById('soap-gender').value = pd.gender;
|
||||||
|
if (pd.type) document.getElementById('soap-type').value = pd.type;
|
||||||
|
} catch(e) {}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
console.log('✅ SOAP module loaded');
|
console.log('✅ SOAP module loaded');
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
160
public/js/speechRecognition.js
Normal file
160
public/js/speechRecognition.js
Normal file
|
|
@ -0,0 +1,160 @@
|
||||||
|
// ============================================================
|
||||||
|
// WEB SPEECH RECOGNITION — Real-time streaming transcription
|
||||||
|
// ⚠️ PRIVACY WARNING: Uses browser's built-in ASR which may
|
||||||
|
// send audio to cloud servers (Chrome/Edge → Google servers)
|
||||||
|
// Only use if you accept this trade-off for real-time streaming
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
(function() {
|
||||||
|
|
||||||
|
var STORAGE_ENABLED = 'ped_web_speech_enabled';
|
||||||
|
|
||||||
|
var SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
|
||||||
|
var _recognition = null;
|
||||||
|
var _transcript = '';
|
||||||
|
var _isListening = false;
|
||||||
|
var _onPartialCallback = null;
|
||||||
|
var _onFinalCallback = null;
|
||||||
|
|
||||||
|
window.WebSpeechRecognition = {
|
||||||
|
|
||||||
|
isSupported: function() {
|
||||||
|
return typeof SpeechRecognition !== 'undefined';
|
||||||
|
},
|
||||||
|
|
||||||
|
isEnabled: function() {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(STORAGE_ENABLED) === '1';
|
||||||
|
} catch(e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
setEnabled: function(val) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_ENABLED, val ? '1' : '0');
|
||||||
|
} catch(e) {}
|
||||||
|
},
|
||||||
|
|
||||||
|
startListening: function(options) {
|
||||||
|
if (!this.isSupported()) {
|
||||||
|
return Promise.reject(new Error('Web Speech API not supported'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.isEnabled()) {
|
||||||
|
return Promise.reject(new Error('Web Speech Recognition not enabled'));
|
||||||
|
}
|
||||||
|
|
||||||
|
_transcript = '';
|
||||||
|
_isListening = true;
|
||||||
|
|
||||||
|
var opts = options || {};
|
||||||
|
_onPartialCallback = opts.onPartial || function() {};
|
||||||
|
_onFinalCallback = opts.onFinal || function() {};
|
||||||
|
|
||||||
|
_recognition = new SpeechRecognition();
|
||||||
|
_recognition.continuous = true; // Keep listening
|
||||||
|
_recognition.interimResults = true; // Show partial results
|
||||||
|
_recognition.lang = opts.language || 'en-US';
|
||||||
|
_recognition.maxAlternatives = 1;
|
||||||
|
|
||||||
|
return new Promise(function(resolve, reject) {
|
||||||
|
_recognition.onstart = function() {
|
||||||
|
console.log('[WebSpeech] Started listening');
|
||||||
|
};
|
||||||
|
|
||||||
|
_recognition.onresult = function(event) {
|
||||||
|
var interim = '';
|
||||||
|
var final = '';
|
||||||
|
|
||||||
|
for (var i = event.resultIndex; i < event.results.length; i++) {
|
||||||
|
var transcript = event.results[i][0].transcript;
|
||||||
|
|
||||||
|
if (event.results[i].isFinal) {
|
||||||
|
final += transcript + ' ';
|
||||||
|
_transcript += transcript + ' ';
|
||||||
|
} else {
|
||||||
|
interim += transcript;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Callback with partial results (shown in real-time)
|
||||||
|
if (interim && _onPartialCallback) {
|
||||||
|
_onPartialCallback(interim);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Callback with final results (confirmed words)
|
||||||
|
if (final && _onFinalCallback) {
|
||||||
|
_onFinalCallback(final.trim());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
_recognition.onerror = function(event) {
|
||||||
|
console.error('[WebSpeech] Error:', event.error);
|
||||||
|
_isListening = false;
|
||||||
|
|
||||||
|
if (event.error === 'no-speech') {
|
||||||
|
reject(new Error('No speech detected'));
|
||||||
|
} else if (event.error === 'not-allowed') {
|
||||||
|
reject(new Error('Microphone permission denied'));
|
||||||
|
} else {
|
||||||
|
reject(new Error('Speech recognition error: ' + event.error));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
_recognition.onend = function() {
|
||||||
|
_isListening = false;
|
||||||
|
console.log('[WebSpeech] Stopped listening');
|
||||||
|
resolve(_transcript.trim());
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
_recognition.start();
|
||||||
|
} catch (e) {
|
||||||
|
reject(new Error('Failed to start recognition: ' + e.message));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
stopListening: function() {
|
||||||
|
if (_recognition && _isListening) {
|
||||||
|
_recognition.stop();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
isListening: function() {
|
||||||
|
return _isListening;
|
||||||
|
},
|
||||||
|
|
||||||
|
getCurrentTranscript: function() {
|
||||||
|
return _transcript.trim();
|
||||||
|
},
|
||||||
|
|
||||||
|
// Check if browser likely sends to cloud
|
||||||
|
getPrivacyInfo: function() {
|
||||||
|
var isChrome = /Chrome/.test(navigator.userAgent) && /Google Inc/.test(navigator.vendor);
|
||||||
|
var isEdge = /Edg/.test(navigator.userAgent);
|
||||||
|
|
||||||
|
if (isChrome || isEdge) {
|
||||||
|
return {
|
||||||
|
provider: 'Google Cloud Speech',
|
||||||
|
privacy: 'Audio sent to Google servers',
|
||||||
|
warning: 'NOT HIPAA-compliant'
|
||||||
|
};
|
||||||
|
} else if (/Safari/.test(navigator.userAgent) && !/Chrome/.test(navigator.userAgent)) {
|
||||||
|
return {
|
||||||
|
provider: 'Apple Speech Recognition',
|
||||||
|
privacy: 'May process on-device or Apple servers',
|
||||||
|
warning: 'Check Apple privacy policy'
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
provider: 'Unknown',
|
||||||
|
privacy: 'May send audio to cloud servers',
|
||||||
|
warning: 'Privacy unknown'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
})();
|
||||||
166
public/js/transcriptionSettings.js
Normal file
166
public/js/transcriptionSettings.js
Normal file
|
|
@ -0,0 +1,166 @@
|
||||||
|
// ============================================================
|
||||||
|
// TRANSCRIPTION SETTINGS — Browser Whisper + Web Speech API
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
(function() {
|
||||||
|
var _inited = false;
|
||||||
|
|
||||||
|
document.addEventListener('tabChanged', function(e) {
|
||||||
|
if (e.detail.tab !== 'settings' || _inited) return;
|
||||||
|
_inited = true;
|
||||||
|
initTranscriptionSettings();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Also init on direct page load
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
setTimeout(function() {
|
||||||
|
if (!_inited && document.getElementById('browser-whisper-enabled')) {
|
||||||
|
_inited = true;
|
||||||
|
initTranscriptionSettings();
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setTimeout(function() {
|
||||||
|
if (!_inited && document.getElementById('browser-whisper-enabled')) {
|
||||||
|
_inited = true;
|
||||||
|
initTranscriptionSettings();
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
function initTranscriptionSettings() {
|
||||||
|
console.log('[TranscriptionSettings] Initializing...');
|
||||||
|
|
||||||
|
// ── Browser Whisper ──────────────────────────────────────
|
||||||
|
var whisperCheckbox = document.getElementById('browser-whisper-enabled');
|
||||||
|
var whisperStatus = document.getElementById('browser-whisper-status');
|
||||||
|
var whisperModel = document.getElementById('browser-whisper-model');
|
||||||
|
var whisperPreload = document.getElementById('btn-whisper-preload');
|
||||||
|
var whisperProgress = document.getElementById('browser-whisper-progress');
|
||||||
|
var whisperProgressText = document.getElementById('browser-whisper-progress-text');
|
||||||
|
|
||||||
|
if (whisperCheckbox && window.BrowserWhisper) {
|
||||||
|
whisperCheckbox.checked = BrowserWhisper.isEnabled();
|
||||||
|
whisperStatus.textContent = BrowserWhisper.isEnabled() ? 'On — audio stays on device' : 'Off';
|
||||||
|
|
||||||
|
if (whisperModel) {
|
||||||
|
whisperModel.value = BrowserWhisper.getModel();
|
||||||
|
}
|
||||||
|
|
||||||
|
whisperCheckbox.addEventListener('change', function() {
|
||||||
|
BrowserWhisper.setEnabled(whisperCheckbox.checked);
|
||||||
|
whisperStatus.textContent = whisperCheckbox.checked ? 'On — audio stays on device' : 'Off';
|
||||||
|
|
||||||
|
if (whisperCheckbox.checked) {
|
||||||
|
// Auto-preload on enable
|
||||||
|
BrowserWhisper.preload(function(file, pct) {
|
||||||
|
if (whisperProgress && whisperProgressText) {
|
||||||
|
if (pct >= 100) {
|
||||||
|
whisperProgress.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
whisperProgress.style.display = 'block';
|
||||||
|
whisperProgressText.textContent = file + (pct > 0 ? ' ' + pct + '%' : '');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (whisperModel) {
|
||||||
|
whisperModel.addEventListener('change', function() {
|
||||||
|
BrowserWhisper.setModel(whisperModel.value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (whisperPreload) {
|
||||||
|
whisperPreload.addEventListener('click', function() {
|
||||||
|
if (!BrowserWhisper || !BrowserWhisper.isSupported()) {
|
||||||
|
showToast('Browser Whisper not supported', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
whisperPreload.disabled = true;
|
||||||
|
whisperPreload.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Downloading...';
|
||||||
|
whisperProgress.style.display = 'block';
|
||||||
|
whisperProgressText.textContent = 'Initializing...';
|
||||||
|
|
||||||
|
BrowserWhisper.setEnabled(true);
|
||||||
|
whisperCheckbox.checked = true;
|
||||||
|
whisperStatus.textContent = 'On — audio stays on device';
|
||||||
|
|
||||||
|
BrowserWhisper.preload(function(file, pct) {
|
||||||
|
if (pct >= 100) {
|
||||||
|
whisperProgress.style.display = 'none';
|
||||||
|
whisperPreload.disabled = false;
|
||||||
|
whisperPreload.innerHTML = '<i class="fas fa-download"></i> Pre-download model';
|
||||||
|
showToast('Whisper model ready!', 'success');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
whisperProgress.style.display = 'block';
|
||||||
|
whisperProgressText.textContent = file + (pct > 0 ? ' ' + pct + '%' : '');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Web Speech Recognition ───────────────────────────────
|
||||||
|
var speechCheckbox = document.getElementById('web-speech-enabled');
|
||||||
|
var speechStatus = document.getElementById('web-speech-status');
|
||||||
|
var speechBrowserInfo = document.getElementById('web-speech-browser-info');
|
||||||
|
|
||||||
|
if (speechCheckbox && window.WebSpeechRecognition) {
|
||||||
|
speechCheckbox.checked = WebSpeechRecognition.isEnabled();
|
||||||
|
speechStatus.textContent = WebSpeechRecognition.isEnabled() ? 'On — real-time streaming' : 'Off';
|
||||||
|
|
||||||
|
// Show privacy info
|
||||||
|
if (speechBrowserInfo) {
|
||||||
|
var privacyInfo = WebSpeechRecognition.getPrivacyInfo();
|
||||||
|
speechBrowserInfo.innerHTML =
|
||||||
|
'<strong>Provider:</strong> ' + privacyInfo.provider + '<br>' +
|
||||||
|
'<strong>Privacy:</strong> ' + privacyInfo.privacy + '<br>' +
|
||||||
|
'<strong style="color:var(--orange);">⚠️ ' + privacyInfo.warning + '</strong>';
|
||||||
|
}
|
||||||
|
|
||||||
|
speechCheckbox.addEventListener('change', function() {
|
||||||
|
if (speechCheckbox.checked) {
|
||||||
|
// Show confirmation for privacy warning
|
||||||
|
if (!confirm(
|
||||||
|
'WARNING: Real-time streaming uses your browser\'s speech recognition, ' +
|
||||||
|
'which may send audio to cloud servers (e.g., Google).\n\n' +
|
||||||
|
'This is NOT HIPAA-compliant.\n\n' +
|
||||||
|
'Only enable if you understand and accept this privacy trade-off.\n\n' +
|
||||||
|
'Continue?'
|
||||||
|
)) {
|
||||||
|
speechCheckbox.checked = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disable Browser Whisper if enabling Web Speech
|
||||||
|
if (whisperCheckbox && whisperCheckbox.checked) {
|
||||||
|
whisperCheckbox.checked = false;
|
||||||
|
BrowserWhisper.setEnabled(false);
|
||||||
|
whisperStatus.textContent = 'Off';
|
||||||
|
showToast('Browser Whisper disabled (Web Speech takes priority)', 'info');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
WebSpeechRecognition.setEnabled(speechCheckbox.checked);
|
||||||
|
speechStatus.textContent = speechCheckbox.checked ? 'On — real-time streaming' : 'Off';
|
||||||
|
});
|
||||||
|
|
||||||
|
// If not supported, disable and show message
|
||||||
|
if (!WebSpeechRecognition.isSupported()) {
|
||||||
|
speechCheckbox.disabled = true;
|
||||||
|
speechStatus.textContent = 'Not supported in this browser';
|
||||||
|
if (speechBrowserInfo) {
|
||||||
|
speechBrowserInfo.innerHTML = '<strong style="color:var(--red);">Not supported</strong> - Web Speech API not available in this browser.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('✅ Transcription settings initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
})();
|
||||||
|
|
@ -15,27 +15,42 @@
|
||||||
var modelTag = document.getElementById('dict-model-tag');
|
var modelTag = document.getElementById('dict-model-tag');
|
||||||
|
|
||||||
var recorder = new AudioRecorder();
|
var recorder = new AudioRecorder();
|
||||||
|
recorder._module = 'dictation';
|
||||||
var timer = createTimer(timerEl);
|
var timer = createTimer(timerEl);
|
||||||
var isRecording = false;
|
var isRecording = false;
|
||||||
var isPaused = false;
|
var isPaused = false;
|
||||||
var recognition = createSpeechRecognition();
|
var recognition = createSpeechRecognition();
|
||||||
var finalText = '';
|
var finalText = '';
|
||||||
|
var sessionFinals = '';
|
||||||
|
|
||||||
|
function escHtml(s) { return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||||||
|
|
||||||
if (recognition) {
|
if (recognition) {
|
||||||
recognition.onresult = function(e) {
|
recognition.onresult = function(e) {
|
||||||
var interim = '';
|
var interim = '';
|
||||||
for (var i = e.resultIndex; i < e.results.length; i++) {
|
for (var i = e.resultIndex; i < e.results.length; i++) {
|
||||||
if (e.results[i].isFinal) finalText += e.results[i][0].transcript + ' ';
|
if (e.results[i].isFinal) {
|
||||||
else interim = e.results[i][0].transcript;
|
var chunk = e.results[i][0].transcript + ' ';
|
||||||
|
var deduped = deduplicateFinal(chunk, finalText + sessionFinals);
|
||||||
|
sessionFinals += deduped;
|
||||||
|
} else {
|
||||||
|
interim = e.results[i][0].transcript;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
transcript.innerHTML = finalText + (interim ? '<span style="color:#9ca3af;">' + interim + '</span>' : '');
|
var combined = finalText + sessionFinals;
|
||||||
|
transcript.innerHTML = escHtml(combined) + (interim ? '<span style="color:#9ca3af;">' + escHtml(interim) + '</span>' : '');
|
||||||
|
};
|
||||||
|
recognition.onend = function() {
|
||||||
|
finalText += sessionFinals;
|
||||||
|
sessionFinals = '';
|
||||||
|
if (isRecording && !isPaused) try { recognition.start(); } catch(e) {}
|
||||||
};
|
};
|
||||||
recognition.onend = function() { if (isRecording && !isPaused) try { recognition.start(); } catch(e) {} };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
recordBtn.addEventListener('click', function() {
|
recordBtn.addEventListener('click', function() {
|
||||||
if (!isRecording) {
|
if (!isRecording) {
|
||||||
finalText = '';
|
finalText = '';
|
||||||
|
sessionFinals = '';
|
||||||
recorder.start().then(function() {
|
recorder.start().then(function() {
|
||||||
isRecording = true;
|
isRecording = true;
|
||||||
isPaused = false;
|
isPaused = false;
|
||||||
|
|
@ -58,15 +73,29 @@
|
||||||
if (recognition) try { recognition.stop(); } catch(e) {}
|
if (recognition) try { recognition.stop(); } catch(e) {}
|
||||||
document.dispatchEvent(new CustomEvent('recording-stopped', { detail: { module: 'dict' } }));
|
document.dispatchEvent(new CustomEvent('recording-stopped', { detail: { module: 'dict' } }));
|
||||||
|
|
||||||
showLoading('Transcribing...');
|
var liveText = (finalText + sessionFinals).trim();
|
||||||
recorder.stop().then(function(blob) {
|
if (window._transcribeAvailable === false) {
|
||||||
if (!blob || blob.size === 0) { hideLoading(); if (finalText.trim()) transcript.textContent = finalText.trim(); return; }
|
recorder.stop().then(function() {});
|
||||||
return transcribeAudio(blob).then(function(data) {
|
if (liveText) transcript.textContent = liveText;
|
||||||
hideLoading();
|
showToast('Using browser speech recognition (no transcription API configured)', 'info');
|
||||||
if (data.success) { transcript.textContent = data.text; showToast('Transcribed ' + dur + 's', 'success'); }
|
} else {
|
||||||
else { if (finalText.trim()) transcript.textContent = finalText.trim(); }
|
showLoading('Transcribing...');
|
||||||
});
|
recorder.stop().then(function(blob) {
|
||||||
}).catch(function() { hideLoading(); if (finalText.trim()) transcript.textContent = finalText.trim(); });
|
if (!blob || blob.size === 0) { hideLoading(); if (liveText) transcript.textContent = liveText; return; }
|
||||||
|
if (blob.size > 24 * 1024 * 1024) {
|
||||||
|
hideLoading();
|
||||||
|
transcript.textContent = liveText;
|
||||||
|
showToast('Recording too large for AI transcription — using live transcript', 'info');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return transcribeAudio(blob).then(function(data) {
|
||||||
|
hideLoading();
|
||||||
|
if (data.success) { transcript.textContent = data.text; showToast('Transcribed ' + dur + 's', 'success'); }
|
||||||
|
else if (data.noProvider) { if (liveText) transcript.textContent = liveText; showToast('Using live transcript', 'info'); }
|
||||||
|
else { if (liveText) transcript.textContent = liveText; showToast(data.error || 'Transcription failed — using live transcript', 'error'); }
|
||||||
|
});
|
||||||
|
}).catch(function(err) { hideLoading(); if (liveText) transcript.textContent = liveText; showToast('Transcription error: ' + (err.message || 'unknown'), 'error'); });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -105,6 +134,8 @@
|
||||||
window._savedEncId_dictation = null;
|
window._savedEncId_dictation = null;
|
||||||
var labelEl = document.getElementById('dict-label');
|
var labelEl = document.getElementById('dict-label');
|
||||||
if (labelEl) labelEl.value = '';
|
if (labelEl) labelEl.value = '';
|
||||||
|
var refineEl = document.getElementById('dict-refine-input');
|
||||||
|
if (refineEl) refineEl.value = '';
|
||||||
});
|
});
|
||||||
|
|
||||||
generateBtn.addEventListener('click', function() {
|
generateBtn.addEventListener('click', function() {
|
||||||
|
|
@ -114,34 +145,40 @@
|
||||||
var outputType = document.getElementById('dict-output-type').value;
|
var outputType = document.getElementById('dict-output-type').value;
|
||||||
var endpoint, bodyData;
|
var endpoint, bodyData;
|
||||||
|
|
||||||
if (outputType === 'soap-full' || outputType === 'soap-subjective') {
|
|
||||||
endpoint = '/api/generate-soap';
|
|
||||||
bodyData = {
|
|
||||||
transcript: text,
|
|
||||||
patientAge: document.getElementById('dict-age').value,
|
|
||||||
patientGender: document.getElementById('dict-gender').value,
|
|
||||||
type: outputType === 'soap-full' ? 'full' : 'subjective',
|
|
||||||
model: getSelectedModel()
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
endpoint = '/api/generate-hpi-dictation';
|
|
||||||
bodyData = {
|
|
||||||
transcript: text,
|
|
||||||
patientAge: document.getElementById('dict-age').value,
|
|
||||||
patientGender: document.getElementById('dict-gender').value,
|
|
||||||
setting: document.getElementById('dict-setting').value,
|
|
||||||
model: getSelectedModel()
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
showLoading('Generating...');
|
showLoading('Generating...');
|
||||||
|
|
||||||
fetch(endpoint, { method: 'POST', headers: getAuthHeaders(), body: JSON.stringify(bodyData) })
|
var memoriesPromise = (typeof getUserMemoryContext === 'function') ? getUserMemoryContext() : Promise.resolve('');
|
||||||
|
|
||||||
|
memoriesPromise.then(function(memCtx) {
|
||||||
|
if (outputType === 'soap-full' || outputType === 'soap-subjective') {
|
||||||
|
endpoint = '/api/generate-soap';
|
||||||
|
bodyData = {
|
||||||
|
transcript: text,
|
||||||
|
patientAge: document.getElementById('dict-age').value,
|
||||||
|
patientGender: document.getElementById('dict-gender').value,
|
||||||
|
type: outputType === 'soap-full' ? 'full' : 'subjective',
|
||||||
|
physicianMemories: memCtx || null,
|
||||||
|
model: getSelectedModel()
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
endpoint = '/api/generate-hpi-dictation';
|
||||||
|
bodyData = {
|
||||||
|
transcript: text,
|
||||||
|
patientAge: document.getElementById('dict-age').value,
|
||||||
|
patientGender: document.getElementById('dict-gender').value,
|
||||||
|
setting: document.getElementById('dict-setting').value,
|
||||||
|
physicianMemories: memCtx || null,
|
||||||
|
model: getSelectedModel()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return fetch(endpoint, { method: 'POST', headers: getAuthHeaders(), body: JSON.stringify(bodyData) });
|
||||||
|
})
|
||||||
.then(function(r) { return r.json(); })
|
.then(function(r) { return r.json(); })
|
||||||
.then(function(data) {
|
.then(function(data) {
|
||||||
hideLoading();
|
hideLoading();
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
setOutputText(hpiText, data.hpi || data.soap);
|
setOutputText(hpiText, data.hpi || data.soap);
|
||||||
|
if (typeof trackAIOutput === 'function') trackAIOutput('dict-hpi-text', data.hpi || data.soap);
|
||||||
modelTag.textContent = (data.model || '').split('/').pop();
|
modelTag.textContent = (data.model || '').split('/').pop();
|
||||||
outputCard.classList.remove('hidden');
|
outputCard.classList.remove('hidden');
|
||||||
outputCard.scrollIntoView({ behavior: 'smooth' });
|
outputCard.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
|
|
||||||
209
public/js/voicePreferences.js
Normal file
209
public/js/voicePreferences.js
Normal file
|
|
@ -0,0 +1,209 @@
|
||||||
|
// ============================================================
|
||||||
|
// VOICE PREFERENCES — STT Model & TTS Voice selection
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
(function() {
|
||||||
|
var _inited = false;
|
||||||
|
|
||||||
|
// Listen for tab changes (correct event name is 'tabChanged' not 'tab-loaded')
|
||||||
|
document.addEventListener('tabChanged', function(e) {
|
||||||
|
if (e.detail.tab !== 'settings' || _inited) return;
|
||||||
|
_inited = true;
|
||||||
|
console.log('[VoicePrefs] Initializing on settings tab load...');
|
||||||
|
// Small delay to ensure DOM is ready
|
||||||
|
setTimeout(function() {
|
||||||
|
initVoicePreferences();
|
||||||
|
}, 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Also init on DOMContentLoaded as backup for direct page load
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
setTimeout(function() {
|
||||||
|
if (!_inited && document.getElementById('btn-preview-voice')) {
|
||||||
|
console.log('[VoicePrefs] Init via DOMContentLoaded (direct settings page load)');
|
||||||
|
_inited = true;
|
||||||
|
initVoicePreferences();
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Page already loaded, init immediately if on settings
|
||||||
|
setTimeout(function() {
|
||||||
|
if (!_inited && document.getElementById('btn-preview-voice')) {
|
||||||
|
console.log('[VoicePrefs] Init immediate (page already loaded)');
|
||||||
|
_inited = true;
|
||||||
|
initVoicePreferences();
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
function initVoicePreferences() {
|
||||||
|
console.log('[VoicePrefs] initVoicePreferences called');
|
||||||
|
|
||||||
|
loadVoiceOptions();
|
||||||
|
loadUserPreferences();
|
||||||
|
|
||||||
|
// Save button
|
||||||
|
var btnSave = document.getElementById('btn-save-voice-prefs');
|
||||||
|
if (btnSave) {
|
||||||
|
console.log('[VoicePrefs] Save button found, adding listener');
|
||||||
|
btnSave.addEventListener('click', saveVoicePreferences);
|
||||||
|
} else {
|
||||||
|
console.warn('[VoicePrefs] Save button NOT found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preview button
|
||||||
|
var btnPreview = document.getElementById('btn-preview-voice');
|
||||||
|
if (btnPreview) {
|
||||||
|
console.log('[VoicePrefs] Preview button found, adding listener');
|
||||||
|
btnPreview.addEventListener('click', function(e) {
|
||||||
|
console.log('[VoicePrefs] Preview button clicked!');
|
||||||
|
e.preventDefault();
|
||||||
|
previewVoice();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.warn('[VoicePrefs] Preview button NOT found');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadVoiceOptions() {
|
||||||
|
fetch('/api/user/preferences/options', {
|
||||||
|
headers: getAuthHeaders()
|
||||||
|
})
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(data) {
|
||||||
|
if (!data.success) return;
|
||||||
|
|
||||||
|
// Populate STT models
|
||||||
|
var sttSelect = document.getElementById('stt-model-select');
|
||||||
|
if (sttSelect && data.sttModels && data.sttModels.length > 0) {
|
||||||
|
sttSelect.innerHTML = '<option value="">Server default (' + data.sttProvider + ')</option>';
|
||||||
|
data.sttModels.forEach(function(model) {
|
||||||
|
var opt = document.createElement('option');
|
||||||
|
opt.value = model.value;
|
||||||
|
opt.textContent = model.label;
|
||||||
|
sttSelect.appendChild(opt);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Populate TTS voices
|
||||||
|
var ttsSelect = document.getElementById('tts-voice-select');
|
||||||
|
if (ttsSelect && data.ttsVoices && data.ttsVoices.length > 0) {
|
||||||
|
ttsSelect.innerHTML = '<option value="">Server default (' + data.ttsProvider + ')</option>';
|
||||||
|
data.ttsVoices.forEach(function(voice) {
|
||||||
|
var opt = document.createElement('option');
|
||||||
|
opt.value = voice.value;
|
||||||
|
opt.textContent = voice.label;
|
||||||
|
ttsSelect.appendChild(opt);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function(err) {
|
||||||
|
console.error('[VoicePrefs] Failed to load options:', err.message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadUserPreferences() {
|
||||||
|
fetch('/api/user/preferences', {
|
||||||
|
headers: getAuthHeaders()
|
||||||
|
})
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(data) {
|
||||||
|
if (!data.success) return;
|
||||||
|
|
||||||
|
var sttSelect = document.getElementById('stt-model-select');
|
||||||
|
if (sttSelect && data.stt_model) {
|
||||||
|
sttSelect.value = data.stt_model;
|
||||||
|
}
|
||||||
|
|
||||||
|
var ttsSelect = document.getElementById('tts-voice-select');
|
||||||
|
if (ttsSelect && data.tts_voice) {
|
||||||
|
ttsSelect.value = data.tts_voice;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function(err) {
|
||||||
|
console.error('[VoicePrefs] Failed to load preferences:', err.message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveVoicePreferences() {
|
||||||
|
var sttSelect = document.getElementById('stt-model-select');
|
||||||
|
var ttsSelect = document.getElementById('tts-voice-select');
|
||||||
|
|
||||||
|
var sttModel = sttSelect ? sttSelect.value : null;
|
||||||
|
var ttsVoice = ttsSelect ? ttsSelect.value : null;
|
||||||
|
|
||||||
|
fetch('/api/user/preferences', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
body: JSON.stringify({
|
||||||
|
stt_model: sttModel || null,
|
||||||
|
tts_voice: ttsVoice || null
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(data) {
|
||||||
|
if (data.success) {
|
||||||
|
showToast('Voice preferences saved!', 'success');
|
||||||
|
} else {
|
||||||
|
showToast(data.error || 'Failed to save', 'error');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function(err) {
|
||||||
|
showToast('Save failed: ' + err.message, 'error');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function previewVoice() {
|
||||||
|
var ttsSelect = document.getElementById('tts-voice-select');
|
||||||
|
var voice = ttsSelect ? ttsSelect.value : null;
|
||||||
|
|
||||||
|
// Allow "Server default" (empty value) to preview
|
||||||
|
var displayVoice = voice || 'server default';
|
||||||
|
var text = 'Hello, this is a preview of the ' + displayVoice + ' voice. This is how your read-aloud feature will sound.';
|
||||||
|
|
||||||
|
var btnPreview = document.getElementById('btn-preview-voice');
|
||||||
|
if (btnPreview) {
|
||||||
|
btnPreview.disabled = true;
|
||||||
|
btnPreview.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Loading...';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save current preference temporarily (or clear it if "server default" selected)
|
||||||
|
fetch('/api/user/preferences', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
body: JSON.stringify({ tts_voice: voice || null })
|
||||||
|
})
|
||||||
|
.then(function() {
|
||||||
|
// Generate audio with new voice
|
||||||
|
return fetch('/api/text-to-speech', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
body: JSON.stringify({ text: text })
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.then(function(r) {
|
||||||
|
if (!r.ok) throw new Error('Preview failed');
|
||||||
|
return r.blob();
|
||||||
|
})
|
||||||
|
.then(function(blob) {
|
||||||
|
var url = URL.createObjectURL(blob);
|
||||||
|
var audio = new Audio(url);
|
||||||
|
audio.onended = function() { URL.revokeObjectURL(url); };
|
||||||
|
audio.play();
|
||||||
|
showToast('Preview: ' + voice, 'success');
|
||||||
|
})
|
||||||
|
.catch(function(err) {
|
||||||
|
console.error('[VoicePrefs] Preview error:', err);
|
||||||
|
showToast('Preview failed: ' + err.message, 'error');
|
||||||
|
})
|
||||||
|
.finally(function() {
|
||||||
|
if (btnPreview) {
|
||||||
|
btnPreview.disabled = false;
|
||||||
|
btnPreview.innerHTML = '<i class="fas fa-play"></i> Preview';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
})();
|
||||||
88
public/js/whisperWorker.js
Normal file
88
public/js/whisperWorker.js
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
// ============================================================
|
||||||
|
// WHISPER WORKER — runs @xenova/transformers in a Web Worker
|
||||||
|
// Audio never leaves the device. Model cached in IndexedDB.
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
console.log('[WhisperWorker] Starting worker initialization');
|
||||||
|
|
||||||
|
// Load transformers.js from our server (bundled, no external CDN dependency)
|
||||||
|
importScripts('/models/transformers.min.js');
|
||||||
|
|
||||||
|
var env = self.transformers.env;
|
||||||
|
var pipeline = self.transformers.pipeline;
|
||||||
|
|
||||||
|
// Configure transformers.js to load models from our local server
|
||||||
|
env.allowLocalModels = false;
|
||||||
|
env.useBrowserCache = true;
|
||||||
|
env.allowRemoteModels = false;
|
||||||
|
env.localModelPath = '/models/';
|
||||||
|
|
||||||
|
console.log('[WhisperWorker] Transformers loaded, models path: /models/');
|
||||||
|
|
||||||
|
var _pipe = null;
|
||||||
|
var _loadedModel = null;
|
||||||
|
|
||||||
|
async function load(modelName) {
|
||||||
|
if (_pipe && _loadedModel === modelName) {
|
||||||
|
console.log('[WhisperWorker] Model already loaded:', modelName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log('[WhisperWorker] Loading model:', modelName);
|
||||||
|
self.postMessage({ type: 'loading', model: modelName });
|
||||||
|
|
||||||
|
try {
|
||||||
|
_pipe = await pipeline('automatic-speech-recognition', modelName, {
|
||||||
|
progress_callback: function(p) {
|
||||||
|
console.log('[WhisperWorker] Progress:', p.status, p.file, p.progress);
|
||||||
|
if (p.status === 'downloading' || p.status === 'progress') {
|
||||||
|
self.postMessage({ type: 'progress', file: p.file || '', progress: Math.round(p.progress || 0) });
|
||||||
|
}
|
||||||
|
if (p.status === 'done' || p.status === 'ready') {
|
||||||
|
self.postMessage({ type: 'progress', file: p.file || '', progress: 100 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
_loadedModel = modelName;
|
||||||
|
console.log('[WhisperWorker] Model loaded successfully');
|
||||||
|
self.postMessage({ type: 'ready', model: modelName });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[WhisperWorker] Load error:', err);
|
||||||
|
self.postMessage({ type: 'error', message: 'Model load failed: ' + err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.addEventListener('message', async function(e) {
|
||||||
|
var d = e.data;
|
||||||
|
console.log('[WhisperWorker] Message received:', d.type);
|
||||||
|
|
||||||
|
if (d.type === 'load') {
|
||||||
|
try {
|
||||||
|
await load(d.model || 'Xenova/whisper-tiny.en');
|
||||||
|
} catch(err) {
|
||||||
|
console.error('[WhisperWorker] Load error:', err);
|
||||||
|
self.postMessage({ type: 'error', message: 'Load failed: ' + err.message });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (d.type === 'transcribe') {
|
||||||
|
try {
|
||||||
|
await load(d.model || 'Xenova/whisper-tiny.en');
|
||||||
|
console.log('[WhisperWorker] Starting transcription...');
|
||||||
|
var result = await _pipe(d.audio, {
|
||||||
|
language: 'english',
|
||||||
|
task: 'transcribe',
|
||||||
|
chunk_length_s: 30,
|
||||||
|
stride_length_s: 5,
|
||||||
|
return_timestamps: false
|
||||||
|
});
|
||||||
|
console.log('[WhisperWorker] Transcription complete');
|
||||||
|
self.postMessage({ type: 'result', text: result.text.trim() });
|
||||||
|
} catch(err) {
|
||||||
|
console.error('[WhisperWorker] Transcribe error:', err);
|
||||||
|
self.postMessage({ type: 'error', message: 'Transcription failed: ' + err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[WhisperWorker] Worker initialized');
|
||||||
135
public/js/whisperWorkerV2.js
Normal file
135
public/js/whisperWorkerV2.js
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
// ============================================================
|
||||||
|
// WHISPER WORKER V2 — Alternative loading using dynamic import
|
||||||
|
// Loads transformers.js via script tag method instead of importScripts
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
console.log('[WhisperWorker] V2 Starting initialization');
|
||||||
|
|
||||||
|
// Alternative loading method that works with CSP
|
||||||
|
var TRANSFORMERS_URL = 'https://cdn.jsdelivr.net/npm/@xenova/transformers@2.17.2/dist/transformers.min.js';
|
||||||
|
|
||||||
|
var _transformers = null;
|
||||||
|
var _pipe = null;
|
||||||
|
var _loadedModel = null;
|
||||||
|
var _loading = false;
|
||||||
|
|
||||||
|
// Load transformers library using alternative method
|
||||||
|
async function initTransformers() {
|
||||||
|
if (_transformers) return _transformers;
|
||||||
|
if (_loading) {
|
||||||
|
// Wait for current load to complete
|
||||||
|
while (_loading) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
}
|
||||||
|
return _transformers;
|
||||||
|
}
|
||||||
|
|
||||||
|
_loading = true;
|
||||||
|
console.log('[WhisperWorker] V2 Loading transformers via importScripts fallback...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Try importScripts first (traditional method)
|
||||||
|
importScripts(TRANSFORMERS_URL);
|
||||||
|
_transformers = self.transformers || transformers;
|
||||||
|
console.log('[WhisperWorker] V2 Loaded via importScripts');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[WhisperWorker] V2 importScripts failed, trying alternative:', err.message);
|
||||||
|
|
||||||
|
// Fallback: Notify main thread to use server-side transcription
|
||||||
|
self.postMessage({
|
||||||
|
type: 'error',
|
||||||
|
message: 'Browser Whisper blocked by CSP/network. Using server transcription instead.'
|
||||||
|
});
|
||||||
|
_loading = false;
|
||||||
|
throw new Error('CDN blocked - use server transcription');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_transformers) {
|
||||||
|
_loading = false;
|
||||||
|
throw new Error('Transformers library did not load');
|
||||||
|
}
|
||||||
|
|
||||||
|
_transformers.env.allowLocalModels = false;
|
||||||
|
_transformers.env.useBrowserCache = true;
|
||||||
|
_loading = false;
|
||||||
|
|
||||||
|
console.log('[WhisperWorker] V2 Transformers ready');
|
||||||
|
return _transformers;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadModel(modelName) {
|
||||||
|
if (_pipe && _loadedModel === modelName) {
|
||||||
|
console.log('[WhisperWorker] V2 Model already loaded:', modelName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[WhisperWorker] V2 Loading model:', modelName);
|
||||||
|
self.postMessage({ type: 'loading', model: modelName });
|
||||||
|
|
||||||
|
try {
|
||||||
|
var T = await initTransformers();
|
||||||
|
|
||||||
|
_pipe = await T.pipeline('automatic-speech-recognition', modelName, {
|
||||||
|
progress_callback: function(p) {
|
||||||
|
console.log('[WhisperWorker] V2 Progress:', p.status, p.file, p.progress);
|
||||||
|
if (p.status === 'downloading' || p.status === 'progress') {
|
||||||
|
self.postMessage({
|
||||||
|
type: 'progress',
|
||||||
|
file: p.file || '',
|
||||||
|
progress: Math.round(p.progress || 0)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (p.status === 'done' || p.status === 'ready') {
|
||||||
|
self.postMessage({ type: 'progress', file: p.file || '', progress: 100 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
_loadedModel = modelName;
|
||||||
|
console.log('[WhisperWorker] V2 Model loaded successfully');
|
||||||
|
self.postMessage({ type: 'ready', model: modelName });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[WhisperWorker] V2 Load error:', err);
|
||||||
|
self.postMessage({
|
||||||
|
type: 'error',
|
||||||
|
message: 'Model load failed: ' + err.message + '. Try server transcription instead.'
|
||||||
|
});
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.addEventListener('message', async function(e) {
|
||||||
|
var d = e.data;
|
||||||
|
console.log('[WhisperWorker] V2 Message received:', d.type);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (d.type === 'load') {
|
||||||
|
await loadModel(d.model || 'Xenova/whisper-tiny.en');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (d.type === 'transcribe') {
|
||||||
|
await loadModel(d.model || 'Xenova/whisper-tiny.en');
|
||||||
|
console.log('[WhisperWorker] V2 Starting transcription...');
|
||||||
|
|
||||||
|
var result = await _pipe(d.audio, {
|
||||||
|
language: 'english',
|
||||||
|
task: 'transcribe',
|
||||||
|
chunk_length_s: 30,
|
||||||
|
stride_length_s: 5,
|
||||||
|
return_timestamps: false
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[WhisperWorker] V2 Transcription complete');
|
||||||
|
self.postMessage({ type: 'result', text: result.text.trim() });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[WhisperWorker] V2 Error:', err);
|
||||||
|
self.postMessage({
|
||||||
|
type: 'error',
|
||||||
|
message: err.message || 'Worker error'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[WhisperWorker] V2 Worker initialized');
|
||||||
|
|
@ -7,6 +7,8 @@
|
||||||
"background_color": "#ffffff",
|
"background_color": "#ffffff",
|
||||||
"theme_color": "#2563eb",
|
"theme_color": "#2563eb",
|
||||||
"orientation": "any",
|
"orientation": "any",
|
||||||
|
"categories": ["medical", "productivity"],
|
||||||
|
"prefer_related_applications": false,
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "/icons/icon-192.png",
|
"src": "/icons/icon-192.png",
|
||||||
|
|
@ -17,6 +19,26 @@
|
||||||
"src": "/icons/icon-512.png",
|
"src": "/icons/icon-512.png",
|
||||||
"sizes": "512x512",
|
"sizes": "512x512",
|
||||||
"type": "image/png"
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icons/icon-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"shortcuts": [
|
||||||
|
{
|
||||||
|
"name": "New Encounter",
|
||||||
|
"short_name": "Encounter",
|
||||||
|
"url": "/?tab=encounter",
|
||||||
|
"icons": [{ "src": "/icons/icon-192.png", "sizes": "192x192" }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "SOAP Note",
|
||||||
|
"short_name": "SOAP",
|
||||||
|
"url": "/?tab=soap",
|
||||||
|
"icons": [{ "src": "/icons/icon-192.png", "sizes": "192x192" }]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
101
public/sw.js
101
public/sw.js
|
|
@ -1,22 +1,93 @@
|
||||||
// Minimal service worker — pass everything through, cache nothing
|
// ============================================================
|
||||||
self.addEventListener('install', function() {
|
// SERVICE WORKER — Cache shell, network-first for API
|
||||||
self.skipWaiting();
|
// Provides offline fallback for app shell while keeping
|
||||||
});
|
// API calls always fresh (critical for medical data accuracy)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
self.addEventListener('activate', function(event) {
|
var CACHE_NAME = 'pedscribe-v12';
|
||||||
// Clear all old caches
|
var SHELL_ASSETS = [
|
||||||
|
'/',
|
||||||
|
'/index.html',
|
||||||
|
'/css/styles.css',
|
||||||
|
'/js/app.js',
|
||||||
|
'/js/auth.js',
|
||||||
|
'/manifest.json'
|
||||||
|
];
|
||||||
|
|
||||||
|
// Install: precache app shell
|
||||||
|
self.addEventListener('install', function(event) {
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
caches.keys().then(function(names) {
|
caches.open(CACHE_NAME).then(function(cache) {
|
||||||
return Promise.all(
|
return cache.addAll(SHELL_ASSETS);
|
||||||
names.map(function(name) { return caches.delete(name); })
|
}).then(function() {
|
||||||
);
|
return self.skipWaiting();
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
self.addEventListener('fetch', function(event) {
|
// Activate: clear old caches
|
||||||
// Only intercept same-origin requests — let external CDNs go through natively
|
self.addEventListener('activate', function(event) {
|
||||||
if (event.request.url.startsWith(self.location.origin)) {
|
event.waitUntil(
|
||||||
event.respondWith(fetch(event.request));
|
caches.keys().then(function(names) {
|
||||||
}
|
return Promise.all(
|
||||||
|
names.filter(function(name) { return name !== CACHE_NAME; })
|
||||||
|
.map(function(name) { return caches.delete(name); })
|
||||||
|
);
|
||||||
|
}).then(function() {
|
||||||
|
return self.clients.claim();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch: network-first for API, cache-first for static assets
|
||||||
|
self.addEventListener('fetch', function(event) {
|
||||||
|
var url = new URL(event.request.url);
|
||||||
|
|
||||||
|
// Only handle same-origin requests
|
||||||
|
if (url.origin !== self.location.origin) return;
|
||||||
|
|
||||||
|
// API calls — always network, never cache (medical data must be fresh)
|
||||||
|
if (url.pathname.startsWith('/api/')) {
|
||||||
|
event.respondWith(fetch(event.request));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Component HTML — network-first with cache fallback
|
||||||
|
if (url.pathname.startsWith('/components/')) {
|
||||||
|
event.respondWith(
|
||||||
|
fetch(event.request).then(function(response) {
|
||||||
|
var clone = response.clone();
|
||||||
|
caches.open(CACHE_NAME).then(function(cache) { cache.put(event.request, clone); });
|
||||||
|
return response;
|
||||||
|
}).catch(function() {
|
||||||
|
return caches.match(event.request);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Static assets (JS, CSS, icons) — network-first so code updates apply immediately
|
||||||
|
if (url.pathname.match(/\.(js|css|png|ico|woff2?)$/)) {
|
||||||
|
event.respondWith(
|
||||||
|
fetch(event.request).then(function(response) {
|
||||||
|
var clone = response.clone();
|
||||||
|
caches.open(CACHE_NAME).then(function(cache) { cache.put(event.request, clone); });
|
||||||
|
return response;
|
||||||
|
}).catch(function() {
|
||||||
|
return caches.match(event.request);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTML pages — network-first
|
||||||
|
event.respondWith(
|
||||||
|
fetch(event.request).then(function(response) {
|
||||||
|
var clone = response.clone();
|
||||||
|
caches.open(CACHE_NAME).then(function(cache) { cache.put(event.request, clone); });
|
||||||
|
return response;
|
||||||
|
}).catch(function() {
|
||||||
|
return caches.match(event.request) || caches.match('/index.html');
|
||||||
|
})
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
69
scripts/download-whisper-models.sh
Executable file
69
scripts/download-whisper-models.sh
Executable file
|
|
@ -0,0 +1,69 @@
|
||||||
|
#!/bin/bash
|
||||||
|
# Download Browser Whisper models for local development
|
||||||
|
# These are bundled during Docker build, but need manual download for dev
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
MODELS_DIR="public/models"
|
||||||
|
|
||||||
|
echo "🎙️ Downloading Browser Whisper models..."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Create directories
|
||||||
|
mkdir -p "$MODELS_DIR/Xenova/whisper-tiny.en/onnx"
|
||||||
|
|
||||||
|
# Download transformers.js (worker-compatible build)
|
||||||
|
echo "📦 Downloading transformers.js..."
|
||||||
|
curl -L --progress-bar -o "$MODELS_DIR/transformers.min.js" \
|
||||||
|
"https://cdn.jsdelivr.net/npm/@xenova/transformers@2.0.0/dist/transformers.min.js"
|
||||||
|
echo "✅ transformers.min.js ($(du -h $MODELS_DIR/transformers.min.js | cut -f1))"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Download model config files
|
||||||
|
echo "📝 Downloading model configs..."
|
||||||
|
cd "$MODELS_DIR/Xenova/whisper-tiny.en"
|
||||||
|
|
||||||
|
curl -sL -o config.json \
|
||||||
|
"https://huggingface.co/Xenova/whisper-tiny.en/resolve/main/config.json"
|
||||||
|
echo "✅ config.json ($(du -h config.json | cut -f1))"
|
||||||
|
|
||||||
|
curl -sL -o tokenizer.json \
|
||||||
|
"https://huggingface.co/Xenova/whisper-tiny.en/resolve/main/tokenizer.json"
|
||||||
|
echo "✅ tokenizer.json ($(du -h tokenizer.json | cut -f1))"
|
||||||
|
|
||||||
|
curl -sL -o preprocessor_config.json \
|
||||||
|
"https://huggingface.co/Xenova/whisper-tiny.en/resolve/main/preprocessor_config.json"
|
||||||
|
echo "✅ preprocessor_config.json ($(du -h preprocessor_config.json | cut -f1))"
|
||||||
|
|
||||||
|
curl -sL -o generation_config.json \
|
||||||
|
"https://huggingface.co/Xenova/whisper-tiny.en/resolve/main/generation_config.json"
|
||||||
|
echo "✅ generation_config.json ($(du -h generation_config.json | cut -f1))"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Download ONNX models (large files)
|
||||||
|
echo "🧠 Downloading encoder model..."
|
||||||
|
curl -L --progress-bar -o onnx/encoder_model_quantized.onnx \
|
||||||
|
"https://huggingface.co/Xenova/whisper-tiny.en/resolve/main/onnx/encoder_model_quantized.onnx"
|
||||||
|
echo "✅ encoder_model_quantized.onnx ($(du -h onnx/encoder_model_quantized.onnx | cut -f1))"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "🧠 Downloading decoder model..."
|
||||||
|
curl -L --progress-bar -o onnx/decoder_model_merged_quantized.onnx \
|
||||||
|
"https://huggingface.co/Xenova/whisper-tiny.en/resolve/main/onnx/decoder_model_merged_quantized.onnx"
|
||||||
|
echo "✅ decoder_model_merged_quantized.onnx ($(du -h onnx/decoder_model_merged_quantized.onnx | cut -f1))"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Show summary
|
||||||
|
cd - > /dev/null
|
||||||
|
echo "════════════════════════════════════════"
|
||||||
|
echo "✅ Browser Whisper models downloaded!"
|
||||||
|
echo "════════════════════════════════════════"
|
||||||
|
echo "Total size: $(du -sh $MODELS_DIR | cut -f1)"
|
||||||
|
echo ""
|
||||||
|
echo "Files:"
|
||||||
|
ls -lh "$MODELS_DIR/Xenova/whisper-tiny.en/" | tail -n +2
|
||||||
|
echo ""
|
||||||
|
ls -lh "$MODELS_DIR/Xenova/whisper-tiny.en/onnx/" | tail -n +2
|
||||||
|
echo ""
|
||||||
|
echo "Models are ready for use. Start server with: npm start"
|
||||||
197
scripts/import-milestones.js
Executable file
197
scripts/import-milestones.js
Executable file
|
|
@ -0,0 +1,197 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Import developmental milestones from static data file into database
|
||||||
|
* Run: node scripts/import-milestones.js
|
||||||
|
*/
|
||||||
|
|
||||||
|
require('dotenv').config();
|
||||||
|
const db = require('../src/db/database');
|
||||||
|
|
||||||
|
// Import static data
|
||||||
|
const MILESTONES_DATA = {
|
||||||
|
"Newborn / 1 month": {
|
||||||
|
"Gross Motor": [
|
||||||
|
"Moves arms and legs equally",
|
||||||
|
"Lifts head briefly when on tummy (prone)",
|
||||||
|
"Strong flexion posture (arms and legs tucked)",
|
||||||
|
"Turns head side to side when lying on back"
|
||||||
|
],
|
||||||
|
"Fine Motor": [
|
||||||
|
"Hands mostly fisted",
|
||||||
|
"Strong grasp reflex when palm is touched",
|
||||||
|
"Brings hands near face"
|
||||||
|
],
|
||||||
|
"Language": [
|
||||||
|
"Cries to express needs",
|
||||||
|
"Startles or quiets to sounds",
|
||||||
|
"Makes brief throaty sounds"
|
||||||
|
],
|
||||||
|
"Social/Emotional": [
|
||||||
|
"Recognizes caregiver voice",
|
||||||
|
"Briefly fixates on faces at close range (8-12 inches)",
|
||||||
|
"Calms when held or hears familiar voice",
|
||||||
|
"Shows brief alert periods"
|
||||||
|
],
|
||||||
|
"Cognitive": [
|
||||||
|
"Focuses on faces briefly",
|
||||||
|
"Follows objects briefly to midline",
|
||||||
|
"Prefers black and white or high-contrast patterns",
|
||||||
|
"Responds to loud sounds (Moro reflex)"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
"2 months": {
|
||||||
|
"Gross Motor": [
|
||||||
|
"Lifts head when prone (45 degrees)",
|
||||||
|
"Holds head steady when held upright briefly",
|
||||||
|
"Moves both arms and both legs",
|
||||||
|
"Pushes up on tummy"
|
||||||
|
],
|
||||||
|
"Fine Motor": [
|
||||||
|
"Opens hands briefly",
|
||||||
|
"Holds rattle if placed in hand briefly",
|
||||||
|
"Brings hands to midline"
|
||||||
|
],
|
||||||
|
"Language": [
|
||||||
|
"Coos and makes gurgling sounds",
|
||||||
|
"Makes sounds other than crying",
|
||||||
|
"Turns head toward sounds"
|
||||||
|
],
|
||||||
|
"Social/Emotional": [
|
||||||
|
"Social smile (responsive to faces)",
|
||||||
|
"Begins to self-soothe (hands to mouth)",
|
||||||
|
"Tries to look at parent",
|
||||||
|
"Calms when spoken to or picked up"
|
||||||
|
],
|
||||||
|
"Cognitive": [
|
||||||
|
"Pays attention to faces",
|
||||||
|
"Begins to follow things with eyes",
|
||||||
|
"Recognizes people at a distance"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
"4 months": {
|
||||||
|
"Gross Motor": [
|
||||||
|
"Holds head steady unsupported",
|
||||||
|
"Pushes up to elbows when on tummy",
|
||||||
|
"May roll over tummy to back",
|
||||||
|
"Pushes down on legs when feet on hard surface"
|
||||||
|
],
|
||||||
|
"Fine Motor": [
|
||||||
|
"Reaches for toys with one hand",
|
||||||
|
"Uses hands and eyes together",
|
||||||
|
"Grasps and shakes hand toys"
|
||||||
|
],
|
||||||
|
"Language": [
|
||||||
|
"Babbles with expression and copies sounds",
|
||||||
|
"Cries differently for hunger pain and tiredness",
|
||||||
|
"Makes vowel sounds ah eh oh"
|
||||||
|
],
|
||||||
|
"Social/Emotional": [
|
||||||
|
"Smiles spontaneously especially at people",
|
||||||
|
"Likes to play and may cry when playing stops",
|
||||||
|
"Copies movements and facial expressions"
|
||||||
|
],
|
||||||
|
"Cognitive": [
|
||||||
|
"Lets you know if happy or sad",
|
||||||
|
"Follows moving things with eyes side to side",
|
||||||
|
"Watches faces closely",
|
||||||
|
"Recognizes familiar people at a distance"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
"6 months": {
|
||||||
|
"Gross Motor": [
|
||||||
|
"Rolls over in both directions",
|
||||||
|
"Begins to sit without support",
|
||||||
|
"Supports weight on legs and might bounce",
|
||||||
|
"Rocks back and forth on hands and knees"
|
||||||
|
],
|
||||||
|
"Fine Motor": [
|
||||||
|
"Reaches for and grasps objects",
|
||||||
|
"Transfers objects hand to hand",
|
||||||
|
"Raking grasp to pick up objects",
|
||||||
|
"Brings things to mouth"
|
||||||
|
],
|
||||||
|
"Language": [
|
||||||
|
"Responds to own name",
|
||||||
|
"Responds to sounds by making sounds",
|
||||||
|
"Strings vowels together when babbling",
|
||||||
|
"Begins consonant sounds m and b",
|
||||||
|
"Makes sounds to show joy and displeasure"
|
||||||
|
],
|
||||||
|
"Social/Emotional": [
|
||||||
|
"Knows familiar faces and recognizes strangers",
|
||||||
|
"Likes to play with others especially parents",
|
||||||
|
"Responds to other peoples emotions",
|
||||||
|
"Likes to look at self in mirror"
|
||||||
|
],
|
||||||
|
"Cognitive": [
|
||||||
|
"Looks around at things nearby",
|
||||||
|
"Shows curiosity and tries to get things out of reach",
|
||||||
|
"Begins to pass things from one hand to the other"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
// Add more age groups as needed...
|
||||||
|
};
|
||||||
|
|
||||||
|
async function importMilestones() {
|
||||||
|
console.log('🚀 Starting milestones import...\n');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check if milestones already exist
|
||||||
|
const existing = await db.query('SELECT COUNT(*) as count FROM developmental_milestones');
|
||||||
|
if (parseInt(existing.rows[0].count) > 0) {
|
||||||
|
console.log(`⚠️ Database already contains ${existing.rows[0].count} milestones.`);
|
||||||
|
console.log(' Clear existing data first or skip import.');
|
||||||
|
const readline = require('readline');
|
||||||
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||||
|
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
rl.question('Clear existing data and re-import? (yes/no): ', (answer) => {
|
||||||
|
rl.close();
|
||||||
|
if (answer.toLowerCase() !== 'yes') {
|
||||||
|
console.log('Import cancelled.');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.query('DELETE FROM developmental_milestones');
|
||||||
|
console.log('✅ Cleared existing milestones\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
let imported = 0;
|
||||||
|
let sortOrder = 0;
|
||||||
|
|
||||||
|
for (const ageGroup in MILESTONES_DATA) {
|
||||||
|
console.log(`📋 Importing: ${ageGroup}`);
|
||||||
|
|
||||||
|
for (const domain in MILESTONES_DATA[ageGroup]) {
|
||||||
|
const milestones = MILESTONES_DATA[ageGroup][domain];
|
||||||
|
|
||||||
|
for (const milestone of milestones) {
|
||||||
|
await db.query(
|
||||||
|
`INSERT INTO developmental_milestones (age_group, domain, milestone_text, sort_order)
|
||||||
|
VALUES ($1, $2, $3, $4)`,
|
||||||
|
[ageGroup, domain, milestone, sortOrder]
|
||||||
|
);
|
||||||
|
sortOrder++;
|
||||||
|
imported++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n✅ Import complete!`);
|
||||||
|
console.log(` Imported: ${imported} milestones`);
|
||||||
|
console.log(`\nYou can now manage milestones via the Admin dashboard.\n`);
|
||||||
|
|
||||||
|
process.exit(0);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('❌ Import failed:', err.message);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
importMilestones();
|
||||||
49
server.js
49
server.js
|
|
@ -19,14 +19,23 @@ app.use(helmet({
|
||||||
contentSecurityPolicy: {
|
contentSecurityPolicy: {
|
||||||
directives: {
|
directives: {
|
||||||
defaultSrc: ["'self'"],
|
defaultSrc: ["'self'"],
|
||||||
scriptSrc: ["'self'"],
|
// 'wasm-unsafe-eval' required for WebAssembly (Whisper in-browser transcription)
|
||||||
|
// cdn.jsdelivr.net required for @xenova/transformers worker script
|
||||||
|
// 'unsafe-eval' needed for transformers.js dynamic imports in worker
|
||||||
|
scriptSrc: ["'self'", "'wasm-unsafe-eval'", "'unsafe-eval'", 'https://cdn.jsdelivr.net'],
|
||||||
scriptSrcAttr: ["'none'"],
|
scriptSrcAttr: ["'none'"],
|
||||||
styleSrc: ["'self'", "'unsafe-inline'", 'https://fonts.googleapis.com', 'https://cdnjs.cloudflare.com'],
|
styleSrc: ["'self'", "'unsafe-inline'", 'https://fonts.googleapis.com', 'https://cdnjs.cloudflare.com'],
|
||||||
fontSrc: ["'self'", 'https://fonts.gstatic.com', 'https://cdnjs.cloudflare.com'],
|
fontSrc: ["'self'", 'https://fonts.gstatic.com', 'https://cdnjs.cloudflare.com'],
|
||||||
imgSrc: ["'self'", 'data:', 'blob:'],
|
imgSrc: ["'self'", 'data:', 'blob:'],
|
||||||
mediaSrc: ["'self'", 'blob:'],
|
mediaSrc: ["'self'", 'blob:'],
|
||||||
connectSrc: ["'self'", 'https://cdnjs.cloudflare.com', 'https://fonts.googleapis.com', 'https://fonts.gstatic.com', 'https://www.google.com', 'wss://www.google.com', 'https://clinicaltables.nlm.nih.gov'],
|
connectSrc: ["'self'", 'https://cdnjs.cloudflare.com', 'https://fonts.googleapis.com', 'https://fonts.gstatic.com', 'https://www.google.com', 'wss://www.google.com', 'https://clinicaltables.nlm.nih.gov',
|
||||||
workerSrc: ["'self'"],
|
// HuggingFace CDN for Whisper model downloads
|
||||||
|
'https://huggingface.co', 'https://cdn-lfs.huggingface.co', 'https://cdn-lfs-us-1.huggingface.co', 'https://cdn-lfs-us-2.huggingface.co',
|
||||||
|
// jsdelivr for worker importScripts
|
||||||
|
'https://cdn.jsdelivr.net'],
|
||||||
|
workerSrc: ["'self'", 'blob:'],
|
||||||
|
// Allow workers to load scripts from jsdelivr
|
||||||
|
childSrc: ["'self'", 'blob:', 'https://cdn.jsdelivr.net'],
|
||||||
frameSrc: ["'none'"],
|
frameSrc: ["'none'"],
|
||||||
objectSrc: ["'none'"],
|
objectSrc: ["'none'"],
|
||||||
}
|
}
|
||||||
|
|
@ -46,15 +55,17 @@ app.use(cors({
|
||||||
if (origin === allowedOrigin) return callback(null, true);
|
if (origin === allowedOrigin) return callback(null, true);
|
||||||
callback(new Error('CORS: origin not allowed'));
|
callback(new Error('CORS: origin not allowed'));
|
||||||
},
|
},
|
||||||
credentials: true
|
credentials: true,
|
||||||
|
exposedHeaders: ['X-TTS-Provider']
|
||||||
}));
|
}));
|
||||||
|
|
||||||
app.use(cookieParser());
|
app.use(cookieParser());
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// BODY LIMITS — 1mb for JSON, transcribe uses multipart (no limit here)
|
// BODY LIMITS — 10mb for JSON (chart review with many notes can be large),
|
||||||
|
// transcribe uses multipart (25mb limit set in multer)
|
||||||
// ============================================================
|
// ============================================================
|
||||||
app.use(express.json({ limit: '1mb' }));
|
app.use(express.json({ limit: '10mb' }));
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// RATE LIMITING
|
// RATE LIMITING
|
||||||
|
|
@ -88,6 +99,13 @@ app.use('/api/auth/resend-verification', rateLimit({
|
||||||
standardHeaders: true, legacyHeaders: false
|
standardHeaders: true, legacyHeaders: false
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Serve .well-known/assetlinks.json for TWA verification (must be before static)
|
||||||
|
app.get('/.well-known/assetlinks.json', (req, res) => {
|
||||||
|
res.setHeader('Content-Type', 'application/json');
|
||||||
|
res.setHeader('Cache-Control', 'public, max-age=86400');
|
||||||
|
res.sendFile(path.join(__dirname, 'public', '.well-known', 'assetlinks.json'));
|
||||||
|
});
|
||||||
|
|
||||||
app.use(loggingMiddleware);
|
app.use(loggingMiddleware);
|
||||||
app.use(express.static(path.join(__dirname, 'public'), {
|
app.use(express.static(path.join(__dirname, 'public'), {
|
||||||
setHeaders: (res, filePath) => {
|
setHeaders: (res, filePath) => {
|
||||||
|
|
@ -108,6 +126,7 @@ app.use(express.static(path.join(__dirname, 'public'), {
|
||||||
|
|
||||||
// Routes
|
// Routes
|
||||||
app.use('/api/auth', require('./src/routes/auth'));
|
app.use('/api/auth', require('./src/routes/auth'));
|
||||||
|
app.use('/api/auth', require('./src/routes/oidc'));
|
||||||
|
|
||||||
// Learning Hub CMS — must come BEFORE general /api/admin to avoid adminMiddleware conflict
|
// Learning Hub CMS — must come BEFORE general /api/admin to avoid adminMiddleware conflict
|
||||||
// (moderators need access to /api/admin/learning but not other /api/admin routes)
|
// (moderators need access to /api/admin/learning but not other /api/admin routes)
|
||||||
|
|
@ -115,15 +134,17 @@ app.use('/api/admin/learning', require('./src/routes/learningAdmin'));
|
||||||
|
|
||||||
app.use('/api/admin', require('./src/routes/admin'));
|
app.use('/api/admin', require('./src/routes/admin'));
|
||||||
app.use('/api/admin', require('./src/routes/adminConfig'));
|
app.use('/api/admin', require('./src/routes/adminConfig'));
|
||||||
|
app.use('/api/admin', require('./src/routes/adminMilestones'));
|
||||||
|
|
||||||
// Public endpoints — must come BEFORE any router that applies authMiddleware to /api/*
|
// Public endpoints — must come BEFORE any router that applies authMiddleware to /api/*
|
||||||
const { getAvailableModels, activeProvider: modelsProvider } = require('./src/utils/models');
|
const { getAvailableModels, activeProvider: modelsProvider } = require('./src/utils/models');
|
||||||
app.get('/api/models', async (req, res) => {
|
app.get('/api/models', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
var { getAvailableModelsWithOverrides } = require('./src/utils/models');
|
var { getAvailableModelsWithOverrides, DEFAULT_MODEL } = require('./src/utils/models');
|
||||||
var db = require('./src/db/database');
|
var db = require('./src/db/database');
|
||||||
var models = await getAvailableModelsWithOverrides(db);
|
var models = await getAvailableModelsWithOverrides(db);
|
||||||
res.json({ models: models, provider: modelsProvider });
|
var defaultOverride = await db.getSetting('models.default');
|
||||||
|
res.json({ models: models, provider: modelsProvider, defaultModel: defaultOverride || DEFAULT_MODEL });
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
res.json({ models: getAvailableModels(), provider: modelsProvider });
|
res.json({ models: getAvailableModels(), provider: modelsProvider });
|
||||||
}
|
}
|
||||||
|
|
@ -132,12 +153,15 @@ app.get('/api/models', async (req, res) => {
|
||||||
const { activeProvider } = require('./src/utils/ai');
|
const { activeProvider } = require('./src/utils/ai');
|
||||||
app.get('/api/health', (req, res) => {
|
app.get('/api/health', (req, res) => {
|
||||||
res.json({
|
res.json({
|
||||||
status: 'running', version: '3.1.0', provider: activeProvider,
|
status: 'running', version: '6.0.0', provider: activeProvider,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
openrouter: process.env.OPENROUTER_API_KEY ? 'configured' : 'missing',
|
openrouter: process.env.OPENROUTER_API_KEY ? 'configured' : 'missing',
|
||||||
bedrock: process.env.AWS_BEDROCK_REGION ? 'configured' : 'not configured',
|
bedrock: process.env.AWS_BEDROCK_REGION ? 'configured' : 'not configured',
|
||||||
azure: process.env.AZURE_OPENAI_ENDPOINT ? 'configured' : 'not configured',
|
azure: process.env.AZURE_OPENAI_ENDPOINT ? 'configured' : 'not configured',
|
||||||
whisper: process.env.OPENAI_API_KEY ? 'configured' : 'missing'
|
vertex: process.env.GOOGLE_VERTEX_PROJECT ? 'configured' : 'not configured',
|
||||||
|
litellm: process.env.LITELLM_API_BASE ? 'configured' : 'not configured',
|
||||||
|
whisper: process.env.OPENAI_API_KEY ? 'configured' : 'missing',
|
||||||
|
tts: process.env.LITELLM_API_BASE ? 'litellm' : (process.env.ELEVENLABS_API_KEY ? 'elevenlabs' : 'none')
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -157,8 +181,11 @@ app.use('/api', require('./src/routes/refine'));
|
||||||
app.use('/api', require('./src/routes/logs'));
|
app.use('/api', require('./src/routes/logs'));
|
||||||
app.use('/api', require('./src/routes/encounters'));
|
app.use('/api', require('./src/routes/encounters'));
|
||||||
app.use('/api', require('./src/routes/memories'));
|
app.use('/api', require('./src/routes/memories'));
|
||||||
|
app.use('/api', require('./src/routes/documents'));
|
||||||
|
app.use('/api', require('./src/routes/audioBackups'));
|
||||||
app.use('/api', require('./src/routes/wellVisit'));
|
app.use('/api', require('./src/routes/wellVisit'));
|
||||||
app.use('/api', require('./src/routes/sickVisit'));
|
app.use('/api', require('./src/routes/sickVisit'));
|
||||||
|
app.use('/api/user', require('./src/routes/userPreferences'));
|
||||||
app.use('/api/admin/learning', require('./src/routes/learningAI'));
|
app.use('/api/admin/learning', require('./src/routes/learningAI'));
|
||||||
|
|
||||||
// User-level preference: save WebDAV learning path (auth only, not moderator-only)
|
// User-level preference: save WebDAV learning path (auth only, not moderator-only)
|
||||||
|
|
@ -194,7 +221,7 @@ const PORT = process.env.PORT || 3000;
|
||||||
server.listen(PORT, () => {
|
server.listen(PORT, () => {
|
||||||
console.log('');
|
console.log('');
|
||||||
console.log('==========================================');
|
console.log('==========================================');
|
||||||
console.log('🏥 PEDIATRIC AI SCRIBE v3.1');
|
console.log('🏥 PEDIATRIC AI SCRIBE v6.0');
|
||||||
console.log('==========================================');
|
console.log('==========================================');
|
||||||
console.log('🌐 http://localhost:' + PORT);
|
console.log('🌐 http://localhost:' + PORT);
|
||||||
console.log('🤖 Provider: ' + activeProvider);
|
console.log('🤖 Provider: ' + activeProvider);
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,14 @@ pool.query('SELECT NOW()')
|
||||||
async function initDatabase() {
|
async function initDatabase() {
|
||||||
var client = await pool.connect();
|
var client = await pool.connect();
|
||||||
try {
|
try {
|
||||||
|
// Enable pgvector extension for embeddings
|
||||||
|
try {
|
||||||
|
await client.query('CREATE EXTENSION IF NOT EXISTS vector');
|
||||||
|
console.log('✅ pgvector extension: enabled');
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('⚠️ pgvector extension not available. Vector search disabled. Install: apt-get install postgresql-16-pgvector');
|
||||||
|
}
|
||||||
|
|
||||||
await client.query(`
|
await client.query(`
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
|
|
@ -125,6 +133,7 @@ async function initDatabase() {
|
||||||
CREATE INDEX IF NOT EXISTS idx_saved_enc_user ON saved_encounters(user_id);
|
CREATE INDEX IF NOT EXISTS idx_saved_enc_user ON saved_encounters(user_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_saved_enc_expires ON saved_encounters(expires_at);
|
CREATE INDEX IF NOT EXISTS idx_saved_enc_expires ON saved_encounters(expires_at);
|
||||||
CREATE INDEX IF NOT EXISTS idx_memories_user ON user_memories(user_id);
|
CREATE INDEX IF NOT EXISTS idx_memories_user ON user_memories(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_memories_user_cat ON user_memories(user_id, category);
|
||||||
|
|
||||||
-- Learning Hub tables
|
-- Learning Hub tables
|
||||||
CREATE TABLE IF NOT EXISTS learning_categories (
|
CREATE TABLE IF NOT EXISTS learning_categories (
|
||||||
|
|
@ -188,6 +197,7 @@ async function initDatabase() {
|
||||||
var migrations = [
|
var migrations = [
|
||||||
"ALTER TABLE users ADD COLUMN IF NOT EXISTS role TEXT DEFAULT 'user'",
|
"ALTER TABLE users ADD COLUMN IF NOT EXISTS role TEXT DEFAULT 'user'",
|
||||||
"ALTER TABLE users ADD COLUMN IF NOT EXISTS disabled BOOLEAN DEFAULT false",
|
"ALTER TABLE users ADD COLUMN IF NOT EXISTS disabled BOOLEAN DEFAULT false",
|
||||||
|
"ALTER TABLE users ADD COLUMN IF NOT EXISTS oidc_sub TEXT",
|
||||||
"CREATE TABLE IF NOT EXISTS saved_encounters (id SERIAL PRIMARY KEY, user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, label TEXT NOT NULL DEFAULT 'Untitled', enc_type TEXT NOT NULL DEFAULT 'encounter', transcript TEXT DEFAULT '', generated_note TEXT DEFAULT '', partial_data TEXT DEFAULT '{}', status TEXT DEFAULT 'active', created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW(), expires_at TIMESTAMPTZ DEFAULT NOW() + INTERVAL '7 days')",
|
"CREATE TABLE IF NOT EXISTS saved_encounters (id SERIAL PRIMARY KEY, user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, label TEXT NOT NULL DEFAULT 'Untitled', enc_type TEXT NOT NULL DEFAULT 'encounter', transcript TEXT DEFAULT '', generated_note TEXT DEFAULT '', partial_data TEXT DEFAULT '{}', status TEXT DEFAULT 'active', created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW(), expires_at TIMESTAMPTZ DEFAULT NOW() + INTERVAL '7 days')",
|
||||||
"CREATE TABLE IF NOT EXISTS user_memories (id SERIAL PRIMARY KEY, user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, category TEXT NOT NULL DEFAULT 'custom', name TEXT NOT NULL, content TEXT NOT NULL, created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW())",
|
"CREATE TABLE IF NOT EXISTS user_memories (id SERIAL PRIMARY KEY, user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, category TEXT NOT NULL DEFAULT 'custom', name TEXT NOT NULL, content TEXT NOT NULL, created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW())",
|
||||||
"CREATE INDEX IF NOT EXISTS idx_saved_enc_user ON saved_encounters(user_id)",
|
"CREATE INDEX IF NOT EXISTS idx_saved_enc_user ON saved_encounters(user_id)",
|
||||||
|
|
@ -212,6 +222,91 @@ async function initDatabase() {
|
||||||
try { await client.query("ALTER TABLE app_settings ADD COLUMN IF NOT EXISTS updated_by INTEGER REFERENCES users(id) ON DELETE SET NULL"); } catch(e) {}
|
try { await client.query("ALTER TABLE app_settings ADD COLUMN IF NOT EXISTS updated_by INTEGER REFERENCES users(id) ON DELETE SET NULL"); } catch(e) {}
|
||||||
// Add webdav_learning_path to users for Learning Hub file browser
|
// Add webdav_learning_path to users for Learning Hub file browser
|
||||||
try { await client.query("ALTER TABLE users ADD COLUMN IF NOT EXISTS webdav_learning_path TEXT DEFAULT NULL"); } catch(e) {}
|
try { await client.query("ALTER TABLE users ADD COLUMN IF NOT EXISTS webdav_learning_path TEXT DEFAULT NULL"); } catch(e) {}
|
||||||
|
// Add user preferences for STT model and TTS voice
|
||||||
|
try { await client.query("ALTER TABLE users ADD COLUMN IF NOT EXISTS stt_model TEXT DEFAULT NULL"); } catch(e) {}
|
||||||
|
try { await client.query("ALTER TABLE users ADD COLUMN IF NOT EXISTS tts_voice TEXT DEFAULT NULL"); } catch(e) {}
|
||||||
|
// Add idempotency_key for duplicate prevention
|
||||||
|
try { await client.query("ALTER TABLE saved_encounters ADD COLUMN IF NOT EXISTS idempotency_key TEXT"); } catch(e) {}
|
||||||
|
try { await client.query("CREATE UNIQUE INDEX IF NOT EXISTS idx_saved_enc_idemp ON saved_encounters(user_id, idempotency_key) WHERE idempotency_key IS NOT NULL"); } catch(e) {}
|
||||||
|
// Audio backups table — server-side, auto-deleted after 24 hours
|
||||||
|
try { await client.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS audio_backups (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
module TEXT NOT NULL DEFAULT 'encounter',
|
||||||
|
mime_type TEXT DEFAULT 'audio/webm',
|
||||||
|
size_bytes INTEGER DEFAULT 0,
|
||||||
|
compressed_bytes INTEGER DEFAULT 0,
|
||||||
|
audio_data BYTEA NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
expires_at TIMESTAMPTZ DEFAULT NOW() + INTERVAL '24 hours'
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audio_backups_user ON audio_backups(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audio_backups_expires ON audio_backups(expires_at);
|
||||||
|
`); } catch(e) {}
|
||||||
|
|
||||||
|
// User documents table for S3 storage
|
||||||
|
try { await client.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS user_documents (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
s3_key TEXT NOT NULL,
|
||||||
|
filename TEXT NOT NULL,
|
||||||
|
mime_type TEXT DEFAULT 'application/octet-stream',
|
||||||
|
size_bytes INTEGER DEFAULT 0,
|
||||||
|
description TEXT DEFAULT '',
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_docs_user ON user_documents(user_id);
|
||||||
|
`); } catch(e) {}
|
||||||
|
|
||||||
|
// Add embedding column to learning_content for vector search (768 dims = Vertex AI text-embedding-005)
|
||||||
|
try {
|
||||||
|
await client.query('ALTER TABLE learning_content ADD COLUMN IF NOT EXISTS embedding vector(768)');
|
||||||
|
console.log('✅ learning_content.embedding: added');
|
||||||
|
} catch(e) {
|
||||||
|
console.warn('⚠️ Could not add embedding column:', e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Developmental milestones table
|
||||||
|
try { await client.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS developmental_milestones (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
age_group TEXT NOT NULL,
|
||||||
|
domain TEXT NOT NULL,
|
||||||
|
milestone_text TEXT NOT NULL,
|
||||||
|
sort_order INTEGER DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_milestones_age_domain ON developmental_milestones(age_group, domain);
|
||||||
|
`);
|
||||||
|
console.log('✅ developmental_milestones: table ready');
|
||||||
|
} catch(e) {
|
||||||
|
console.warn('⚠️ Could not create milestones table:', e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create IVFFLAT index for fast similarity search (after data is populated)
|
||||||
|
try {
|
||||||
|
var indexExists = await client.query(
|
||||||
|
"SELECT 1 FROM pg_indexes WHERE indexname = 'idx_learning_content_embedding'"
|
||||||
|
);
|
||||||
|
if (indexExists.rows.length === 0) {
|
||||||
|
// Check if we have at least some embeddings before creating index
|
||||||
|
var embCount = await client.query('SELECT COUNT(*) as count FROM learning_content WHERE embedding IS NOT NULL');
|
||||||
|
if (parseInt(embCount.rows[0]?.count || 0) >= 10) {
|
||||||
|
// IVFFLAT requires lists parameter — use sqrt(rows) as heuristic
|
||||||
|
var lists = Math.max(10, Math.floor(Math.sqrt(embCount.rows[0].count)));
|
||||||
|
await client.query(
|
||||||
|
`CREATE INDEX idx_learning_content_embedding ON learning_content
|
||||||
|
USING ivfflat (embedding vector_cosine_ops) WITH (lists = ${lists})`
|
||||||
|
);
|
||||||
|
console.log('✅ Vector search index: created');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch(e) {
|
||||||
|
// Index creation can fail if not enough data or pgvector not installed — safe to ignore
|
||||||
|
}
|
||||||
|
|
||||||
// Seed all default config values (ON CONFLICT DO NOTHING — never overwrites admin changes)
|
// Seed all default config values (ON CONFLICT DO NOTHING — never overwrites admin changes)
|
||||||
var defaults = [
|
var defaults = [
|
||||||
|
|
@ -251,15 +346,19 @@ async function initDatabase() {
|
||||||
|
|
||||||
initDatabase();
|
initDatabase();
|
||||||
|
|
||||||
// Clean up expired saved encounters daily
|
// Clean up expired saved encounters and audio backups
|
||||||
async function cleanupExpiredEncounters() {
|
async function cleanupExpired() {
|
||||||
try {
|
try {
|
||||||
var result = await pool.query('DELETE FROM saved_encounters WHERE expires_at < NOW()');
|
var enc = await pool.query('DELETE FROM saved_encounters WHERE expires_at < NOW()');
|
||||||
if (result.rowCount > 0) console.log('[DB] Cleaned up ' + result.rowCount + ' expired encounters');
|
if (enc.rowCount > 0) console.log('[DB] Cleaned up ' + enc.rowCount + ' expired encounters');
|
||||||
} catch(e) { console.error('[DB] Cleanup error:', e.message); }
|
} catch(e) { console.error('[DB] Encounter cleanup error:', e.message); }
|
||||||
|
try {
|
||||||
|
var audio = await pool.query('DELETE FROM audio_backups WHERE expires_at < NOW()');
|
||||||
|
if (audio.rowCount > 0) console.log('[DB] Cleaned up ' + audio.rowCount + ' expired audio backups');
|
||||||
|
} catch(e) { /* table may not exist yet */ }
|
||||||
}
|
}
|
||||||
setInterval(cleanupExpiredEncounters, 6 * 60 * 60 * 1000); // every 6 hours
|
setInterval(cleanupExpired, 60 * 60 * 1000); // every hour (audio backups are 24h)
|
||||||
setTimeout(cleanupExpiredEncounters, 10000); // also run 10s after startup
|
setTimeout(cleanupExpired, 10000); // also run 10s after startup
|
||||||
|
|
||||||
// Query helpers
|
// Query helpers
|
||||||
var db = {
|
var db = {
|
||||||
|
|
|
||||||
|
|
@ -48,37 +48,6 @@ router.get('/config', async function(req, res) {
|
||||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── PUT update a single config entry ──────────────────────────────────────
|
|
||||||
router.put('/config/:key(*)', async function(req, res) {
|
|
||||||
try {
|
|
||||||
var key = req.params.key;
|
|
||||||
var value = req.body.value;
|
|
||||||
|
|
||||||
if (value === undefined || value === null) {
|
|
||||||
return res.status(400).json({ error: 'value is required' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Security: only allow known key prefixes
|
|
||||||
var allowed = ['announcement.', 'feature.', 'email.', 'prompt.', 'registration_enabled', 'site.', 'smtp.', 'models.'];
|
|
||||||
var isAllowed = allowed.some(function(p) { return key === p || key.startsWith(p); });
|
|
||||||
if (!isAllowed) {
|
|
||||||
return res.status(400).json({ error: 'Unknown config key' });
|
|
||||||
}
|
|
||||||
|
|
||||||
await db.setSetting(key, String(value));
|
|
||||||
|
|
||||||
// Update in-memory prompt immediately if it's a prompt key
|
|
||||||
if (key.startsWith('prompt.')) {
|
|
||||||
var promptKey = key.replace('prompt.', '');
|
|
||||||
PROMPTS.updatePrompt(promptKey, String(value));
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.audit(req.user.id, 'admin_config_update', 'Updated config: ' + key, req, { category: 'admin' });
|
|
||||||
|
|
||||||
res.json({ success: true });
|
|
||||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── POST send test email ───────────────────────────────────────────────────
|
// ── POST send test email ───────────────────────────────────────────────────
|
||||||
router.post('/config/test-email', async function(req, res) {
|
router.post('/config/test-email', async function(req, res) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -97,11 +66,12 @@ router.post('/config/test-email', async function(req, res) {
|
||||||
}
|
}
|
||||||
|
|
||||||
var emailWrapper = require('./auth').__emailWrapper;
|
var emailWrapper = require('./auth').__emailWrapper;
|
||||||
var btnStyle = require('./auth').__btnStyle;
|
var btnHtml = require('./auth').__btnHtml;
|
||||||
|
|
||||||
var html = emailWrapper(
|
var html = emailWrapper(
|
||||||
'<p style="color:#4b5563;margin:0 0 20px;line-height:1.6;">' + bodyText.replace(/\n/g, '<br>') + '</p>' +
|
'<p style="margin:0 0 6px;font-size:18px;font-weight:600;color:#111827;">Test Email</p>' +
|
||||||
(btnStyle ? '<a href="#" style="' + btnStyle() + '">Test Button</a>' : '')
|
'<p style="color:#6b7280;margin:12px 0 20px;line-height:1.6;font-size:14px;">' + bodyText.replace(/\n/g, '<br>') + '</p>' +
|
||||||
|
btnHtml('#', 'Test Button')
|
||||||
);
|
);
|
||||||
|
|
||||||
var ok = await sendEmail(to, '[TEST] ' + subject, html);
|
var ok = await sendEmail(to, '[TEST] ' + subject, html);
|
||||||
|
|
@ -192,6 +162,7 @@ router.get('/config/smtp/status', async function(req, res) {
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── PUT update SMTP settings ─────────────────────────────────────────────
|
// ── PUT update SMTP settings ─────────────────────────────────────────────
|
||||||
|
// NOTE: This must come BEFORE the wildcard PUT /config/:key(*) below
|
||||||
router.put('/config/smtp', async function(req, res) {
|
router.put('/config/smtp', async function(req, res) {
|
||||||
try {
|
try {
|
||||||
var { host, port, user, pass, from, secure } = req.body;
|
var { host, port, user, pass, from, secure } = req.body;
|
||||||
|
|
@ -203,7 +174,7 @@ router.put('/config/smtp', async function(req, res) {
|
||||||
await db.setSetting('smtp.from', (from || user || '').trim());
|
await db.setSetting('smtp.from', (from || user || '').trim());
|
||||||
await db.setSetting('smtp.secure', String(secure === true || secure === 'true'));
|
await db.setSetting('smtp.secure', String(secure === true || secure === 'true'));
|
||||||
if (pass && pass.trim()) {
|
if (pass && pass.trim()) {
|
||||||
await db.setSetting('smtp.pass', pass.trim()); // stored as-is; consider encrypting at rest
|
await db.setSetting('smtp.pass', pass.trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.audit(req.user.id, 'admin_smtp_update', 'Updated SMTP settings', req, { category: 'admin' });
|
logger.audit(req.user.id, 'admin_smtp_update', 'Updated SMTP settings', req, { category: 'admin' });
|
||||||
|
|
@ -223,14 +194,28 @@ router.delete('/config/smtp', async function(req, res) {
|
||||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// MODEL ROUTES — All must come BEFORE the wildcard PUT /config/:key(*)
|
||||||
|
// The wildcard uses :key(*) which matches slashes, so it would intercept
|
||||||
|
// /config/models/toggle and /config/models/default if registered after it.
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
// ── GET models config ────────────────────────────────────────────────────
|
// ── GET models config ────────────────────────────────────────────────────
|
||||||
router.get('/config/models', async function(req, res) {
|
router.get('/config/models', async function(req, res) {
|
||||||
try {
|
try {
|
||||||
var { OPENROUTER_MODELS, BEDROCK_MODELS, AZURE_MODELS, activeProvider } = require('../utils/models');
|
var { OPENROUTER_MODELS, BEDROCK_MODELS, AZURE_MODELS, VERTEX_MODELS, activeProvider } = require('../utils/models');
|
||||||
var providerModels = activeProvider === 'bedrock' ? BEDROCK_MODELS : (activeProvider === 'azure' ? AZURE_MODELS : OPENROUTER_MODELS);
|
var providerModels;
|
||||||
|
switch (activeProvider) {
|
||||||
|
case 'bedrock': providerModels = BEDROCK_MODELS; break;
|
||||||
|
case 'azure': providerModels = AZURE_MODELS; break;
|
||||||
|
case 'vertex': providerModels = VERTEX_MODELS; break;
|
||||||
|
case 'litellm': providerModels = []; break; // LiteLLM: no built-ins — use discover
|
||||||
|
default: providerModels = OPENROUTER_MODELS;
|
||||||
|
}
|
||||||
|
|
||||||
var disabledRaw = await db.getSetting('models.disabled') || '[]';
|
var disabledRaw = await db.getSetting('models.disabled') || '[]';
|
||||||
var customRaw = await db.getSetting('models.custom') || '[]';
|
var customRaw = await db.getSetting('models.custom') || '[]';
|
||||||
|
var defaultModel = await db.getSetting('models.default') || '';
|
||||||
var disabled, custom;
|
var disabled, custom;
|
||||||
try { disabled = JSON.parse(disabledRaw); } catch(e) { disabled = []; }
|
try { disabled = JSON.parse(disabledRaw); } catch(e) { disabled = []; }
|
||||||
try { custom = JSON.parse(customRaw); } catch(e) { custom = []; }
|
try { custom = JSON.parse(customRaw); } catch(e) { custom = []; }
|
||||||
|
|
@ -239,11 +224,18 @@ router.get('/config/models', async function(req, res) {
|
||||||
return Object.assign({}, m, { enabled: !disabled.includes(m.id) });
|
return Object.assign({}, m, { enabled: !disabled.includes(m.id) });
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json({ success: true, provider: activeProvider, models: models, custom: custom });
|
res.json({
|
||||||
|
success: true,
|
||||||
|
provider: activeProvider,
|
||||||
|
models: models,
|
||||||
|
custom: custom,
|
||||||
|
defaultModel: defaultModel,
|
||||||
|
litellmHint: activeProvider === 'litellm'
|
||||||
|
});
|
||||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── PUT update model enabled/disabled state ──────────────────────────────
|
// ── PUT toggle model enabled/disabled ────────────────────────────────────
|
||||||
router.put('/config/models/toggle', async function(req, res) {
|
router.put('/config/models/toggle', async function(req, res) {
|
||||||
try {
|
try {
|
||||||
var { modelId, enabled } = req.body;
|
var { modelId, enabled } = req.body;
|
||||||
|
|
@ -260,31 +252,59 @@ router.put('/config/models/toggle', async function(req, res) {
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.setSetting('models.disabled', JSON.stringify(disabled));
|
await db.setSetting('models.disabled', JSON.stringify(disabled));
|
||||||
|
logger.audit(req.user.id, 'admin_model_toggle', (enabled ? 'Enabled' : 'Disabled') + ' model: ' + modelId, req, { category: 'admin' });
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── POST add custom model ────────────────────────────────────────────────
|
// ── PUT set default model ─────────────────────────────────────────────────
|
||||||
|
router.put('/config/models/default', async function(req, res) {
|
||||||
|
try {
|
||||||
|
var { modelId } = req.body;
|
||||||
|
if (!modelId) return res.status(400).json({ error: 'modelId required' });
|
||||||
|
await db.setSetting('models.default', modelId.trim());
|
||||||
|
logger.audit(req.user.id, 'admin_model_default', 'Set default model: ' + modelId, req, { category: 'admin' });
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── POST add custom model (manual entry) ─────────────────────────────────
|
||||||
router.post('/config/models/custom', async function(req, res) {
|
router.post('/config/models/custom', async function(req, res) {
|
||||||
try {
|
try {
|
||||||
var { id, name, cost, category } = req.body;
|
var { id, name, cost, category } = req.body;
|
||||||
if (!id || !name) return res.status(400).json({ error: 'id and name required' });
|
if (!id || !name) return res.status(400).json({ error: 'id and name required' });
|
||||||
|
|
||||||
|
var trimmedId = id.trim();
|
||||||
|
if (trimmedId.length > 200) return res.status(400).json({ error: 'Model ID too long (max 200 chars)' });
|
||||||
|
if (!/^[a-zA-Z0-9._\-\/\:]+$/.test(trimmedId)) {
|
||||||
|
return res.status(400).json({ error: 'Invalid model ID format. Use only letters, numbers, dots, dashes, slashes, and colons.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if model ID conflicts with a built-in model (all providers)
|
||||||
|
var { OPENROUTER_MODELS, BEDROCK_MODELS, AZURE_MODELS, VERTEX_MODELS } = require('../utils/models');
|
||||||
|
var allBuiltIn = [].concat(OPENROUTER_MODELS, BEDROCK_MODELS, AZURE_MODELS, VERTEX_MODELS);
|
||||||
|
if (allBuiltIn.find(function(m) { return m.id === trimmedId; })) {
|
||||||
|
return res.status(400).json({ error: 'Model ID conflicts with a built-in model. Use the toggle to enable/disable built-in models.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
var validCategories = ['free', 'fast', 'smart', 'premium'];
|
||||||
|
var cat = validCategories.includes(category) ? category : 'smart';
|
||||||
|
|
||||||
var customRaw = await db.getSetting('models.custom') || '[]';
|
var customRaw = await db.getSetting('models.custom') || '[]';
|
||||||
var custom;
|
var custom;
|
||||||
try { custom = JSON.parse(customRaw); } catch(e) { custom = []; }
|
try { custom = JSON.parse(customRaw); } catch(e) { custom = []; }
|
||||||
|
|
||||||
// Prevent duplicates
|
var existing = custom.find(function(m) { return m.id === trimmedId; });
|
||||||
custom = custom.filter(function(m) { return m.id !== id; });
|
custom = custom.filter(function(m) { return m.id !== trimmedId; });
|
||||||
custom.push({ id: id.trim(), name: name.trim(), cost: cost || '?', category: category || 'smart', tag: 'CUSTOM' });
|
custom.push({ id: trimmedId, name: name.trim().substring(0, 100), cost: (cost || '?').substring(0, 20), category: cat, tag: 'CUSTOM' });
|
||||||
|
|
||||||
await db.setSetting('models.custom', JSON.stringify(custom));
|
await db.setSetting('models.custom', JSON.stringify(custom));
|
||||||
logger.audit(req.user.id, 'admin_model_add', 'Added custom model: ' + id, req, { category: 'admin' });
|
logger.audit(req.user.id, existing ? 'admin_model_update' : 'admin_model_add', (existing ? 'Updated' : 'Added') + ' custom model: ' + trimmedId, req, { category: 'admin' });
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── DELETE custom model ──────────────────────────────────────────────────
|
// ── DELETE custom model ───────────────────────────────────────────────────
|
||||||
router.delete('/config/models/custom/:modelId(*)', async function(req, res) {
|
router.delete('/config/models/custom/:modelId(*)', async function(req, res) {
|
||||||
try {
|
try {
|
||||||
var modelId = req.params.modelId;
|
var modelId = req.params.modelId;
|
||||||
|
|
@ -293,6 +313,95 @@ router.delete('/config/models/custom/:modelId(*)', async function(req, res) {
|
||||||
try { custom = JSON.parse(customRaw); } catch(e) { custom = []; }
|
try { custom = JSON.parse(customRaw); } catch(e) { custom = []; }
|
||||||
custom = custom.filter(function(m) { return m.id !== modelId; });
|
custom = custom.filter(function(m) { return m.id !== modelId; });
|
||||||
await db.setSetting('models.custom', JSON.stringify(custom));
|
await db.setSetting('models.custom', JSON.stringify(custom));
|
||||||
|
logger.audit(req.user.id, 'admin_model_delete', 'Removed custom model: ' + modelId, req, { category: 'admin' });
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── POST clear all custom/discovered models ───────────────────────────────
|
||||||
|
router.post('/config/models/clear-all', async function(req, res) {
|
||||||
|
try {
|
||||||
|
await db.setSetting('models.custom', '[]');
|
||||||
|
await db.setSetting('models.disabled', '[]');
|
||||||
|
await db.setSetting('models.default', '');
|
||||||
|
logger.audit(req.user.id, 'admin_models_clear_all', 'Cleared all custom models and disabled list', req, { category: 'admin' });
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── GET discover models from provider API ─────────────────────────────────
|
||||||
|
router.get('/config/models/discover', async function(req, res) {
|
||||||
|
try {
|
||||||
|
var { discoverModels } = require('../utils/ai');
|
||||||
|
var discovered = await discoverModels();
|
||||||
|
var search = (req.query.q || '').toLowerCase().trim();
|
||||||
|
if (search) {
|
||||||
|
discovered = discovered.filter(function(m) {
|
||||||
|
return m.id.toLowerCase().indexOf(search) !== -1 || m.name.toLowerCase().indexOf(search) !== -1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
res.json({ success: true, models: discovered, count: discovered.length });
|
||||||
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── POST add discovered model to custom list ──────────────────────────────
|
||||||
|
router.post('/config/models/add-discovered', async function(req, res) {
|
||||||
|
try {
|
||||||
|
var { id, name, cost, category } = req.body;
|
||||||
|
if (!id || !name) return res.status(400).json({ error: 'id and name required' });
|
||||||
|
|
||||||
|
var trimmedId = id.trim();
|
||||||
|
if (trimmedId.length > 200) return res.status(400).json({ error: 'Model ID too long (max 200 chars)' });
|
||||||
|
|
||||||
|
var validCategories = ['free', 'fast', 'smart', 'premium'];
|
||||||
|
var cat = validCategories.includes(category) ? category : 'smart';
|
||||||
|
|
||||||
|
var customRaw = await db.getSetting('models.custom') || '[]';
|
||||||
|
var custom;
|
||||||
|
try { custom = JSON.parse(customRaw); } catch(e) { custom = []; }
|
||||||
|
|
||||||
|
custom = custom.filter(function(m) { return m.id !== trimmedId; });
|
||||||
|
custom.push({ id: trimmedId, name: name.trim().substring(0, 100), cost: (cost || '?').substring(0, 20), category: cat, tag: 'DISCOVERED' });
|
||||||
|
|
||||||
|
await db.setSetting('models.custom', JSON.stringify(custom));
|
||||||
|
logger.audit(req.user.id, 'admin_model_discover_add', 'Added discovered model: ' + trimmedId, req, { category: 'admin' });
|
||||||
|
res.json({ success: true, id: trimmedId });
|
||||||
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// WILDCARD CONFIG — Must come AFTER all specific model routes above
|
||||||
|
// :key(*) matches slashes, so it would intercept /config/models/toggle
|
||||||
|
// and /config/models/default if registered before them.
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
// ── PUT update a single config entry ──────────────────────────────────────
|
||||||
|
router.put('/config/:key(*)', async function(req, res) {
|
||||||
|
try {
|
||||||
|
var key = req.params.key;
|
||||||
|
var value = req.body.value;
|
||||||
|
|
||||||
|
if (value === undefined || value === null) {
|
||||||
|
return res.status(400).json({ error: 'value is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Security: only allow known key prefixes
|
||||||
|
var allowed = ['announcement.', 'feature.', 'email.', 'prompt.', 'registration_enabled', 'site.', 'smtp.', 'models.'];
|
||||||
|
var isAllowed = allowed.some(function(p) { return key === p || key.startsWith(p); });
|
||||||
|
if (!isAllowed) {
|
||||||
|
return res.status(400).json({ error: 'Unknown config key' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.setSetting(key, String(value));
|
||||||
|
|
||||||
|
// Update in-memory prompt immediately if it's a prompt key
|
||||||
|
if (key.startsWith('prompt.')) {
|
||||||
|
var promptKey = key.replace('prompt.', '');
|
||||||
|
PROMPTS.updatePrompt(promptKey, String(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.audit(req.user.id, 'admin_config_update', 'Updated config: ' + key, req, { category: 'admin' });
|
||||||
|
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
});
|
});
|
||||||
|
|
|
||||||
149
src/routes/adminMilestones.js
Normal file
149
src/routes/adminMilestones.js
Normal file
|
|
@ -0,0 +1,149 @@
|
||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const { adminMiddleware } = require('../middleware/auth');
|
||||||
|
const db = require('../db/database');
|
||||||
|
|
||||||
|
// Get all milestones (optionally filtered by age group)
|
||||||
|
router.get('/milestones', adminMiddleware, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { age_group } = req.query;
|
||||||
|
let query = 'SELECT * FROM developmental_milestones';
|
||||||
|
let params = [];
|
||||||
|
|
||||||
|
if (age_group) {
|
||||||
|
query += ' WHERE age_group = $1';
|
||||||
|
params.push(age_group);
|
||||||
|
}
|
||||||
|
|
||||||
|
query += ' ORDER BY age_group, domain, sort_order, id';
|
||||||
|
|
||||||
|
const result = await db.query(query, params);
|
||||||
|
res.json({ success: true, milestones: result.rows });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get unique age groups and domains
|
||||||
|
router.get('/milestones/meta', adminMiddleware, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const ageGroups = await db.query(
|
||||||
|
'SELECT DISTINCT age_group FROM developmental_milestones ORDER BY age_group'
|
||||||
|
);
|
||||||
|
const domains = await db.query(
|
||||||
|
'SELECT DISTINCT domain FROM developmental_milestones ORDER BY domain'
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
age_groups: ageGroups.rows.map(r => r.age_group),
|
||||||
|
domains: domains.rows.map(r => r.domain)
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create new milestone
|
||||||
|
router.post('/milestones', adminMiddleware, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { age_group, domain, milestone_text, sort_order } = req.body;
|
||||||
|
|
||||||
|
if (!age_group || !domain || !milestone_text) {
|
||||||
|
return res.status(400).json({ error: 'Missing required fields' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await db.query(
|
||||||
|
`INSERT INTO developmental_milestones (age_group, domain, milestone_text, sort_order)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
RETURNING *`,
|
||||||
|
[age_group, domain, milestone_text, sort_order || 0]
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({ success: true, milestone: result.rows[0] });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update milestone
|
||||||
|
router.put('/milestones/:id', adminMiddleware, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { age_group, domain, milestone_text, sort_order } = req.body;
|
||||||
|
|
||||||
|
if (!age_group || !domain || !milestone_text) {
|
||||||
|
return res.status(400).json({ error: 'Missing required fields' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await db.query(
|
||||||
|
`UPDATE developmental_milestones
|
||||||
|
SET age_group = $1, domain = $2, milestone_text = $3, sort_order = $4, updated_at = NOW()
|
||||||
|
WHERE id = $5
|
||||||
|
RETURNING *`,
|
||||||
|
[age_group, domain, milestone_text, sort_order || 0, id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
return res.status(404).json({ error: 'Milestone not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: true, milestone: result.rows[0] });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete milestone
|
||||||
|
router.delete('/milestones/:id', adminMiddleware, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
const result = await db.query(
|
||||||
|
'DELETE FROM developmental_milestones WHERE id = $1 RETURNING *',
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
return res.status(404).json({ error: 'Milestone not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: true, message: 'Milestone deleted' });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Bulk import milestones (from static data)
|
||||||
|
router.post('/milestones/bulk-import', adminMiddleware, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { milestones, clearExisting } = req.body;
|
||||||
|
|
||||||
|
if (!Array.isArray(milestones)) {
|
||||||
|
return res.status(400).json({ error: 'Expected array of milestones' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear existing if requested
|
||||||
|
if (clearExisting) {
|
||||||
|
await db.query('DELETE FROM developmental_milestones');
|
||||||
|
}
|
||||||
|
|
||||||
|
let imported = 0;
|
||||||
|
for (const m of milestones) {
|
||||||
|
if (!m.age_group || !m.domain || !m.milestone_text) continue;
|
||||||
|
|
||||||
|
await db.query(
|
||||||
|
`INSERT INTO developmental_milestones (age_group, domain, milestone_text, sort_order)
|
||||||
|
VALUES ($1, $2, $3, $4)`,
|
||||||
|
[m.age_group, m.domain, m.milestone_text, m.sort_order || 0]
|
||||||
|
);
|
||||||
|
imported++;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: true, imported });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
93
src/routes/audioBackups.js
Normal file
93
src/routes/audioBackups.js
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
// ============================================================
|
||||||
|
// AUDIO BACKUPS ROUTES — Server-side audio backup storage
|
||||||
|
// Stores compressed audio in PostgreSQL, auto-deletes after 24h
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
var express = require('express');
|
||||||
|
var router = express.Router();
|
||||||
|
var zlib = require('zlib');
|
||||||
|
var multer = require('multer');
|
||||||
|
var db = require('../db/database');
|
||||||
|
var { authMiddleware } = require('../middleware/auth');
|
||||||
|
|
||||||
|
// 25MB upload limit (same as transcribe)
|
||||||
|
var upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 25 * 1024 * 1024 } });
|
||||||
|
|
||||||
|
router.use(authMiddleware);
|
||||||
|
|
||||||
|
// ── POST save audio backup (compressed) ──────────────────────────────────
|
||||||
|
router.post('/audio-backups', upload.single('audio'), async function(req, res) {
|
||||||
|
try {
|
||||||
|
if (!req.file) return res.status(400).json({ error: 'No audio file' });
|
||||||
|
|
||||||
|
var module = req.body.module || 'encounter';
|
||||||
|
var originalSize = req.file.size;
|
||||||
|
|
||||||
|
// Gzip compress the audio data
|
||||||
|
var compressed = await new Promise(function(resolve, reject) {
|
||||||
|
zlib.gzip(req.file.buffer, { level: 6 }, function(err, result) {
|
||||||
|
if (err) reject(err); else resolve(result);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
var result = await db.run(
|
||||||
|
'INSERT INTO audio_backups (user_id, module, mime_type, size_bytes, compressed_bytes, audio_data) VALUES ($1,$2,$3,$4,$5,$6)',
|
||||||
|
[req.user.id, module, req.file.mimetype || 'audio/webm', originalSize, compressed.length, compressed]
|
||||||
|
);
|
||||||
|
|
||||||
|
var ratio = originalSize > 0 ? Math.round((1 - compressed.length / originalSize) * 100) : 0;
|
||||||
|
console.log('[AudioBackup] Saved ' + (originalSize / 1024).toFixed(0) + 'KB → ' + (compressed.length / 1024).toFixed(0) + 'KB (' + ratio + '% compression)');
|
||||||
|
|
||||||
|
res.json({ success: true, id: result.lastInsertRowid, originalSize: originalSize, compressedSize: compressed.length });
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[AudioBackup] Save error:', e.message);
|
||||||
|
res.status(500).json({ error: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── GET list audio backups ───────────────────────────────────────────────
|
||||||
|
router.get('/audio-backups', async function(req, res) {
|
||||||
|
try {
|
||||||
|
var rows = await db.all(
|
||||||
|
"SELECT id, module, mime_type, size_bytes, compressed_bytes, created_at, expires_at FROM audio_backups WHERE user_id = $1 AND expires_at > NOW() ORDER BY created_at DESC",
|
||||||
|
[req.user.id]
|
||||||
|
);
|
||||||
|
res.json({ success: true, backups: rows });
|
||||||
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── GET download audio backup (decompressed) ─────────────────────────────
|
||||||
|
router.get('/audio-backups/:id/audio', async function(req, res) {
|
||||||
|
try {
|
||||||
|
var row = await db.get(
|
||||||
|
'SELECT audio_data, mime_type, size_bytes FROM audio_backups WHERE id = $1 AND user_id = $2 AND expires_at > NOW()',
|
||||||
|
[req.params.id, req.user.id]
|
||||||
|
);
|
||||||
|
if (!row) return res.status(404).json({ error: 'Backup not found or expired' });
|
||||||
|
|
||||||
|
// Decompress
|
||||||
|
var decompressed = await new Promise(function(resolve, reject) {
|
||||||
|
zlib.gunzip(row.audio_data, function(err, result) {
|
||||||
|
if (err) reject(err); else resolve(result);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
res.setHeader('Content-Type', row.mime_type || 'audio/webm');
|
||||||
|
res.setHeader('Content-Length', decompressed.length);
|
||||||
|
res.send(decompressed);
|
||||||
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── DELETE audio backup ──────────────────────────────────────────────────
|
||||||
|
router.delete('/audio-backups/:id', async function(req, res) {
|
||||||
|
try {
|
||||||
|
var result = await db.run(
|
||||||
|
'DELETE FROM audio_backups WHERE id = $1 AND user_id = $2',
|
||||||
|
[req.params.id, req.user.id]
|
||||||
|
);
|
||||||
|
if (result.changes === 0) return res.status(404).json({ error: 'Not found' });
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
|
|
@ -38,49 +38,71 @@ function safeAppUrl() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// EMAIL TEMPLATES — responsive, max-width constrained
|
// EMAIL TEMPLATES — markdown-style, Resend/Linear aesthetic
|
||||||
|
// Plain, spacious, no decorative chrome. Reads like a document.
|
||||||
// ============================================================
|
// ============================================================
|
||||||
function emailWrapper(body) {
|
function emailWrapper(body) {
|
||||||
return `<!DOCTYPE html><html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
|
var siteName = process.env.SITE_NAME || 'Pediatric AI Scribe';
|
||||||
<style>
|
var F = '-apple-system,BlinkMacSystemFont,\'Segoe UI\',Helvetica,Arial,sans-serif';
|
||||||
body,table,td{font-family:'Segoe UI',Roboto,Arial,sans-serif;}
|
return `<!DOCTYPE html>
|
||||||
@media only screen and (max-width:600px){
|
<html lang="en">
|
||||||
.email-outer{padding:16px 8px !important;}
|
<head>
|
||||||
.email-inner{width:100% !important;min-width:0 !important;}
|
<meta charset="UTF-8">
|
||||||
.email-body{padding:24px 20px !important;}
|
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||||
.email-header{padding:20px 20px !important;}
|
<title>${escHtml(siteName)}</title>
|
||||||
.email-footer{padding:12px 20px 20px !important;}
|
<style>
|
||||||
.email-btn{padding:12px 24px !important;font-size:14px !important;}
|
body,table,td,p,a{font-family:${F};}
|
||||||
}
|
@media only screen and (max-width:600px){
|
||||||
</style>
|
.outer{padding:24px 16px !important;}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body style="margin:0;padding:0;background:#f3f4f6;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%;">
|
<body style="margin:0;padding:0;background:#ffffff;-webkit-text-size-adjust:100%;">
|
||||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f3f4f6;">
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
|
||||||
<tr><td class="email-outer" style="padding:32px 16px;" align="center">
|
<tr><td class="outer" align="center" style="padding:48px 24px;">
|
||||||
<table role="presentation" class="email-inner" cellpadding="0" cellspacing="0" border="0" style="background:white;border-radius:12px;overflow:hidden;box-shadow:0 2px 8px rgba(0,0,0,0.08);max-width:520px;width:100%;">
|
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="max-width:520px;width:100%;">
|
||||||
<tr><td class="email-header" style="background:linear-gradient(135deg,#2563eb 0%,#7c3aed 100%);padding:24px 32px;">
|
|
||||||
<p style="margin:0;color:white;font-size:20px;font-weight:700;">Pediatric AI Scribe</p>
|
<!-- Wordmark -->
|
||||||
<p style="margin:4px 0 0;color:rgba(255,255,255,0.8);font-size:12px;">AI-Powered Clinical Documentation</p>
|
<tr><td style="padding-bottom:32px;border-bottom:1px solid #e5e7eb;">
|
||||||
|
<p style="margin:0;font-size:14px;font-weight:600;color:#111827;letter-spacing:-0.1px;">${escHtml(siteName)}</p>
|
||||||
</td></tr>
|
</td></tr>
|
||||||
<tr><td class="email-body" style="padding:32px;">${body}</td></tr>
|
|
||||||
<tr><td class="email-footer" style="padding:14px 32px 24px;border-top:1px solid #e5e7eb;">
|
<!-- Body -->
|
||||||
<p style="margin:0;color:#9ca3af;font-size:11px;line-height:1.5;">This email was sent by Pediatric AI Scribe. If you did not request this, you can safely ignore it.</p>
|
<tr><td style="padding:32px 0;">
|
||||||
|
${body}
|
||||||
</td></tr>
|
</td></tr>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<tr><td style="padding-top:24px;border-top:1px solid #e5e7eb;">
|
||||||
|
<p style="margin:0;font-size:12px;color:#9ca3af;line-height:1.7;">
|
||||||
|
${escHtml(siteName)} — AI-powered clinical documentation<br>
|
||||||
|
If you didn’t request this, you can safely ignore it.
|
||||||
|
</p>
|
||||||
|
</td></tr>
|
||||||
|
|
||||||
</table>
|
</table>
|
||||||
</td></tr>
|
</td></tr>
|
||||||
</table>
|
</table>
|
||||||
</body></html>`;
|
</body>
|
||||||
|
</html>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function btnHtml(href, label) {
|
function btnHtml(href, label) {
|
||||||
return `<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:24px auto;"><tr><td align="center" style="border-radius:8px;background:linear-gradient(135deg,#2563eb,#7c3aed);">
|
return `<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:24px 0;">
|
||||||
<a class="email-btn" href="${escHtml(href)}" target="_blank" style="display:inline-block;color:white;padding:13px 32px;border-radius:8px;text-decoration:none;font-weight:600;font-size:15px;font-family:'Segoe UI',Roboto,Arial,sans-serif;">${escHtml(label)}</a>
|
<tr><td style="border-radius:5px;background:#111827;">
|
||||||
</td></tr></table>`;
|
<a href="${escHtml(href)}" target="_blank"
|
||||||
|
style="display:inline-block;color:#ffffff;padding:11px 22px;border-radius:5px;
|
||||||
|
text-decoration:none;font-weight:500;font-size:14px;line-height:1;
|
||||||
|
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;">${escHtml(label)}</a>
|
||||||
|
</td></tr>
|
||||||
|
</table>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function linkFallback(url) {
|
function linkFallback(url) {
|
||||||
return `<p style="color:#6b7280;font-size:12px;margin:16px 0 4px;">Button not working? Copy this link into your browser:</p>
|
return `<p style="margin:16px 0 4px;font-size:13px;color:#6b7280;line-height:1.5;">If the button doesn’t work, copy this link:</p>
|
||||||
<p style="background:#f3f4f6;border-radius:6px;padding:10px 14px;font-size:11px;word-break:break-all;color:#374151;margin:0;max-width:100%;overflow-wrap:break-word;">${escHtml(url)}</p>`;
|
<p style="margin:0;padding:10px 12px;background:#f9fafb;border:1px solid #e5e7eb;border-radius:4px;
|
||||||
|
font-size:12px;font-family:'SFMono-Regular',Consolas,'Liberation Mono',Menlo,monospace;
|
||||||
|
word-break:break-all;color:#374151;line-height:1.6;">${escHtml(url)}</p>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Email helper — DB settings override env vars
|
// Email helper — DB settings override env vars
|
||||||
|
|
@ -183,11 +205,11 @@ router.get('/verify-email', async (req, res) => {
|
||||||
if (!token) return res.status(400).send('Missing token');
|
if (!token) return res.status(400).send('Missing token');
|
||||||
var user = await db.get('SELECT id, name FROM users WHERE verify_token = ? AND verify_expires > ?', [token, Date.now()]);
|
var user = await db.get('SELECT id, name FROM users WHERE verify_token = ? AND verify_expires > ?', [token, Date.now()]);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return res.send('<html><body style="font-family:sans-serif;text-align:center;padding:60px;"><h2 style="color:#ef4444;">Invalid or Expired Link</h2><p><a href="' + safeAppUrl() + '">Go to app</a></p></body></html>');
|
return res.send('<!DOCTYPE html><html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Link Expired</title></head><body style="margin:0;padding:40px 24px;font-family:-apple-system,BlinkMacSystemFont,\'Segoe UI\',Helvetica,Arial,sans-serif;background:#fafafa;text-align:center;"><p style="font-size:15px;font-weight:600;color:#111827;margin:0 0 8px;">Link expired or invalid</p><p style="color:#6b7280;font-size:14px;margin:0 0 24px;">This verification link has expired. Request a new one from the app.</p><a href="' + safeAppUrl() + '" style="display:inline-block;background:#111827;color:#fff;padding:10px 22px;border-radius:6px;text-decoration:none;font-size:14px;font-weight:500;">Go to app</a></body></html>');
|
||||||
}
|
}
|
||||||
await db.run('UPDATE users SET email_verified = true, verify_token = NULL, verify_expires = NULL WHERE id = ?', [user.id]);
|
await db.run('UPDATE users SET email_verified = true, verify_token = NULL, verify_expires = NULL WHERE id = ?', [user.id]);
|
||||||
await db.run('INSERT INTO audit_log (user_id, action) VALUES (?, ?)', [user.id, 'email_verified']);
|
await db.run('INSERT INTO audit_log (user_id, action) VALUES (?, ?)', [user.id, 'email_verified']);
|
||||||
res.send('<html><body style="font-family:sans-serif;text-align:center;padding:60px;"><h2 style="color:#10b981;">Email Verified!</h2><p>Welcome, ' + escHtml(user.name) + '!</p><p style="margin-top:20px;"><a href="' + safeAppUrl() + '" style="background:#2563eb;color:white;padding:12px 30px;border-radius:8px;text-decoration:none;">Open App</a></p></body></html>');
|
res.send('<!DOCTYPE html><html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Email Verified</title></head><body style="margin:0;padding:40px 24px;font-family:-apple-system,BlinkMacSystemFont,\'Segoe UI\',Helvetica,Arial,sans-serif;background:#fafafa;text-align:center;"><p style="font-size:15px;font-weight:600;color:#111827;margin:0 0 8px;">Email verified</p><p style="color:#6b7280;font-size:14px;margin:0 0 24px;">Welcome, ' + escHtml(user.name) + '. You\'re all set.</p><a href="' + safeAppUrl() + '" style="display:inline-block;background:#111827;color:#fff;padding:10px 22px;border-radius:6px;text-decoration:none;font-size:14px;font-weight:500;">Open app</a></body></html>');
|
||||||
} catch (err) { console.error('[Auth] Verify error:', err.message); res.status(500).send('Verification failed. Please try again.'); }
|
} catch (err) { console.error('[Auth] Verify error:', err.message); res.status(500).send('Verification failed. Please try again.'); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,31 +19,38 @@ router.post('/generate-chart-review', authMiddleware, async (req, res) => {
|
||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
const today = new Date().toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
|
const today = new Date().toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
|
||||||
let prompt;
|
|
||||||
let clinicalData = `Today's date: ${today}\nPatient: ${patientAge || 'Unknown'}, ${patientGender || 'Unknown'}`;
|
let clinicalData = `Today's date: ${today}\nPatient: ${patientAge || 'Unknown'}, ${patientGender || 'Unknown'}`;
|
||||||
if (pmh) clinicalData += `\nPMH: ${pmh}`;
|
if (pmh) clinicalData += `\nPMH: ${pmh}`;
|
||||||
|
|
||||||
if (type === 'ed' || (edVisits && edVisits.length > 0)) {
|
// Use the top-level review type to select the prompt —
|
||||||
|
// the per-visit types only control data formatting, not the prompt.
|
||||||
|
let prompt;
|
||||||
|
if (type === 'ed') {
|
||||||
prompt = PROMPTS.chartReviewED;
|
prompt = PROMPTS.chartReviewED;
|
||||||
(edVisits || []).forEach(v => {
|
} else if (type === 'subspecialty') {
|
||||||
clinicalData += `\n\n=== ED VISIT (${v.date}) ===\n${v.content}`;
|
|
||||||
if (v.labs) clinicalData += `\nLabs: ${v.labs}`;
|
|
||||||
});
|
|
||||||
} else if (type === 'subspecialty' || (subspecialty && subspecialty.length > 0)) {
|
|
||||||
prompt = PROMPTS.chartReviewSubspecialty;
|
prompt = PROMPTS.chartReviewSubspecialty;
|
||||||
(subspecialty || []).forEach(s => {
|
|
||||||
clinicalData += `\n\n=== ${(s.specialty || 'Subspecialty').toUpperCase()} — ${s.specialistName || 'Unknown'} (${s.date}) ===\n${s.content}`;
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
prompt = PROMPTS.chartReviewOutpatient;
|
prompt = PROMPTS.chartReviewOutpatient;
|
||||||
(visits || []).forEach(v => {
|
|
||||||
clinicalData += `\n\n=== ${(v.type || 'Visit').toUpperCase()} (${v.date}) ===\n${v.content}`;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Include ALL visit data regardless of per-visit type —
|
||||||
|
// an outpatient chart review should include subspecialty consults too
|
||||||
|
(visits || []).forEach(v => {
|
||||||
|
clinicalData += `\n\n=== OUTPATIENT VISIT (${v.date}) ===\n${v.content}`;
|
||||||
|
if (v.labs && v.labs.trim()) clinicalData += `\n--- Labs from this visit (${v.date}) ---\n${v.labs}`;
|
||||||
|
});
|
||||||
|
(subspecialty || []).forEach(s => {
|
||||||
|
clinicalData += `\n\n=== SUBSPECIALTY: ${(s.specialty || 'Subspecialty').toUpperCase()} — ${s.specialistName || 'Unknown'} (${s.date}) ===\n${s.content}`;
|
||||||
|
if (s.labs && s.labs.trim()) clinicalData += `\n--- Labs from this visit (${s.date}) ---\n${s.labs}`;
|
||||||
|
});
|
||||||
|
(edVisits || []).forEach(v => {
|
||||||
|
clinicalData += `\n\n=== ED VISIT (${v.date}) ===\n${v.content}`;
|
||||||
|
if (v.labs && v.labs.trim()) clinicalData += `\n--- Labs from this visit (${v.date}) ---\n${v.labs}`;
|
||||||
|
});
|
||||||
|
|
||||||
if (labs && labs.length > 0) {
|
if (labs && labs.length > 0) {
|
||||||
clinicalData += '\n\n=== LABS ===';
|
clinicalData += '\n\n=== ADDITIONAL LABS (not tied to a specific visit) ===';
|
||||||
labs.forEach(l => { clinicalData += `\n${l.date}: ${l.values}`; });
|
labs.forEach(l => { clinicalData += `\n${l.date ? l.date + ': ' : ''}${l.values}`; });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (additionalInstructions) {
|
if (additionalInstructions) {
|
||||||
|
|
|
||||||
156
src/routes/documents.js
Normal file
156
src/routes/documents.js
Normal file
|
|
@ -0,0 +1,156 @@
|
||||||
|
// ============================================================
|
||||||
|
// DOCUMENTS ROUTES — S3-backed document upload & management
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
var express = require('express');
|
||||||
|
var router = express.Router();
|
||||||
|
var multer = require('multer');
|
||||||
|
var crypto = require('crypto');
|
||||||
|
var db = require('../db/database');
|
||||||
|
var { authMiddleware } = require('../middleware/auth');
|
||||||
|
var logger = require('../utils/logger');
|
||||||
|
|
||||||
|
router.use(authMiddleware);
|
||||||
|
|
||||||
|
var upload = multer({
|
||||||
|
storage: multer.memoryStorage(),
|
||||||
|
limits: { fileSize: 10 * 1024 * 1024 } // 10 MB
|
||||||
|
});
|
||||||
|
|
||||||
|
var ALLOWED_TYPES = [
|
||||||
|
'application/pdf',
|
||||||
|
'image/jpeg', 'image/png', 'image/gif',
|
||||||
|
'application/msword',
|
||||||
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||||
|
'text/plain', 'text/csv'
|
||||||
|
];
|
||||||
|
|
||||||
|
// Lazy-load S3 client
|
||||||
|
var _s3Client = null;
|
||||||
|
function getS3Client() {
|
||||||
|
if (_s3Client) return _s3Client;
|
||||||
|
try {
|
||||||
|
var { S3Client } = require('@aws-sdk/client-s3');
|
||||||
|
var region = process.env.S3_REGION || process.env.AWS_BEDROCK_REGION || 'us-east-1';
|
||||||
|
var config = { region: region };
|
||||||
|
|
||||||
|
// Custom endpoint for S3-compatible providers (Backblaze B2, MinIO, etc.)
|
||||||
|
if (process.env.S3_ENDPOINT) {
|
||||||
|
config.endpoint = process.env.S3_ENDPOINT;
|
||||||
|
config.forcePathStyle = process.env.S3_FORCE_PATH_STYLE === 'true'; // Required for MinIO
|
||||||
|
}
|
||||||
|
|
||||||
|
// Credentials: use S3-specific keys first, fall back to AWS keys
|
||||||
|
var accessKey = process.env.S3_ACCESS_KEY_ID || process.env.AWS_ACCESS_KEY_ID;
|
||||||
|
var secretKey = process.env.S3_SECRET_ACCESS_KEY || process.env.AWS_SECRET_ACCESS_KEY;
|
||||||
|
if (accessKey && secretKey) {
|
||||||
|
config.credentials = {
|
||||||
|
accessKeyId: accessKey,
|
||||||
|
secretAccessKey: secretKey
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
_s3Client = new S3Client(config);
|
||||||
|
return _s3Client;
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isS3Configured() {
|
||||||
|
return !!process.env.S3_BUCKET && !!getS3Client();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GET list user documents ────────────────────────────────────────────
|
||||||
|
router.get('/documents', async function(req, res) {
|
||||||
|
try {
|
||||||
|
if (!isS3Configured()) return res.json({ success: true, documents: [], s3_configured: false });
|
||||||
|
var rows = await db.all(
|
||||||
|
'SELECT id, filename, mime_type, size_bytes, description, created_at FROM user_documents WHERE user_id = $1 ORDER BY created_at DESC',
|
||||||
|
[req.user.id]
|
||||||
|
);
|
||||||
|
res.json({ success: true, documents: rows, s3_configured: true });
|
||||||
|
} catch (e) { logger.error('GET /documents', e.message); res.status(500).json({ error: e.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── POST upload document ───────────────────────────────────────────────
|
||||||
|
router.post('/documents/upload', upload.single('file'), async function(req, res) {
|
||||||
|
try {
|
||||||
|
if (!isS3Configured()) return res.status(400).json({ error: 'S3 not configured. Set S3_BUCKET and S3_REGION in .env' });
|
||||||
|
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
|
||||||
|
if (!ALLOWED_TYPES.includes(req.file.mimetype)) {
|
||||||
|
return res.status(400).json({ error: 'File type not allowed. Supported: PDF, images, Word docs, text, CSV' });
|
||||||
|
}
|
||||||
|
|
||||||
|
var { PutObjectCommand } = require('@aws-sdk/client-s3');
|
||||||
|
var prefix = process.env.S3_PREFIX || 'documents/';
|
||||||
|
var uuid = crypto.randomUUID();
|
||||||
|
var s3Key = prefix + req.user.id + '/' + uuid + '/' + req.file.originalname;
|
||||||
|
|
||||||
|
await getS3Client().send(new PutObjectCommand({
|
||||||
|
Bucket: process.env.S3_BUCKET,
|
||||||
|
Key: s3Key,
|
||||||
|
Body: req.file.buffer,
|
||||||
|
ContentType: req.file.mimetype,
|
||||||
|
ServerSideEncryption: 'AES256'
|
||||||
|
}));
|
||||||
|
|
||||||
|
var result = await db.run(
|
||||||
|
'INSERT INTO user_documents (user_id, s3_key, filename, mime_type, size_bytes, description) VALUES ($1,$2,$3,$4,$5,$6)',
|
||||||
|
[req.user.id, s3Key, req.file.originalname, req.file.mimetype, req.file.size, req.body.description || '']
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({ success: true, id: result.lastInsertRowid, filename: req.file.originalname });
|
||||||
|
} catch (e) { logger.error('POST /documents/upload', e.message); res.status(500).json({ error: e.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── GET download document (presigned URL) ──────────────────────────────
|
||||||
|
router.get('/documents/:id/download', async function(req, res) {
|
||||||
|
try {
|
||||||
|
if (!isS3Configured()) return res.status(400).json({ error: 'S3 not configured' });
|
||||||
|
var doc = await db.get(
|
||||||
|
'SELECT * FROM user_documents WHERE id = $1 AND user_id = $2',
|
||||||
|
[req.params.id, req.user.id]
|
||||||
|
);
|
||||||
|
if (!doc) return res.status(404).json({ error: 'Document not found' });
|
||||||
|
|
||||||
|
var { GetObjectCommand } = require('@aws-sdk/client-s3');
|
||||||
|
var { getSignedUrl } = require('@aws-sdk/s3-request-presigner');
|
||||||
|
|
||||||
|
var command = new GetObjectCommand({
|
||||||
|
Bucket: process.env.S3_BUCKET,
|
||||||
|
Key: doc.s3_key,
|
||||||
|
ResponseContentDisposition: 'attachment; filename="' + doc.filename + '"'
|
||||||
|
});
|
||||||
|
var url = await getSignedUrl(getS3Client(), command, { expiresIn: 300 }); // 5 min
|
||||||
|
|
||||||
|
res.json({ success: true, url: url });
|
||||||
|
} catch (e) { logger.error('GET /documents/:id/download', e.message); res.status(500).json({ error: e.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── DELETE document ────────────────────────────────────────────────────
|
||||||
|
router.delete('/documents/:id', async function(req, res) {
|
||||||
|
try {
|
||||||
|
if (!isS3Configured()) return res.status(400).json({ error: 'S3 not configured' });
|
||||||
|
var doc = await db.get(
|
||||||
|
'SELECT * FROM user_documents WHERE id = $1 AND user_id = $2',
|
||||||
|
[req.params.id, req.user.id]
|
||||||
|
);
|
||||||
|
if (!doc) return res.status(404).json({ error: 'Document not found' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
var { DeleteObjectCommand } = require('@aws-sdk/client-s3');
|
||||||
|
await getS3Client().send(new DeleteObjectCommand({
|
||||||
|
Bucket: process.env.S3_BUCKET,
|
||||||
|
Key: doc.s3_key
|
||||||
|
}));
|
||||||
|
} catch (s3err) {
|
||||||
|
logger.warn('S3 delete failed (continuing with DB delete):', s3err.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.run('DELETE FROM user_documents WHERE id = $1 AND user_id = $2', [req.params.id, req.user.id]);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (e) { logger.error('DELETE /documents/:id', e.message); res.status(500).json({ error: e.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
|
|
@ -36,7 +36,7 @@ router.get('/encounters/saved/:id', async function(req, res) {
|
||||||
// ── POST save/update encounter progress ─────────────────────────────────
|
// ── POST save/update encounter progress ─────────────────────────────────
|
||||||
router.post('/encounters/saved', async function(req, res) {
|
router.post('/encounters/saved', async function(req, res) {
|
||||||
try {
|
try {
|
||||||
var { id, label, enc_type, transcript, generated_note, partial_data, status } = req.body;
|
var { id, label, enc_type, transcript, generated_note, partial_data, status, idempotency_key } = req.body;
|
||||||
var autoDeleteDays = parseInt(await db.getSetting('site.auto_delete_days') || '7', 10);
|
var autoDeleteDays = parseInt(await db.getSetting('site.auto_delete_days') || '7', 10);
|
||||||
|
|
||||||
if (id) {
|
if (id) {
|
||||||
|
|
@ -56,9 +56,41 @@ router.post('/encounters/saved', async function(req, res) {
|
||||||
);
|
);
|
||||||
res.json({ success: true, id: id });
|
res.json({ success: true, id: id });
|
||||||
} else {
|
} else {
|
||||||
|
// Enforce unique label per user (within active/non-expired encounters)
|
||||||
|
if (label && label.trim()) {
|
||||||
|
var labelDup = await db.get(
|
||||||
|
"SELECT id FROM saved_encounters WHERE user_id = $1 AND LOWER(label) = LOWER($2) AND expires_at > NOW()",
|
||||||
|
[req.user.id, label.trim()]
|
||||||
|
);
|
||||||
|
if (labelDup) {
|
||||||
|
return res.status(409).json({ error: 'An encounter with this label already exists. Use a unique label or load the existing one.' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Check for duplicate via idempotency_key
|
||||||
|
if (idempotency_key) {
|
||||||
|
var dup = await db.get(
|
||||||
|
'SELECT id FROM saved_encounters WHERE user_id = $1 AND idempotency_key = $2',
|
||||||
|
[req.user.id, idempotency_key]
|
||||||
|
);
|
||||||
|
if (dup) {
|
||||||
|
// Update existing instead of creating duplicate
|
||||||
|
await db.run(
|
||||||
|
'UPDATE saved_encounters SET label=$1, transcript=$2, generated_note=$3, partial_data=$4, status=$5, updated_at=NOW() WHERE id=$6 AND user_id=$7',
|
||||||
|
[
|
||||||
|
label || 'Untitled',
|
||||||
|
transcript || '',
|
||||||
|
generated_note || '',
|
||||||
|
typeof partial_data === 'object' ? JSON.stringify(partial_data) : (partial_data || '{}'),
|
||||||
|
status || 'active',
|
||||||
|
dup.id, req.user.id
|
||||||
|
]
|
||||||
|
);
|
||||||
|
return res.json({ success: true, id: dup.id });
|
||||||
|
}
|
||||||
|
}
|
||||||
// Create new
|
// Create new
|
||||||
var result = await db.run(
|
var result = await db.run(
|
||||||
'INSERT INTO saved_encounters (user_id, label, enc_type, transcript, generated_note, partial_data, status, expires_at) VALUES ($1,$2,$3,$4,$5,$6,$7, NOW() + ($8 || \' days\')::INTERVAL)',
|
'INSERT INTO saved_encounters (user_id, label, enc_type, transcript, generated_note, partial_data, status, idempotency_key, expires_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8, NOW() + ($9 || \' days\')::INTERVAL)',
|
||||||
[
|
[
|
||||||
req.user.id,
|
req.user.id,
|
||||||
label || 'Untitled',
|
label || 'Untitled',
|
||||||
|
|
@ -67,6 +99,7 @@ router.post('/encounters/saved', async function(req, res) {
|
||||||
generated_note || '',
|
generated_note || '',
|
||||||
typeof partial_data === 'object' ? JSON.stringify(partial_data) : (partial_data || '{}'),
|
typeof partial_data === 'object' ? JSON.stringify(partial_data) : (partial_data || '{}'),
|
||||||
status || 'active',
|
status || 'active',
|
||||||
|
idempotency_key || null,
|
||||||
autoDeleteDays
|
autoDeleteDays
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,8 @@ router.post('/generate-hospital-course', authMiddleware, async (req, res) => {
|
||||||
los, // length of stay
|
los, // length of stay
|
||||||
model,
|
model,
|
||||||
formatPreference, // 'auto' | 'prose' | 'dayByDay' | 'organSystem'
|
formatPreference, // 'auto' | 'prose' | 'dayByDay' | 'organSystem'
|
||||||
additionalInstructions
|
additionalInstructions,
|
||||||
|
physicianMemories
|
||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
if (!notes || notes.length === 0) {
|
if (!notes || notes.length === 0) {
|
||||||
|
|
@ -80,6 +81,8 @@ router.post('/generate-hospital-course', authMiddleware, async (req, res) => {
|
||||||
prompt += `\n\nADDITIONAL INSTRUCTIONS FROM PHYSICIAN:\n${additionalInstructions}`;
|
prompt += `\n\nADDITIONAL INSTRUCTIONS FROM PHYSICIAN:\n${additionalInstructions}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (physicianMemories) clinicalData += '\n\n[PHYSICIAN PREFERENCES & LEARNED PATTERNS — Apply these preferences to your output. These reflect how this physician writes notes, their preferred style, terminology, and corrections from past outputs. Adapt your response accordingly, but user instructions in the current prompt take priority if they conflict.]\n' + physicianMemories + '\n[END PREFERENCES]';
|
||||||
|
|
||||||
const result = await callAI([
|
const result = await callAI([
|
||||||
{ role: 'system', content: prompt },
|
{ role: 'system', content: prompt },
|
||||||
{ role: 'user', content: clinicalData }
|
{ role: 'user', content: clinicalData }
|
||||||
|
|
|
||||||
|
|
@ -7,14 +7,17 @@ const { authMiddleware } = require('../middleware/auth');
|
||||||
// HPI from encounter
|
// HPI from encounter
|
||||||
router.post('/generate-hpi-encounter', authMiddleware, async (req, res) => {
|
router.post('/generate-hpi-encounter', authMiddleware, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { transcript, patientAge, patientGender, model, setting } = req.body;
|
const { transcript, patientAge, patientGender, model, setting, physicianMemories } = req.body;
|
||||||
if (!transcript || !transcript.trim()) return res.status(400).json({ error: 'Transcript empty' });
|
if (!transcript || !transcript.trim()) return res.status(400).json({ error: 'Transcript empty' });
|
||||||
|
|
||||||
const prompt = setting === 'inpatient' ? PROMPTS.hpiInpatient : PROMPTS.hpiEncounter;
|
const prompt = setting === 'inpatient' ? PROMPTS.hpiInpatient : PROMPTS.hpiEncounter;
|
||||||
|
|
||||||
|
var context = `Patient: ${patientAge || 'Unknown'}, ${patientGender || 'Unknown'}\nSetting: ${setting || 'outpatient'}\n\nTRANSCRIPT:\n${transcript}`;
|
||||||
|
if (physicianMemories) context += '\n\n[PHYSICIAN PREFERENCES & LEARNED PATTERNS — Apply these preferences to your output. These reflect how this physician writes notes, their preferred style, terminology, and corrections from past outputs. Adapt your response accordingly, but user instructions in the current prompt take priority if they conflict.]\n' + physicianMemories + '\n[END PREFERENCES]';
|
||||||
|
|
||||||
const result = await callAI([
|
const result = await callAI([
|
||||||
{ role: 'system', content: prompt },
|
{ role: 'system', content: prompt },
|
||||||
{ role: 'user', content: `Patient: ${patientAge || 'Unknown'}, ${patientGender || 'Unknown'}\nSetting: ${setting || 'outpatient'}\n\nTRANSCRIPT:\n${transcript}` }
|
{ role: 'user', content: context }
|
||||||
], { model });
|
], { model });
|
||||||
|
|
||||||
res.json({ success: true, hpi: result.content, model: result.model });
|
res.json({ success: true, hpi: result.content, model: result.model });
|
||||||
|
|
@ -26,14 +29,17 @@ router.post('/generate-hpi-encounter', authMiddleware, async (req, res) => {
|
||||||
// HPI from dictation
|
// HPI from dictation
|
||||||
router.post('/generate-hpi-dictation', authMiddleware, async (req, res) => {
|
router.post('/generate-hpi-dictation', authMiddleware, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { transcript, patientAge, patientGender, model, setting } = req.body;
|
const { transcript, patientAge, patientGender, model, setting, physicianMemories } = req.body;
|
||||||
if (!transcript || !transcript.trim()) return res.status(400).json({ error: 'Dictation empty' });
|
if (!transcript || !transcript.trim()) return res.status(400).json({ error: 'Dictation empty' });
|
||||||
|
|
||||||
const prompt = setting === 'inpatient' ? PROMPTS.hpiInpatient : PROMPTS.hpiDictation;
|
const prompt = setting === 'inpatient' ? PROMPTS.hpiInpatient : PROMPTS.hpiDictation;
|
||||||
|
|
||||||
|
var context = `Patient: ${patientAge || 'Unknown'}, ${patientGender || 'Unknown'}\nSetting: ${setting || 'outpatient'}\n\nDICTATION:\n${transcript}`;
|
||||||
|
if (physicianMemories) context += '\n\n[PHYSICIAN PREFERENCES & LEARNED PATTERNS — Apply these preferences to your output. These reflect how this physician writes notes, their preferred style, terminology, and corrections from past outputs. Adapt your response accordingly, but user instructions in the current prompt take priority if they conflict.]\n' + physicianMemories + '\n[END PREFERENCES]';
|
||||||
|
|
||||||
const result = await callAI([
|
const result = await callAI([
|
||||||
{ role: 'system', content: prompt },
|
{ role: 'system', content: prompt },
|
||||||
{ role: 'user', content: `Patient: ${patientAge || 'Unknown'}, ${patientGender || 'Unknown'}\nSetting: ${setting || 'outpatient'}\n\nDICTATION:\n${transcript}` }
|
{ role: 'user', content: context }
|
||||||
], { model });
|
], { model });
|
||||||
|
|
||||||
res.json({ success: true, hpi: result.content, model: result.model });
|
res.json({ success: true, hpi: result.content, model: result.model });
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,26 @@ router.use(moderatorMiddleware);
|
||||||
|
|
||||||
var upload = multer({
|
var upload = multer({
|
||||||
storage: multer.memoryStorage(),
|
storage: multer.memoryStorage(),
|
||||||
limits: { fileSize: 20 * 1024 * 1024 } // 20 MB
|
limits: {
|
||||||
|
fileSize: 100 * 1024 * 1024, // 100 MB per file (large PDFs supported)
|
||||||
|
files: 10 // max 10 files at once
|
||||||
|
},
|
||||||
|
fileFilter: function(req, file, cb) {
|
||||||
|
// Whitelist allowed file types
|
||||||
|
var allowed = [
|
||||||
|
'application/pdf',
|
||||||
|
'text/plain',
|
||||||
|
'text/markdown',
|
||||||
|
'text/html',
|
||||||
|
'text/csv',
|
||||||
|
'application/json'
|
||||||
|
];
|
||||||
|
if (allowed.includes(file.mimetype) || file.originalname.match(/\.(pdf|txt|md|html|htm|csv|json)$/i)) {
|
||||||
|
cb(null, true);
|
||||||
|
} else {
|
||||||
|
cb(new Error('File type not allowed. Only PDF, TXT, MD, HTML, CSV, JSON are supported.'));
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Text extraction helpers ──────────────────────────────────
|
// ── Text extraction helpers ──────────────────────────────────
|
||||||
|
|
@ -156,7 +175,7 @@ Rules:
|
||||||
// ── POST /api/admin/learning/ai-generate ────────────────────
|
// ── POST /api/admin/learning/ai-generate ────────────────────
|
||||||
// Accepts: multipart/form-data OR application/json
|
// Accepts: multipart/form-data OR application/json
|
||||||
|
|
||||||
router.post('/ai-generate', upload.single('file'), async function(req, res) {
|
router.post('/ai-generate', upload.array('files', 10), async function(req, res) {
|
||||||
try {
|
try {
|
||||||
var topic = req.body.topic || '';
|
var topic = req.body.topic || '';
|
||||||
var contentType = req.body.contentType || 'article';
|
var contentType = req.body.contentType || 'article';
|
||||||
|
|
@ -168,10 +187,23 @@ router.post('/ai-generate', upload.single('file'), async function(req, res) {
|
||||||
var slideCount = parseInt(req.body.slideCount) || 0;
|
var slideCount = parseInt(req.body.slideCount) || 0;
|
||||||
|
|
||||||
var docText = '';
|
var docText = '';
|
||||||
|
var fileCount = 0;
|
||||||
|
|
||||||
// 1 — Uploaded file
|
// 1 — Uploaded files (multiple)
|
||||||
if (req.file) {
|
if (req.files && req.files.length > 0) {
|
||||||
docText = await extractText(req.file.buffer, req.file.mimetype, req.file.originalname);
|
var allTexts = [];
|
||||||
|
for (var i = 0; i < req.files.length; i++) {
|
||||||
|
var file = req.files[i];
|
||||||
|
try {
|
||||||
|
var text = await extractText(file.buffer, file.mimetype, file.originalname);
|
||||||
|
allTexts.push('### Source File: ' + file.originalname + '\n\n' + text);
|
||||||
|
fileCount++;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[LearningAI] Failed to extract text from ' + file.originalname + ':', e.message);
|
||||||
|
// Continue with other files even if one fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
docText = allTexts.join('\n\n---\n\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2 — Nextcloud WebDAV path
|
// 2 — Nextcloud WebDAV path
|
||||||
|
|
@ -287,7 +319,8 @@ router.post('/ai-generate', upload.single('file'), async function(req, res) {
|
||||||
success: true,
|
success: true,
|
||||||
content: parsed,
|
content: parsed,
|
||||||
model: result.model,
|
model: result.model,
|
||||||
docLength: docText.length
|
docLength: docText.length,
|
||||||
|
fileCount: fileCount || (webdavPath ? 1 : 0)
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ var express = require('express');
|
||||||
var router = express.Router();
|
var router = express.Router();
|
||||||
var db = require('../db/database');
|
var db = require('../db/database');
|
||||||
var { authMiddleware, moderatorMiddleware } = require('../middleware/auth');
|
var { authMiddleware, moderatorMiddleware } = require('../middleware/auth');
|
||||||
|
var { generateContentEmbedding, isEmbeddingsAvailable } = require('../utils/embeddings');
|
||||||
|
|
||||||
router.use(authMiddleware);
|
router.use(authMiddleware);
|
||||||
router.use(moderatorMiddleware);
|
router.use(moderatorMiddleware);
|
||||||
|
|
@ -149,7 +150,22 @@ router.post('/content', async function(req, res) {
|
||||||
[title.trim(), slug, body || '', category_id || null, subject || '', content_type || 'article', published ? true : false, req.user.id]
|
[title.trim(), slug, body || '', category_id || null, subject || '', content_type || 'article', published ? true : false, req.user.id]
|
||||||
);
|
);
|
||||||
|
|
||||||
res.json({ success: true, id: result.lastInsertRowid, slug: slug });
|
var contentId = result.lastInsertRowid;
|
||||||
|
|
||||||
|
// Generate embedding asynchronously (don't block response)
|
||||||
|
if (isEmbeddingsAvailable() && body && body.trim()) {
|
||||||
|
generateContentEmbedding({ title: title.trim(), subject: subject || '', body: body })
|
||||||
|
.then(function(embedding) {
|
||||||
|
return db.query(
|
||||||
|
'UPDATE learning_content SET embedding = $1 WHERE id = $2',
|
||||||
|
[JSON.stringify(embedding), contentId]
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.then(function() { console.log('[Embeddings] Generated for content ID:', contentId); })
|
||||||
|
.catch(function(err) { console.error('[Embeddings] Failed for content ID ' + contentId + ':', err.message); });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: true, id: contentId, slug: slug });
|
||||||
} catch (err) { console.error('[LearningAdmin]', err.message); res.status(500).json({ error: 'Internal server error' }); }
|
} catch (err) { console.error('[LearningAdmin]', err.message); res.status(500).json({ error: 'Internal server error' }); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -160,19 +176,38 @@ router.put('/content/:id', async function(req, res) {
|
||||||
|
|
||||||
var { title, body, category_id, subject, content_type, published } = req.body;
|
var { title, body, category_id, subject, content_type, published } = req.body;
|
||||||
|
|
||||||
|
var newTitle = title !== undefined ? title : item.title;
|
||||||
|
var newBody = body !== undefined ? body : item.body;
|
||||||
|
var newSubject = subject !== undefined ? subject : item.subject;
|
||||||
|
|
||||||
await db.run(
|
await db.run(
|
||||||
'UPDATE learning_content SET title = ?, body = ?, category_id = ?, subject = ?, content_type = ?, published = ?, updated_at = NOW() WHERE id = ?',
|
'UPDATE learning_content SET title = ?, body = ?, category_id = ?, subject = ?, content_type = ?, published = ?, updated_at = NOW() WHERE id = ?',
|
||||||
[
|
[
|
||||||
title !== undefined ? title : item.title,
|
newTitle,
|
||||||
body !== undefined ? body : item.body,
|
newBody,
|
||||||
category_id !== undefined ? (category_id || null) : item.category_id,
|
category_id !== undefined ? (category_id || null) : item.category_id,
|
||||||
subject !== undefined ? subject : item.subject,
|
newSubject,
|
||||||
content_type !== undefined ? content_type : item.content_type,
|
content_type !== undefined ? content_type : item.content_type,
|
||||||
published !== undefined ? published : item.published,
|
published !== undefined ? published : item.published,
|
||||||
item.id
|
item.id
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Regenerate embedding if title/body/subject changed (async, don't block)
|
||||||
|
if (isEmbeddingsAvailable() && (title !== undefined || body !== undefined || subject !== undefined)) {
|
||||||
|
if (newBody && newBody.trim()) {
|
||||||
|
generateContentEmbedding({ title: newTitle, subject: newSubject, body: newBody })
|
||||||
|
.then(function(embedding) {
|
||||||
|
return db.query(
|
||||||
|
'UPDATE learning_content SET embedding = $1 WHERE id = $2',
|
||||||
|
[JSON.stringify(embedding), item.id]
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.then(function() { console.log('[Embeddings] Regenerated for content ID:', item.id); })
|
||||||
|
.catch(function(err) { console.error('[Embeddings] Failed to regenerate for content ID ' + item.id + ':', err.message); });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
} catch (err) { console.error('[LearningAdmin]', err.message); res.status(500).json({ error: 'Internal server error' }); }
|
} catch (err) { console.error('[LearningAdmin]', err.message); res.status(500).json({ error: 'Internal server error' }); }
|
||||||
});
|
});
|
||||||
|
|
@ -264,6 +299,7 @@ router.get('/stats', async function(req, res) {
|
||||||
var totalCategories = await db.get('SELECT COUNT(*) as count FROM learning_categories', []);
|
var totalCategories = await db.get('SELECT COUNT(*) as count FROM learning_categories', []);
|
||||||
var totalQuizzes = await db.get("SELECT COUNT(DISTINCT content_id) as count FROM learning_questions", []);
|
var totalQuizzes = await db.get("SELECT COUNT(DISTINCT content_id) as count FROM learning_questions", []);
|
||||||
var totalAttempts = await db.get('SELECT COUNT(*) as count FROM learning_progress', []);
|
var totalAttempts = await db.get('SELECT COUNT(*) as count FROM learning_progress', []);
|
||||||
|
var withEmbeddings = await db.get('SELECT COUNT(*) as count FROM learning_content WHERE embedding IS NOT NULL', []);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
|
|
@ -272,10 +308,112 @@ router.get('/stats', async function(req, res) {
|
||||||
publishedContent: parseInt(published.count),
|
publishedContent: parseInt(published.count),
|
||||||
totalCategories: parseInt(totalCategories.count),
|
totalCategories: parseInt(totalCategories.count),
|
||||||
totalQuizzes: parseInt(totalQuizzes.count),
|
totalQuizzes: parseInt(totalQuizzes.count),
|
||||||
totalAttempts: parseInt(totalAttempts.count)
|
totalAttempts: parseInt(totalAttempts.count),
|
||||||
|
withEmbeddings: parseInt(withEmbeddings.count),
|
||||||
|
embeddingsEnabled: isEmbeddingsAvailable()
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (err) { console.error('[LearningAdmin]', err.message); res.status(500).json({ error: 'Internal server error' }); }
|
} catch (err) { console.error('[LearningAdmin]', err.message); res.status(500).json({ error: 'Internal server error' }); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// EMBEDDINGS — Backfill & Management
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
// Generate embeddings for all content (or just missing ones)
|
||||||
|
router.post('/embeddings/generate', async function(req, res) {
|
||||||
|
try {
|
||||||
|
if (!isEmbeddingsAvailable()) {
|
||||||
|
return res.status(400).json({ error: 'Embeddings not configured. Set LITELLM_API_BASE, VERTEX_PROJECT, or OPENAI_API_KEY' });
|
||||||
|
}
|
||||||
|
|
||||||
|
var { regenerateAll } = req.body;
|
||||||
|
|
||||||
|
// Get content without embeddings (or all if regenerateAll=true)
|
||||||
|
var whereClause = regenerateAll ? '' : 'WHERE embedding IS NULL';
|
||||||
|
var content = await db.all(
|
||||||
|
'SELECT id, title, subject, body FROM learning_content ' + whereClause + ' ORDER BY id ASC',
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (content.length === 0) {
|
||||||
|
return res.json({ success: true, message: 'All content already has embeddings', processed: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process in background
|
||||||
|
var processed = 0;
|
||||||
|
var failed = 0;
|
||||||
|
|
||||||
|
console.log('[Embeddings] Starting batch generation for ' + content.length + ' items...');
|
||||||
|
|
||||||
|
// Don't await — run in background
|
||||||
|
(async function() {
|
||||||
|
for (var i = 0; i < content.length; i++) {
|
||||||
|
var item = content[i];
|
||||||
|
try {
|
||||||
|
if (!item.body || !item.body.trim()) {
|
||||||
|
console.log('[Embeddings] Skipping empty content ID:', item.id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
var embedding = await generateContentEmbedding(item);
|
||||||
|
await db.query(
|
||||||
|
'UPDATE learning_content SET embedding = $1 WHERE id = $2',
|
||||||
|
[JSON.stringify(embedding), item.id]
|
||||||
|
);
|
||||||
|
processed++;
|
||||||
|
console.log('[Embeddings] Generated ' + processed + '/' + content.length + ' (ID: ' + item.id + ')');
|
||||||
|
} catch (err) {
|
||||||
|
failed++;
|
||||||
|
console.error('[Embeddings] Failed for content ID ' + item.id + ':', err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log('[Embeddings] Batch complete: ' + processed + ' succeeded, ' + failed + ' failed');
|
||||||
|
|
||||||
|
// Create index if we have enough embeddings now
|
||||||
|
if (processed >= 10) {
|
||||||
|
try {
|
||||||
|
var lists = Math.max(10, Math.floor(Math.sqrt(processed)));
|
||||||
|
await db.query(
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_learning_content_embedding ON learning_content USING ivfflat (embedding vector_cosine_ops) WITH (lists = ' + lists + ')'
|
||||||
|
);
|
||||||
|
console.log('[Embeddings] Vector index created/updated');
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[Embeddings] Index creation failed:', e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Embedding generation started in background',
|
||||||
|
total: content.length
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[LearningAdmin]', err.message);
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check embedding status
|
||||||
|
router.get('/embeddings/status', async function(req, res) {
|
||||||
|
try {
|
||||||
|
var total = await db.get('SELECT COUNT(*) as count FROM learning_content', []);
|
||||||
|
var withEmbeddings = await db.get('SELECT COUNT(*) as count FROM learning_content WHERE embedding IS NOT NULL', []);
|
||||||
|
var missing = parseInt(total.count) - parseInt(withEmbeddings.count);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
enabled: isEmbeddingsAvailable(),
|
||||||
|
total: parseInt(total.count),
|
||||||
|
withEmbeddings: parseInt(withEmbeddings.count),
|
||||||
|
missing: missing,
|
||||||
|
model: process.env.EMBEDDING_MODEL || 'vertex_ai/text-embedding-005',
|
||||||
|
dimensions: parseInt(process.env.EMBEDDING_DIMENSIONS) || 768
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ var express = require('express');
|
||||||
var router = express.Router();
|
var router = express.Router();
|
||||||
var db = require('../db/database');
|
var db = require('../db/database');
|
||||||
var { authMiddleware } = require('../middleware/auth');
|
var { authMiddleware } = require('../middleware/auth');
|
||||||
|
var { searchSimilar, isEmbeddingsAvailable } = require('../utils/embeddings');
|
||||||
|
|
||||||
router.use(authMiddleware);
|
router.use(authMiddleware);
|
||||||
|
|
||||||
|
|
@ -228,7 +229,7 @@ router.post('/submit-quiz', async function(req, res) {
|
||||||
});
|
});
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// SEARCH CONTENT
|
// SEARCH CONTENT (keyword-based)
|
||||||
// ============================================================
|
// ============================================================
|
||||||
router.get('/search', async function(req, res) {
|
router.get('/search', async function(req, res) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -247,8 +248,122 @@ router.get('/search', async function(req, res) {
|
||||||
[pattern, pattern, pattern]
|
[pattern, pattern, pattern]
|
||||||
);
|
);
|
||||||
|
|
||||||
res.json({ success: true, content: content });
|
res.json({ success: true, content: content, method: 'keyword' });
|
||||||
} catch (err) { console.error('[LearningHub]', err.message); res.status(500).json({ error: 'Internal server error' }); }
|
} catch (err) { console.error('[LearningHub]', err.message); res.status(500).json({ error: 'Internal server error' }); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// SEMANTIC SEARCH (vector-based)
|
||||||
|
// ============================================================
|
||||||
|
router.get('/search/semantic', async function(req, res) {
|
||||||
|
try {
|
||||||
|
if (!isEmbeddingsAvailable()) {
|
||||||
|
return res.status(400).json({ error: 'Semantic search not available. Embeddings not configured.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
var q = (req.query.q || '').trim();
|
||||||
|
if (!q) return res.json({ success: true, content: [], method: 'semantic' });
|
||||||
|
|
||||||
|
var limit = Math.min(parseInt(req.query.limit) || 10, 50);
|
||||||
|
var threshold = parseFloat(req.query.threshold) || 0.5;
|
||||||
|
|
||||||
|
var results = await searchSimilar(q, {
|
||||||
|
limit: limit,
|
||||||
|
threshold: threshold,
|
||||||
|
contentType: req.query.contentType || null
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
content: results,
|
||||||
|
method: 'semantic',
|
||||||
|
query: q
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[LearningHub] Semantic search error:', err.message);
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// HYBRID SEARCH (keyword + semantic combined)
|
||||||
|
// ============================================================
|
||||||
|
router.get('/search/hybrid', async function(req, res) {
|
||||||
|
try {
|
||||||
|
var q = (req.query.q || '').trim();
|
||||||
|
if (!q) return res.json({ success: true, content: [], method: 'hybrid' });
|
||||||
|
|
||||||
|
var limit = Math.min(parseInt(req.query.limit) || 20, 50);
|
||||||
|
|
||||||
|
// 1. Get keyword matches
|
||||||
|
var pattern = '%' + q + '%';
|
||||||
|
var keywordResults = await db.all(
|
||||||
|
`SELECT c.id, c.title, c.slug, c.subject, c.content_type, c.created_at,
|
||||||
|
cat.name as category_name, cat.slug as category_slug,
|
||||||
|
(SELECT COUNT(*) FROM learning_questions lq WHERE lq.content_id = c.id) as question_count,
|
||||||
|
1.0 as score, 'keyword' as match_type
|
||||||
|
FROM learning_content c
|
||||||
|
LEFT JOIN learning_categories cat ON c.category_id = cat.id
|
||||||
|
WHERE c.published = true AND (c.title ILIKE ? OR c.subject ILIKE ? OR c.body ILIKE ?)
|
||||||
|
LIMIT 15`,
|
||||||
|
[pattern, pattern, pattern]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. Get semantic matches (if available)
|
||||||
|
var semanticResults = [];
|
||||||
|
if (isEmbeddingsAvailable()) {
|
||||||
|
try {
|
||||||
|
semanticResults = await searchSimilar(q, {
|
||||||
|
limit: 15,
|
||||||
|
threshold: 0.4
|
||||||
|
});
|
||||||
|
// Add match_type
|
||||||
|
semanticResults = semanticResults.map(function(r) {
|
||||||
|
return Object.assign(r, { score: r.similarity, match_type: 'semantic' });
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[LearningHub] Semantic search in hybrid failed:', err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Merge and deduplicate (favor semantic if both match)
|
||||||
|
var seen = {};
|
||||||
|
var combined = [];
|
||||||
|
|
||||||
|
// Add semantic results first (higher quality)
|
||||||
|
semanticResults.forEach(function(r) {
|
||||||
|
if (!seen[r.id]) {
|
||||||
|
seen[r.id] = true;
|
||||||
|
combined.push(r);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add keyword results if not already included
|
||||||
|
keywordResults.forEach(function(r) {
|
||||||
|
if (!seen[r.id]) {
|
||||||
|
seen[r.id] = true;
|
||||||
|
combined.push(r);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sort by score descending, limit results
|
||||||
|
combined.sort(function(a, b) { return (b.score || 0) - (a.score || 0); });
|
||||||
|
combined = combined.slice(0, limit);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
content: combined,
|
||||||
|
method: 'hybrid',
|
||||||
|
query: q,
|
||||||
|
keywordCount: keywordResults.length,
|
||||||
|
semanticCount: semanticResults.length
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[LearningHub] Hybrid search error:', err.message);
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,11 @@ var logger = require('../utils/logger');
|
||||||
|
|
||||||
router.use(authMiddleware);
|
router.use(authMiddleware);
|
||||||
|
|
||||||
var VALID_CATEGORIES = ['physical_exam', 'ros', 'encounter_format', 'family_history', 'assessment_plan', 'custom'];
|
var VALID_CATEGORIES = [
|
||||||
|
'physical_exam', 'ros', 'encounter_format', 'family_history', 'assessment_plan', 'custom',
|
||||||
|
'template_soap', 'template_hpi', 'template_wellvisit', 'template_sickvisit',
|
||||||
|
'correction_soap', 'correction_hpi', 'correction_encounter', 'correction_wellvisit', 'correction_sickvisit'
|
||||||
|
];
|
||||||
|
|
||||||
// ── GET all memories for current user ───────────────────────────────────
|
// ── GET all memories for current user ───────────────────────────────────
|
||||||
router.get('/memories', async function(req, res) {
|
router.get('/memories', async function(req, res) {
|
||||||
|
|
@ -33,7 +37,7 @@ router.post('/memories', async function(req, res) {
|
||||||
|
|
||||||
// Limit per user
|
// Limit per user
|
||||||
var count = await db.get('SELECT COUNT(*) as cnt FROM user_memories WHERE user_id = $1', [req.user.id]);
|
var count = await db.get('SELECT COUNT(*) as cnt FROM user_memories WHERE user_id = $1', [req.user.id]);
|
||||||
if (count && parseInt(count.cnt) >= 50) return res.status(400).json({ error: 'Maximum 50 memories per user' });
|
if (count && parseInt(count.cnt) >= 200) return res.status(400).json({ error: 'Maximum 200 memories per user' });
|
||||||
|
|
||||||
var result = await db.run(
|
var result = await db.run(
|
||||||
'INSERT INTO user_memories (user_id, category, name, content) VALUES ($1,$2,$3,$4)',
|
'INSERT INTO user_memories (user_id, category, name, content) VALUES ($1,$2,$3,$4)',
|
||||||
|
|
@ -79,12 +83,65 @@ router.get('/memories/context', async function(req, res) {
|
||||||
);
|
);
|
||||||
if (rows.length === 0) return res.json({ success: true, context: '' });
|
if (rows.length === 0) return res.json({ success: true, context: '' });
|
||||||
|
|
||||||
var context = '\n\nPHYSICIAN TEMPLATES AND PREFERENCES:\n';
|
var templates = [];
|
||||||
|
var corrections = [];
|
||||||
rows.forEach(function(r) {
|
rows.forEach(function(r) {
|
||||||
context += '--- ' + r.category.toUpperCase().replace('_', ' ') + ': ' + r.name + ' ---\n' + r.content + '\n\n';
|
if (r.category.startsWith('correction_')) corrections.push(r);
|
||||||
|
else templates.push(r);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
var context = '';
|
||||||
|
if (templates.length > 0) {
|
||||||
|
context += '\n\nPHYSICIAN TEMPLATES AND PREFERENCES:\n';
|
||||||
|
templates.forEach(function(r) {
|
||||||
|
context += '--- ' + r.category.toUpperCase().replace(/_/g, ' ') + ': ' + r.name + ' ---\n' + r.content + '\n\n';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (corrections.length > 0) {
|
||||||
|
context += '\n\nPHYSICIAN CORRECTION HISTORY (learn from these preferences — apply similar corrections to future outputs):\n';
|
||||||
|
corrections.slice(-20).forEach(function(r) {
|
||||||
|
context += '--- CORRECTION (' + r.category.replace('correction_', '').toUpperCase() + '): ' + r.name + ' ---\n' + r.content + '\n\n';
|
||||||
|
});
|
||||||
|
}
|
||||||
res.json({ success: true, context: context.trim() });
|
res.json({ success: true, context: context.trim() });
|
||||||
} catch (e) { logger.error('GET /memories/context', e.message); res.status(500).json({ error: e.message }); }
|
} catch (e) { logger.error('GET /memories/context', e.message); res.status(500).json({ error: e.message }); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── POST auto-save correction (Dragon-like learning) ──────────────────
|
||||||
|
router.post('/memories/correction', async function(req, res) {
|
||||||
|
try {
|
||||||
|
var { section, original_snippet, corrected_snippet } = req.body;
|
||||||
|
if (!section || !original_snippet || !corrected_snippet) {
|
||||||
|
return res.status(400).json({ error: 'section, original_snippet, and corrected_snippet required' });
|
||||||
|
}
|
||||||
|
if (original_snippet.trim() === corrected_snippet.trim()) {
|
||||||
|
return res.json({ success: true, skipped: true });
|
||||||
|
}
|
||||||
|
var cat = 'correction_' + section;
|
||||||
|
if (!VALID_CATEGORIES.includes(cat)) cat = 'correction_encounter';
|
||||||
|
|
||||||
|
// Limit corrections per category: keep only latest 20
|
||||||
|
var existing = await db.all(
|
||||||
|
'SELECT id FROM user_memories WHERE user_id = $1 AND category = $2 ORDER BY created_at ASC',
|
||||||
|
[req.user.id, cat]
|
||||||
|
);
|
||||||
|
if (existing.length >= 20) {
|
||||||
|
// Delete oldest to make room
|
||||||
|
var toDelete = existing.slice(0, existing.length - 19);
|
||||||
|
for (var i = 0; i < toDelete.length; i++) {
|
||||||
|
await db.run('DELETE FROM user_memories WHERE id = $1 AND user_id = $2', [toDelete[i].id, req.user.id]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var name = original_snippet.substring(0, 60).replace(/\n/g, ' ') + '...';
|
||||||
|
var content = 'ORIGINAL: ' + original_snippet.substring(0, 2000) + '\nCORRECTED TO: ' + corrected_snippet.substring(0, 2000);
|
||||||
|
|
||||||
|
await db.run(
|
||||||
|
'INSERT INTO user_memories (user_id, category, name, content) VALUES ($1,$2,$3,$4)',
|
||||||
|
[req.user.id, cat, name, content]
|
||||||
|
);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (e) { logger.error('POST /memories/correction', e.message); res.status(500).json({ error: e.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,32 @@ const router = express.Router();
|
||||||
const { callAI } = require('../utils/ai');
|
const { callAI } = require('../utils/ai');
|
||||||
const PROMPTS = require('../utils/prompts');
|
const PROMPTS = require('../utils/prompts');
|
||||||
const { authMiddleware } = require('../middleware/auth');
|
const { authMiddleware } = require('../middleware/auth');
|
||||||
|
const db = require('../db/database');
|
||||||
|
|
||||||
|
// Get all milestones for client (authenticated users)
|
||||||
|
router.get('/milestones-data', authMiddleware, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const result = await db.query(
|
||||||
|
'SELECT * FROM developmental_milestones ORDER BY age_group, domain, sort_order, id'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Group by age_group and domain to match the static data structure
|
||||||
|
const grouped = {};
|
||||||
|
result.rows.forEach(row => {
|
||||||
|
if (!grouped[row.age_group]) {
|
||||||
|
grouped[row.age_group] = {};
|
||||||
|
}
|
||||||
|
if (!grouped[row.age_group][row.domain]) {
|
||||||
|
grouped[row.age_group][row.domain] = [];
|
||||||
|
}
|
||||||
|
grouped[row.age_group][row.domain].push(row.milestone_text);
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ success: true, milestones: grouped });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Narrative format
|
// Narrative format
|
||||||
router.post('/generate-milestone-narrative', authMiddleware, async (req, res) => {
|
router.post('/generate-milestone-narrative', authMiddleware, async (req, res) => {
|
||||||
|
|
|
||||||
|
|
@ -60,8 +60,10 @@ router.post('/nextcloud/export', authMiddleware, async function(req, res) {
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/nextcloud/disconnect', authMiddleware, async function(req, res) {
|
router.post('/nextcloud/disconnect', authMiddleware, async function(req, res) {
|
||||||
await db.run('UPDATE users SET nextcloud_url = NULL, nextcloud_user = NULL, nextcloud_token = NULL, nextcloud_folder = NULL WHERE id = ?', [req.user.id]);
|
try {
|
||||||
res.json({ success: true });
|
await db.run('UPDATE users SET nextcloud_url = NULL, nextcloud_user = NULL, nextcloud_token = NULL, nextcloud_folder = NULL WHERE id = ?', [req.user.id]);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|
|
||||||
225
src/routes/oidc.js
Normal file
225
src/routes/oidc.js
Normal file
|
|
@ -0,0 +1,225 @@
|
||||||
|
// ============================================================
|
||||||
|
// OIDC ROUTES — OpenID Connect SSO authentication
|
||||||
|
// Compatible with: Azure AD, Okta, Keycloak, PocketID, Google
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
var express = require('express');
|
||||||
|
var router = express.Router();
|
||||||
|
var crypto = require('crypto');
|
||||||
|
var jwt = require('jsonwebtoken');
|
||||||
|
var db = require('../db/database');
|
||||||
|
var { JWT_SECRET, authMiddleware, adminMiddleware } = require('../middleware/auth');
|
||||||
|
|
||||||
|
// In-memory OIDC state store (short-lived, no DB needed)
|
||||||
|
var pendingStates = {};
|
||||||
|
|
||||||
|
// Clean expired states every 10 minutes
|
||||||
|
setInterval(function() {
|
||||||
|
var now = Date.now();
|
||||||
|
Object.keys(pendingStates).forEach(function(k) {
|
||||||
|
if (pendingStates[k].expires < now) delete pendingStates[k];
|
||||||
|
});
|
||||||
|
}, 10 * 60 * 1000);
|
||||||
|
|
||||||
|
function setAuthCookie(res, token) {
|
||||||
|
var isProduction = process.env.NODE_ENV === 'production' || process.env.APP_URL;
|
||||||
|
res.cookie('ped_auth', token, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: !!isProduction,
|
||||||
|
sameSite: 'lax',
|
||||||
|
maxAge: 7 * 24 * 60 * 60 * 1000,
|
||||||
|
path: '/'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GET OIDC status (public — frontend checks this to show SSO button) ──
|
||||||
|
router.get('/oidc-status', async function(req, res) {
|
||||||
|
try {
|
||||||
|
var enabled = await db.getSetting('oidc.enabled');
|
||||||
|
var disableLocal = await db.getSetting('oidc.disable_local_auth');
|
||||||
|
var buttonLabel = await db.getSetting('oidc.button_label');
|
||||||
|
res.json({
|
||||||
|
oidcEnabled: enabled === 'true',
|
||||||
|
disableLocalAuth: disableLocal === 'true',
|
||||||
|
buttonLabel: buttonLabel || 'Sign in with SSO'
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
res.json({ oidcEnabled: false, disableLocalAuth: false });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── GET /api/auth/oidc — initiate OIDC login (redirects to IdP) ──────────
|
||||||
|
router.get('/oidc', async function(req, res) {
|
||||||
|
try {
|
||||||
|
var enabled = await db.getSetting('oidc.enabled');
|
||||||
|
if (enabled !== 'true') {
|
||||||
|
return res.status(400).json({ error: 'SSO is not enabled' });
|
||||||
|
}
|
||||||
|
|
||||||
|
var issuer = await db.getSetting('oidc.issuer');
|
||||||
|
var clientId = await db.getSetting('oidc.client_id');
|
||||||
|
|
||||||
|
if (!issuer || !clientId) {
|
||||||
|
return res.status(500).json({ error: 'OIDC not fully configured' });
|
||||||
|
}
|
||||||
|
|
||||||
|
var oidc = require('openid-client');
|
||||||
|
|
||||||
|
var appUrl = (process.env.APP_URL || 'http://localhost:3000').replace(/\/$/, '');
|
||||||
|
var redirectUri = appUrl + '/api/auth/oidc/callback';
|
||||||
|
|
||||||
|
var config = await oidc.discovery(new URL(issuer), clientId);
|
||||||
|
|
||||||
|
var state = crypto.randomBytes(24).toString('hex');
|
||||||
|
var nonce = crypto.randomBytes(24).toString('hex');
|
||||||
|
var codeVerifier = oidc.randomPKCECodeVerifier();
|
||||||
|
var codeChallenge = await oidc.calculatePKCECodeChallenge(codeVerifier);
|
||||||
|
|
||||||
|
// Store state for callback verification (5 min TTL)
|
||||||
|
pendingStates[state] = {
|
||||||
|
nonce: nonce,
|
||||||
|
codeVerifier: codeVerifier,
|
||||||
|
expires: Date.now() + 5 * 60 * 1000
|
||||||
|
};
|
||||||
|
|
||||||
|
var authUrl = oidc.buildAuthorizationUrl(config, {
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
scope: 'openid email profile',
|
||||||
|
state: state,
|
||||||
|
nonce: nonce,
|
||||||
|
code_challenge: codeChallenge,
|
||||||
|
code_challenge_method: 'S256'
|
||||||
|
});
|
||||||
|
|
||||||
|
res.redirect(authUrl.href);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[OIDC] Auth initiation failed:', err.message);
|
||||||
|
res.status(500).json({ error: 'SSO login failed: ' + err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── GET /api/auth/oidc/callback — handle IdP callback ────────────────────
|
||||||
|
router.get('/oidc/callback', async function(req, res) {
|
||||||
|
var appUrl = (process.env.APP_URL || 'http://localhost:3000').replace(/\/$/, '');
|
||||||
|
|
||||||
|
try {
|
||||||
|
var state = req.query.state;
|
||||||
|
if (!state || !pendingStates[state]) {
|
||||||
|
return res.redirect(appUrl + '?error=invalid_state');
|
||||||
|
}
|
||||||
|
|
||||||
|
var pending = pendingStates[state];
|
||||||
|
delete pendingStates[state];
|
||||||
|
|
||||||
|
if (pending.expires < Date.now()) {
|
||||||
|
return res.redirect(appUrl + '?error=expired');
|
||||||
|
}
|
||||||
|
|
||||||
|
var issuer = await db.getSetting('oidc.issuer');
|
||||||
|
var clientId = await db.getSetting('oidc.client_id');
|
||||||
|
var clientSecret = await db.getSetting('oidc.client_secret');
|
||||||
|
|
||||||
|
var oidc = require('openid-client');
|
||||||
|
var redirectUri = appUrl + '/api/auth/oidc/callback';
|
||||||
|
|
||||||
|
var config = await oidc.discovery(new URL(issuer), clientId, clientSecret || undefined);
|
||||||
|
|
||||||
|
var tokens = await oidc.authorizationCodeGrant(config, new URL(req.protocol + '://' + req.get('host') + req.originalUrl), {
|
||||||
|
pkceCodeVerifier: pending.codeVerifier,
|
||||||
|
expectedNonce: pending.nonce,
|
||||||
|
expectedState: state
|
||||||
|
});
|
||||||
|
|
||||||
|
var claims = tokens.claims();
|
||||||
|
var sub = claims.sub;
|
||||||
|
var email = claims.email;
|
||||||
|
var name = claims.name || claims.preferred_username || email;
|
||||||
|
|
||||||
|
if (!email) {
|
||||||
|
// Try userinfo endpoint
|
||||||
|
var userinfo = await oidc.fetchUserInfo(config, tokens.access_token, sub);
|
||||||
|
email = userinfo.email;
|
||||||
|
name = name || userinfo.name || userinfo.preferred_username || email;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!email) {
|
||||||
|
return res.redirect(appUrl + '?error=no_email');
|
||||||
|
}
|
||||||
|
|
||||||
|
email = email.toLowerCase();
|
||||||
|
|
||||||
|
// Find or create user
|
||||||
|
var user = await db.get('SELECT * FROM users WHERE email = ?', [email]);
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
// Existing user — link OIDC sub if not already linked
|
||||||
|
if (!user.oidc_sub) {
|
||||||
|
await db.run('UPDATE users SET oidc_sub = ?, email_verified = true WHERE id = ?', [sub, user.id]);
|
||||||
|
}
|
||||||
|
if (user.disabled) {
|
||||||
|
return res.redirect(appUrl + '?error=disabled');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Auto-create user from OIDC
|
||||||
|
var userCount = await db.get('SELECT COUNT(*) as count FROM users', []);
|
||||||
|
var role = (userCount && parseInt(userCount.count) === 0) ? 'admin' : 'user';
|
||||||
|
var randomPw = crypto.randomBytes(32).toString('hex'); // Not used for OIDC login
|
||||||
|
|
||||||
|
var result = await db.run(
|
||||||
|
'INSERT INTO users (email, password, name, role, email_verified, oidc_sub) VALUES (?, ?, ?, ?, true, ?)',
|
||||||
|
[email, randomPw, name, role, sub]
|
||||||
|
);
|
||||||
|
user = await db.get('SELECT * FROM users WHERE id = ?', [result.lastInsertRowid]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Issue JWT
|
||||||
|
var token = jwt.sign({ userId: user.id }, JWT_SECRET, { expiresIn: '7d' });
|
||||||
|
setAuthCookie(res, token);
|
||||||
|
|
||||||
|
await db.run('INSERT INTO audit_log (user_id, action, ip_address, details) VALUES (?, ?, ?, ?)',
|
||||||
|
[user.id, 'login_oidc', req.ip, 'SSO via ' + issuer]);
|
||||||
|
|
||||||
|
// Redirect to app — token is in httpOnly cookie, pass minimal flag
|
||||||
|
res.redirect(appUrl + '?sso=ok');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[OIDC] Callback error:', err.message);
|
||||||
|
res.redirect(appUrl + '?error=sso_failed');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Admin: GET OIDC config ──────────────────────────────────────────────
|
||||||
|
router.get('/oidc/config', authMiddleware, adminMiddleware, async function(req, res) {
|
||||||
|
try {
|
||||||
|
var keys = ['oidc.enabled', 'oidc.issuer', 'oidc.client_id', 'oidc.client_secret', 'oidc.disable_local_auth', 'oidc.button_label', 'oidc.allowed_ips'];
|
||||||
|
var config = {};
|
||||||
|
for (var i = 0; i < keys.length; i++) {
|
||||||
|
config[keys[i]] = await db.getSetting(keys[i]) || '';
|
||||||
|
}
|
||||||
|
// Mask client secret
|
||||||
|
if (config['oidc.client_secret']) {
|
||||||
|
config['oidc.client_secret'] = '••••••••' + config['oidc.client_secret'].slice(-4);
|
||||||
|
}
|
||||||
|
res.json({ success: true, config: config });
|
||||||
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Admin: PUT update OIDC config ───────────────────────────────────────
|
||||||
|
router.put('/oidc/config', authMiddleware, adminMiddleware, async function(req, res) {
|
||||||
|
try {
|
||||||
|
var allowed = ['oidc.enabled', 'oidc.issuer', 'oidc.client_id', 'oidc.client_secret', 'oidc.disable_local_auth', 'oidc.button_label', 'oidc.allowed_ips'];
|
||||||
|
var updates = req.body;
|
||||||
|
|
||||||
|
for (var i = 0; i < allowed.length; i++) {
|
||||||
|
var key = allowed[i];
|
||||||
|
if (updates[key] !== undefined) {
|
||||||
|
// Don't overwrite secret with masked value
|
||||||
|
if (key === 'oidc.client_secret' && updates[key].indexOf('••••') === 0) continue;
|
||||||
|
await db.setSetting(key, updates[key]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
|
|
@ -49,7 +49,7 @@ router.post('/sick-visit/note', authMiddleware, async function(req, res) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (physicianMemories) {
|
if (physicianMemories) {
|
||||||
context += physicianMemories + '\n\n';
|
context += '[PHYSICIAN PREFERENCES & LEARNED PATTERNS — Apply these preferences to your output. These reflect how this physician writes notes, their preferred style, terminology, and corrections from past outputs. Adapt your response accordingly, but user instructions in the current prompt take priority if they conflict.]\n' + physicianMemories + '\n[END PREFERENCES]\n\n';
|
||||||
}
|
}
|
||||||
|
|
||||||
var result = await callAI([
|
var result = await callAI([
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ const { authMiddleware } = require('../middleware/auth');
|
||||||
|
|
||||||
router.post('/generate-soap', authMiddleware, async (req, res) => {
|
router.post('/generate-soap', authMiddleware, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { transcript, patientAge, patientGender, model, type, additionalInstructions } = req.body;
|
const { transcript, patientAge, patientGender, model, type, additionalInstructions, physicianMemories } = req.body;
|
||||||
if (!transcript || !transcript.trim()) return res.status(400).json({ error: 'Empty input' });
|
if (!transcript || !transcript.trim()) return res.status(400).json({ error: 'Empty input' });
|
||||||
|
|
||||||
let prompt;
|
let prompt;
|
||||||
|
|
@ -20,9 +20,12 @@ router.post('/generate-soap', authMiddleware, async (req, res) => {
|
||||||
prompt += `\n\nADDITIONAL INSTRUCTIONS:\n${additionalInstructions}`;
|
prompt += `\n\nADDITIONAL INSTRUCTIONS:\n${additionalInstructions}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var context = `Patient: ${patientAge || 'Unknown'}, ${patientGender || 'Unknown'}\n\nINPUT:\n${transcript}`;
|
||||||
|
if (physicianMemories) context += '\n\n[PHYSICIAN PREFERENCES & LEARNED PATTERNS — Apply these preferences to your output. These reflect how this physician writes notes, their preferred style, terminology, and corrections from past outputs. Adapt your response accordingly, but user instructions in the current prompt take priority if they conflict.]\n' + physicianMemories + '\n[END PREFERENCES]';
|
||||||
|
|
||||||
const result = await callAI([
|
const result = await callAI([
|
||||||
{ role: 'system', content: prompt },
|
{ role: 'system', content: prompt },
|
||||||
{ role: 'user', content: `Patient: ${patientAge || 'Unknown'}, ${patientGender || 'Unknown'}\n\nINPUT:\n${transcript}` }
|
{ role: 'user', content: context }
|
||||||
], { model });
|
], { model });
|
||||||
|
|
||||||
res.json({ success: true, soap: result.content, model: result.model });
|
res.json({ success: true, soap: result.content, model: result.model });
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,145 @@
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const multer = require('multer');
|
const multer = require('multer');
|
||||||
const { whisperClient } = require('../utils/ai');
|
const { whisperClient, litellmClient } = require('../utils/ai');
|
||||||
|
const { transcribeWithAWS, isAWSTranscribeConfigured } = require('../utils/transcribeAWS');
|
||||||
|
const { transcribeWithLocal, isLocalWhisperConfigured } = require('../utils/transcribeLocal');
|
||||||
|
const { transcribeWithGemini, isGoogleSTTConfigured } = require('../utils/transcribeGoogle');
|
||||||
const { authMiddleware } = require('../middleware/auth');
|
const { authMiddleware } = require('../middleware/auth');
|
||||||
|
|
||||||
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 25 * 1024 * 1024 } });
|
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 25 * 1024 * 1024 } });
|
||||||
|
|
||||||
|
// Provider priority (auto-detect):
|
||||||
|
// TRANSCRIBE_PROVIDER=google → Vertex AI / Gemini (direct, HIPAA eligible)
|
||||||
|
// TRANSCRIBE_PROVIDER=aws → Amazon Transcribe (HIPAA eligible)
|
||||||
|
// TRANSCRIBE_PROVIDER=local → local whisper.cpp / faster-whisper
|
||||||
|
// TRANSCRIBE_PROVIDER=openai → OpenAI Whisper (direct)
|
||||||
|
// TRANSCRIBE_PROVIDER=litellm→ LiteLLM proxy (explicit only — audio routing bugs)
|
||||||
|
// Auto: google > aws > openai
|
||||||
|
function getTranscribeProvider() {
|
||||||
|
var env = process.env.TRANSCRIBE_PROVIDER;
|
||||||
|
if (env === 'google') return 'google';
|
||||||
|
if (env === 'aws') return 'aws';
|
||||||
|
if (env === 'local') return 'local';
|
||||||
|
if (env === 'openai') return 'openai';
|
||||||
|
if (env === 'litellm') return 'litellm';
|
||||||
|
// Auto-detect
|
||||||
|
if (isGoogleSTTConfigured()) return 'google';
|
||||||
|
if (isAWSTranscribeConfigured()) return 'aws';
|
||||||
|
return 'openai';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTranscribeAvailable() {
|
||||||
|
if (isGoogleSTTConfigured()) return true;
|
||||||
|
if (isLocalWhisperConfigured()) return true;
|
||||||
|
if (isAWSTranscribeConfigured()) return true;
|
||||||
|
if (litellmClient) return true;
|
||||||
|
if (whisperClient) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var provider = getTranscribeProvider();
|
||||||
|
var medical = process.env.AWS_TRANSCRIBE_MEDICAL === 'true';
|
||||||
|
var available = isTranscribeAvailable();
|
||||||
|
console.log('🎙️ Transcribe provider:', provider +
|
||||||
|
(provider === 'aws' && medical ? ' (Medical)' : '') +
|
||||||
|
(provider === 'google' ? ' (model: ' + (process.env.GOOGLE_STT_MODEL || 'gemini-2.0-flash') + ')' : '') +
|
||||||
|
(available ? '' : ' (NOT CONFIGURED — browser speech only)'));
|
||||||
|
|
||||||
|
router.get('/transcribe/status', authMiddleware, (req, res) => {
|
||||||
|
res.json({ available: available, provider: available ? provider : 'none' });
|
||||||
|
});
|
||||||
|
|
||||||
router.post('/transcribe', authMiddleware, upload.single('audio'), async (req, res) => {
|
router.post('/transcribe', authMiddleware, upload.single('audio'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
if (!req.file) return res.status(400).json({ error: 'No audio' });
|
if (!req.file) return res.status(400).json({ error: 'No audio' });
|
||||||
if (!whisperClient) return res.status(400).json({ error: 'Whisper not configured' });
|
var startTime = Date.now();
|
||||||
|
var fileSize = req.file.size;
|
||||||
|
|
||||||
const file = new File([req.file.buffer], 'audio.webm', { type: req.file.mimetype || 'audio/webm' });
|
// Get user's preferred STT model (if set)
|
||||||
const result = await whisperClient.audio.transcriptions.create({
|
var db = require('../db/database');
|
||||||
|
var userPrefs = await db.get('SELECT stt_model FROM users WHERE id = ?', [req.user.id]);
|
||||||
|
var userModel = userPrefs?.stt_model;
|
||||||
|
|
||||||
|
console.log('[Transcribe] Received ' + (fileSize / 1024).toFixed(0) + 'KB audio (' + (req.file.mimetype || 'unknown') + ') via ' + provider + (userModel ? ' (user model: ' + userModel + ')' : ''));
|
||||||
|
|
||||||
|
if (provider === 'google') {
|
||||||
|
var model = userModel || process.env.GOOGLE_STT_MODEL || 'gemini-2.0-flash';
|
||||||
|
var text = await transcribeWithGemini(req.file.buffer, req.file.mimetype || 'audio/webm', model);
|
||||||
|
console.log('[Transcribe] Google/' + model + ' done in ' + (Date.now() - startTime) + 'ms');
|
||||||
|
return res.json({ success: true, text: text, provider: 'google-' + model, duration: Date.now() - startTime });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (provider === 'local') {
|
||||||
|
var text = await transcribeWithLocal(req.file.buffer, req.file.mimetype || 'audio/webm');
|
||||||
|
console.log('[Transcribe] Local done in ' + (Date.now() - startTime) + 'ms');
|
||||||
|
return res.json({ success: true, text: text, provider: 'local-whisper', duration: Date.now() - startTime });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (provider === 'aws') {
|
||||||
|
if (!isAWSTranscribeConfigured()) return res.status(400).json({ error: 'AWS Transcribe not configured.' });
|
||||||
|
var text = await transcribeWithAWS(req.file.buffer, req.file.mimetype || 'audio/webm');
|
||||||
|
console.log('[Transcribe] AWS done in ' + (Date.now() - startTime) + 'ms');
|
||||||
|
return res.json({ success: true, text: text, provider: 'aws-transcribe', duration: Date.now() - startTime });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (provider === 'litellm') {
|
||||||
|
if (!process.env.LITELLM_API_BASE) return res.status(400).json({ error: 'LITELLM_API_BASE not set.' });
|
||||||
|
// LiteLLM /audio/transcriptions does NOT support Vertex AI Chirp (Unmapped provider error).
|
||||||
|
// Instead, use /v1/chat/completions with a Gemini model — Gemini understands audio natively
|
||||||
|
// via base64 inline data. Set LITELLM_STT_MODEL to your Gemini model name in LiteLLM.
|
||||||
|
var sttModel = userModel || process.env.LITELLM_STT_MODEL || 'gemini-2.0-flash';
|
||||||
|
var axios = require('axios');
|
||||||
|
var base64audio = req.file.buffer.toString('base64');
|
||||||
|
var mimeType = req.file.mimetype || 'audio/webm';
|
||||||
|
var base = process.env.LITELLM_API_BASE.replace(/\/+$/, '');
|
||||||
|
var headers = { 'Content-Type': 'application/json' };
|
||||||
|
if (process.env.LITELLM_API_KEY) headers['Authorization'] = 'Bearer ' + process.env.LITELLM_API_KEY;
|
||||||
|
|
||||||
|
var sttResp = await axios.post(base + '/v1/chat/completions', {
|
||||||
|
model: sttModel,
|
||||||
|
messages: [{
|
||||||
|
role: 'user',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'input_audio',
|
||||||
|
input_audio: { data: base64audio, format: mimeType.split('/')[1] || 'webm' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: 'Transcribe this audio of a medical encounter. Output the spoken words only, exactly as heard. No commentary, no formatting.'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}]
|
||||||
|
}, { headers: headers, timeout: 120000 });
|
||||||
|
|
||||||
|
var text = '';
|
||||||
|
if (sttResp.data && sttResp.data.choices && sttResp.data.choices[0]) {
|
||||||
|
var msg = sttResp.data.choices[0].message;
|
||||||
|
text = (msg && msg.content) ? msg.content : '';
|
||||||
|
}
|
||||||
|
console.log('[Transcribe] LiteLLM/' + sttModel + ' done in ' + (Date.now() - startTime) + 'ms');
|
||||||
|
return res.json({ success: true, text: text.trim(), provider: 'litellm-gemini', duration: Date.now() - startTime });
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenAI Whisper (direct)
|
||||||
|
if (!whisperClient) return res.status(400).json({ error: 'Whisper not configured. Set OPENAI_API_KEY.' });
|
||||||
|
var file = new File([req.file.buffer], 'audio.webm', { type: req.file.mimetype || 'audio/webm' });
|
||||||
|
var result = await whisperClient.audio.transcriptions.create({
|
||||||
file, model: 'whisper-1', language: 'en',
|
file, model: 'whisper-1', language: 'en',
|
||||||
|
response_format: 'text',
|
||||||
prompt: 'Medical patient encounter. Pediatric. Clinical terms, diagnoses, medications.'
|
prompt: 'Medical patient encounter. Pediatric. Clinical terms, diagnoses, medications.'
|
||||||
});
|
});
|
||||||
res.json({ success: true, text: result.text });
|
var text = typeof result === 'string' ? result : result.text;
|
||||||
|
console.log('[Transcribe] Whisper done in ' + (Date.now() - startTime) + 'ms');
|
||||||
|
res.json({ success: true, text: text, provider: 'openai-whisper', duration: Date.now() - startTime });
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(500).json({ error: err.message });
|
var detail = err.response && err.response.data
|
||||||
|
? JSON.stringify(err.response.data).substring(0, 500)
|
||||||
|
: err.message;
|
||||||
|
console.error('[Transcribe] Error (' + provider + '):', detail);
|
||||||
|
res.status(500).json({ error: detail });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,90 @@
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const axios = require('axios');
|
const axios = require('axios');
|
||||||
|
const { synthesizeWithGoogleTTS, isGoogleTTSConfigured } = require('../utils/ttsGoogle');
|
||||||
const { authMiddleware } = require('../middleware/auth');
|
const { authMiddleware } = require('../middleware/auth');
|
||||||
|
|
||||||
|
// Provider priority (auto-detect):
|
||||||
|
// TTS_PROVIDER=google → Google Cloud TTS direct (HIPAA eligible)
|
||||||
|
// TTS_PROVIDER=litellm → LiteLLM proxy
|
||||||
|
// TTS_PROVIDER=elevenlabs → ElevenLabs (not HIPAA)
|
||||||
|
// Auto: google > litellm > elevenlabs
|
||||||
|
function getTTSProvider() {
|
||||||
|
var env = process.env.TTS_PROVIDER;
|
||||||
|
if (env === 'google') return 'google';
|
||||||
|
if (env === 'litellm') return 'litellm';
|
||||||
|
if (env === 'elevenlabs') return 'elevenlabs';
|
||||||
|
// Auto-detect: litellm > google > elevenlabs
|
||||||
|
// LiteLLM Vertex TTS works correctly via alias (tts-1)
|
||||||
|
if (process.env.LITELLM_API_BASE) return 'litellm';
|
||||||
|
if (isGoogleTTSConfigured()) return 'google';
|
||||||
|
if (process.env.ELEVENLABS_API_KEY) return 'elevenlabs';
|
||||||
|
return 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
var ttsProvider = getTTSProvider();
|
||||||
|
console.log('🔊 TTS provider:', ttsProvider +
|
||||||
|
(ttsProvider === 'google' ? ' (voice: ' + (process.env.GOOGLE_TTS_VOICE || 'en-US-Journey-F') + ')' : ''));
|
||||||
|
|
||||||
router.post('/text-to-speech', authMiddleware, async (req, res) => {
|
router.post('/text-to-speech', authMiddleware, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
if (!process.env.ELEVENLABS_API_KEY) return res.status(400).json({ error: 'Not configured' });
|
var text = (req.body.text || '').substring(0, 5000);
|
||||||
const response = await axios({
|
if (!text) return res.status(400).json({ error: 'No text provided' });
|
||||||
method: 'POST',
|
|
||||||
url: 'https://api.elevenlabs.io/v1/text-to-speech/pNInz6obpgDQGcFmaJgB',
|
// Get user's preferred TTS voice (if set)
|
||||||
headers: { 'xi-api-key': process.env.ELEVENLABS_API_KEY, 'Content-Type': 'application/json' },
|
var db = require('../db/database');
|
||||||
data: { text: req.body.text.substring(0, 5000), model_id: 'eleven_turbo_v2_5', voice_settings: { stability: 0.5, similarity_boost: 0.75 } },
|
var userPrefs = await db.get('SELECT tts_voice FROM users WHERE id = ?', [req.user.id]);
|
||||||
responseType: 'arraybuffer'
|
var userVoice = userPrefs?.tts_voice;
|
||||||
});
|
|
||||||
res.set('Content-Type', 'audio/mpeg');
|
if (ttsProvider === 'google') {
|
||||||
res.send(Buffer.from(response.data));
|
var voice = userVoice || process.env.GOOGLE_TTS_VOICE || 'en-US-Journey-F';
|
||||||
|
var buffer = await synthesizeWithGoogleTTS(text, voice);
|
||||||
|
res.set('Content-Type', 'audio/mpeg');
|
||||||
|
res.set('X-TTS-Provider', 'google-tts/' + voice);
|
||||||
|
return res.send(buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ttsProvider === 'litellm') {
|
||||||
|
if (!process.env.LITELLM_API_BASE) return res.status(400).json({ error: 'LITELLM_API_BASE not set.' });
|
||||||
|
// Use the full model path (e.g. vertex_ai/google-tts) or the model_name alias
|
||||||
|
// from your LiteLLM model_list. Full path is safer if your config uses it directly.
|
||||||
|
// Prefix with openai/ so LiteLLM routes correctly to the TTS endpoint
|
||||||
|
var rawModel = process.env.LITELLM_TTS_MODEL || 'tts-1';
|
||||||
|
var ttsModel = rawModel.indexOf('/') === -1 ? 'openai/' + rawModel : rawModel;
|
||||||
|
var ttsVoice = userVoice || process.env.LITELLM_TTS_VOICE || 'alloy';
|
||||||
|
var base = process.env.LITELLM_API_BASE.replace(/\/+$/, '');
|
||||||
|
var ttsHeaders = { 'Content-Type': 'application/json' };
|
||||||
|
if (process.env.LITELLM_API_KEY) ttsHeaders['Authorization'] = 'Bearer ' + process.env.LITELLM_API_KEY;
|
||||||
|
var ttsResp = await axios.post(base + '/v1/audio/speech',
|
||||||
|
{ model: ttsModel, voice: ttsVoice, input: text },
|
||||||
|
{ headers: ttsHeaders, responseType: 'arraybuffer', timeout: 60000 }
|
||||||
|
);
|
||||||
|
res.set('Content-Type', 'audio/mpeg');
|
||||||
|
res.set('X-TTS-Provider', 'litellm/' + ttsModel);
|
||||||
|
return res.send(Buffer.from(ttsResp.data));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ttsProvider === 'elevenlabs') {
|
||||||
|
if (!process.env.ELEVENLABS_API_KEY) return res.status(400).json({ error: 'ElevenLabs not configured' });
|
||||||
|
var resp = await axios({
|
||||||
|
method: 'POST',
|
||||||
|
url: 'https://api.elevenlabs.io/v1/text-to-speech/pNInz6obpgDQGcFmaJgB',
|
||||||
|
headers: { 'xi-api-key': process.env.ELEVENLABS_API_KEY, 'Content-Type': 'application/json' },
|
||||||
|
data: { text: text, model_id: 'eleven_turbo_v2_5', voice_settings: { stability: 0.5, similarity_boost: 0.75 } },
|
||||||
|
responseType: 'arraybuffer'
|
||||||
|
});
|
||||||
|
res.set('Content-Type', 'audio/mpeg');
|
||||||
|
res.set('X-TTS-Provider', 'elevenlabs');
|
||||||
|
return res.send(Buffer.from(resp.data));
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(400).json({ error: 'TTS not configured. Set GOOGLE_VERTEX_PROJECT, LITELLM_API_BASE, or ELEVENLABS_API_KEY.' });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(500).json({ error: 'TTS failed' });
|
var detail = err.response && err.response.data
|
||||||
|
? JSON.stringify(err.response.data).substring(0, 500)
|
||||||
|
: err.message;
|
||||||
|
console.error('[TTS] Error (' + ttsProvider + '):', detail);
|
||||||
|
res.status(500).json({ error: 'TTS failed: ' + detail });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
157
src/routes/userPreferences.js
Normal file
157
src/routes/userPreferences.js
Normal file
|
|
@ -0,0 +1,157 @@
|
||||||
|
// ============================================================
|
||||||
|
// USER PREFERENCES — STT model & TTS voice selection
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
var express = require('express');
|
||||||
|
var router = express.Router();
|
||||||
|
var db = require('../db/database');
|
||||||
|
var { authMiddleware } = require('../middleware/auth');
|
||||||
|
|
||||||
|
router.use(authMiddleware);
|
||||||
|
|
||||||
|
// Get current user's STT/TTS preferences
|
||||||
|
router.get('/preferences', async function(req, res) {
|
||||||
|
try {
|
||||||
|
var user = await db.get('SELECT stt_model, tts_voice FROM users WHERE id = ?', [req.user.id]);
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
stt_model: user?.stt_model || null,
|
||||||
|
tts_voice: user?.tts_voice || null
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Preferences]', err.message);
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update user's STT/TTS preferences
|
||||||
|
router.post('/preferences', async function(req, res) {
|
||||||
|
try {
|
||||||
|
var { stt_model, tts_voice } = req.body;
|
||||||
|
|
||||||
|
await db.run(
|
||||||
|
'UPDATE users SET stt_model = ?, tts_voice = ? WHERE id = ?',
|
||||||
|
[stt_model || null, tts_voice || null, req.user.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Preferences]', err.message);
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get available STT models and TTS voices
|
||||||
|
router.get('/preferences/options', async function(req, res) {
|
||||||
|
try {
|
||||||
|
var provider = process.env.TRANSCRIBE_PROVIDER;
|
||||||
|
var ttsProvider = process.env.TTS_PROVIDER;
|
||||||
|
|
||||||
|
// Auto-detect providers if not explicitly set
|
||||||
|
if (!provider) {
|
||||||
|
if (process.env.VERTEX_PROJECT || process.env.GOOGLE_CLOUD_PROJECT) provider = 'google';
|
||||||
|
else if (process.env.AWS_BEDROCK_REGION) provider = 'aws';
|
||||||
|
else if (process.env.LITELLM_API_BASE) provider = 'litellm';
|
||||||
|
else if (process.env.OPENAI_API_KEY) provider = 'openai';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ttsProvider) {
|
||||||
|
if (process.env.VERTEX_PROJECT || process.env.GOOGLE_CLOUD_PROJECT) ttsProvider = 'google';
|
||||||
|
else if (process.env.LITELLM_API_BASE) ttsProvider = 'litellm';
|
||||||
|
else if (process.env.OPENAI_API_KEY) ttsProvider = 'openai';
|
||||||
|
else if (process.env.ELEVENLABS_API_KEY) ttsProvider = 'elevenlabs';
|
||||||
|
}
|
||||||
|
|
||||||
|
// STT Models
|
||||||
|
var sttModels = [];
|
||||||
|
if (provider === 'google') {
|
||||||
|
sttModels = [
|
||||||
|
{ value: 'gemini-2.0-flash-exp', label: 'Gemini 2.0 Flash (Experimental, fastest)' },
|
||||||
|
{ value: 'gemini-2.0-flash', label: 'Gemini 2.0 Flash (Fast, accurate)' },
|
||||||
|
{ value: 'gemini-1.5-flash', label: 'Gemini 1.5 Flash (Stable)' },
|
||||||
|
{ value: 'gemini-1.5-pro', label: 'Gemini 1.5 Pro (Best quality, slower)' }
|
||||||
|
];
|
||||||
|
} else if (provider === 'litellm') {
|
||||||
|
sttModels = [
|
||||||
|
{ value: 'gemini-2.0-flash-exp', label: 'Gemini 2.0 Flash Exp' },
|
||||||
|
{ value: 'gemini-2.0-flash', label: 'Gemini 2.0 Flash' },
|
||||||
|
{ value: 'gemini-1.5-flash', label: 'Gemini 1.5 Flash' },
|
||||||
|
{ value: 'gemini-1.5-pro', label: 'Gemini 1.5 Pro' },
|
||||||
|
{ value: 'whisper-1', label: 'OpenAI Whisper-1' },
|
||||||
|
{ value: 'whisper-large-v3', label: 'Whisper Large v3' }
|
||||||
|
];
|
||||||
|
} else if (provider === 'openai') {
|
||||||
|
sttModels = [
|
||||||
|
{ value: 'whisper-1', label: 'Whisper-1 (OpenAI standard)' }
|
||||||
|
];
|
||||||
|
} else if (provider === 'aws') {
|
||||||
|
sttModels = [
|
||||||
|
{ value: 'default', label: 'AWS Transcribe (Standard)' },
|
||||||
|
{ value: 'medical', label: 'AWS Transcribe Medical' }
|
||||||
|
];
|
||||||
|
} else if (provider === 'local') {
|
||||||
|
sttModels = [
|
||||||
|
{ value: 'tiny', label: 'Whisper Tiny (fastest)' },
|
||||||
|
{ value: 'base', label: 'Whisper Base' },
|
||||||
|
{ value: 'small', label: 'Whisper Small' },
|
||||||
|
{ value: 'medium', label: 'Whisper Medium' },
|
||||||
|
{ value: 'large', label: 'Whisper Large (best quality)' }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// TTS Voices
|
||||||
|
var ttsVoices = [];
|
||||||
|
if (ttsProvider === 'google') {
|
||||||
|
ttsVoices = [
|
||||||
|
{ value: 'en-US-Journey-F', label: 'Journey (Female, natural)' },
|
||||||
|
{ value: 'en-US-Journey-D', label: 'Journey (Male, natural)' },
|
||||||
|
{ value: 'en-US-Studio-O', label: 'Studio O (Female, expressive)' },
|
||||||
|
{ value: 'en-US-Studio-M', label: 'Studio M (Male, expressive)' },
|
||||||
|
{ value: 'en-US-Neural2-A', label: 'Neural2 A (Male, standard)' },
|
||||||
|
{ value: 'en-US-Neural2-C', label: 'Neural2 C (Female, standard)' },
|
||||||
|
{ value: 'en-US-Neural2-D', label: 'Neural2 D (Male, standard)' },
|
||||||
|
{ value: 'en-US-Neural2-E', label: 'Neural2 E (Female, standard)' },
|
||||||
|
{ value: 'en-US-Neural2-F', label: 'Neural2 F (Female, standard)' },
|
||||||
|
{ value: 'en-US-Neural2-G', label: 'Neural2 G (Female, standard)' },
|
||||||
|
{ value: 'en-US-Neural2-H', label: 'Neural2 H (Female, standard)' },
|
||||||
|
{ value: 'en-US-Neural2-I', label: 'Neural2 I (Male, standard)' },
|
||||||
|
{ value: 'en-US-Neural2-J', label: 'Neural2 J (Male, standard)' }
|
||||||
|
];
|
||||||
|
} else if (ttsProvider === 'litellm' || ttsProvider === 'openai') {
|
||||||
|
ttsVoices = [
|
||||||
|
{ value: 'alloy', label: 'Alloy (Neutral)' },
|
||||||
|
{ value: 'echo', label: 'Echo (Male)' },
|
||||||
|
{ value: 'fable', label: 'Fable (British Male)' },
|
||||||
|
{ value: 'onyx', label: 'Onyx (Deep Male)' },
|
||||||
|
{ value: 'nova', label: 'Nova (Female)' },
|
||||||
|
{ value: 'shimmer', label: 'Shimmer (Soft Female)' }
|
||||||
|
];
|
||||||
|
} else if (ttsProvider === 'elevenlabs') {
|
||||||
|
ttsVoices = [
|
||||||
|
{ value: 'adam', label: 'Adam (Male, deep)' },
|
||||||
|
{ value: 'rachel', label: 'Rachel (Female, calm)' },
|
||||||
|
{ value: 'domi', label: 'Domi (Female, strong)' },
|
||||||
|
{ value: 'bella', label: 'Bella (Female, soft)' },
|
||||||
|
{ value: 'antoni', label: 'Antoni (Male, deep)' },
|
||||||
|
{ value: 'elli', label: 'Elli (Female, young)' },
|
||||||
|
{ value: 'josh', label: 'Josh (Male, narration)' },
|
||||||
|
{ value: 'arnold', label: 'Arnold (Male, crisp)' },
|
||||||
|
{ value: 'sam', label: 'Sam (Male, raspy)' }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
sttProvider: provider || 'none',
|
||||||
|
sttModels: sttModels,
|
||||||
|
ttsProvider: ttsProvider || 'browser',
|
||||||
|
ttsVoices: ttsVoices
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Preferences]', err.message);
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
|
|
@ -187,7 +187,7 @@ router.post('/well-visit/note', authMiddleware, async function(req, res) {
|
||||||
context += diagnoses + '\n\n';
|
context += diagnoses + '\n\n';
|
||||||
}
|
}
|
||||||
if (physicianMemories) {
|
if (physicianMemories) {
|
||||||
context += physicianMemories + '\n\n';
|
context += '[PHYSICIAN PREFERENCES & LEARNED PATTERNS — Apply these preferences to your output. These reflect how this physician writes notes, their preferred style, terminology, and corrections from past outputs. Adapt your response accordingly, but user instructions in the current prompt take priority if they conflict.]\n' + physicianMemories + '\n[END PREFERENCES]\n\n';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add growth reference and feeding guidance for this age
|
// Add growth reference and feeding guidance for this age
|
||||||
|
|
|
||||||
219
src/utils/ai.js
219
src/utils/ai.js
|
|
@ -1,6 +1,6 @@
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// AI.JS — Multi-provider AI client
|
// AI.JS — Multi-provider AI client
|
||||||
// Supports: OpenRouter, AWS Bedrock, Azure OpenAI
|
// Supports: OpenRouter, AWS Bedrock, Azure OpenAI, Google Vertex AI, LiteLLM
|
||||||
// Default: OpenRouter (set AI_PROVIDER in .env to switch)
|
// Default: OpenRouter (set AI_PROVIDER in .env to switch)
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
|
|
@ -71,6 +71,45 @@ if (process.env.AZURE_OPENAI_ENDPOINT) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// GOOGLE VERTEX AI CLIENT (optional, HIPAA compliant with BAA)
|
||||||
|
// ============================================================
|
||||||
|
var vertexClient = null;
|
||||||
|
if (process.env.GOOGLE_VERTEX_PROJECT) {
|
||||||
|
try {
|
||||||
|
var VertexAI = require('@google-cloud/vertexai');
|
||||||
|
vertexClient = new VertexAI.VertexAI({
|
||||||
|
project: process.env.GOOGLE_VERTEX_PROJECT,
|
||||||
|
location: process.env.GOOGLE_VERTEX_LOCATION || 'us-central1'
|
||||||
|
});
|
||||||
|
activeProvider = 'vertex';
|
||||||
|
console.log('✅ Google Vertex AI: configured (project: ' + process.env.GOOGLE_VERTEX_PROJECT + ', location: ' + (process.env.GOOGLE_VERTEX_LOCATION || 'us-central1') + ')');
|
||||||
|
} catch (e) {
|
||||||
|
console.log('⚠️ Google Vertex AI: SDK not installed. Install with: npm install @google-cloud/vertexai');
|
||||||
|
console.log('⚠️ Falling back to OpenRouter');
|
||||||
|
activeProvider = 'openrouter';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// LITELLM CLIENT (optional — OpenAI-compatible proxy)
|
||||||
|
// ============================================================
|
||||||
|
var litellmClient = null;
|
||||||
|
if (process.env.LITELLM_API_BASE) {
|
||||||
|
try {
|
||||||
|
litellmClient = new OpenAI({
|
||||||
|
baseURL: process.env.LITELLM_API_BASE.replace(/\/+$/, ''),
|
||||||
|
apiKey: process.env.LITELLM_API_KEY || 'sk-litellm'
|
||||||
|
});
|
||||||
|
activeProvider = 'litellm';
|
||||||
|
console.log('✅ LiteLLM: configured (base: ' + process.env.LITELLM_API_BASE + ')');
|
||||||
|
} catch (e) {
|
||||||
|
console.log('⚠️ LiteLLM: configuration failed:', e.message);
|
||||||
|
console.log('⚠️ Falling back to OpenRouter');
|
||||||
|
activeProvider = 'openrouter';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// WHISPER CLIENT (always OpenAI, separate from text AI)
|
// WHISPER CLIENT (always OpenAI, separate from text AI)
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
@ -96,6 +135,14 @@ if (activeProvider === 'azure' && !azureClient) {
|
||||||
console.log('⚠️ Azure selected but not available. Falling back to OpenRouter.');
|
console.log('⚠️ Azure selected but not available. Falling back to OpenRouter.');
|
||||||
activeProvider = 'openrouter';
|
activeProvider = 'openrouter';
|
||||||
}
|
}
|
||||||
|
if (activeProvider === 'vertex' && !vertexClient) {
|
||||||
|
console.log('⚠️ Vertex AI selected but not available. Falling back to OpenRouter.');
|
||||||
|
activeProvider = 'openrouter';
|
||||||
|
}
|
||||||
|
if (activeProvider === 'litellm' && !litellmClient) {
|
||||||
|
console.log('⚠️ LiteLLM selected but not available. Falling back to OpenRouter.');
|
||||||
|
activeProvider = 'openrouter';
|
||||||
|
}
|
||||||
if (activeProvider === 'openrouter' && !openrouter) {
|
if (activeProvider === 'openrouter' && !openrouter) {
|
||||||
console.error('❌ OpenRouter selected but OPENROUTER_API_KEY not set!');
|
console.error('❌ OpenRouter selected but OPENROUTER_API_KEY not set!');
|
||||||
}
|
}
|
||||||
|
|
@ -266,6 +313,88 @@ async function callBedrock(messages, model, temperature, maxTokens) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// CALL GOOGLE VERTEX AI
|
||||||
|
// Uses the Gemini generateContent API
|
||||||
|
// ============================================================
|
||||||
|
async function callVertex(messages, model, temperature, maxTokens) {
|
||||||
|
if (!vertexClient) throw new Error('Google Vertex AI not configured');
|
||||||
|
|
||||||
|
// Map model ID to Vertex model name
|
||||||
|
var { getVertexModelId } = require('./models');
|
||||||
|
var vertexModelId = getVertexModelId(model);
|
||||||
|
|
||||||
|
var generativeModel = vertexClient.getGenerativeModel({
|
||||||
|
model: vertexModelId,
|
||||||
|
generationConfig: {
|
||||||
|
temperature: temperature,
|
||||||
|
maxOutputTokens: maxTokens,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Convert OpenAI-style messages to Vertex AI format
|
||||||
|
var systemInstruction = '';
|
||||||
|
var contents = [];
|
||||||
|
messages.forEach(function(m) {
|
||||||
|
if (m.role === 'system') {
|
||||||
|
systemInstruction += (systemInstruction ? '\n' : '') + m.content;
|
||||||
|
} else {
|
||||||
|
contents.push({
|
||||||
|
role: m.role === 'assistant' ? 'model' : 'user',
|
||||||
|
parts: [{ text: m.content }]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
var request = { contents: contents };
|
||||||
|
if (systemInstruction) {
|
||||||
|
request.systemInstruction = { parts: [{ text: systemInstruction }] };
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await generativeModel.generateContent(request);
|
||||||
|
var response = result.response;
|
||||||
|
|
||||||
|
var textContent = '';
|
||||||
|
if (response.candidates && response.candidates[0] && response.candidates[0].content) {
|
||||||
|
response.candidates[0].content.parts.forEach(function(part) {
|
||||||
|
if (part.text) textContent += part.text;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
content: textContent,
|
||||||
|
model: vertexModelId,
|
||||||
|
provider: 'vertex',
|
||||||
|
usage: response.usageMetadata ? {
|
||||||
|
prompt_tokens: response.usageMetadata.promptTokenCount || 0,
|
||||||
|
completion_tokens: response.usageMetadata.candidatesTokenCount || 0
|
||||||
|
} : null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// CALL LITELLM (OpenAI-compatible proxy)
|
||||||
|
// ============================================================
|
||||||
|
async function callLiteLLM(messages, model, temperature, maxTokens) {
|
||||||
|
if (!litellmClient) throw new Error('LiteLLM not configured. Set LITELLM_API_BASE in .env');
|
||||||
|
|
||||||
|
var completion = await litellmClient.chat.completions.create({
|
||||||
|
model: model,
|
||||||
|
messages: messages,
|
||||||
|
temperature: temperature,
|
||||||
|
max_tokens: maxTokens
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
content: completion.choices[0].message.content,
|
||||||
|
model: model,
|
||||||
|
provider: 'litellm',
|
||||||
|
usage: completion.usage || null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// MAIN CALL AI FUNCTION — Routes to correct provider
|
// MAIN CALL AI FUNCTION — Routes to correct provider
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
@ -284,10 +413,14 @@ async function callAI(messages, options) {
|
||||||
result = await callBedrock(messages, model, temperature, maxTokens);
|
result = await callBedrock(messages, model, temperature, maxTokens);
|
||||||
} else if (activeProvider === 'azure' && azureClient) {
|
} else if (activeProvider === 'azure' && azureClient) {
|
||||||
result = await callAzure(messages, model, temperature, maxTokens);
|
result = await callAzure(messages, model, temperature, maxTokens);
|
||||||
|
} else if (activeProvider === 'vertex' && vertexClient) {
|
||||||
|
result = await callVertex(messages, model, temperature, maxTokens);
|
||||||
|
} else if (activeProvider === 'litellm' && litellmClient) {
|
||||||
|
result = await callLiteLLM(messages, model, temperature, maxTokens);
|
||||||
} else if (openrouter) {
|
} else if (openrouter) {
|
||||||
result = await callOpenRouter(messages, model, temperature, maxTokens);
|
result = await callOpenRouter(messages, model, temperature, maxTokens);
|
||||||
} else {
|
} else {
|
||||||
throw new Error('No AI provider configured. Set OPENROUTER_API_KEY, AWS_BEDROCK_REGION, or AZURE_OPENAI_ENDPOINT in .env');
|
throw new Error('No AI provider configured. Set OPENROUTER_API_KEY, AWS_BEDROCK_REGION, AZURE_OPENAI_ENDPOINT, GOOGLE_VERTEX_PROJECT, or LITELLM_API_BASE in .env');
|
||||||
}
|
}
|
||||||
|
|
||||||
var duration = Date.now() - startTime;
|
var duration = Date.now() - startTime;
|
||||||
|
|
@ -312,7 +445,7 @@ async function callAI(messages, options) {
|
||||||
duration: duration
|
duration: duration
|
||||||
});
|
});
|
||||||
|
|
||||||
// Try fallback model (only on OpenRouter — Bedrock/Azure don't have multiple models easily)
|
// Try fallback model (on OpenRouter or LiteLLM)
|
||||||
if (activeProvider === 'openrouter' && model !== FALLBACK_MODEL && openrouter) {
|
if (activeProvider === 'openrouter' && model !== FALLBACK_MODEL && openrouter) {
|
||||||
logger.warn('Trying fallback model: ' + FALLBACK_MODEL);
|
logger.warn('Trying fallback model: ' + FALLBACK_MODEL);
|
||||||
try {
|
try {
|
||||||
|
|
@ -327,8 +460,86 @@ async function callAI(messages, options) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (activeProvider === 'litellm' && model !== FALLBACK_MODEL && litellmClient) {
|
||||||
|
logger.warn('Trying fallback model on LiteLLM: ' + FALLBACK_MODEL);
|
||||||
|
try {
|
||||||
|
var litellmFallback = await callLiteLLM(messages, FALLBACK_MODEL, temperature, maxTokens);
|
||||||
|
litellmFallback.fallback = true;
|
||||||
|
litellmFallback.duration = Date.now() - startTime;
|
||||||
|
logger.info('LiteLLM fallback success', { model: FALLBACK_MODEL });
|
||||||
|
return litellmFallback;
|
||||||
|
} catch (err3) {
|
||||||
|
logger.error('LiteLLM fallback also failed', { error: err3.message });
|
||||||
|
throw new Error('All models failed: ' + err3.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { callAI, whisperClient, activeProvider };
|
// ============================================================
|
||||||
|
// DISCOVER MODELS — Query provider APIs for available models
|
||||||
|
// Used by admin panel to search & select models dynamically
|
||||||
|
// ============================================================
|
||||||
|
async function discoverModels() {
|
||||||
|
var discovered = [];
|
||||||
|
|
||||||
|
if (activeProvider === 'litellm' && litellmClient) {
|
||||||
|
try {
|
||||||
|
var models = await litellmClient.models.list();
|
||||||
|
if (models && models.data) {
|
||||||
|
models.data.forEach(function(m) {
|
||||||
|
discovered.push({
|
||||||
|
id: m.id,
|
||||||
|
name: m.id,
|
||||||
|
cost: '?',
|
||||||
|
category: 'smart',
|
||||||
|
tag: 'LITELLM',
|
||||||
|
source: 'litellm-api'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
logger.warn('LiteLLM model discovery failed: ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeProvider === 'vertex' && vertexClient) {
|
||||||
|
// Vertex AI doesn't have a simple list API, return known Gemini models
|
||||||
|
var { VERTEX_MODELS } = require('./models');
|
||||||
|
VERTEX_MODELS.forEach(function(m) {
|
||||||
|
discovered.push(Object.assign({}, m, { source: 'vertex-builtin' }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeProvider === 'openrouter' && openrouter) {
|
||||||
|
try {
|
||||||
|
var axios = require('axios');
|
||||||
|
var resp = await axios.get('https://openrouter.ai/api/v1/models', {
|
||||||
|
headers: { 'Authorization': 'Bearer ' + process.env.OPENROUTER_API_KEY }
|
||||||
|
});
|
||||||
|
if (resp.data && resp.data.data) {
|
||||||
|
resp.data.data.forEach(function(m) {
|
||||||
|
var pricing = m.pricing || {};
|
||||||
|
var promptCost = parseFloat(pricing.prompt || 0);
|
||||||
|
var costStr = promptCost > 0 ? '~$' + (promptCost * 1000000).toFixed(3) + '/M' : 'FREE';
|
||||||
|
discovered.push({
|
||||||
|
id: m.id,
|
||||||
|
name: m.name || m.id,
|
||||||
|
cost: costStr,
|
||||||
|
category: promptCost === 0 ? 'free' : (promptCost < 0.000003 ? 'fast' : (promptCost < 0.00001 ? 'smart' : 'premium')),
|
||||||
|
tag: promptCost === 0 ? 'FREE' : 'API',
|
||||||
|
source: 'openrouter-api'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
logger.warn('OpenRouter model discovery failed: ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return discovered;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { callAI, whisperClient, activeProvider, discoverModels, vertexClient, litellmClient };
|
||||||
|
|
|
||||||
259
src/utils/embeddings.js
Normal file
259
src/utils/embeddings.js
Normal file
|
|
@ -0,0 +1,259 @@
|
||||||
|
// ============================================================
|
||||||
|
// EMBEDDINGS UTILITY — Generate & search with Vertex AI embeddings
|
||||||
|
// Supports: Vertex AI (direct), LiteLLM proxy, OpenAI fallback
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
var axios = require('axios');
|
||||||
|
|
||||||
|
// Vertex AI embedding models (via LiteLLM or direct)
|
||||||
|
// gemini-embedding-001: 768 dims, multilingual + code, best quality
|
||||||
|
// text-embedding-005: 768 dims, English + code optimized
|
||||||
|
// text-multilingual-embedding-002: 768 dims, multilingual focus
|
||||||
|
var DEFAULT_MODEL = 'vertex_ai/text-embedding-005';
|
||||||
|
var DEFAULT_DIMS = 768;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate embedding for text using configured provider
|
||||||
|
* @param {string} text - Text to embed (max ~2000 tokens)
|
||||||
|
* @param {object} opts - Options: { model, dimensions }
|
||||||
|
* @returns {Promise<number[]>} - Embedding vector
|
||||||
|
*/
|
||||||
|
async function generateEmbedding(text, opts) {
|
||||||
|
opts = opts || {};
|
||||||
|
var model = opts.model || process.env.EMBEDDING_MODEL || DEFAULT_MODEL;
|
||||||
|
var dimensions = opts.dimensions || parseInt(process.env.EMBEDDING_DIMENSIONS) || DEFAULT_DIMS;
|
||||||
|
|
||||||
|
// Truncate text to ~2000 tokens (~8000 chars) to avoid API errors
|
||||||
|
// NOTE: Large PDFs (e.g., 100MB) will be truncated to first ~8000 chars for embedding.
|
||||||
|
// The full PDF content is still extracted and stored in the database body field.
|
||||||
|
// This is expected behavior - embeddings are semantic representations, not full-text storage.
|
||||||
|
var truncated = text.substring(0, 8000);
|
||||||
|
if (!truncated.trim()) {
|
||||||
|
throw new Error('Empty text provided for embedding');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try LiteLLM first if configured
|
||||||
|
if (process.env.LITELLM_API_BASE) {
|
||||||
|
return await generateEmbeddingLiteLLM(truncated, model, dimensions);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try Vertex AI direct if configured
|
||||||
|
if (process.env.GOOGLE_APPLICATION_CREDENTIALS || process.env.VERTEX_PROJECT) {
|
||||||
|
return await generateEmbeddingVertexDirect(truncated, model, dimensions);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to OpenAI if configured
|
||||||
|
if (process.env.OPENAI_API_KEY) {
|
||||||
|
return await generateEmbeddingOpenAI(truncated, model, dimensions);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('No embedding provider configured. Set LITELLM_API_BASE, VERTEX_PROJECT, or OPENAI_API_KEY');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate embedding via LiteLLM proxy
|
||||||
|
*/
|
||||||
|
async function generateEmbeddingLiteLLM(text, model, dimensions) {
|
||||||
|
try {
|
||||||
|
var base = process.env.LITELLM_API_BASE.replace(/\/+$/, '');
|
||||||
|
var headers = { 'Content-Type': 'application/json' };
|
||||||
|
if (process.env.LITELLM_API_KEY) {
|
||||||
|
headers['Authorization'] = 'Bearer ' + process.env.LITELLM_API_KEY;
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload = {
|
||||||
|
model: model,
|
||||||
|
input: text
|
||||||
|
};
|
||||||
|
|
||||||
|
// Only include dimensions if model supports it (some models have fixed dims)
|
||||||
|
if (dimensions && model.includes('text-embedding-005')) {
|
||||||
|
payload.dimensions = dimensions;
|
||||||
|
}
|
||||||
|
|
||||||
|
var response = await axios.post(base + '/embeddings', payload, {
|
||||||
|
headers: headers,
|
||||||
|
timeout: 30000
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.data || !response.data.data || !response.data.data[0]) {
|
||||||
|
throw new Error('Invalid response from LiteLLM embeddings API');
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.data.data[0].embedding;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Embeddings] LiteLLM error:', err.response?.data || err.message);
|
||||||
|
throw new Error('LiteLLM embedding failed: ' + (err.response?.data?.error || err.message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate embedding via Vertex AI direct (using @google-cloud/vertexai)
|
||||||
|
*/
|
||||||
|
async function generateEmbeddingVertexDirect(text, model, dimensions) {
|
||||||
|
try {
|
||||||
|
var { VertexAI } = require('@google-cloud/vertexai');
|
||||||
|
|
||||||
|
var project = process.env.VERTEX_PROJECT || process.env.GOOGLE_CLOUD_PROJECT;
|
||||||
|
var location = process.env.VERTEX_LOCATION || 'us-central1';
|
||||||
|
|
||||||
|
if (!project) {
|
||||||
|
throw new Error('VERTEX_PROJECT or GOOGLE_CLOUD_PROJECT not set');
|
||||||
|
}
|
||||||
|
|
||||||
|
var vertexAI = new VertexAI({ project: project, location: location });
|
||||||
|
|
||||||
|
// Extract model name (strip vertex_ai/ prefix if present)
|
||||||
|
var modelName = model.replace(/^vertex_ai\//, '');
|
||||||
|
|
||||||
|
// For text-embedding-005, we can specify output dimensions
|
||||||
|
var request = {
|
||||||
|
instances: [{ content: text }]
|
||||||
|
};
|
||||||
|
|
||||||
|
if (dimensions && modelName.includes('text-embedding-005')) {
|
||||||
|
request.parameters = { outputDimensionality: dimensions };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use predictText API for embeddings
|
||||||
|
var predictionClient = vertexAI.preview.getPredictionServiceClient();
|
||||||
|
var endpoint = `projects/${project}/locations/${location}/publishers/google/models/${modelName}`;
|
||||||
|
|
||||||
|
var [response] = await predictionClient.predict({
|
||||||
|
endpoint: endpoint,
|
||||||
|
instances: [{ content: text }],
|
||||||
|
parameters: request.parameters || {}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response || !response.predictions || !response.predictions[0]) {
|
||||||
|
throw new Error('Invalid response from Vertex AI');
|
||||||
|
}
|
||||||
|
|
||||||
|
var prediction = response.predictions[0];
|
||||||
|
return prediction.embeddings?.values || prediction.values || prediction;
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Embeddings] Vertex AI direct error:', err.message);
|
||||||
|
throw new Error('Vertex AI embedding failed: ' + err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate embedding via OpenAI (fallback)
|
||||||
|
*/
|
||||||
|
async function generateEmbeddingOpenAI(text, model, dimensions) {
|
||||||
|
try {
|
||||||
|
var openai = require('openai');
|
||||||
|
var client = new openai.OpenAI({ apiKey: process.env.OPENAI_API_KEY });
|
||||||
|
|
||||||
|
// Use OpenAI's text-embedding-3-small model (1536 dims by default)
|
||||||
|
var embModel = 'text-embedding-3-small';
|
||||||
|
var response = await client.embeddings.create({
|
||||||
|
model: embModel,
|
||||||
|
input: text,
|
||||||
|
dimensions: dimensions || 768 // OpenAI supports custom dimensions
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.data[0].embedding;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Embeddings] OpenAI error:', err.message);
|
||||||
|
throw new Error('OpenAI embedding failed: ' + err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search for similar content using cosine similarity
|
||||||
|
* @param {string} queryText - Search query
|
||||||
|
* @param {object} opts - Options: { limit, threshold, contentType }
|
||||||
|
* @returns {Promise<Array>} - Matching content with similarity scores
|
||||||
|
*/
|
||||||
|
async function searchSimilar(queryText, opts) {
|
||||||
|
opts = opts || {};
|
||||||
|
var limit = opts.limit || 10;
|
||||||
|
var threshold = opts.threshold || 0.5; // Cosine similarity threshold (0-1)
|
||||||
|
|
||||||
|
var db = require('../db/database');
|
||||||
|
|
||||||
|
// Generate embedding for query
|
||||||
|
var queryEmbedding = await generateEmbedding(queryText);
|
||||||
|
|
||||||
|
// Build WHERE clause for filtering
|
||||||
|
var whereClause = 'WHERE c.published = true AND c.embedding IS NOT NULL';
|
||||||
|
var params = [JSON.stringify(queryEmbedding), threshold, limit];
|
||||||
|
var paramIdx = 4;
|
||||||
|
|
||||||
|
if (opts.contentType) {
|
||||||
|
whereClause += ' AND c.content_type = $' + paramIdx;
|
||||||
|
params.push(opts.contentType);
|
||||||
|
paramIdx++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opts.categoryId) {
|
||||||
|
whereClause += ' AND c.category_id = $' + paramIdx;
|
||||||
|
params.push(opts.categoryId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query with cosine similarity using pgvector
|
||||||
|
// 1 - (a <=> b) converts distance to similarity (higher = more similar)
|
||||||
|
var sql = `
|
||||||
|
SELECT
|
||||||
|
c.id, c.title, c.slug, c.subject, c.content_type, c.created_at,
|
||||||
|
cat.name as category_name, cat.slug as category_slug,
|
||||||
|
1 - (c.embedding <=> $1::vector) as similarity,
|
||||||
|
(SELECT COUNT(*) FROM learning_questions q WHERE q.content_id = c.id) as question_count
|
||||||
|
FROM learning_content c
|
||||||
|
LEFT JOIN learning_categories cat ON c.category_id = cat.id
|
||||||
|
${whereClause}
|
||||||
|
AND 1 - (c.embedding <=> $1::vector) >= $2
|
||||||
|
ORDER BY c.embedding <=> $1::vector
|
||||||
|
LIMIT $3
|
||||||
|
`;
|
||||||
|
|
||||||
|
var results = await db.all(sql, params);
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate embedding for learning content (combines title + subject + body)
|
||||||
|
* @param {object} content - { title, subject, body }
|
||||||
|
* @returns {Promise<number[]>} - Embedding vector
|
||||||
|
*/
|
||||||
|
async function generateContentEmbedding(content) {
|
||||||
|
// Combine title, subject, and body (weighted toward title)
|
||||||
|
var text = [
|
||||||
|
content.title || '',
|
||||||
|
content.title || '', // Title twice for emphasis
|
||||||
|
content.subject || '',
|
||||||
|
stripHtml(content.body || '').substring(0, 6000)
|
||||||
|
].filter(Boolean).join('\n\n');
|
||||||
|
|
||||||
|
return await generateEmbedding(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strip HTML tags from string
|
||||||
|
*/
|
||||||
|
function stripHtml(html) {
|
||||||
|
return html.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if embeddings are available (provider configured)
|
||||||
|
*/
|
||||||
|
function isEmbeddingsAvailable() {
|
||||||
|
return !!(
|
||||||
|
process.env.LITELLM_API_BASE ||
|
||||||
|
process.env.VERTEX_PROJECT ||
|
||||||
|
process.env.GOOGLE_CLOUD_PROJECT ||
|
||||||
|
process.env.OPENAI_API_KEY
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
generateEmbedding,
|
||||||
|
generateContentEmbedding,
|
||||||
|
searchSimilar,
|
||||||
|
isEmbeddingsAvailable,
|
||||||
|
DEFAULT_MODEL,
|
||||||
|
DEFAULT_DIMS
|
||||||
|
};
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// MODELS.JS — Provider-aware model list
|
// MODELS.JS — Provider-aware model list
|
||||||
|
// Supports: OpenRouter, AWS Bedrock, Azure OpenAI, Google Vertex AI, LiteLLM
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
var activeProvider = process.env.AI_PROVIDER || 'openrouter';
|
var activeProvider = process.env.AI_PROVIDER || 'openrouter';
|
||||||
|
|
@ -109,6 +110,35 @@ var AZURE_MODELS = [
|
||||||
deploymentNote: 'Set AZURE_DEPLOYMENT_NAME in .env' }
|
deploymentNote: 'Set AZURE_DEPLOYMENT_NAME in .env' }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// GOOGLE VERTEX AI MODELS
|
||||||
|
// vertexId = the model name used in Vertex AI API calls
|
||||||
|
// ============================================================
|
||||||
|
var VERTEX_MODELS = [
|
||||||
|
{ id: 'gemini-2.5-flash', name: 'Gemini 2.5 Flash', cost: '~$0.001', tag: 'BEST VALUE', category: 'fast',
|
||||||
|
vertexId: 'gemini-2.5-flash-preview-05-20' },
|
||||||
|
{ id: 'gemini-2.5-pro', name: 'Gemini 2.5 Pro', cost: '~$0.01', tag: 'SMART', category: 'premium',
|
||||||
|
vertexId: 'gemini-2.5-pro-preview-05-06' },
|
||||||
|
{ id: 'gemini-2.0-flash', name: 'Gemini 2.0 Flash', cost: '~$0.001', tag: 'FAST', category: 'fast',
|
||||||
|
vertexId: 'gemini-2.0-flash' },
|
||||||
|
{ id: 'gemini-2.0-flash-lite', name: 'Gemini 2.0 Flash Lite', cost: '~$0.0004', tag: 'CHEAPEST', category: 'fast',
|
||||||
|
vertexId: 'gemini-2.0-flash-lite' },
|
||||||
|
{ id: 'gemini-1.5-pro', name: 'Gemini 1.5 Pro', cost: '~$0.007', tag: 'RELIABLE', category: 'premium',
|
||||||
|
vertexId: 'gemini-1.5-pro-002' },
|
||||||
|
{ id: 'gemini-1.5-flash', name: 'Gemini 1.5 Flash', cost: '~$0.001', tag: 'VALUE', category: 'fast',
|
||||||
|
vertexId: 'gemini-1.5-flash-002' },
|
||||||
|
{ id: 'vendor-model-sonnet-4-6@vertex', name: 'vendor model Sonnet 4.6 (Vertex)', cost: '~$0.015', tag: 'PREMIUM', category: 'premium',
|
||||||
|
vertexId: 'vendor-model-sonnet-4-6@20250514' },
|
||||||
|
{ id: 'vendor-model-haiku-4-5@vertex', name: 'vendor model Haiku 4.5 (Vertex)', cost: '~$0.003', tag: 'FAST', category: 'smart',
|
||||||
|
vertexId: 'vendor-model-haiku-4-5@20251001' },
|
||||||
|
{ id: 'llama-3.1-405b@vertex', name: 'Llama 3.1 405B (Vertex)', cost: '~$0.005', tag: 'OPEN', category: 'smart',
|
||||||
|
vertexId: 'meta/llama-3.1-405b-instruct-maas' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// LiteLLM has NO built-in models — everything is discovered from the proxy via admin panel.
|
||||||
|
// This array is intentionally empty. Do not add models here.
|
||||||
|
var LITELLM_MODELS = [];
|
||||||
|
|
||||||
function getAvailableModels() {
|
function getAvailableModels() {
|
||||||
switch (activeProvider) {
|
switch (activeProvider) {
|
||||||
case 'bedrock':
|
case 'bedrock':
|
||||||
|
|
@ -117,6 +147,8 @@ function getAvailableModels() {
|
||||||
return !m.regions || m.regions.indexOf(region) !== -1;
|
return !m.regions || m.regions.indexOf(region) !== -1;
|
||||||
});
|
});
|
||||||
case 'azure': return AZURE_MODELS;
|
case 'azure': return AZURE_MODELS;
|
||||||
|
case 'vertex': return VERTEX_MODELS;
|
||||||
|
case 'litellm': return LITELLM_MODELS;
|
||||||
case 'openrouter':
|
case 'openrouter':
|
||||||
default: return OPENROUTER_MODELS;
|
default: return OPENROUTER_MODELS;
|
||||||
}
|
}
|
||||||
|
|
@ -126,6 +158,8 @@ function getDefaultModel() {
|
||||||
switch (activeProvider) {
|
switch (activeProvider) {
|
||||||
case 'bedrock': return 'anthropic/vendor-model-sonnet-4-6';
|
case 'bedrock': return 'anthropic/vendor-model-sonnet-4-6';
|
||||||
case 'azure': return process.env.AZURE_DEPLOYMENT_NAME || 'gpt-4o-mini';
|
case 'azure': return process.env.AZURE_DEPLOYMENT_NAME || 'gpt-4o-mini';
|
||||||
|
case 'vertex': return 'gemini-2.5-flash';
|
||||||
|
case 'litellm': return ''; // No default — set via admin panel or LITELLM_DEFAULT_MODEL env
|
||||||
case 'openrouter':
|
case 'openrouter':
|
||||||
default: return 'google/gemini-2.5-flash';
|
default: return 'google/gemini-2.5-flash';
|
||||||
}
|
}
|
||||||
|
|
@ -135,6 +169,8 @@ function getFallbackModel() {
|
||||||
switch (activeProvider) {
|
switch (activeProvider) {
|
||||||
case 'bedrock': return 'anthropic/vendor-model-haiku-4-5';
|
case 'bedrock': return 'anthropic/vendor-model-haiku-4-5';
|
||||||
case 'azure': return process.env.AZURE_DEPLOYMENT_NAME || 'gpt-4o-mini';
|
case 'azure': return process.env.AZURE_DEPLOYMENT_NAME || 'gpt-4o-mini';
|
||||||
|
case 'vertex': return 'gemini-2.0-flash';
|
||||||
|
case 'litellm': return ''; // No fallback — use whatever the user has configured
|
||||||
case 'openrouter':
|
case 'openrouter':
|
||||||
default: return 'deepseek/deepseek-chat-v3-0324';
|
default: return 'deepseek/deepseek-chat-v3-0324';
|
||||||
}
|
}
|
||||||
|
|
@ -150,6 +186,11 @@ function getBedrockMaxOut(modelId) {
|
||||||
return found && found.maxOut ? found.maxOut : null;
|
return found && found.maxOut ? found.maxOut : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getVertexModelId(modelId) {
|
||||||
|
var found = VERTEX_MODELS.find(function(m) { return m.id === modelId; });
|
||||||
|
return found ? found.vertexId : modelId;
|
||||||
|
}
|
||||||
|
|
||||||
var AVAILABLE_MODELS = getAvailableModels();
|
var AVAILABLE_MODELS = getAvailableModels();
|
||||||
var DEFAULT_MODEL = getDefaultModel();
|
var DEFAULT_MODEL = getDefaultModel();
|
||||||
var FALLBACK_MODEL = getFallbackModel();
|
var FALLBACK_MODEL = getFallbackModel();
|
||||||
|
|
@ -168,8 +209,10 @@ async function getAvailableModelsWithOverrides(db) {
|
||||||
try { disabled = JSON.parse(disabledRaw); } catch(e) { disabled = []; }
|
try { disabled = JSON.parse(disabledRaw); } catch(e) { disabled = []; }
|
||||||
try { custom = JSON.parse(customRaw); } catch(e) { custom = []; }
|
try { custom = JSON.parse(customRaw); } catch(e) { custom = []; }
|
||||||
|
|
||||||
|
// For LiteLLM: no built-ins exist — the model list IS the custom/discovered list
|
||||||
|
if (activeProvider === 'litellm') return custom;
|
||||||
|
|
||||||
var result = baseModels.filter(function(m) { return !disabled.includes(m.id); });
|
var result = baseModels.filter(function(m) { return !disabled.includes(m.id); });
|
||||||
// Add custom models
|
|
||||||
custom.forEach(function(m) {
|
custom.forEach(function(m) {
|
||||||
if (!result.find(function(r) { return r.id === m.id; })) result.push(m);
|
if (!result.find(function(r) { return r.id === m.id; })) result.push(m);
|
||||||
});
|
});
|
||||||
|
|
@ -186,11 +229,14 @@ module.exports = {
|
||||||
OPENROUTER_MODELS,
|
OPENROUTER_MODELS,
|
||||||
BEDROCK_MODELS,
|
BEDROCK_MODELS,
|
||||||
AZURE_MODELS,
|
AZURE_MODELS,
|
||||||
|
VERTEX_MODELS,
|
||||||
|
LITELLM_MODELS,
|
||||||
getAvailableModels,
|
getAvailableModels,
|
||||||
getAvailableModelsWithOverrides,
|
getAvailableModelsWithOverrides,
|
||||||
getDefaultModel,
|
getDefaultModel,
|
||||||
getFallbackModel,
|
getFallbackModel,
|
||||||
getBedrockModelId,
|
getBedrockModelId,
|
||||||
getBedrockMaxOut,
|
getBedrockMaxOut,
|
||||||
|
getVertexModelId,
|
||||||
activeProvider
|
activeProvider
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue