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:
parent
15494b4675
commit
5f066bb760
35 changed files with 1363 additions and 29 deletions
22
.env.example
22
.env.example
|
|
@ -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
122
.github/workflows/build-apk.yml
vendored
Normal 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
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
|
||||
12
.gitignore
vendored
12
.gitignore
vendored
|
|
@ -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
41
android/app/build.gradle
Normal 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'
|
||||
}
|
||||
70
android/app/src/main/AndroidManifest.xml
Normal file
70
android/app/src/main/AndroidManifest.xml
Normal 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>
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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>
|
||||
4
android/app/src/main/res/drawable/splash.xml
Normal file
4
android/app/src/main/res/drawable/splash.xml
Normal 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>
|
||||
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">#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
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'
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 & 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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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
184
public/js/audioBackup.js
Normal 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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
// Clean up on load
|
||||
cleanupOldBackups();
|
||||
|
||||
console.log('Audio backup system loaded');
|
||||
})();
|
||||
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 _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();
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
|
|
|
|||
|
|
@ -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" }]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
|
|
|
|||
|
|
@ -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
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 ─────────────────────────────────
|
||||
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
|
||||
]
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue