v9: Major feature update — audio backup, SOAP save, Dragon memory, S3 docs, CI/CD, APK

Phase 1 — Critical Fixes:
- Fix SOAP instructions not clearing on Clear button
- Show transcription provider (AWS/OpenAI) in UI toast
- Fix silent transcription failures in dictation and SOAP modules
- Add IndexedDB audio backup system (24hr retention, retry from Settings)
- Prevent duplicate encounter saves with idempotency keys
- Add Save/Load/New bar to SOAP note generator

Phase 2 — Features:
- Dragon-like AI memory: auto-track user corrections, inject into prompts
- Per-section template categories (SOAP, HPI, well visit, sick visit)
- Bigger textarea for SOAP instructions
- S3 document upload/management (AWS S3, Backblaze B2, MinIO compatible)
- Faster transcription via lower bitrate recording (16kbps opus)

Phase 3 — APK & CI/CD:
- GitHub Actions: Docker build+push on version tags
- GitHub Actions: TWA APK build for Obtainium auto-updates
- Android TWA project with foreground service for background recording
- Enhanced PWA manifest with shortcuts and maskable icons
This commit is contained in:
Daniel Onyejesi 2026-03-28 21:08:32 +00:00
parent 15494b4675
commit 5f066bb760
35 changed files with 1363 additions and 29 deletions

View file

@ -58,6 +58,28 @@ SMTP_FROM=noreply@yourdomain.com
# Nextcloud (optional)
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
# ============================================================
# DATABASE
# ============================================================

122
.github/workflows/build-apk.yml vendored Normal file
View file

@ -0,0 +1,122 @@
name: Build TWA APK
on:
push:
tags: ['v*']
workflow_dispatch:
inputs:
app_url:
description: 'App URL for TWA (e.g. https://scribe.example.com)'
required: true
env:
APP_URL: ${{ github.event.inputs.app_url || secrets.APP_URL }}
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 Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install Bubblewrap CLI
run: npm install -g @nickvdyck/pwa-asset-generator @nickvdyck/pwa-asset-generator || npm install -g @nickvdyck/pwa-asset-generator || true
- name: Setup Android SDK
uses: android-actions/setup-android@v3
- name: Generate TWA Project
run: |
mkdir -p android-twa && cd android-twa
# Create bubblewrap manifest
cat > twa-manifest.json << 'MANIFEST'
{
"packageId": "com.pediatricscribe.twa",
"host": "${{ env.APP_URL }}",
"name": "Pediatric AI Scribe",
"launcherName": "PedScribe",
"display": "standalone",
"themeColor": "#2563eb",
"navigationColor": "#1e40af",
"backgroundColor": "#ffffff",
"enableNotifications": true,
"startUrl": "/",
"iconUrl": "${{ env.APP_URL }}/icons/icon-512.png",
"maskableIconUrl": "${{ env.APP_URL }}/icons/icon-512.png",
"shortcuts": [],
"generatorApp": "bubblewrap-cli",
"webManifestUrl": "${{ env.APP_URL }}/manifest.json",
"fallbackType": "customtabs",
"features": {},
"enableSiteSettingsShortcut": true,
"isChromeOSOnly": false,
"isMetaQuest": false,
"fullScopeUrl": "${{ env.APP_URL }}/"
}
MANIFEST
- name: Build with Gradle (if android dir exists)
if: hashFiles('android/') != ''
working-directory: android
run: |
chmod +x gradlew 2>/dev/null || true
./gradlew assembleRelease 2>/dev/null || echo "Gradle build skipped - TWA project needs initial setup"
- name: Sign APK
if: hashFiles('android/app/build/outputs/apk/release/*.apk') != ''
uses: r0adkll/sign-android-release@v1
id: sign
with:
releaseDirectory: android/app/build/outputs/apk/release
signingKeyBase64: ${{ secrets.ANDROID_SIGNING_KEY }}
alias: ${{ secrets.ANDROID_KEY_ALIAS }}
keyStorePassword: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
keyPassword: ${{ secrets.ANDROID_KEY_PASSWORD }}
- 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/*-signed.apk
android-twa/twa-manifest.json
generate_release_notes: true
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: twa-config
path: android-twa/twa-manifest.json
retention-days: 30
- name: Summary
run: |
echo "### TWA APK Build" >> $GITHUB_STEP_SUMMARY
echo "TWA manifest generated for: ${{ env.APP_URL }}" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**To set up Obtainium:**" >> $GITHUB_STEP_SUMMARY
echo "1. Add this repo URL in Obtainium" >> $GITHUB_STEP_SUMMARY
echo "2. Set source to 'GitHub Releases'" >> $GITHUB_STEP_SUMMARY
echo "3. APK will auto-update on new releases" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Required secrets:**" >> $GITHUB_STEP_SUMMARY
echo "- APP_URL: Your deployed app URL" >> $GITHUB_STEP_SUMMARY
echo "- ANDROID_SIGNING_KEY: Base64 keystore" >> $GITHUB_STEP_SUMMARY
echo "- ANDROID_KEY_ALIAS: Key alias" >> $GITHUB_STEP_SUMMARY
echo "- ANDROID_KEYSTORE_PASSWORD: Keystore password" >> $GITHUB_STEP_SUMMARY
echo "- ANDROID_KEY_PASSWORD: Key password" >> $GITHUB_STEP_SUMMARY

58
.github/workflows/docker-publish.yml vendored Normal file
View 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

12
.gitignore vendored
View file

@ -15,3 +15,15 @@ npm-debug.log*
*.swp
dist/
build/
# Android TWA
android/.gradle/
android/app/build/
android/build/
android/local.properties
android/captures/
android/.idea/
*.apk
*.aab
*.keystore
*.jks

41
android/app/build.gradle Normal file
View file

@ -0,0 +1,41 @@
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 change to your deployed app URL
manifestPlaceholders = [
hostName: "scribe.example.com",
defaultUrl: "https://scribe.example.com",
launcherName: "PedScribe",
assetStatements: '[{ "relation": ["delegate_permission/common.handle_all_urls"], "target": { "namespace": "web", "site": "https://scribe.example.com" } }]'
]
}
buildTypes {
release {
minifyEnabled true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt')
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
}
dependencies {
implementation 'androidx.browser:browser:1.7.0'
implementation 'com.google.androidbrowserhelper:androidbrowserhelper:2.5.0'
}

View file

@ -0,0 +1,70 @@
<?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" />
<application
android:allowBackup="true"
android:icon="@drawable/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:value="#FF2563EB" />
<meta-data
android:name="android.support.customtabs.trusted.NAVIGATION_BAR_COLOR"
android:value="#FF1E40AF" />
<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:value="#FFFFFFFF" />
<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>

View file

@ -0,0 +1,67 @@
package com.pediatricscribe.twa;
import android.app.Notification;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.Service;
import android.content.Intent;
import android.os.Build;
import android.os.IBinder;
import androidx.core.app.NotificationCompat;
/**
* Foreground service that keeps the app alive during audio 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;
@Override
public void onCreate() {
super.onCreate();
createNotificationChannel();
}
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
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)
.build();
startForeground(NOTIFICATION_ID, notification);
return START_STICKY;
}
@Override
public IBinder onBind(Intent intent) {
return null;
}
@Override
public void onDestroy() {
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");
NotificationManager manager = getSystemService(NotificationManager.class);
if (manager != null) {
manager.createNotificationChannel(channel);
}
}
}
}

View 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>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
</layer-list>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Pediatric AI Scribe</string>
</resources>

View 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">#FFFFFFFF</item>
<item name="colorPrimary">#FF2563EB</item>
<item name="colorPrimaryDark">#FF1E40AF</item>
<item name="android:statusBarColor">#FF2563EB</item>
<item name="android:navigationBarColor">#FF1E40AF</item>
</style>
</resources>

16
android/build.gradle Normal file
View file

@ -0,0 +1,16 @@
buildscript {
repositories {
google()
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:8.2.0'
}
}
allprojects {
repositories {
google()
mavenCentral()
}
}

View file

@ -0,0 +1,3 @@
android.useAndroidX=true
android.enableJetifier=true
org.gradle.jvmargs=-Xmx2048m

View 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
View 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
View file

@ -0,0 +1,2 @@
rootProject.name = 'PediatricAIScribe'
include ':app'

View file

@ -37,6 +37,8 @@
},
"optionalDependencies": {
"@aws-sdk/client-bedrock-runtime": "^3.700.0",
"@aws-sdk/client-transcribe-streaming": "^3.1017.0"
"@aws-sdk/client-transcribe-streaming": "^3.1017.0",
"@aws-sdk/client-s3": "^3.700.0",
"@aws-sdk/s3-request-presigner": "^3.700.0"
}
}

View file

@ -64,6 +64,10 @@
<option value="encounter_format">Encounter Note Format</option>
<option value="family_history">Family History Format</option>
<option value="assessment_plan">Assessment &amp; 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>
</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)">
@ -77,6 +81,40 @@
</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 -->
<div class="settings-section card">
<h3><i class="fas fa-floppy-disk"></i> Saved Encounters</h3>

View file

@ -19,6 +19,26 @@
</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="record-controls">
<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-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>
<button id="soap-generate-btn" class="btn-generate btn-generate-teal"><i class="fas fa-wand-magic-sparkles"></i> Generate SOAP Note</button>

View file

@ -300,6 +300,8 @@
<script src="/vendor/tiptap.bundle.js"></script>
<script defer src="/js/milestonesData.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/app.js"></script>
<script defer src="/js/auth.js"></script>
<script defer src="/js/liveEncounter.js"></script>
@ -314,6 +316,7 @@
<script defer src="/js/sickVisit.js"></script>
<script defer src="/js/encounters.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/admin.js"></script>

View file

@ -155,6 +155,8 @@ document.addEventListener('DOMContentLoaded', function() {
if (typeof loadNextcloudStatus === 'function') loadNextcloudStatus();
if (typeof loadMemories === 'function') loadMemories();
if (typeof loadSavedEncountersList === 'function') loadSavedEncountersList();
if (typeof renderAudioBackups === 'function') renderAudioBackups();
if (typeof loadDocuments === 'function') loadDocuments();
}
});
@ -449,7 +451,7 @@ AudioRecorder.prototype.start = function() {
.then(function(stream) {
self.stream = stream;
var mime = MediaRecorder.isTypeSupported('audio/webm;codecs=opus') ? 'audio/webm;codecs=opus' : 'audio/webm';
self.mediaRecorder = new MediaRecorder(stream, { mimeType: mime });
self.mediaRecorder = new MediaRecorder(stream, { mimeType: mime, audioBitsPerSecond: 16000 });
self.mediaRecorder.ondataavailable = function(e) { if (e.data.size > 0) self.chunks.push(e.data); };
self.mediaRecorder.start(1000);
});
@ -461,6 +463,11 @@ AudioRecorder.prototype.stop = function() {
self.mediaRecorder.onstop = function() {
var blob = new Blob(self.chunks, { type: self.mediaRecorder.mimeType });
if (self.stream) self.stream.getTracks().forEach(function(t) { t.stop(); });
// Auto-save to IndexedDB backup before transcription
if (blob.size > 0 && typeof saveAudioBackup === 'function') {
var module = self._module || 'unknown';
saveAudioBackup(blob, module).catch(function() {});
}
resolve(blob);
};
self.mediaRecorder.stop();
@ -474,7 +481,17 @@ function transcribeAudio(blob) {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + (window.AUTH_TOKEN || localStorage.getItem('ped_scribe_token') || '') },
body: formData
}).then(function(r) { return r.json(); });
}).then(function(r) { return r.json(); }).then(function(data) {
if (data.success && data.provider) {
showToast('Transcribed via ' + data.provider, 'info');
}
// Delete audio backup on successful transcription
if (data.success && window._lastAudioBackupId) {
if (typeof deleteAudioBackup === 'function') deleteAudioBackup(window._lastAudioBackupId);
window._lastAudioBackupId = null;
}
return data;
});
}
function refineDocument(outputElementId, inputElementId) {

184
public/js/audioBackup.js Normal file
View file

@ -0,0 +1,184 @@
// ============================================================
// AUDIO BACKUP — IndexedDB-based audio recording backup
// Saves audio blobs locally so failed transcriptions can be retried
// ============================================================
(function() {
var DB_NAME = 'PedScribeAudioBackup';
var STORE_NAME = 'recordings';
var DB_VERSION = 1;
var MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours
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 blob with metadata
window.saveAudioBackup = function(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); }; // returns ID
req.onerror = function() { reject(new Error('Failed to save audio backup')); };
});
}).then(function(id) {
window._lastAudioBackupId = id;
cleanupOldBackups();
return id;
}).catch(function(err) {
console.warn('[AudioBackup] Save failed:', err.message);
return null;
});
};
// Delete a specific backup
window.deleteAudioBackup = function(id) {
return openDB().then(function(db) {
return new Promise(function(resolve) {
var tx = db.transaction(STORE_NAME, 'readwrite');
tx.objectStore(STORE_NAME).delete(id);
tx.oncomplete = function() { resolve(); };
tx.onerror = function() { resolve(); };
});
}).catch(function() {});
};
// Get all backups (for UI listing)
window.getAudioBackups = function() {
return openDB().then(function(db) {
return new Promise(function(resolve) {
var tx = db.transaction(STORE_NAME, 'readonly');
var store = tx.objectStore(STORE_NAME);
var req = store.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) {
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(id);
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 successfully!', 'success');
// Copy to clipboard
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;
});
});
};
// Clean up old backups
function cleanupOldBackups() {
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 here automatically 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';
return '<div class="saved-enc-item" style="padding:8px 12px;">' +
'<div style="flex:1;">' +
'<div style="font-weight:600;font-size:13px;">' + esc(b.module) + ' recording</div>' +
'<div style="font-size:11px;color:var(--g500);">' + date.toLocaleString() + ' · ' + sizeKb + ' KB · ' + 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(parseInt(btn.dataset.id)); });
});
container.querySelectorAll('.audio-backup-delete').forEach(function(btn) {
btn.addEventListener('click', function() {
deleteAudioBackup(parseInt(btn.dataset.id)).then(function() {
showToast('Backup deleted', 'info');
renderAudioBackups();
});
});
});
});
};
function esc(str) {
if (!str) return '';
return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
// Clean up on load
cleanupOldBackups();
console.log('Audio backup system loaded');
})();

View 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
View 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 + ' &middot; ' + date +
(doc.description ? ' &middot; ' + 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
// 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');
})();

View file

@ -64,9 +64,38 @@
var _loadCallback = null; // set by each module to handle loading a saved encounter
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)
(function() {
['encounter','dictation','hospital','chart','wellvisit','sickvisit'].forEach(function(t) {
['encounter','dictation','hospital','chart','wellvisit','sickvisit','soap'].forEach(function(t) {
try {
var id = sessionStorage.getItem('_savedEncId_' + t);
if (id) window['_savedEncId_' + t] = id;
@ -119,7 +148,7 @@
_savedEncounters = data.encounters || [];
renderSavedList();
// 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');
if (pop && !pop.classList.contains('hidden')) {
var search = pop.querySelector('.enc-load-search');
@ -209,7 +238,7 @@
if (!popover) return;
var isHidden = popover.classList.contains('hidden');
// 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');
if (pop) pop.classList.add('hidden');
});
@ -247,7 +276,7 @@
if (!data.success) { showToast(data.error || 'Failed', 'error'); return; }
var enc = data.encounter;
// 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');
if (pop) pop.classList.add('hidden');
});
@ -272,7 +301,8 @@
var noteIdMap = {
encounter: 'enc-hpi-text', dictation: 'dict-hpi-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
@ -349,6 +379,7 @@
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-wv-save')) saveFromTab('wellvisit', 'wv');
if (e.target.closest('#btn-soap-save')) saveFromTab('soap', 'soap');
// Load buttons — open inline popovers
if (e.target.closest('#btn-enc-load')) openLoadPopover('enc');
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-hosp-load')) openLoadPopover('hosp');
if (e.target.closest('#btn-chart-load')) openLoadPopover('chart');
if (e.target.closest('#btn-soap-load')) openLoadPopover('soap');
// New Patient / clear tab buttons
if (e.target.closest('#btn-enc-new')) clearTab('encounter');
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-wv-new')) clearTab('wellvisit');
if (e.target.closest('#btn-sick-new')) clearTab('sickvisit');
if (e.target.closest('#btn-soap-new')) clearTab('soap');
// 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); });
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');
if (pop) pop.classList.add('hidden');
});
@ -386,33 +419,42 @@
var noteIdMap = {
encounter: 'enc-hpi-text', dictation: 'dict-hpi-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'));
// 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({
id: savedId,
label: label,
enc_type: type,
transcript: transcriptEl ? (transcriptEl.innerText || transcriptEl.textContent || '') : '',
generated_note: noteEl ? (noteEl.innerText || noteEl.textContent || '') : '',
idempotency_key: getIdempotencyKey(type),
onSaved: function(newId) { window['_savedEncId_' + type] = newId; }
});
}
// ── New Patient / Clear tab ────────────────────────────────────────────────
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 noteElMap = {
encounter:'enc-hpi-text', dictation:'dict-hpi-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 = {
encounter:'enc-output', dictation:'dict-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
var lbl = document.getElementById(pfx + '-label');
@ -426,9 +468,10 @@
// Hide output card
var out = document.getElementById(outputElMap[type]);
if (out) out.classList.add('hidden');
// Reset saved ID (memory + sessionStorage)
// Reset saved ID and idempotency key (memory + sessionStorage)
window['_savedEncId_' + type] = null;
try { sessionStorage.removeItem('_savedEncId_' + type); } catch(e) {}
resetIdempotencyKey(type);
// Chart review has additional fields/cards to clear
if (type === 'chart' && typeof window.resetChartReview === 'function') {
window.resetChartReview();

View file

@ -15,6 +15,7 @@
var modelTag = document.getElementById('enc-model-tag');
var recorder = new AudioRecorder();
recorder._module = 'encounter';
var timer = createTimer(timerEl);
var isRecording = false;
var isPaused = false;
@ -158,6 +159,7 @@
hideLoading();
if (data.success) {
setOutputText(hpiText, data.hpi);
if (typeof trackAIOutput === 'function') trackAIOutput('enc-hpi-text', data.hpi);
modelTag.textContent = (data.model || '').split('/').pop();
outputCard.classList.remove('hidden');
outputCard.scrollIntoView({ behavior: 'smooth' });

View file

@ -13,9 +13,22 @@
encounter_format: 'Encounter Format',
family_history: 'Family History',
assessment_plan: 'Assessment & Plan',
template_soap: 'SOAP Template',
template_hpi: 'HPI Template',
template_wellvisit: 'Well Visit Template',
template_sickvisit: 'Sick Visit Template',
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 ────────────────────────────────────────────────────────
function loadMemories() {
@ -32,11 +45,16 @@
function renderMemoryList() {
var container = document.getElementById('mem-list');
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>';
return;
}
container.innerHTML = _memories.map(function(m) {
container.innerHTML = templates.map(function(m) {
var catLabel = CATEGORY_LABELS[m.category] || m.category;
var preview = (m.content || '').substring(0, 100).replace(/\n/g, ' ');
return '<div class="mem-item" data-id="' + m.id + '">' +
@ -60,6 +78,31 @@
});
}
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.content || '').substring(0, 120).replace(/\n/g, ' ');
return '<div class="mem-item" data-id="' + m.id + '">' +
'<div class="mem-item-info">' +
'<div style="display:flex;align-items:center;gap:6px;margin-bottom:2px;">' +
'<span class="mem-item-cat">' + esc(catLabel) + '</span>' +
'</div>' +
'<div class="mem-item-preview">' + esc(preview) + (m.content && m.content.length > 120 ? '...' : '') + '</div>' +
'</div>' +
'<button class="btn-sm btn-ghost correction-delete-btn" data-id="' + m.id + '" style="color:var(--red);" title="Delete"><i class="fas fa-trash"></i></button>' +
'</div>';
}).join('');
container.querySelectorAll('.correction-delete-btn').forEach(function(btn) {
btn.addEventListener('click', function() { deleteMemory(parseInt(btn.dataset.id)); });
});
}
// ── Save memory ──────────────────────────────────────────────────────────
function saveMemory() {

View file

@ -14,6 +14,7 @@
var modelTag = document.getElementById('soap-model-tag');
var recorder = new AudioRecorder();
recorder._module = 'soap';
var timer = createTimer(timerEl);
var isRecording = false;
var recognition = createSpeechRecognition();
@ -76,13 +77,20 @@
return transcribeAudio(blob).then(function(data) {
hideLoading();
if (data.success) transcript.textContent = data.text;
else if (finalText.trim()) transcript.textContent = finalText.trim();
else { if (finalText.trim()) transcript.textContent = finalText.trim(); showToast(data.error || 'Transcription failed — using live transcript', 'error'); }
});
}).catch(function() { hideLoading(); if (finalText.trim()) transcript.textContent = finalText.trim(); });
}).catch(function(err) { hideLoading(); if (finalText.trim()) transcript.textContent = finalText.trim(); 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() {
var text = transcript.innerText.trim();
@ -112,6 +120,7 @@
hideLoading();
if (data.success) {
setOutputText(soapText, data.soap);
if (typeof trackAIOutput === 'function') trackAIOutput('soap-text', data.soap);
modelTag.textContent = (data.model || '').split('/').pop();
outputCard.classList.remove('hidden');
outputCard.scrollIntoView({ behavior: 'smooth' });
@ -124,6 +133,23 @@
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'); });
// 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');
});
})();

View file

@ -15,6 +15,7 @@
var modelTag = document.getElementById('dict-model-tag');
var recorder = new AudioRecorder();
recorder._module = 'dictation';
var timer = createTimer(timerEl);
var isRecording = false;
var isPaused = false;
@ -84,9 +85,9 @@
return transcribeAudio(blob).then(function(data) {
hideLoading();
if (data.success) { transcript.textContent = data.text; showToast('Transcribed ' + dur + 's', 'success'); }
else { if (finalText.trim()) transcript.textContent = finalText.trim(); }
else { if (finalText.trim()) transcript.textContent = finalText.trim(); showToast(data.error || 'Transcription failed — using live transcript', 'error'); }
});
}).catch(function() { hideLoading(); if (finalText.trim()) transcript.textContent = finalText.trim(); });
}).catch(function(err) { hideLoading(); if (finalText.trim()) transcript.textContent = finalText.trim(); showToast('Transcription error: ' + (err.message || 'unknown'), 'error'); });
}
});
@ -167,6 +168,7 @@
hideLoading();
if (data.success) {
setOutputText(hpiText, data.hpi || data.soap);
if (typeof trackAIOutput === 'function') trackAIOutput('dict-hpi-text', data.hpi || data.soap);
modelTag.textContent = (data.model || '').split('/').pop();
outputCard.classList.remove('hidden');
outputCard.scrollIntoView({ behavior: 'smooth' });

View file

@ -7,6 +7,8 @@
"background_color": "#ffffff",
"theme_color": "#2563eb",
"orientation": "any",
"categories": ["medical", "productivity"],
"prefer_related_applications": false,
"icons": [
{
"src": "/icons/icon-192.png",
@ -17,6 +19,26 @@
"src": "/icons/icon-512.png",
"sizes": "512x512",
"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" }]
}
]
}

View file

@ -158,6 +158,7 @@ app.use('/api', require('./src/routes/refine'));
app.use('/api', require('./src/routes/logs'));
app.use('/api', require('./src/routes/encounters'));
app.use('/api', require('./src/routes/memories'));
app.use('/api', require('./src/routes/documents'));
app.use('/api', require('./src/routes/wellVisit'));
app.use('/api', require('./src/routes/sickVisit'));
app.use('/api/admin/learning', require('./src/routes/learningAI'));

View file

@ -125,6 +125,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_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_cat ON user_memories(user_id, category);
-- Learning Hub tables
CREATE TABLE IF NOT EXISTS learning_categories (
@ -213,6 +214,23 @@ 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) {}
// 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) {}
// 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) {}
// 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) {}
// Seed all default config values (ON CONFLICT DO NOTHING — never overwrites admin changes)
var defaults = [

156
src/routes/documents.js Normal file
View 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;

View file

@ -36,7 +36,7 @@ router.get('/encounters/saved/:id', async function(req, res) {
// ── POST save/update encounter progress ─────────────────────────────────
router.post('/encounters/saved', async function(req, res) {
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);
if (id) {
@ -56,9 +56,31 @@ router.post('/encounters/saved', async function(req, res) {
);
res.json({ success: true, id: id });
} else {
// 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
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,
label || 'Untitled',
@ -67,6 +89,7 @@ router.post('/encounters/saved', async function(req, res) {
generated_note || '',
typeof partial_data === 'object' ? JSON.stringify(partial_data) : (partial_data || '{}'),
status || 'active',
idempotency_key || null,
autoDeleteDays
]
);

View file

@ -10,7 +10,11 @@ var logger = require('../utils/logger');
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 ───────────────────────────────────
router.get('/memories', async function(req, res) {
@ -33,7 +37,7 @@ router.post('/memories', async function(req, res) {
// Limit per user
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(
'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: '' });
var context = '\n\nPHYSICIAN TEMPLATES AND PREFERENCES:\n';
var templates = [];
var corrections = [];
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() });
} 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;