Compare commits

..

No commits in common. "main" and "v7.14.4-rc1" have entirely different histories.

48 changed files with 226 additions and 1494 deletions

View file

@ -15,7 +15,6 @@ services:
LITELLM_TTS_MODEL: local-kokoro-tts LITELLM_TTS_MODEL: local-kokoro-tts
LITELLM_TTS_VOICE: sherpa/kokoro:am_adam LITELLM_TTS_VOICE: sherpa/kokoro:am_adam
LITELLM_TTS_VOICES: sherpa/kokoro:am_adam,sherpa/kokoro:am_michael,sherpa/kokoro:af_bella,sherpa/kokoro:af_nicole,sherpa/kokoro:bf_emma,sherpa/kokoro:bm_lewis LITELLM_TTS_VOICES: sherpa/kokoro:am_adam,sherpa/kokoro:am_michael,sherpa/kokoro:af_bella,sherpa/kokoro:af_nicole,sherpa/kokoro:bf_emma,sherpa/kokoro:bm_lewis
CLINICAL_ASSISTANT_PROMPT_POOL_TARGET: 1000
volumes: volumes:
- scribe-logs:/app/data/logs - scribe-logs:/app/data/logs
- clinical-assistant-mcp-data:/app/mcp-data:ro - clinical-assistant-mcp-data:/app/mcp-data:ro
@ -28,9 +27,9 @@ services:
container_name: pediatric-ai-scribe container_name: pediatric-ai-scribe
networks: networks:
- default - default
- danvics_mcp - mcp-server_default
- danvics_monitoring - monitoring_default
- danvics_speech - speech_net
healthcheck: healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000/api/health"] test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000/api/health"]
interval: 30s interval: 30s
@ -46,7 +45,7 @@ services:
environment: environment:
POSTGRES_DB: pedscribe POSTGRES_DB: pedscribe
POSTGRES_USER: pedscribe POSTGRES_USER: pedscribe
POSTGRES_PASSWORD: ${DB_PASSWORD:-pedscribe} POSTGRES_PASSWORD: ${DB_PASSWORD:?set DB_PASSWORD}
volumes: volumes:
- pgdata:/var/lib/postgresql/data - pgdata:/var/lib/postgresql/data
restart: unless-stopped restart: unless-stopped
@ -72,7 +71,7 @@ services:
retries: 5 retries: 5
networks: networks:
- default - default
- danvics_mcp - mcp-server_default
volumes: volumes:
pgdata: pgdata:
@ -83,9 +82,9 @@ volumes:
name: mcp-server_mcp-data name: mcp-server_mcp-data
networks: networks:
danvics_mcp: mcp-server_default:
external: true external: true
danvics_monitoring: monitoring_default:
external: true external: true
danvics_speech: speech_net:
external: true external: true

View file

@ -103,7 +103,8 @@ Authenticate a user. Supports optional TOTP two-factor authentication. On succes
{ {
"email": "string", "email": "string",
"password": "string", "password": "string",
"totpCode": "string (optional, required if 2FA is enabled)" "totpCode": "string (optional, required if 2FA is enabled)",
"turnstileToken": "string"
} }
``` ```
- **Response:** - **Response:**

View file

@ -123,23 +123,9 @@ necessary UX tradeoff over perfect indistinguishability.
## Turnstile (Cloudflare bot protection) ## Turnstile (Cloudflare bot protection)
Applied to `/api/auth/register` and `/api/auth/forgot-password` when Applied to `/api/auth/login`, `/register`, `/forgot-password` when
`TURNSTILE_SECRET_KEY` is set. No-op when unset (dev mode). `TURNSTILE_SECRET_KEY` is set. No-op when unset (dev mode).
`/api/auth/login` is deliberately **not** gated: the widget could not
reliably complete a challenge inside the Capacitor WebView, which locked
mobile users out of the app. Login is covered instead by its per-IP rate
limit (10 / 15 min), the constant-time credential check, and TOTP 2FA.
The two remaining widgets are rendered explicitly (`api.js?render=explicit`)
the first time their form becomes visible — Turnstile does not reliably
complete a challenge inside a `display:none` container, and both forms start
hidden. Tokens are captured from the render callback, not read back out of
the injected `[name="cf-turnstile-response"]` input.
Note that the site key is currently **hardcoded** in `public/index.html`.
`TURNSTILE_SITE_KEY` exists in OpenBao but is not read by any code.
## Encryption at rest ## Encryption at rest
`src/utils/crypto.js` provides AES-256-GCM helpers. Key loaded from `src/utils/crypto.js` provides AES-256-GCM helpers. Key loaded from

View file

@ -2,15 +2,14 @@
// SESSION PERSISTENCE — full logout → login → still on the same // SESSION PERSISTENCE — full logout → login → still on the same
// tab + same sub-pill. // tab + same sub-pill.
// //
// The test does a programmatic logout (clear the ped_auth cookie, // The UI's login form is gated by a Cloudflare Turnstile token
// same effect server-side as clicking Logout) followed by a fresh // whose site key is hardcoded in index.html, which can't be
// programmatic login. This exercises the same localStorage // completed in the e2e container (Turnstile rejects the non-prod
// persistence path a real logout/login would. // origin). So the test does a programmatic logout (clear the
// // ped_auth cookie, same effect server-side as clicking Logout)
// (Historically this was a workaround for the Turnstile challenge on // followed by a fresh programmatic login — this exercises the
// the login form, which could not be completed in the e2e container. // same localStorage persistence path a real logout/login would,
// Login is no longer gated, but driving it programmatically keeps // without depending on the bot challenge.
// the test focused on persistence rather than form mechanics.)
// ============================================================ // ============================================================
const { test, expect, E2E_BASE, loginAs } = require('../fixtures'); const { test, expect, E2E_BASE, loginAs } = require('../fixtures');

View file

@ -9,8 +9,8 @@ android {
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
// Version values below are overwritten by scripts/release.sh from // Version values below are overwritten by scripts/release.sh from
// the root package.json. versionCode auto-increments per release. // the root package.json. versionCode auto-increments per release.
versionCode 714016 versionCode 714003
versionName "7.14.16" versionName "7.14.3"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions { aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.

View file

@ -15,8 +15,6 @@ import android.print.PrintDocumentAdapter;
import android.print.PrintManager; import android.print.PrintManager;
import android.provider.MediaStore; import android.provider.MediaStore;
import android.util.Base64; import android.util.Base64;
import android.view.WindowManager;
import android.webkit.CookieManager;
import android.webkit.PermissionRequest; import android.webkit.PermissionRequest;
import android.webkit.WebChromeClient; import android.webkit.WebChromeClient;
import android.webkit.WebViewClient; import android.webkit.WebViewClient;
@ -38,11 +36,6 @@ public class MainActivity extends BridgeActivity {
private PermissionRequest pendingPermissionRequest; private PermissionRequest pendingPermissionRequest;
private WebView printWebView; private WebView printWebView;
// True between startForegroundService() and stopForegroundService(), i.e.
// while the web app has an active MediaRecorder. Drives the keep-screen-on
// flag and the timer-throttling workaround below.
private volatile boolean recordingActive = false;
@Override @Override
protected void onCreate(Bundle savedInstanceState) { protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
@ -54,9 +47,6 @@ public class MainActivity extends BridgeActivity {
new String[]{ Manifest.permission.RECORD_AUDIO }, MIC_PERMISSION_CODE); new String[]{ Manifest.permission.RECORD_AUDIO }, MIC_PERMISSION_CODE);
} }
// Allow the Cloudflare Turnstile iframe to use storage.
setupThirdPartyCookies();
// Setup WebView mic permission granting // Setup WebView mic permission granting
setupWebViewPermissions(); setupWebViewPermissions();
@ -70,79 +60,6 @@ public class MainActivity extends BridgeActivity {
setupFileBridge(); setupFileBridge();
} }
// Recording Lifecycle
//
// Recording happens in the WebView (MediaRecorder), not in native code,
// so keeping the foreground service alive is necessary but not sufficient
// the WebView also has to keep executing JS. Two things protect that:
//
// 1. FLAG_KEEP_SCREEN_ON while recording, so the device does not
// auto-lock mid-encounter. This is the case that actually bites
// clinicians: a long pause in conversation and the screen times out.
//
// 2. resumeTimers() if the activity is paused anyway (user presses the
// power button, or a call comes in). Chromium throttles timers hard
// for hidden WebViews, which starves MediaRecorder's chunk delivery.
// Capacitor never calls webView.onPause(), so the WebView itself is
// still live it is only the timers that need rescuing.
//
// Note resumeTimers()/pauseTimers() are process-global in WebView, not
// per-instance; calling resume here is safe because this app has no other
// WebView that wants throttling (printWebView is transient).
void setKeepScreenOn(final boolean on) {
runOnUiThread(() -> {
if (on) {
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
} else {
getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
}
});
}
void setRecordingActive(boolean active) {
recordingActive = active;
setKeepScreenOn(active);
}
// NB: BridgeActivity declares these public narrowing to protected would
// not compile.
@Override
public void onPause() {
super.onPause();
if (recordingActive && this.bridge != null && this.bridge.getWebView() != null) {
this.bridge.getWebView().resumeTimers();
}
}
@Override
public void onResume() {
super.onResume();
if (this.bridge != null && this.bridge.getWebView() != null) {
this.bridge.getWebView().resumeTimers();
}
}
// Third-Party Cookies
//
// Android WebView blocks third-party cookies by default (unlike Chrome,
// which still allows them for now). Cloudflare Turnstile runs inside a
// cross-origin iframe from challenges.cloudflare.com and needs its own
// storage to run and persist a challenge without this the widget
// silently stalls or errors and never emits a token, so registration and
// password reset are impossible from inside the app.
//
// This is scoped to our own WebView, which only ever loads the PedScribe
// origin (see allowNavigation in capacitor.config.json), so it is not a
// general relaxation of the app's cookie policy.
private void setupThirdPartyCookies() {
WebView webView = this.bridge.getWebView();
CookieManager cookieManager = CookieManager.getInstance();
cookieManager.setAcceptCookie(true);
cookieManager.setAcceptThirdPartyCookies(webView, true);
}
// WebView Microphone Permission // WebView Microphone Permission
private void setupWebViewPermissions() { private void setupWebViewPermissions() {
@ -207,7 +124,6 @@ public class MainActivity extends BridgeActivity {
public void startForegroundService() { public void startForegroundService() {
Intent intent = new Intent(activity, AudioRecordingService.class); Intent intent = new Intent(activity, AudioRecordingService.class);
ContextCompat.startForegroundService(activity, intent); ContextCompat.startForegroundService(activity, intent);
activity.setRecordingActive(true);
} }
@android.webkit.JavascriptInterface @android.webkit.JavascriptInterface
@ -215,17 +131,6 @@ public class MainActivity extends BridgeActivity {
Intent intent = new Intent(activity, AudioRecordingService.class); Intent intent = new Intent(activity, AudioRecordingService.class);
intent.setAction(AudioRecordingService.ACTION_STOP); intent.setAction(AudioRecordingService.ACTION_STOP);
activity.startService(intent); activity.startService(intent);
activity.setRecordingActive(false);
}
// Standalone keep-awake, exposed so the web app can hold the screen on
// for non-recording work too. window.nativeKeepAwake() previously
// called Capacitor's KeepAwake plugin, which is not installed in this
// project so it silently did nothing and the screen slept during
// recordings.
@android.webkit.JavascriptInterface
public void keepAwake(boolean on) {
activity.setKeepScreenOn(on);
} }
} }

View file

@ -13,7 +13,7 @@
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar"> <style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar">
<item name="windowActionBar">false</item> <item name="windowActionBar">false</item>
<item name="windowNoTitle">true</item> <item name="windowNoTitle">true</item>
<item name="android:background">@color/colorPrimary</item> <item name="android:background">@null</item>
<item name="android:statusBarColor">@color/colorPrimaryDark</item> <item name="android:statusBarColor">@color/colorPrimaryDark</item>
<item name="android:navigationBarColor">@color/colorPrimaryDark</item> <item name="android:navigationBarColor">@color/colorPrimaryDark</item>
</style> </style>
@ -22,4 +22,4 @@
<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen"> <style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
<item name="android:background">@drawable/splash</item> <item name="android:background">@drawable/splash</item>
</style> </style>
</resources> </resources>

View file

@ -1,12 +1,12 @@
{ {
"name": "pedscribe-mobile", "name": "pedscribe-mobile",
"version": "7.14.14", "version": "7.14.3",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "pedscribe-mobile", "name": "pedscribe-mobile",
"version": "7.14.14", "version": "7.14.3",
"dependencies": { "dependencies": {
"@aparajita/capacitor-biometric-auth": "^8.0.0", "@aparajita/capacitor-biometric-auth": "^8.0.0",
"@capacitor/android": "^6.0.0", "@capacitor/android": "^6.0.0",

View file

@ -1,6 +1,6 @@
{ {
"name": "pedscribe-mobile", "name": "pedscribe-mobile",
"version": "7.14.16", "version": "7.14.3",
"description": "PedScribe native mobile app — Capacitor wrapper for Pediatric AI Scribe", "description": "PedScribe native mobile app — Capacitor wrapper for Pediatric AI Scribe",
"private": true, "private": true,
"scripts": { "scripts": {

4
package-lock.json generated
View file

@ -1,12 +1,12 @@
{ {
"name": "pediatric-ai-scribe", "name": "pediatric-ai-scribe",
"version": "7.14.14", "version": "7.14.3",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "pediatric-ai-scribe", "name": "pediatric-ai-scribe",
"version": "7.14.14", "version": "7.14.3",
"dependencies": { "dependencies": {
"@marp-team/marp-cli": "^4.3.1", "@marp-team/marp-cli": "^4.3.1",
"@marp-team/marp-core": "^4.3.0", "@marp-team/marp-core": "^4.3.0",

View file

@ -1,6 +1,6 @@
{ {
"name": "pediatric-ai-scribe", "name": "pediatric-ai-scribe",
"version": "7.14.16", "version": "7.14.3",
"description": "AI-powered pediatric clinical documentation platform", "description": "AI-powered pediatric clinical documentation platform",
"main": "server.js", "main": "server.js",
"scripts": { "scripts": {

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -318,17 +318,6 @@
<button id="btn-test-assistant-chat-model" class="btn-sm btn-primary" type="button"><i class="fas fa-vial"></i> Test</button> <button id="btn-test-assistant-chat-model" class="btn-sm btn-primary" type="button"><i class="fas fa-vial"></i> Test</button>
</div> </div>
<div id="assistant-chat-test-result" style="font-size:12px;color:var(--g500);"></div> <div id="assistant-chat-test-result" style="font-size:12px;color:var(--g500);"></div>
<div style="border:1px solid var(--g100);border-radius:8px;padding:10px;display:flex;gap:10px;align-items:center;justify-content:space-between;flex-wrap:wrap;">
<div>
<div style="font-size:13px;font-weight:600;color:var(--g700);">Starter prompt pool</div>
<div id="assistant-prompt-pool-status" style="font-size:12px;color:var(--g500);margin-top:3px;">Checking prompt pool...</div>
</div>
<button id="btn-regenerate-assistant-prompt-pool" class="btn-sm btn-ghost" type="button"><i class="fas fa-rotate"></i> Regenerate Pool</button>
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;width:100%;">
<select id="assistant-prompt-pool-snapshots" style="font-size:12px;padding:5px 8px;border:1px solid var(--g300);border-radius:6px;flex:1;min-width:220px;"><option value="">Loading snapshots...</option></select>
<button id="btn-restore-assistant-prompt-pool" class="btn-sm btn-ghost" type="button"><i class="fas fa-clock-rotate-left"></i> Restore Snapshot</button>
</div>
</div>
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;"> <div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;">
<label style="font-size:13px;font-weight:600;min-width:130px;">Image model:</label> <label style="font-size:13px;font-weight:600;min-width:130px;">Image model:</label>
<select id="assistant-image-model" style="font-size:13px;padding:5px 8px;border:1px solid var(--g300);border-radius:6px;flex:1;max-width:420px;"></select> <select id="assistant-image-model" style="font-size:13px;padding:5px 8px;border:1px solid var(--g300);border-radius:6px;flex:1;max-width:420px;"></select>

View file

@ -41,7 +41,6 @@
<textarea id="assistant-input" rows="3" placeholder="Ask a focused clinical question..." autocomplete="off"></textarea> <textarea id="assistant-input" rows="3" placeholder="Ask a focused clinical question..." autocomplete="off"></textarea>
<div class="assistant-composer-footer"> <div class="assistant-composer-footer">
<label class="assistant-check"><input type="checkbox" id="assistant-include-context" checked> retrieve broader context</label> <label class="assistant-check"><input type="checkbox" id="assistant-include-context" checked> retrieve broader context</label>
<button id="btn-assistant-cancel" class="btn-sm btn-ghost" type="button" hidden><i class="fas fa-stop"></i> Cancel search</button>
<button id="btn-assistant-send" class="btn-generate" type="submit"><i class="fas fa-paper-plane"></i> Ask</button> <button id="btn-assistant-send" class="btn-generate" type="submit"><i class="fas fa-paper-plane"></i> Ask</button>
</div> </div>
</form> </form>
@ -144,8 +143,6 @@
.assistant-composer textarea:focus, .assistant-side textarea:focus { border-color:var(--blue); box-shadow:0 0 0 3px var(--blue-light); } .assistant-composer textarea:focus, .assistant-side textarea:focus { border-color:var(--blue); box-shadow:0 0 0 3px var(--blue-light); }
.assistant-composer-footer { display:flex; justify-content:space-between; align-items:center; gap:10px; } .assistant-composer-footer { display:flex; justify-content:space-between; align-items:center; gap:10px; }
.assistant-composer-footer .btn-generate { width:auto; margin:0; padding:9px 18px; } .assistant-composer-footer .btn-generate { width:auto; margin:0; padding:9px 18px; }
.assistant-composer-footer #btn-assistant-cancel[hidden] { display:none !important; }
.assistant-composer-footer #btn-assistant-cancel:not([hidden]) { display:inline-flex; }
.assistant-check { font-size:12px; color:var(--g500); display:flex; align-items:center; gap:6px; } .assistant-check { font-size:12px; color:var(--g500); display:flex; align-items:center; gap:6px; }
.assistant-side { display:grid; gap:12px; } .assistant-side { display:grid; gap:12px; }
.assistant-side-body { padding:12px; display:grid; gap:10px; font-size:13px; } .assistant-side-body { padding:12px; display:grid; gap:10px; font-size:13px; }
@ -201,7 +198,6 @@
.assistant-composer { position:sticky; bottom:0; z-index:3; } .assistant-composer { position:sticky; bottom:0; z-index:3; }
.assistant-composer-footer { flex-direction:column; align-items:stretch; } .assistant-composer-footer { flex-direction:column; align-items:stretch; }
.assistant-composer-footer .btn-generate { width:100%; } .assistant-composer-footer .btn-generate { width:100%; }
.assistant-composer-footer #btn-assistant-cancel:not([hidden]) { width:100%; justify-content:center; }
.assistant-side { gap:10px; } .assistant-side { gap:10px; }
} }
</style> </style>

View file

@ -11,9 +11,6 @@
<button class="wv-subtab-btn" data-subtab="milestones"> <button class="wv-subtab-btn" data-subtab="milestones">
<i class="fas fa-baby"></i> Milestones <i class="fas fa-baby"></i> Milestones
</button> </button>
<button class="wv-subtab-btn" data-subtab="lincoln">
<i class="fas fa-clipboard-list"></i> Lincoln
</button>
<button class="wv-subtab-btn" data-subtab="shadess" style="display:none;"> <button class="wv-subtab-btn" data-subtab="shadess" style="display:none;">
<i class="fas fa-brain"></i> SSHADESS (12+) <i class="fas fa-brain"></i> SSHADESS (12+)
</button> </button>
@ -122,65 +119,6 @@
</div> </div>
<!-- Lincoln quick-reference sub-panel -->
<div id="wv-panel-lincoln" class="wv-subpanel hidden">
<div class="card" style="margin-bottom:10px;">
<div class="card-header output-header">
<h3><i class="fas fa-clipboard-list"></i> Lincoln Well-Child Quick Reference</h3>
<div class="output-actions">
<button class="btn-sm btn-primary" data-action="copy" data-target="wv-lincoln-reference"><i class="fas fa-copy"></i> Copy</button>
</div>
</div>
<div id="wv-lincoln-reference" class="wv-lincoln-reference">
<div class="wv-lincoln-grid">
<section class="wv-lincoln-card">
<h4>Infancy</h4>
<ul>
<li><strong>Newborn:</strong> POC visit; check for jaundice.</li>
<li><strong>2 weeks:</strong> weight gain, newborn screen, umbilicus check.</li>
<li><strong>1 month:</strong> maternal PHQ-9.</li>
<li><strong>2 months:</strong> Vaxelis, rotavirus, Prevnar.</li>
<li><strong>4 months:</strong> Vaxelis, rotavirus, Prevnar.</li>
<li><strong>6 months:</strong> routine vaccines and Prevnar; confirm rotavirus eligibility by product and age.</li>
<li><strong>9 months:</strong> SWYC; no routine vaccines noted.</li>
</ul>
</section>
<section class="wv-lincoln-card">
<h4>Toddler / Preschool</h4>
<ul>
<li><strong>12 months:</strong> MMR, varicella, Hep A; CBC and lead.</li>
<li><strong>15 months:</strong> Pentacel, Prevnar, influenza.</li>
<li><strong>18 months:</strong> POSI, SWYC; Hep A second dose.</li>
<li><strong>2 years:</strong> POSI/SWYC; CBC and lead.</li>
<li><strong>3 years:</strong> blood pressure check and vision screening; BP is commonly missed and can be added on diagnosis.</li>
<li><strong>4 years:</strong> hearing and vision start; ProQuad and Kinrix.</li>
</ul>
</section>
<section class="wv-lincoln-card">
<h4>School Age / Adolescence</h4>
<ul>
<li><strong>Lipid screening:</strong> AAP screening at 9-11 years and 17-21 years.</li>
<li><strong>Depression screening:</strong> begin at 12 years and older.</li>
<li><strong>MenB:</strong> discuss Bexsero/MenB at 16-23 years, preferably 16-18 years, when chosen or indicated.</li>
<li><strong>Age &ge;18 years:</strong> Hep C testing.</li>
<li><strong>Cervical cancer screening:</strong> start Pap smear screening at 21 years.</li>
</ul>
</section>
<section class="wv-lincoln-card">
<h4>Catch-Up / Screening Reminders</h4>
<ul>
<li><strong>Influenza:</strong> if a child 6 months through 8 years needs 2 doses, give doses 4 weeks apart.</li>
<li><strong>Lead:</strong> continue lead screening reminders through age 6 years; add diagnosis when needed.</li>
</ul>
</section>
</div>
</div>
</div>
</div>
<!-- SSHADESS sub-panel (age 12+) --> <!-- SSHADESS sub-panel (age 12+) -->
<div id="wv-panel-shadess" class="wv-subpanel hidden"> <div id="wv-panel-shadess" class="wv-subpanel hidden">
<div class="card" style="margin-bottom:10px;"> <div class="card" style="margin-bottom:10px;">
@ -367,3 +305,4 @@
</div> </div>
</div> </div>
</div> </div>

View file

@ -143,7 +143,6 @@ body{font-family:'Inter',system-ui,sans-serif;background:var(--g50);color:var(--
.btn-generate-green{background:var(--green);box-shadow:0 3px 10px rgba(16,185,129,0.25);} .btn-generate-green{background:var(--green);box-shadow:0 3px 10px rgba(16,185,129,0.25);}
.btn-sm{display:inline-flex;align-items:center;gap:4px;padding:5px 10px;border:none;border-radius:6px;font-size:12px;font-weight:500;cursor:pointer;font-family:inherit;transition:all 0.15s;} .btn-sm{display:inline-flex;align-items:center;gap:4px;padding:5px 10px;border:none;border-radius:6px;font-size:12px;font-weight:500;cursor:pointer;font-family:inherit;transition:all 0.15s;}
#btn-assistant-cancel[hidden]{display:none!important;}
.btn-lg{display:inline-flex;align-items:center;gap:6px;padding:9px 18px;border:none;border-radius:8px;font-size:14px;font-weight:600;cursor:pointer;font-family:inherit;transition:all 0.15s;} .btn-lg{display:inline-flex;align-items:center;gap:6px;padding:9px 18px;border:none;border-radius:8px;font-size:14px;font-weight:600;cursor:pointer;font-family:inherit;transition:all 0.15s;}
.btn-primary{background:var(--blue);color:white;}.btn-primary:hover{background:var(--blue-dark);} .btn-primary{background:var(--blue);color:white;}.btn-primary:hover{background:var(--blue-dark);}
.btn-ghost{background:var(--g200);color:var(--g700);}.btn-ghost:hover{background:var(--g300);} .btn-ghost{background:var(--g200);color:var(--g700);}.btn-ghost:hover{background:var(--g300);}
@ -397,21 +396,6 @@ textarea.full-input{resize:vertical;}
.wv-section-title{font-size:14px;font-weight:700;color:var(--g700);margin-bottom:12px;display:flex;align-items:center;gap:8px;} .wv-section-title{font-size:14px;font-weight:700;color:var(--g700);margin-bottom:12px;display:flex;align-items:center;gap:8px;}
.wv-section-title i{color:var(--blue);} .wv-section-title i{color:var(--blue);}
/* Lincoln quick reference */
.wv-lincoln-reference{padding:14px 16px;background:var(--g50);}
.wv-lincoln-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(250px,1fr));gap:14px;}
.wv-lincoln-card{border:1px solid var(--g100);border-radius:12px;padding:14px 16px;background:white;box-shadow:0 1px 2px rgba(15,23,42,0.04);}
.wv-lincoln-card h4{margin:0 0 10px;font-size:14px;color:var(--g800);}
.wv-lincoln-card ul{margin:0;padding-left:18px;color:var(--g700);font-size:13px;line-height:1.65;}
.wv-lincoln-card li{margin-bottom:6px;}
.wv-lincoln-card li:last-child{margin-bottom:0;}
@media(max-width:640px){
.wv-lincoln-reference{padding:10px;}
.wv-lincoln-grid{grid-template-columns:1fr;gap:10px;}
.wv-lincoln-card{padding:12px;}
.wv-lincoln-card ul{font-size:12.5px;line-height:1.55;}
}
/* Billing */ /* Billing */
.wv-billing-grid{display:flex;flex-wrap:wrap;gap:12px;align-items:center;} .wv-billing-grid{display:flex;flex-wrap:wrap;gap:12px;align-items:center;}
.wv-billing-cell{display:flex;align-items:center;gap:8px;} .wv-billing-cell{display:flex;align-items:center;gap:8px;}
@ -642,7 +626,7 @@ textarea.full-input{resize:vertical;}
.lh-quiz-q-type{font-size:11px;color:var(--g400);background:var(--g100);padding:2px 8px;border-radius:4px;} .lh-quiz-q-type{font-size:11px;color:var(--g400);background:var(--g100);padding:2px 8px;border-radius:4px;}
.lh-quiz-q-text{font-size:16px;font-weight:600;margin-bottom:14px;color:var(--g800);line-height:1.5;} .lh-quiz-q-text{font-size:16px;font-weight:600;margin-bottom:14px;color:var(--g800);line-height:1.5;}
.lh-quiz-options{display:flex;flex-direction:column;gap:8px;} .lh-quiz-options{display:flex;flex-direction:column;gap:8px;}
.lh-quiz-option{display:flex;align-items:center;gap:12px;padding:14px 18px;border:2px solid var(--g200);border-radius:10px;cursor:pointer;transition:all 0.2s;font-size:14px;line-height:1.4;background:white;user-select:none;} .lh-quiz-option{display:flex;align-items:center;gap:12px;padding:14px 18px;border:2px solid var(--g200);border-radius:10px;cursor:pointer;transition:all 0.2s;font-size:14px;line-height:1.4;background:white;}
.lh-quiz-option:hover{border-color:var(--blue);background:var(--blue-light);transform:translateY(-1px);box-shadow:0 2px 8px rgba(37,99,235,0.1);} .lh-quiz-option:hover{border-color:var(--blue);background:var(--blue-light);transform:translateY(-1px);box-shadow:0 2px 8px rgba(37,99,235,0.1);}
.lh-quiz-option input[type="radio"]{accent-color:var(--blue);width:18px;height:18px;flex-shrink:0;} .lh-quiz-option input[type="radio"]{accent-color:var(--blue);width:18px;height:18px;flex-shrink:0;}
.lh-quiz-option span{flex:1;} .lh-quiz-option span{flex:1;}

View file

@ -11,11 +11,7 @@
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css" <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css"
integrity="sha384-/o6I2CkkWC//PSjvWC/eYN7l3xM3tJm8ZzVkCOfp//W05QcE3mlGskpoHB6XqI+B" crossorigin="anonymous" referrerpolicy="no-referrer"> integrity="sha384-/o6I2CkkWC//PSjvWC/eYN7l3xM3tJm8ZzVkCOfp//W05QcE3mlGskpoHB6XqI+B" crossorigin="anonymous" referrerpolicy="no-referrer">
<!-- Explicit render mode: the register/forgot widgets live inside forms that <script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
start hidden, and Turnstile's implicit auto-render does not reliably
complete a challenge inside a display:none container. auth.js renders
each widget the first time its form is shown. -->
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit" async defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.1.6/purify.min.js" <script src="https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.1.6/purify.min.js"
integrity="sha384-+VfUPEb0PdtChMwmBcBmykRMDd+v6D/oFmB3rZM/puCMDYcIvF968OimRh4KQY9a" integrity="sha384-+VfUPEb0PdtChMwmBcBmykRMDd+v6D/oFmB3rZM/puCMDYcIvF968OimRh4KQY9a"
crossorigin="anonymous" referrerpolicy="no-referrer" defer></script> crossorigin="anonymous" referrerpolicy="no-referrer" defer></script>
@ -74,6 +70,7 @@
<label>2FA Code</label> <label>2FA Code</label>
<input type="text" id="login-totp" placeholder="6-digit code" maxlength="6"> <input type="text" id="login-totp" placeholder="6-digit code" maxlength="6">
</div> </div>
<div class="cf-turnstile" id="turnstile-login" data-sitekey="0x4AAAAAAC0VtKAhC8rzpMx6" data-theme="light"></div>
<button type="submit" class="btn-auth" id="btn-local-login">Sign In</button> <button type="submit" class="btn-auth" id="btn-local-login">Sign In</button>
<div id="sso-divider" class="hidden" style="display:none;text-align:center;margin:16px 0 12px;position:relative;"> <div id="sso-divider" class="hidden" style="display:none;text-align:center;margin:16px 0 12px;position:relative;">
<span style="background:white;padding:0 12px;color:#9ca3af;font-size:12px;position:relative;z-index:1;">or</span> <span style="background:white;padding:0 12px;color:#9ca3af;font-size:12px;position:relative;z-index:1;">or</span>
@ -107,7 +104,7 @@
<label>Password (8+ characters)</label> <label>Password (8+ characters)</label>
<input type="password" id="reg-password" required minlength="8" placeholder="••••••••"> <input type="password" id="reg-password" required minlength="8" placeholder="••••••••">
</div> </div>
<div id="turnstile-register" data-sitekey="0x4AAAAAAC0VtKAhC8rzpMx6"></div> <div class="cf-turnstile" data-sitekey="0x4AAAAAAC0VtKAhC8rzpMx6" data-theme="light"></div>
<button type="submit" class="btn-auth">Create Account</button> <button type="submit" class="btn-auth">Create Account</button>
<div class="auth-links"> <div class="auth-links">
<a href="#" id="show-login">Back to sign in</a> <a href="#" id="show-login">Back to sign in</a>
@ -121,7 +118,7 @@
<label>Email</label> <label>Email</label>
<input type="email" id="forgot-email" required placeholder="your@email.com"> <input type="email" id="forgot-email" required placeholder="your@email.com">
</div> </div>
<div id="turnstile-forgot" data-sitekey="0x4AAAAAAC0VtKAhC8rzpMx6"></div> <div class="cf-turnstile" id="turnstile-forgot" data-sitekey="0x4AAAAAAC0VtKAhC8rzpMx6" data-theme="light"></div>
<button type="submit" class="btn-auth">Send Reset Link</button> <button type="submit" class="btn-auth">Send Reset Link</button>
<div class="auth-links"> <div class="auth-links">
<a href="#" id="show-login-2">Back to sign in</a> <a href="#" id="show-login-2">Back to sign in</a>
@ -134,7 +131,7 @@
</div> </div>
<div id="apk-download-link" style="text-align:center;margin:14px 0 0;font-size:13px;"> <div id="apk-download-link" style="text-align:center;margin:14px 0 0;font-size:13px;">
<a href="https://git.danvics.com/danvics/pediatric-ai-scribe-v3/releases/latest" target="_blank" rel="noopener" style="color:#2563eb;text-decoration:none;font-weight:500;"> <a href="https://github.com/ifedan-ed/pediatric-ai-scribe-v3/releases/latest" target="_blank" rel="noopener" style="color:#2563eb;text-decoration:none;font-weight:500;">
<i class="fas fa-mobile-screen"></i> Download Android app (APK) <i class="fas fa-mobile-screen"></i> Download Android app (APK)
</a> </a>
</div> </div>

View file

@ -701,8 +701,6 @@ function adminFlashButtonBackground(btn, color) {
document.addEventListener('click', function(e) { document.addEventListener('click', function(e) {
if (e.target.closest('#btn-save-assistant-config')) saveAssistantAdmin(); if (e.target.closest('#btn-save-assistant-config')) saveAssistantAdmin();
if (e.target.closest('#btn-test-assistant-chat-model')) testAssistantChatModel(); if (e.target.closest('#btn-test-assistant-chat-model')) testAssistantChatModel();
if (e.target.closest('#btn-regenerate-assistant-prompt-pool')) regenerateAssistantPromptPool();
if (e.target.closest('#btn-restore-assistant-prompt-pool')) restoreAssistantPromptPool();
if (e.target.closest('#btn-refresh-assistant-image-models')) loadAssistantImageModels(); if (e.target.closest('#btn-refresh-assistant-image-models')) loadAssistantImageModels();
if (e.target.closest('#btn-test-assistant-image-model')) testAssistantImageModel(); if (e.target.closest('#btn-test-assistant-image-model')) testAssistantImageModel();
if (e.target.closest('#btn-use-custom-assistant-image-model')) useCustomAssistantImageModel(); if (e.target.closest('#btn-use-custom-assistant-image-model')) useCustomAssistantImageModel();
@ -743,7 +741,6 @@ function adminFlashButtonBackground(btn, color) {
} }
window._assistantImageModelValue = cfg['clinical_assistant.image_model'] || ''; window._assistantImageModelValue = cfg['clinical_assistant.image_model'] || '';
loadAssistantImageModels(); loadAssistantImageModels();
loadAssistantPromptPoolStatus();
setValue('assistant-search-limit', cfg['clinical_assistant.search_limit'] || '8'); setValue('assistant-search-limit', cfg['clinical_assistant.search_limit'] || '8');
setValue('assistant-context-chars', cfg['clinical_assistant.context_chars'] || '1400'); setValue('assistant-context-chars', cfg['clinical_assistant.context_chars'] || '1400');
setValue('assistant-system-behavior', cfg['clinical_assistant.system_behavior'] || defaults.behavior); setValue('assistant-system-behavior', cfg['clinical_assistant.system_behavior'] || defaults.behavior);
@ -813,91 +810,6 @@ function adminFlashButtonBackground(btn, color) {
return match ? match[1] : ''; return match ? match[1] : '';
} }
function loadAssistantPromptPoolStatus() {
var result = document.getElementById('assistant-prompt-pool-status');
if (result) result.textContent = 'Checking prompt pool...';
fetch('/api/admin/clinical-assistant/prompt-pool', { headers: getAuthHeaders() })
.then(function(r) { return r.json(); })
.then(function(data) {
if (!data.success) throw new Error(data.error || 'Prompt pool status failed');
renderAssistantPromptPoolStatus(data.meta);
renderAssistantPromptPoolSnapshots(data.snapshots || []);
})
.catch(function(err) {
if (result) result.textContent = err.message;
});
}
function regenerateAssistantPromptPool() {
var result = document.getElementById('assistant-prompt-pool-status');
var btn = document.getElementById('btn-regenerate-assistant-prompt-pool');
if (btn) btn.disabled = true;
if (result) result.textContent = 'Regenerating prompt pool. This can take several minutes...';
fetch('/api/admin/clinical-assistant/prompt-pool/regenerate', {
method: 'POST', headers: getAuthHeaders(), body: JSON.stringify({})
}).then(function(r) { return r.json(); }).then(function(data) {
if (!data.success) throw new Error(data.error || 'Prompt pool regeneration failed');
renderAssistantPromptPoolStatus(data.meta || { count: data.count, generatedAt: Date.now() });
renderAssistantPromptPoolSnapshots(data.snapshots || []);
showToast('Prompt pool regenerated', 'success');
}).catch(function(err) {
if (result) result.textContent = err.message;
showToast(err.message, 'error');
}).finally(function() {
if (btn) btn.disabled = false;
});
}
function renderAssistantPromptPoolStatus(meta) {
var result = document.getElementById('assistant-prompt-pool-status');
if (!result) return;
if (!meta) {
result.textContent = 'No generated pool found. The assistant will use indexed-topic fallback until you generate one.';
return;
}
var generated = meta.generatedAt ? new Date(meta.generatedAt).toLocaleString() : 'unknown time';
result.textContent = 'Generated pool: ' + (meta.count || 0) + ' prompts, target ' + (meta.target || '?') + ', generated ' + generated + (meta.restoredFrom ? ', restored from snapshot #' + meta.restoredFrom : '') + '.';
}
function renderAssistantPromptPoolSnapshots(snapshots) {
var select = document.getElementById('assistant-prompt-pool-snapshots');
if (!select) return;
select.innerHTML = '';
if (!snapshots.length) {
var empty = document.createElement('option');
empty.value = '';
empty.textContent = 'No saved snapshots';
select.appendChild(empty);
return;
}
snapshots.forEach(function(s) {
var opt = document.createElement('option');
opt.value = s.id;
var date = s.created_at ? new Date(s.created_at).toLocaleString() : 'unknown time';
opt.textContent = '#' + s.id + ' - ' + (s.count || 0) + ' prompts - ' + date + (s.restored_from ? ' (restored from #' + s.restored_from + ')' : '');
select.appendChild(opt);
});
}
function restoreAssistantPromptPool() {
var select = document.getElementById('assistant-prompt-pool-snapshots');
var id = select ? select.value : '';
var result = document.getElementById('assistant-prompt-pool-status');
if (!id) { showToast('Select a prompt pool snapshot', 'error'); return; }
if (result) result.textContent = 'Restoring prompt pool snapshot #' + id + '...';
fetch('/api/admin/clinical-assistant/prompt-pool/restore', {
method: 'POST', headers: getAuthHeaders(), body: JSON.stringify({ id: Number(id) })
}).then(function(r) { return r.json(); }).then(function(data) {
if (!data.success) throw new Error(data.error || 'Prompt pool restore failed');
renderAssistantPromptPoolStatus(data.meta || { count: data.count, generatedAt: Date.now(), restoredFrom: id });
renderAssistantPromptPoolSnapshots(data.snapshots || []);
showToast('Prompt pool restored', 'success');
}).catch(function(err) {
if (result) result.textContent = err.message;
showToast(err.message, 'error');
});
}
function testAssistantImageModel() { function testAssistantImageModel() {
var model = getValue('assistant-image-model') || 'openai-gpt-image-1'; var model = getValue('assistant-image-model') || 'openai-gpt-image-1';
var result = document.getElementById('assistant-image-test-result'); var result = document.getElementById('assistant-image-test-result');

View file

@ -600,19 +600,9 @@ window.nativeStopRecordingService = function() {
try { if (window.NativeRecording) window.NativeRecording.stopForegroundService(); } catch(e) {} try { if (window.NativeRecording) window.NativeRecording.stopForegroundService(); } catch(e) {}
}; };
// Keep screen awake during recording. // Keep screen awake during recording (Capacitor KeepAwake or InsomniaCap)
//
// Prefer the NativeRecording bridge (addJavascriptInterface, so it is present
// on the remote origin the launcher navigates to). The Capacitor KeepAwake
// plugin is kept as a fallback but is NOT installed in this project — relying
// on it alone meant this function silently did nothing and the screen slept
// mid-recording, killing the MediaRecorder.
window.nativeKeepAwake = function(on) { window.nativeKeepAwake = function(on) {
try { try {
if (window.NativeRecording && typeof window.NativeRecording.keepAwake === 'function') {
window.NativeRecording.keepAwake(!!on);
return;
}
if (window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.KeepAwake) { if (window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.KeepAwake) {
if (on) window.Capacitor.Plugins.KeepAwake.keepAwake(); if (on) window.Capacitor.Plugins.KeepAwake.keepAwake();
else window.Capacitor.Plugins.KeepAwake.allowSleep(); else window.Capacitor.Plugins.KeepAwake.allowSleep();

View file

@ -19,24 +19,20 @@ export function fetchAssistantExamples() {
.then(function(r) { return r.json(); }); .then(function(r) { return r.json(); });
} }
export function openAssistantStream(payload, options) { export function openAssistantStream(payload) {
options = options || {};
return fetch('/api/clinical-assistant/chat/stream', { return fetch('/api/clinical-assistant/chat/stream', {
method: 'POST', method: 'POST',
headers: authHeaders(), headers: authHeaders(),
credentials: 'same-origin', credentials: 'same-origin',
signal: options.signal,
body: JSON.stringify(payload) body: JSON.stringify(payload)
}); });
} }
export function fetchAssistantChat(payload, options) { export function fetchAssistantChat(payload) {
options = options || {};
return fetch('/api/clinical-assistant/chat', { return fetch('/api/clinical-assistant/chat', {
method: 'POST', method: 'POST',
headers: authHeaders(), headers: authHeaders(),
credentials: 'same-origin', credentials: 'same-origin',
signal: options.signal,
body: JSON.stringify(payload) body: JSON.stringify(payload)
}).then(parseJsonWithStatus); }).then(parseJsonWithStatus);
} }

View file

@ -5,9 +5,9 @@ export function createAssistantImageStore() {
var generatedImageSeq = 0; var generatedImageSeq = 0;
var previewKeyHandler = null; var previewKeyHandler = null;
function renderGeneratedImage(src, alt, downloadUrl) { function renderGeneratedImage(src, alt) {
var id = 'img-' + (++generatedImageSeq); var id = 'img-' + (++generatedImageSeq);
generatedImages[id] = { src: src, downloadUrl: downloadUrl || '' }; generatedImages[id] = src;
return '<div class="assistant-generated-image"><img src="' + escapeAttr(src) + '" alt="' + escapeAttr(alt || 'Generated image') + '">' + return '<div class="assistant-generated-image"><img src="' + escapeAttr(src) + '" alt="' + escapeAttr(alt || 'Generated image') + '">' +
'<div class="assistant-image-actions">' + '<div class="assistant-image-actions">' +
'<button type="button" class="btn-sm btn-ghost" data-assistant-open-image="' + escapeAttr(id) + '"><i class="fas fa-expand"></i> Preview</button>' + '<button type="button" class="btn-sm btn-ghost" data-assistant-open-image="' + escapeAttr(id) + '"><i class="fas fa-expand"></i> Preview</button>' +
@ -16,8 +16,7 @@ export function createAssistantImageStore() {
} }
function openImagePreview(id) { function openImagePreview(id) {
var item = generatedImages[id]; var src = generatedImages[id];
var src = item && (item.src || item);
if (!src) return; if (!src) return;
closeImagePreview(); closeImagePreview();
var modal = document.createElement('div'); var modal = document.createElement('div');
@ -42,25 +41,21 @@ export function createAssistantImageStore() {
} }
async function downloadImage(id) { async function downloadImage(id) {
var item = generatedImages[id]; var src = generatedImages[id];
var src = item && (item.src || item);
var downloadUrl = item && item.downloadUrl;
if (!src) return; if (!src) return;
try { try {
if (downloadUrl) {
await downloadFromServer(downloadUrl);
return;
}
if (await saveWithNativeShare(src)) return; if (await saveWithNativeShare(src)) return;
if (isNativeApp()) { if (isNativeApp()) {
if (typeof window.showToast === 'function') window.showToast('Image saving is not available in this app build yet. Update the app and try again.', 'error'); if (typeof window.showToast === 'function') window.showToast('Image saving is not available in this app build yet. Update the app and try again.', 'error');
return; return;
} }
if (isMobileBrowser()) { var a = document.createElement('a');
if (typeof window.showToast === 'function') window.showToast('Mobile browser download is not supported here. Use Preview and long-press the image to save it.', 'info'); a.href = src;
return; a.download = 'clinical-visual.png';
} a.rel = 'noopener';
await downloadWithBrowser(src); document.body.appendChild(a);
a.click();
a.remove();
} catch (e) { } catch (e) {
if (typeof window.showToast === 'function') window.showToast(e.message || 'Image download failed', 'error'); if (typeof window.showToast === 'function') window.showToast(e.message || 'Image download failed', 'error');
} }
@ -134,7 +129,7 @@ async function saveWithCapacitorShare(src) {
if (typeof window.showToast === 'function') window.showToast('Image prepared', 'success'); if (typeof window.showToast === 'function') window.showToast('Image prepared', 'success');
return true; return true;
} catch (e) { } catch (e) {
if (typeof window.showToast === 'function') window.showToast('Could not save with the app. Use Preview and long-press the image to save it.', 'error'); if (typeof window.showToast === 'function') window.showToast('Could not save with the app. Opening browser download instead.', 'error');
return false; return false;
} }
} }
@ -147,101 +142,7 @@ async function shareWithWebFile(src) {
if (!navigator.canShare({ files: [file] })) return 'unavailable'; if (!navigator.canShare({ files: [file] })) return 'unavailable';
await navigator.share({ files: [file], title: 'Clinical visual', text: 'Clinical Assistant generated visual' }); await navigator.share({ files: [file], title: 'Clinical visual', text: 'Clinical Assistant generated visual' });
return 'shared'; return 'shared';
} catch (e) { return isShareCancel(e) ? 'unavailable' : 'unavailable'; } } catch (e) { return isShareCancel(e) ? 'cancelled' : 'unavailable'; }
}
async function downloadWithBrowser(src) {
if (isMobileBrowser()) {
if (typeof window.showToast === 'function') window.showToast('Mobile browser download is not supported here. Use Preview and long-press the image to save it.', 'info');
return;
}
var blob = await imageSourceToBlob(src);
var objectUrl = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = objectUrl;
a.download = 'clinical-visual.png';
a.rel = 'noopener';
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(function() { URL.revokeObjectURL(objectUrl); }, 60000);
}
async function downloadFromServer(url) {
var response = await fetch(url, { headers: authHeadersForDownload(), credentials: 'same-origin' });
if (!response.ok) throw new Error('Image download failed');
var blob = await response.blob();
var name = 'clinical-visual-' + new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19) + '.png';
if (await saveBlobWithNativeImageBridge(blob, name)) return;
if (await saveBlobWithCapacitorShare(blob, name)) return;
if (await shareBlobWithWebFile(blob, name)) return;
if (isMobileBrowser()) {
if (typeof window.showToast === 'function') window.showToast('Could not start download here. Use Preview and long-press the image to save it.', 'error');
return;
}
downloadBlobWithAnchor(blob, name);
}
function authHeadersForDownload() {
var headers = window.getAuthHeaders ? window.getAuthHeaders() : {};
var clean = {};
Object.keys(headers || {}).forEach(function(key) {
if (key.toLowerCase() !== 'content-type') clean[key] = headers[key];
});
return clean;
}
async function saveBlobWithNativeImageBridge(blob, name) {
if (!window.NativeFiles || typeof window.NativeFiles.saveImage !== 'function') return false;
try {
var base64 = await blobToBase64(blob);
var result = String(window.NativeFiles.saveImage(name, base64) || '');
if (result.indexOf('saved:') === 0) {
if (typeof window.showToast === 'function') window.showToast('Image saved to Photos', 'success');
return true;
}
} catch (e) {}
return false;
}
async function saveBlobWithCapacitorShare(blob, name) {
var plugins = window.Capacitor && window.Capacitor.Plugins ? window.Capacitor.Plugins : null;
if (!plugins || !plugins.Filesystem) return false;
try {
var base64 = await blobToBase64(blob);
var saved = await plugins.Filesystem.writeFile({ path: name, data: base64, directory: 'CACHE' });
if (plugins.Share && saved && saved.uri) {
await plugins.Share.share({ title: 'Clinical visual', text: 'Clinical Assistant generated visual', url: saved.uri, dialogTitle: 'Save or share clinical visual' });
}
if (typeof window.showToast === 'function') window.showToast('Image prepared', 'success');
return true;
} catch (e) { return false; }
}
async function shareBlobWithWebFile(blob, name) {
if (!navigator.share || !navigator.canShare || typeof File === 'undefined') return false;
try {
var file = new File([blob], name, { type: blob.type || 'image/png' });
if (!navigator.canShare({ files: [file] })) return false;
await navigator.share({ files: [file], title: 'Clinical visual', text: 'Clinical Assistant generated visual' });
return true;
} catch (e) { return false; }
}
function downloadBlobWithAnchor(blob, name) {
var objectUrl = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = objectUrl;
a.download = name;
a.rel = 'noopener';
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(function() { URL.revokeObjectURL(objectUrl); }, 60000);
}
function isMobileBrowser() {
return !isNativeApp() && /Android|iPhone|iPad|iPod|Mobile/i.test(navigator.userAgent || '');
} }
function isNativeApp() { function isNativeApp() {
@ -256,10 +157,6 @@ function isShareCancel(error) {
async function imageSourceToBase64(src) { async function imageSourceToBase64(src) {
if (/^data:image\//i.test(src)) return src.split(',')[1] || ''; if (/^data:image\//i.test(src)) return src.split(',')[1] || '';
var blob = await imageSourceToBlob(src); var blob = await imageSourceToBlob(src);
return blobToBase64(blob);
}
async function blobToBase64(blob) {
return new Promise(function(resolve, reject) { return new Promise(function(resolve, reject) {
var reader = new FileReader(); var reader = new FileReader();
reader.onload = function() { resolve(String(reader.result || '').split(',')[1] || ''); }; reader.onload = function() { resolve(String(reader.result || '').split(',')[1] || ''); };

View file

@ -47,86 +47,50 @@ document.addEventListener('DOMContentLoaded', function() {
// 2FA still applies on top: biometric replaces the password step but // 2FA still applies on top: biometric replaces the password step but
// a 2FA-enabled account still gets the TOTP prompt afterwards. That // a 2FA-enabled account still gets the TOTP prompt afterwards. That
// is the intended defense-in-depth. // is the intended defense-in-depth.
// Two plugins cooperate here, because the one that does the biometric var BIO_SERVER = 'pedscribe-bio'; // namespace for the keychain/keystore item
// prompt does not store anything:
// - BiometricAuthNative (@aparajita/capacitor-biometric-auth) — presents
// the Face ID / fingerprint prompt. checkBiometry() + authenticate().
// - SecureStoragePlugin (capacitor-secure-storage-plugin), via the
// window.SecureStorage wrapper — holds the credentials in the iOS
// Keychain / Android EncryptedSharedPreferences.
//
// This previously called window.Capacitor.Plugins.NativeBiometric, the API
// of capacitor-native-biometric — a package that is not a dependency of this
// project. The plugin object was always undefined, so bioAvailable() always
// resolved {ok:false} and the biometric button was never revealed. The
// feature has been dead since it was written.
var BIO_CREDS_KEY = 'ped_bio_creds'; // SecureStorage key holding {username,password}
var BIO_ENABLED_KEY = 'ped_bio_enabled'; // localStorage flag — used to decide whether to even probe var BIO_ENABLED_KEY = 'ped_bio_enabled'; // localStorage flag — used to decide whether to even probe
// BiometryType enum from the plugin (numeric) → human label.
var BIO_TYPE_NAMES = {
1: 'Touch ID',
2: 'Face ID',
3: 'fingerprint',
4: 'face recognition',
5: 'iris recognition'
};
function bioPlugin() { function bioPlugin() {
try { try {
if (!isNativeApp()) return null; if (!isNativeApp()) return null;
var p = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.BiometricAuthNative; var p = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.NativeBiometric;
return p || null; return p || null;
} catch (e) { return null; } } catch (e) { return null; }
} }
function bioAvailable() { function bioAvailable() {
var p = bioPlugin(); var p = bioPlugin();
if (!p) return Promise.resolve({ ok: false }); if (!p) return Promise.resolve({ ok: false });
return p.checkBiometry() return p.isAvailable()
.then(function (r) { .then(function (r) { return { ok: !!(r && r.isAvailable), type: r && r.biometryType }; })
return {
ok: !!(r && r.isAvailable),
type: r && r.biometryType,
typeName: (r && BIO_TYPE_NAMES[r.biometryType]) || 'biometric'
};
})
.catch(function () { return { ok: false }; }); .catch(function () { return { ok: false }; });
} }
function bioStored() { function bioStored() {
// Cheap check first — was biometric ever enrolled? If not, skip the // Cheap check first — was biometric ever enrolled? If not, skip the
// prompt path entirely so we don't rattle the user. // verifyIdentity prompt path entirely so we don't rattle the user.
try { return localStorage.getItem(BIO_ENABLED_KEY) === '1'; } catch (e) { return false; } try { return localStorage.getItem(BIO_ENABLED_KEY) === '1'; } catch (e) { return false; }
} }
function bioEnroll(email, password) { function bioEnroll(email, password) {
if (!bioPlugin()) return Promise.reject(new Error('Biometric plugin unavailable')); var p = bioPlugin();
if (!window.SecureStorage) return Promise.reject(new Error('Secure storage unavailable')); if (!p) return Promise.reject(new Error('Biometric plugin unavailable'));
return Promise.resolve( return p.setCredentials({ username: email, password: password, server: BIO_SERVER })
window.SecureStorage.set(BIO_CREDS_KEY, JSON.stringify({ username: email, password: password })) .then(function () { try { localStorage.setItem(BIO_ENABLED_KEY, '1'); } catch (e) {} });
).then(function () { try { localStorage.setItem(BIO_ENABLED_KEY, '1'); } catch (e) {} });
} }
function bioRetrieve() { function bioRetrieve() {
var p = bioPlugin(); var p = bioPlugin();
if (!p) return Promise.reject(new Error('Biometric plugin unavailable')); if (!p) return Promise.reject(new Error('Biometric plugin unavailable'));
if (!window.SecureStorage) return Promise.reject(new Error('Secure storage unavailable')); return p.verifyIdentity({
// authenticate() resolves on success and rejects on cancel/failure, so the
// credentials are only read after the OS has verified the user.
return p.authenticate({
reason: 'Sign in to PedScribe', reason: 'Sign in to PedScribe',
androidTitle: 'PedScribe', title: 'PedScribe',
androidSubtitle: 'Use biometric to sign in', subtitle: 'Use biometric to sign in',
cancelTitle: 'Use password', description: 'Confirm your identity to continue.'
allowDeviceCredential: false
}).then(function () { }).then(function () {
return window.SecureStorage.get(BIO_CREDS_KEY); return p.getCredentials({ server: BIO_SERVER });
}).then(function (raw) {
if (!raw) throw new Error('No stored credentials');
return JSON.parse(raw);
}); });
} }
function bioForget() { function bioForget() {
var p = bioPlugin();
try { localStorage.removeItem(BIO_ENABLED_KEY); } catch (e) {} try { localStorage.removeItem(BIO_ENABLED_KEY); } catch (e) {}
if (!window.SecureStorage) return Promise.resolve(); if (!p) return Promise.resolve();
return Promise.resolve(window.SecureStorage.remove(BIO_CREDS_KEY)).catch(function () { /* fine if missing */ }); return p.deleteCredentials({ server: BIO_SERVER }).catch(function () { /* fine if missing */ });
} }
// Expose a small surface so settings/logout/etc can call into it. // Expose a small surface so settings/logout/etc can call into it.
window.PedBio = { available: bioAvailable, stored: bioStored, enroll: bioEnroll, retrieve: bioRetrieve, forget: bioForget }; window.PedBio = { available: bioAvailable, stored: bioStored, enroll: bioEnroll, retrieve: bioRetrieve, forget: bioForget };
@ -149,8 +113,9 @@ document.addEventListener('DOMContentLoaded', function() {
if (!s.ok) return; if (!s.ok) return;
// Tweak the label to the actual biometry type when known. // Tweak the label to the actual biometry type when known.
var label = document.getElementById('bio-login-label'); var label = document.getElementById('bio-login-label');
if (label && s.typeName) { if (label && s.type) {
label.textContent = 'Sign in with ' + s.typeName; var typeMap = { 'FACE_ID': 'Sign in with Face ID', 'TOUCH_ID': 'Sign in with Touch ID', 'FACE_AUTHENTICATION': 'Sign in with face recognition', 'FINGERPRINT': 'Sign in with fingerprint' };
label.textContent = typeMap[s.type] || 'Sign in with biometric';
} }
btn.classList.remove('hidden'); btn.style.display = ''; btn.classList.remove('hidden'); btn.style.display = '';
if (div) { div.classList.remove('hidden'); div.style.display = ''; } if (div) { div.classList.remove('hidden'); div.style.display = ''; }
@ -249,71 +214,6 @@ document.addEventListener('DOMContentLoaded', function() {
} }
} }
// ---- CLOUDFLARE TURNSTILE ----
// Gates registration and password reset. Login is deliberately NOT gated:
// it is already covered by a 10-per-15-min rate limit and a constant-time
// credential check, and the widget is unreliable inside the Capacitor
// WebView — which locked mobile users out of the app entirely.
//
// Tokens are captured from the render callback rather than read back out of
// the injected [name="cf-turnstile-response"] input. That lookup is easy to
// leave unscoped, which is exactly how the register form ended up
// submitting the login widget's token (single-use, 5-minute expiry).
//
// Rendering is explicit and deferred until the owning form is visible:
// both widgets live in forms that start at display:none, and Turnstile does
// not reliably complete a challenge inside a hidden container.
var turnstileWidgets = {
register: { el: 'turnstile-register', id: null, token: '', pending: false },
forgot: { el: 'turnstile-forgot', id: null, token: '', pending: false }
};
var turnstileReady = false;
// api.js?render=explicit invokes this once the Turnstile API is available.
window.onloadTurnstileCallback = function() {
turnstileReady = true;
Object.keys(turnstileWidgets).forEach(function(name) {
// Catch up on any form shown before the script finished loading.
if (turnstileWidgets[name].pending) renderTurnstile(name);
});
};
function renderTurnstile(name) {
var w = turnstileWidgets[name];
if (!w || w.id !== null) return; // already rendered
var el = document.getElementById(w.el);
if (!el) return;
if (!turnstileReady || !window.turnstile) { w.pending = true; return; }
w.pending = false;
w.id = window.turnstile.render(el, {
sitekey: el.getAttribute('data-sitekey'),
theme: 'light',
callback: function(token) { w.token = token; },
'expired-callback': function() { w.token = ''; },
'timeout-callback': function() { w.token = ''; },
// Without this a widget failure is silent and the user only ever sees
// the generic "complete the verification" toast with no way to tell
// whether the challenge failed, expired, or never loaded at all.
'error-callback': function(code) {
w.token = '';
console.error('[Auth] Turnstile error on ' + name + ' widget:', code);
showToast('Verification unavailable (' + (code || 'error') + '). Check your connection and try again.', 'error');
}
});
}
function turnstileToken(name) {
var w = turnstileWidgets[name];
return w ? w.token : '';
}
function resetTurnstile(name) {
var w = turnstileWidgets[name];
if (!w) return;
w.token = '';
if (w.id !== null && window.turnstile) window.turnstile.reset(w.id);
}
// ---- HELPER FUNCTIONS ---- // ---- HELPER FUNCTIONS ----
function showLoginForm() { function showLoginForm() {
@ -326,14 +226,12 @@ document.addEventListener('DOMContentLoaded', function() {
if (loginForm) loginForm.style.display = 'none'; if (loginForm) loginForm.style.display = 'none';
if (registerForm) registerForm.style.display = 'block'; if (registerForm) registerForm.style.display = 'block';
if (forgotForm) forgotForm.style.display = 'none'; if (forgotForm) forgotForm.style.display = 'none';
renderTurnstile('register');
} }
function showForgotForm() { function showForgotForm() {
if (loginForm) loginForm.style.display = 'none'; if (loginForm) loginForm.style.display = 'none';
if (registerForm) registerForm.style.display = 'none'; if (registerForm) registerForm.style.display = 'none';
if (forgotForm) forgotForm.style.display = 'block'; if (forgotForm) forgotForm.style.display = 'block';
renderTurnstile('forgot');
} }
function enterApp(user, token) { function enterApp(user, token) {
@ -629,7 +527,7 @@ document.addEventListener('DOMContentLoaded', function() {
// ---- BIOMETRIC LOGIN BUTTON ---- // ---- BIOMETRIC LOGIN BUTTON ----
// Reads the email + password from the OS-secured keychain (gated behind // Reads the email + password from the OS-secured keychain (gated behind
// Face ID / Touch ID / fingerprint) and fills the login form. Submits the // Face ID / Touch ID / fingerprint) and fills the login form. Submits the
// form so all the existing flow (2FA prompt, error handling, // form so all the existing flow (turnstile, 2FA prompt, error handling,
// session storage) runs unchanged. If biometric verification fails, the // session storage) runs unchanged. If biometric verification fails, the
// user just gets a toast and falls through to typing the password. // user just gets a toast and falls through to typing the password.
var bioBtn = document.getElementById('btn-bio-login'); var bioBtn = document.getElementById('btn-bio-login');
@ -646,7 +544,10 @@ document.addEventListener('DOMContentLoaded', function() {
if (emailEl) emailEl.value = creds.username; if (emailEl) emailEl.value = creds.username;
if (pwEl) pwEl.value = creds.password; if (pwEl) pwEl.value = creds.password;
// Trigger the same submit path as the password form so all the // Trigger the same submit path as the password form so all the
// existing handling (2FA, session storage, etc.) runs unchanged. // existing handling (turnstile token, 2FA, session storage, etc.)
// runs unchanged. If turnstile hasn't auto-solved yet the form
// will toast "Please complete the verification" — same as a
// manual login attempt before turnstile resolves.
if (loginForm && typeof loginForm.requestSubmit === 'function') loginForm.requestSubmit(); if (loginForm && typeof loginForm.requestSubmit === 'function') loginForm.requestSubmit();
else if (loginForm) loginForm.dispatchEvent(new Event('submit', { cancelable: true, bubbles: true })); else if (loginForm) loginForm.dispatchEvent(new Event('submit', { cancelable: true, bubbles: true }));
}) })
@ -676,9 +577,17 @@ document.addEventListener('DOMContentLoaded', function() {
return false; return false;
} }
// Cloudflare Turnstile
var loginTurnstile = document.querySelector('#login-form [name="cf-turnstile-response"]');
var loginToken = loginTurnstile ? loginTurnstile.value : '';
if (!loginToken) {
showToast('Please complete the verification', 'error');
return false;
}
showLoading('Signing in...'); showLoading('Signing in...');
var body = { email: email, password: password }; var body = { email: email, password: password, turnstileToken: loginToken };
if (totpCode) body.totpCode = totpCode; if (totpCode) body.totpCode = totpCode;
fetch('/api/auth/login', { fetch('/api/auth/login', {
@ -716,7 +625,7 @@ document.addEventListener('DOMContentLoaded', function() {
if (isNativeApp() && !bioStored()) { if (isNativeApp() && !bioStored()) {
bioAvailable().then(function (s) { bioAvailable().then(function (s) {
if (!s.ok) return; if (!s.ok) return;
var typeName = s.typeName || 'biometric'; var typeName = ({ 'FACE_ID': 'Face ID', 'TOUCH_ID': 'Touch ID', 'FACE_AUTHENTICATION': 'face recognition', 'FINGERPRINT': 'fingerprint' })[s.type] || 'biometric';
if (typeof showConfirm === 'function') { if (typeof showConfirm === 'function') {
showConfirm('Enable ' + typeName + ' for faster sign-in next time?', function () { showConfirm('Enable ' + typeName + ' for faster sign-in next time?', function () {
bioEnroll(email, password) bioEnroll(email, password)
@ -728,12 +637,14 @@ document.addEventListener('DOMContentLoaded', function() {
} }
} else { } else {
showToast(data.error || 'Login failed', 'error'); showToast(data.error || 'Login failed', 'error');
if (window.turnstile) turnstile.reset('#turnstile-login');
} }
}) })
.catch(function(err) { .catch(function(err) {
hideLoading(); hideLoading();
console.error('[Auth] Login error:', err); console.error('[Auth] Login error:', err);
showToast('Connection error', 'error'); showToast('Connection error', 'error');
if (window.turnstile) turnstile.reset('#turnstile-login');
}); });
return false; return false;
@ -792,8 +703,9 @@ document.addEventListener('DOMContentLoaded', function() {
} }
// Cloudflare Turnstile verification // Cloudflare Turnstile verification
var regToken = turnstileToken('register'); var turnstileResponse = document.querySelector('[name="cf-turnstile-response"]');
if (!regToken) { var turnstileToken = turnstileResponse ? turnstileResponse.value : '';
if (!turnstileToken) {
showToast('Please complete the verification challenge', 'error'); showToast('Please complete the verification challenge', 'error');
return false; return false;
} }
@ -803,7 +715,7 @@ document.addEventListener('DOMContentLoaded', function() {
fetch('/api/auth/register', { fetch('/api/auth/register', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: name, email: email, password: password, turnstileToken: regToken }) body: JSON.stringify({ name: name, email: email, password: password, turnstileToken: turnstileToken })
}) })
.then(function(r) { return r.json(); }) .then(function(r) { return r.json(); })
.then(function(data) { .then(function(data) {
@ -817,20 +729,17 @@ document.addEventListener('DOMContentLoaded', function() {
showToast(data.message || 'Account created!', 'success'); showToast(data.message || 'Account created!', 'success');
} else if (data.success && data.needsVerification) { } else if (data.success && data.needsVerification) {
showToast(data.message || 'Check email to verify', 'success'); showToast(data.message || 'Check email to verify', 'success');
// The token was just consumed server-side — clear it so coming back
// to this form doesn't resubmit a spent one.
resetTurnstile('register');
showLoginForm(); showLoginForm();
} else { } else {
showToast(data.error || 'Registration failed', 'error'); showToast(data.error || 'Registration failed', 'error');
resetTurnstile('register'); if (window.turnstile) turnstile.reset();
} }
}) })
.catch(function(err) { .catch(function(err) {
hideLoading(); hideLoading();
console.error('[Auth] Register error:', err); console.error('[Auth] Register error:', err);
showToast('Connection error', 'error'); showToast('Connection error', 'error');
resetTurnstile('register'); if (window.turnstile) turnstile.reset();
}); });
return false; return false;
@ -847,7 +756,8 @@ document.addEventListener('DOMContentLoaded', function() {
if (!email) { showToast('Enter email', 'error'); return false; } if (!email) { showToast('Enter email', 'error'); return false; }
// Cloudflare Turnstile // Cloudflare Turnstile
var forgotToken = turnstileToken('forgot'); var forgotTurnstile = document.querySelector('#forgot-form [name="cf-turnstile-response"]');
var forgotToken = forgotTurnstile ? forgotTurnstile.value : '';
if (!forgotToken) { if (!forgotToken) {
showToast('Please complete the verification', 'error'); showToast('Please complete the verification', 'error');
return false; return false;
@ -864,13 +774,12 @@ document.addEventListener('DOMContentLoaded', function() {
.then(function(data) { .then(function(data) {
hideLoading(); hideLoading();
showToast(data.message || 'Check your email', 'success'); showToast(data.message || 'Check your email', 'success');
resetTurnstile('forgot');
showLoginForm(); showLoginForm();
}) })
.catch(function(err) { .catch(function(err) {
hideLoading(); hideLoading();
showToast('Error', 'error'); showToast('Error', 'error');
resetTurnstile('forgot'); if (window.turnstile) turnstile.reset('#turnstile-forgot');
}); });
return false; return false;

View file

@ -29,8 +29,6 @@ import {
var lastGeneratedImageSrc = ''; var lastGeneratedImageSrc = '';
var markdownRenderer = null; var markdownRenderer = null;
var assistantBusy = false; var assistantBusy = false;
var activeAssistantRequest = null;
var STREAM_MARKDOWN_LIMIT = 3500;
var exporter = createAssistantExporter({ renderMarkdown: renderMarkdown, showToast: window.showToast }); var exporter = createAssistantExporter({ renderMarkdown: renderMarkdown, showToast: window.showToast });
var imageStore = createAssistantImageStore(); var imageStore = createAssistantImageStore();
@ -51,7 +49,6 @@ import {
function bindEvents() { function bindEvents() {
var form = document.getElementById('assistant-form'); var form = document.getElementById('assistant-form');
var clearBtn = document.getElementById('btn-assistant-clear'); var clearBtn = document.getElementById('btn-assistant-clear');
var cancelBtn = document.getElementById('btn-assistant-cancel');
var copyBtn = document.getElementById('btn-assistant-copy'); var copyBtn = document.getElementById('btn-assistant-copy');
var saveBtn = document.getElementById('btn-assistant-save'); var saveBtn = document.getElementById('btn-assistant-save');
var saveConfirmBtn = document.getElementById('btn-assistant-save-confirm'); var saveConfirmBtn = document.getElementById('btn-assistant-save-confirm');
@ -63,7 +60,6 @@ import {
if (form) form.addEventListener('submit', onAsk); if (form) form.addEventListener('submit', onAsk);
if (clearBtn) clearBtn.addEventListener('click', clearConversation); if (clearBtn) clearBtn.addEventListener('click', clearConversation);
if (cancelBtn) cancelBtn.addEventListener('click', cancelAssistantSearch);
if (copyBtn) copyBtn.addEventListener('click', copyLastAnswer); if (copyBtn) copyBtn.addEventListener('click', copyLastAnswer);
if (saveBtn) saveBtn.addEventListener('click', showSavePanel); if (saveBtn) saveBtn.addEventListener('click', showSavePanel);
if (saveConfirmBtn) saveConfirmBtn.addEventListener('click', saveCurrentChat); if (saveConfirmBtn) saveConfirmBtn.addEventListener('click', saveCurrentChat);
@ -130,40 +126,21 @@ import {
return; return;
} }
var request = createAssistantRequest();
activeAssistantRequest = request;
setBusy(true, 'Looking up sources...'); setBusy(true, 'Looking up sources...');
var loading = appendLoadingMessage('Looking up sources', 'Retrieving and synthesizing references...'); var loading = appendLoadingMessage('Looking up sources', 'Retrieving and synthesizing references...');
request.loading = loading;
streamAssistantResponse({ streamAssistantResponse({
message: text, message: text,
history: messages.slice(-8), history: messages.slice(-8),
includeContext: !includeContext || includeContext.checked includeContext: !includeContext || includeContext.checked
}, loading, request) }, loading)
.catch(function (err) { .catch(function (err) {
if (request.cancelled) return;
setBusy(false, 'Error', true); setBusy(false, 'Error', true);
replaceLoadingMessage(loading, 'I could not complete the assistant request. ' + err.message + '\n\nThe indexed search service may be busy or temporarily unavailable. Please try again in a moment.'); replaceLoadingMessage(loading, 'I could not complete the assistant request. ' + err.message + '\n\nThe indexed search service may be busy or temporarily unavailable. Please try again in a moment.');
if (typeof showToast === 'function') showToast(err.message, 'error'); if (typeof showToast === 'function') showToast(err.message, 'error');
})
.finally(function () {
if (activeAssistantRequest === request) activeAssistantRequest = null;
}); });
} }
function createAssistantRequest() {
var controller = typeof AbortController === 'function' ? new AbortController() : null;
return {
cancelled: false,
signal: controller ? controller.signal : undefined,
abort: function () {
this.cancelled = true;
if (controller) controller.abort();
}
};
}
async function fetchAssistantResponse(payload, loading) { async function fetchAssistantResponse(payload, loading) {
updateLoadingMessage(loading, 'Looking up sources...'); updateLoadingMessage(loading, 'Looking up sources...');
var data = await fetchAssistantFallback(payload); var data = await fetchAssistantFallback(payload);
@ -178,8 +155,8 @@ import {
} }
} }
async function streamAssistantResponse(payload, loading, request) { async function streamAssistantResponse(payload, loading) {
var response = await openAssistantStream(payload, { signal: request ? request.signal : undefined }); var response = await openAssistantStream(payload);
if (!response.ok || !response.body) { if (!response.ok || !response.body) {
var fallback = await response.json().catch(function () { return {}; }); var fallback = await response.json().catch(function () { return {}; });
throw new Error(fallback.error || ('Request failed (' + response.status + ')')); throw new Error(fallback.error || ('Request failed (' + response.status + ')'));
@ -200,7 +177,7 @@ import {
if (!bubble) return; if (!bubble) return;
loading.classList.remove('assistant-loading-msg'); loading.classList.remove('assistant-loading-msg');
bubble.classList.remove('assistant-thinking'); bubble.classList.remove('assistant-thinking');
bubble.innerHTML = partial ? renderStreamingAnswerHtml(partial, streamSources) : '<p class="assistant-muted">Generating answer...</p>'; bubble.innerHTML = partial ? renderAssistantBubbleHtml(partial, streamSources, false) : '<p class="assistant-muted">Generating answer...</p>';
renderEmbeddedBlocks(bubble); renderEmbeddedBlocks(bubble);
var wrap = document.getElementById('assistant-messages'); var wrap = document.getElementById('assistant-messages');
if (wrap) wrap.scrollTop = wrap.scrollHeight; if (wrap) wrap.scrollTop = wrap.scrollHeight;
@ -230,7 +207,6 @@ import {
var reader = response.body.getReader(); var reader = response.body.getReader();
while (true) { while (true) {
if (request && request.cancelled) return;
var chunk = await reader.read(); var chunk = await reader.read();
if (chunk.done) break; if (chunk.done) break;
buffer += decoder.decode(chunk.value, { stream: true }); buffer += decoder.decode(chunk.value, { stream: true });
@ -248,11 +224,9 @@ import {
if (!finalData) { if (!finalData) {
updateLoadingMessage(loading, 'Stream ended early. Retrying without streaming...'); updateLoadingMessage(loading, 'Stream ended early. Retrying without streaming...');
finalData = await fetchAssistantFallback(payload, request); finalData = await fetchAssistantFallback(payload);
} }
if (request && request.cancelled) return;
setBusy(false, 'Ready'); setBusy(false, 'Ready');
lastAnswer = finalData.answer || finalData.markdown || ''; lastAnswer = finalData.answer || finalData.markdown || '';
lastSources = finalData.sources || finalData.citations || streamSources; lastSources = finalData.sources || finalData.citations || streamSources;
@ -264,20 +238,6 @@ import {
} }
} }
function renderStreamingAnswerHtml(text, sources) {
if (shouldUseLightweightStreamingRender(text)) {
return '<pre class="assistant-streaming-text">' + escapeHtml(text) + '</pre>';
}
return renderAssistantBubbleHtml(text, sources, false);
}
function shouldUseLightweightStreamingRender(text) {
text = String(text || '');
if (text.length > STREAM_MARKDOWN_LIMIT) return true;
var pipeRows = text.split('\n').filter(function (line) { return /^\s*\|.*\|\s*$/.test(line); }).length;
return pipeRows >= 8;
}
function parseSseEvent(block) { function parseSseEvent(block) {
var type = 'message'; var type = 'message';
var data = ''; var data = '';
@ -290,15 +250,8 @@ import {
catch (e) { return null; } catch (e) { return null; }
} }
async function fetchAssistantFallback(payload, request) { async function fetchAssistantFallback(payload) {
var data; var data = await fetchAssistantChat(payload);
try {
data = await fetchAssistantChat(payload, { signal: request ? request.signal : undefined });
} catch (e) {
if (request && request.cancelled) throw e;
if (e && e.name === 'AbortError') throw new Error('Assistant request cancelled.');
throw e;
}
if (!data.success) throw new Error(data.error || ('Request failed (' + data._status + ')')); if (!data.success) throw new Error(data.error || ('Request failed (' + data._status + ')'));
return data; return data;
} }
@ -492,11 +445,10 @@ import {
if (!src) throw new Error('No image returned'); if (!src) throw new Error('No image returned');
lastGeneratedImageSrc = src; lastGeneratedImageSrc = src;
exporter.invalidate(); exporter.invalidate();
var downloadUrl = data.downloadUrl || ''; if (out) out.innerHTML = imageStore.renderGeneratedImage(src, 'Generated clinical visual');
if (out) out.innerHTML = imageStore.renderGeneratedImage(src, 'Generated clinical visual', downloadUrl);
if (fromChat) { if (fromChat) {
setBusy(false, 'Ready'); setBusy(false, 'Ready');
var html = imageStore.renderGeneratedImage(src, 'Generated clinical visual', downloadUrl); var html = imageStore.renderGeneratedImage(src, 'Generated clinical visual');
replaceLoadingMessage(loading, html, [], [], true); replaceLoadingMessage(loading, html, [], [], true);
} }
}) })
@ -595,17 +547,6 @@ import {
loadSavedChats(); loadSavedChats();
} }
function cancelAssistantSearch() {
if (!activeAssistantRequest) return;
var request = activeAssistantRequest;
request.abort();
activeAssistantRequest = null;
setBusy(false, 'Ready');
if (request.loading && request.loading.parentNode) {
replaceLoadingMessage(request.loading, 'Search cancelled.');
}
}
function renderEmptyState() { function renderEmptyState() {
var examples = pickExamples(); var examples = pickExamples();
return '<div class="assistant-empty"><i class="fas fa-book-medical"></i>' + return '<div class="assistant-empty"><i class="fas fa-book-medical"></i>' +
@ -817,7 +758,6 @@ import {
var status = document.getElementById('assistant-status'); var status = document.getElementById('assistant-status');
var label = document.getElementById('assistant-status-text'); var label = document.getElementById('assistant-status-text');
var send = document.getElementById('btn-assistant-send'); var send = document.getElementById('btn-assistant-send');
var cancel = document.getElementById('btn-assistant-cancel');
var input = document.getElementById('assistant-input'); var input = document.getElementById('assistant-input');
if (status) { if (status) {
status.classList.toggle('busy', !!isBusy); status.classList.toggle('busy', !!isBusy);
@ -828,13 +768,6 @@ import {
send.disabled = !!isBusy; send.disabled = !!isBusy;
send.innerHTML = isBusy ? '<i class="fas fa-spinner fa-spin"></i> Searching' : '<i class="fas fa-paper-plane"></i> Ask'; send.innerHTML = isBusy ? '<i class="fas fa-spinner fa-spin"></i> Searching' : '<i class="fas fa-paper-plane"></i> Ask';
} }
if (cancel) {
var canCancel = !!isBusy && !!activeAssistantRequest;
if (canCancel) cancel.removeAttribute('hidden');
else cancel.setAttribute('hidden', '');
cancel.disabled = !canCancel;
cancel.style.display = canCancel ? 'inline-flex' : 'none';
}
if (input) input.disabled = !!isBusy; if (input) input.disabled = !!isBusy;
} }

View file

@ -5,6 +5,7 @@
# Usage: # Usage:
# scripts/release.sh 6.1.1 # bump to 6.1.1 # scripts/release.sh 6.1.1 # bump to 6.1.1
# scripts/release.sh 6.1.1 --push # also git push + tag push # scripts/release.sh 6.1.1 --push # also git push + tag push
# scripts/release.sh 6.2.0 --push --gh # also create GitHub release
# #
# What it does: # What it does:
# 1. Updates version in root package.json # 1. Updates version in root package.json
@ -12,32 +13,31 @@
# 3. Updates versionName + bumps versionCode in Android build.gradle # 3. Updates versionName + bumps versionCode in Android build.gradle
# 4. Commits the version bump # 4. Commits the version bump
# 5. (optional) git push + push the new tag # 5. (optional) git push + push the new tag
# 6. (optional) create a GitHub release via gh CLI
# #
# It does NOT: # It does NOT:
# - Build the Docker image (run `docker compose build`/`up -d` yourself # - Build the Docker image (run `docker compose build`/`up -d` yourself
# or wire it to a deploy script / CI hook) # or wire it to a deploy script / CI hook)
# - Build the Android APK itself. Forgejo CI does that # - Build the Android APK (run `cd mobile/android && ./gradlew ...`
# (.forgejo/workflows/android-apk.yml): any branch push builds a signed # yourself). This script just marks the version so the build tags
# APK as a 30-day workflow artifact, and a v* tag push additionally # correctly.
# attaches it to a Forgejo release, which is what Obtainium tracks for
# updates. So --push is all you need to ship a build.
# - Publish the release itself. CI does that on the tag push; there is no
# manual publish step to run.
# ============================================================ # ============================================================
set -euo pipefail set -euo pipefail
VERSION="${1:-}" VERSION="${1:-}"
PUSH=false PUSH=false
DO_RELEASE=false
for arg in "${@:2}"; do for arg in "${@:2}"; do
case "$arg" in case "$arg" in
--push) PUSH=true ;; --push) PUSH=true ;;
--gh) DO_RELEASE=true ;;
esac esac
done done
if [[ -z "$VERSION" ]]; then if [[ -z "$VERSION" ]]; then
echo "usage: $0 <version> [--push]" echo "usage: $0 <version> [--push] [--gh]"
echo " example: $0 6.1.1 --push" echo " example: $0 6.1.1 --push --gh"
exit 1 exit 1
fi fi
if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
@ -90,25 +90,30 @@ git tag -a "v${VERSION}" -m "Release v${VERSION}"
echo " tagged v${VERSION}" echo " tagged v${VERSION}"
if $PUSH; then if $PUSH; then
# This repo's remote is "forgejo", not "origin". Prefer forgejo, fall back git push origin HEAD
# to origin, otherwise use the only remote there is — so this keeps working git push origin "v${VERSION}"
# if the remote is ever renamed. Pushing the tag is what makes CI attach the echo " pushed to origin"
# signed APK to a Forgejo release for Obtainium; the branch push alone only fi
# builds it as a 30-day workflow artifact.
REMOTE="" if $DO_RELEASE; then
for candidate in forgejo origin; do if ! command -v gh >/dev/null; then
if git remote get-url "$candidate" >/dev/null 2>&1; then REMOTE="$candidate"; break; fi echo "WARN: gh CLI not installed, skipping GitHub release creation" >&2
done else
if [[ -z "$REMOTE" ]]; then APK="mobile/android/app/build/outputs/apk/release/app-release.apk"
REMOTE=$(git remote | head -1) if [[ -f "$APK" ]]; then
gh release create "v${VERSION}" "$APK" \
--title "PedScribe ${VERSION}" \
--notes "Release ${VERSION}" \
--latest
echo " created GitHub release with APK"
else
gh release create "v${VERSION}" \
--title "PedScribe ${VERSION}" \
--notes "Release ${VERSION}" \
--latest
echo " created GitHub release (no APK attached — run gradle first then gh release upload)"
fi
fi fi
if [[ -z "$REMOTE" ]]; then
echo "ERROR: no git remote configured — cannot push. Commit and tag are still local." >&2
exit 1
fi
git push "$REMOTE" HEAD
git push "$REMOTE" "v${VERSION}"
echo " pushed to $REMOTE"
fi fi
echo "==> Done. v$VERSION." echo "==> Done. v$VERSION."

View file

@ -376,26 +376,6 @@ async function initDatabase() {
console.warn('⚠️ Could not create milestones table:', e.message); console.warn('⚠️ Could not create milestones table:', e.message);
} }
// Durable history for generated clinical assistant starter prompts.
// Redis serves the active pool; Postgres keeps snapshots for rollback.
try { await client.query(`
CREATE TABLE IF NOT EXISTS clinical_prompt_pool_snapshots (
id SERIAL PRIMARY KEY,
generated_at TIMESTAMPTZ DEFAULT NOW(),
target INTEGER DEFAULT 0,
count INTEGER DEFAULT 0,
payload JSONB NOT NULL,
restored_from INTEGER REFERENCES clinical_prompt_pool_snapshots(id) ON DELETE SET NULL,
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_prompt_pool_snapshots_created ON clinical_prompt_pool_snapshots(created_at DESC);
`);
console.log('✅ clinical_prompt_pool_snapshots: table ready');
} catch(e) {
console.warn('⚠️ Could not create clinical prompt pool snapshots table:', e.message);
}
// Create IVFFLAT index for fast similarity search (after data is populated) // Create IVFFLAT index for fast similarity search (after data is populated)
try { try {
var indexExists = await client.query( var indexExists = await client.query(

View file

@ -9,7 +9,7 @@ var { authMiddleware, adminMiddleware } = require('../middleware/auth');
var PROMPTS = require('../utils/prompts'); var PROMPTS = require('../utils/prompts');
var logger = require('../utils/logger'); var logger = require('../utils/logger');
var { gatewayUrl } = require('../utils/errors'); var { gatewayUrl } = require('../utils/errors');
var { getTTSEnvProvider, getLiteLLMTTSDiscoveryItems, getLiteLLMTTSRequestOptions, getLiteLLMTTSVoicesForModel, isLiteLLMTTSVoiceCompatible, getTTSProvider } = require('../utils/ttsProvider'); var { getTTSEnvProvider, getLiteLLMTTSDiscoveryItems, getTTSProvider, getTTSVoiceLists } = require('../utils/ttsProvider');
var { getLiteLLMHeaders, getLiteLLMAdminHeaders } = require('../utils/litellm'); var { getLiteLLMHeaders, getLiteLLMAdminHeaders } = require('../utils/litellm');
var { getSTTDependencies, getLiteLLMSTTModels, getSTTModelLists, getSTTProvider } = require('../utils/sttProvider'); var { getSTTDependencies, getLiteLLMSTTModels, getSTTModelLists, getSTTProvider } = require('../utils/sttProvider');
var { getLiteLLMEmbeddingModels } = require('../utils/embeddings'); var { getLiteLLMEmbeddingModels } = require('../utils/embeddings');
@ -91,55 +91,6 @@ router.get('/config', async function(req, res) {
} catch (e) { res.status(500).json({ error: 'Request failed' }); } } catch (e) { res.status(500).json({ error: 'Request failed' }); }
}); });
// ── Clinical assistant starter prompt pool ─────────────────────────────────
router.get('/clinical-assistant/prompt-pool', async function(req, res) {
try {
var clinicalAssistant = require('./clinicalAssistant');
var meta = await clinicalAssistant.getPromptPoolMeta();
var snapshots = await clinicalAssistant.getPromptPoolSnapshots(20);
res.json({ success: true, meta: meta || null, snapshots: snapshots });
} catch (e) {
res.status(500).json({ error: e.message || 'Prompt pool status failed' });
}
});
router.get('/clinical-assistant/prompt-pool/snapshots', async function(req, res) {
try {
var clinicalAssistant = require('./clinicalAssistant');
var snapshots = await clinicalAssistant.getPromptPoolSnapshots(req.query.limit || 20);
res.json({ success: true, snapshots: snapshots });
} catch (e) {
res.status(500).json({ error: e.message || 'Prompt pool snapshot listing failed' });
}
});
router.post('/clinical-assistant/prompt-pool/regenerate', async function(req, res) {
try {
var clinicalAssistant = require('./clinicalAssistant');
var examples = await clinicalAssistant.refreshPromptPool(true, req.user && req.user.id);
var meta = await clinicalAssistant.getPromptPoolMeta();
var snapshots = await clinicalAssistant.getPromptPoolSnapshots(20);
res.json({ success: true, count: examples.length, meta: meta || null, snapshots: snapshots });
} catch (e) {
res.status(500).json({ error: e.message || 'Prompt pool regeneration failed' });
}
});
router.post('/clinical-assistant/prompt-pool/restore', async function(req, res) {
try {
var clinicalAssistant = require('./clinicalAssistant');
var id = Number(req.body && req.body.id);
if (!Number.isFinite(id) || id <= 0) return res.status(400).json({ error: 'Snapshot id is required' });
var payload = await clinicalAssistant.restorePromptPoolSnapshot(id, req.user && req.user.id);
if (!payload) return res.status(404).json({ error: 'Snapshot not found' });
var meta = await clinicalAssistant.getPromptPoolMeta();
var snapshots = await clinicalAssistant.getPromptPoolSnapshots(20);
res.json({ success: true, count: Array.isArray(payload.examples) ? payload.examples.length : 0, meta: meta || null, snapshots: snapshots });
} catch (e) {
res.status(500).json({ error: e.message || 'Prompt pool restore failed' });
}
});
// ── POST send test email ─────────────────────────────────────────────────── // ── POST send test email ───────────────────────────────────────────────────
router.post('/config/test-email', async function(req, res) { router.post('/config/test-email', async function(req, res) {
try { try {
@ -550,17 +501,12 @@ router.get('/config/tts', async function(req, res) {
var dbModel = await db.getSetting('tts.model') || ''; var dbModel = await db.getSetting('tts.model') || '';
var envVoice = process.env.LITELLM_TTS_VOICE || ''; var envVoice = process.env.LITELLM_TTS_VOICE || '';
var envModel = process.env.LITELLM_TTS_MODEL || ''; var envModel = process.env.LITELLM_TTS_MODEL || '';
var currentModel = dbModel || envModel;
var voices = getLiteLLMTTSVoicesForModel(currentModel, { currentVoice: dbVoice });
var currentVoice = [dbVoice, envVoice, voices[0]].find(function(voice) {
return isLiteLLMTTSVoiceCompatible(currentModel, voice);
}) || '';
res.json({ res.json({
success: true, success: true,
provider: activeProvider, provider: activeProvider,
envProvider: envProvider, envProvider: envProvider,
currentVoice: currentVoice, currentVoice: dbVoice || envVoice,
currentModel: currentModel, currentModel: dbModel || envModel,
dbVoice: dbVoice, dbVoice: dbVoice,
dbModel: dbModel, dbModel: dbModel,
envVoice: envVoice, envVoice: envVoice,
@ -568,9 +514,7 @@ router.get('/config/tts', async function(req, res) {
configured: { configured: {
litellm: !!process.env.LITELLM_API_BASE litellm: !!process.env.LITELLM_API_BASE
}, },
voices: { voices: getTTSVoiceLists()
litellm: voices
}
}); });
} catch (e) { res.status(500).json({ error: 'Request failed' }); } } catch (e) { res.status(500).json({ error: 'Request failed' }); }
}); });
@ -623,15 +567,11 @@ router.post('/config/tts/test', async function(req, res) {
var adminModel = await db.getSetting('tts.model') || ''; var adminModel = await db.getSetting('tts.model') || '';
var adminVoice = await db.getSetting('tts.voice') || ''; var adminVoice = await db.getSetting('tts.voice') || '';
var ttsModel = adminModel || process.env.LITELLM_TTS_MODEL || ''; var ttsModel = adminModel || process.env.LITELLM_TTS_MODEL || '';
var defaultVoices = getLiteLLMTTSVoicesForModel(ttsModel, { currentVoice: adminVoice }); var usedVoice = voice || adminVoice || process.env.LITELLM_TTS_VOICE || '';
var usedVoice = [voice, adminVoice, process.env.LITELLM_TTS_VOICE || '', defaultVoices[0]].find(function(candidate) {
return isLiteLLMTTSVoiceCompatible(ttsModel, candidate);
}) || '';
if (!ttsModel) return res.json({ success: false, error: 'No LiteLLM TTS model configured' }); if (!ttsModel) return res.json({ success: false, error: 'No LiteLLM TTS model configured' });
var payload = Object.assign({ model: ttsModel, voice: usedVoice, input: text }, getLiteLLMTTSRequestOptions(ttsModel));
var ttsResp = await axios.post(gatewayUrl('/audio/speech'), var ttsResp = await axios.post(gatewayUrl('/audio/speech'),
payload, { model: ttsModel, voice: usedVoice, input: text },
{ headers: getLiteLLMHeaders('application/json'), responseType: 'arraybuffer', timeout: 60000 } { headers: getLiteLLMHeaders('application/json'), responseType: 'arraybuffer', timeout: 60000 }
); );
var buffer = Buffer.from(ttsResp.data); var buffer = Buffer.from(ttsResp.data);

View file

@ -299,15 +299,19 @@ router.post('/resend-verification', async (req, res) => {
// ============================================================ // ============================================================
router.post('/login', async (req, res) => { router.post('/login', async (req, res) => {
try { try {
var { email, password, totpCode } = req.body; var { email, password, totpCode, turnstileToken } = req.body;
if (!email || !password) return res.status(400).json({ error: 'Email and password required' }); if (!email || !password) return res.status(400).json({ error: 'Email and password required' });
// No Turnstile on login. The widget could not reliably complete a // Cloudflare Turnstile verification
// challenge inside the Capacitor WebView, which locked mobile users out. if (process.env.TURNSTILE_SECRET_KEY) {
// Brute-force cover here comes from the 10-per-15-min per-IP rate limit if (!turnstileToken) return res.status(400).json({ error: 'Please complete the verification' });
// (server.js), the constant-time bcrypt comparison below, and TOTP 2FA. var tsRes = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
// Registration and password reset — the endpoints that actually attract method: 'POST', headers: { 'Content-Type': 'application/json' },
// bots — are still gated. body: JSON.stringify({ secret: process.env.TURNSTILE_SECRET_KEY, response: turnstileToken, remoteip: req.ip })
});
var tsData = await tsRes.json();
if (!tsData.success) return res.status(400).json({ error: 'Verification failed. Please try again.' });
}
var user = await db.get('SELECT * FROM users WHERE email = ?', [email.toLowerCase()]); var user = await db.get('SELECT * FROM users WHERE email = ?', [email.toLowerCase()]);
// Enumeration-resistant: always run bcrypt to keep timing constant, and return // Enumeration-resistant: always run bcrypt to keep timing constant, and return

View file

@ -55,9 +55,7 @@ var promptPool = createClinicalPromptPool({
semanticSearch: semanticSearch, semanticSearch: semanticSearch,
dedupeSources: dedupeSources, dedupeSources: dedupeSources,
normalizeMcpSearchResponse: normalizeMcpSearchResponse, normalizeMcpSearchResponse: normalizeMcpSearchResponse,
getIndexedTopicExamples: getIndexedTopicExamples, getIndexedTopicExamples: getIndexedTopicExamples
loadStoredPromptPool: loadLatestPromptPoolSnapshot,
savePromptPool: savePromptPoolSnapshot
}); });
function positiveInt(value, fallback) { function positiveInt(value, fallback) {
@ -325,7 +323,6 @@ router.get('/clinical-assistant/image/jobs/:id', async function(req, res) {
imageUrl: job.imageUrl || null, imageUrl: job.imageUrl || null,
url: job.url || null, url: job.url || null,
base64: job.base64 || null, base64: job.base64 || null,
downloadUrl: job.status === 'done' && job.base64 ? '/api/clinical-assistant/image/jobs/' + encodeURIComponent(job.id) + '/download' : null,
error: job.error || null error: job.error || null
}); });
} catch (e) { } catch (e) {
@ -333,22 +330,6 @@ router.get('/clinical-assistant/image/jobs/:id', async function(req, res) {
} }
}); });
router.get('/clinical-assistant/image/jobs/:id/download', async function(req, res) {
try {
var job = await getImageJob(req.params.id);
if (!job || String(job.userId) !== String(req.user.id) || job.status !== 'done' || !job.base64) {
return res.status(404).json({ error: 'Image job not found' });
}
var buffer = Buffer.from(String(job.base64), 'base64');
res.setHeader('Content-Type', 'image/png');
res.setHeader('Content-Disposition', 'attachment; filename="clinical-visual.png"');
res.setHeader('Cache-Control', 'private, max-age=900');
res.send(buffer);
} catch (e) {
res.status(500).json({ error: 'Image download failed' });
}
});
async function prepareAssistantChat(body) { async function prepareAssistantChat(body) {
body = body || {}; body = body || {};
var message = String(body.message || '').trim(); var message = String(body.message || '').trim();
@ -587,49 +568,6 @@ async function getAvailableExamples() {
return promptPool.getAvailableExamples(); return promptPool.getAvailableExamples();
} }
router.refreshPromptPool = function(force, userId) {
return promptPool.refreshIfNeeded(force !== false, { userId: userId || null });
};
router.getPromptPoolMeta = function() {
return promptPool.getMeta();
};
router.getPromptPoolSnapshots = getPromptPoolSnapshots;
router.restorePromptPoolSnapshot = restorePromptPoolSnapshot;
async function savePromptPoolSnapshot(payload, context) {
context = context || {};
var result = await db.run(
'INSERT INTO clinical_prompt_pool_snapshots (generated_at, target, count, payload, restored_from, created_by) VALUES (to_timestamp($1 / 1000.0), $2, $3, $4::jsonb, $5, $6) RETURNING id',
[payload.generatedAt || Date.now(), payload.target || 0, Array.isArray(payload.examples) ? payload.examples.length : 0, JSON.stringify(payload), context.restoredFrom || null, context.userId || null]
);
return result.lastInsertRowid;
}
async function loadLatestPromptPoolSnapshot() {
var row = await db.get('SELECT payload FROM clinical_prompt_pool_snapshots ORDER BY created_at DESC, id DESC LIMIT 1', []);
if (!row || !row.payload) return null;
return typeof row.payload === 'string' ? JSON.parse(row.payload) : row.payload;
}
async function getPromptPoolSnapshots(limit) {
limit = clampInt(limit || 20, 1, 50, 20);
return db.all(
'SELECT id, generated_at, target, count, restored_from, created_at FROM clinical_prompt_pool_snapshots ORDER BY created_at DESC, id DESC LIMIT $1',
[limit]
);
}
async function restorePromptPoolSnapshot(id, userId) {
var row = await db.get('SELECT id, payload FROM clinical_prompt_pool_snapshots WHERE id = $1', [id]);
if (!row || !row.payload) return null;
var original = typeof row.payload === 'string' ? JSON.parse(row.payload) : row.payload;
var payload = Object.assign({}, original, { generatedAt: Date.now(), restoredFrom: Number(row.id) });
await promptPool.writePromptPool(payload, { userId: userId || null, restoredFrom: Number(row.id) });
return payload;
}
async function getIndexedTopicExamples() { async function getIndexedTopicExamples() {
var response = await indexedTopicSuggestions(12); var response = await indexedTopicSuggestions(12);
var data = response && (response.structuredContent || response.data || response); var data = response && (response.structuredContent || response.data || response);
@ -652,18 +590,7 @@ async function getIndexedTopicExamples() {
sourceTitle: Array.isArray(item.sample_titles) ? item.sample_titles[0] : '', sourceTitle: Array.isArray(item.sample_titles) ? item.sample_titles[0] : '',
category: item.category || '' category: item.category || ''
}; };
}).filter(isUsefulIndexedTopicExample); }).filter(function(item) { return item.prompt; });
}
function isUsefulIndexedTopicExample(item) {
var label = String(item && item.label || '');
var prompt = String(item && item.prompt || '');
if (!prompt || prompt.indexOf('?') === -1) return false;
var haystack = (label + ' ' + prompt + ' ' + String(item.sourceTitle || '')).toLowerCase();
if (/\b(start|end) of picture text\b/.test(haystack)) return false;
if (/\b(comparative|cross[-\s]?sectional|retrospective|prospective) study\b/.test(label.toLowerCase())) return false;
if (/\b(prevalence|correlation|association) of\b/.test(label.toLowerCase())) return false;
return true;
} }
async function generateImage(prompt, model) { async function generateImage(prompt, model) {

View file

@ -3,7 +3,7 @@ const router = express.Router();
const { authMiddleware } = require('../middleware/auth'); const { authMiddleware } = require('../middleware/auth');
var logger = require('../utils/logger'); var logger = require('../utils/logger');
var { gatewayUrl } = require('../utils/errors'); var { gatewayUrl } = require('../utils/errors');
var { getLiteLLMTTSRequestOptions, getLiteLLMTTSVoicesForModel, isLiteLLMTTSVoiceCompatible, getTTSProvider } = require('../utils/ttsProvider'); var { getTTSProvider } = require('../utils/ttsProvider');
var { getLiteLLMHeaders } = require('../utils/litellm'); var { getLiteLLMHeaders } = require('../utils/litellm');
// TTS is intentionally routed only through LiteLLM. Provider-specific voice // TTS is intentionally routed only through LiteLLM. Provider-specific voice
@ -22,22 +22,19 @@ router.post('/text-to-speech', authMiddleware, async (req, res) => {
var userVoice = userPrefs?.tts_voice; var userVoice = userPrefs?.tts_voice;
var adminVoice = await db.getSetting('tts.voice') || ''; var adminVoice = await db.getSetting('tts.voice') || '';
var adminModel = await db.getSetting('tts.model') || ''; var adminModel = await db.getSetting('tts.model') || '';
var resolvedVoice = userVoice || adminVoice || '';
if (ttsProvider !== 'litellm' || !process.env.LITELLM_API_BASE) { if (ttsProvider !== 'litellm' || !process.env.LITELLM_API_BASE) {
return res.status(400).json({ error: 'TTS not configured. Set LITELLM_API_BASE.' }); return res.status(400).json({ error: 'TTS not configured. Set LITELLM_API_BASE.' });
} }
var ttsModel = adminModel || process.env.LITELLM_TTS_MODEL || ''; var ttsModel = adminModel || process.env.LITELLM_TTS_MODEL || '';
var defaultVoices = getLiteLLMTTSVoicesForModel(ttsModel, { currentVoice: adminVoice }); var ttsVoice = resolvedVoice || process.env.LITELLM_TTS_VOICE || '';
var ttsVoice = [userVoice, adminVoice, process.env.LITELLM_TTS_VOICE || '', defaultVoices[0]].find(function(voice) {
return isLiteLLMTTSVoiceCompatible(ttsModel, voice);
}) || '';
if (!ttsModel) return res.status(400).json({ error: 'No LiteLLM TTS model configured.' }); if (!ttsModel) return res.status(400).json({ error: 'No LiteLLM TTS model configured.' });
var payload = Object.assign({ model: ttsModel, input: text, voice: ttsVoice }, getLiteLLMTTSRequestOptions(ttsModel));
var ttsResp = await fetch(gatewayUrl('/audio/speech'), { var ttsResp = await fetch(gatewayUrl('/audio/speech'), {
method: 'POST', method: 'POST',
headers: getLiteLLMHeaders('application/json'), headers: getLiteLLMHeaders('application/json'),
body: JSON.stringify(payload) body: JSON.stringify({ model: ttsModel, input: text, voice: ttsVoice })
}); });
if (!ttsResp.ok) { if (!ttsResp.ok) {
var errBody = await ttsResp.text(); var errBody = await ttsResp.text();

View file

@ -7,7 +7,7 @@ var router = express.Router();
var db = require('../db/database'); var db = require('../db/database');
var { authMiddleware } = require('../middleware/auth'); var { authMiddleware } = require('../middleware/auth');
var { getSTTModelLists, getSTTProvider } = require('../utils/sttProvider'); var { getSTTModelLists, getSTTProvider } = require('../utils/sttProvider');
var { getLiteLLMTTSVoicesForModel, getTTSProvider } = require('../utils/ttsProvider'); var { getTTSProvider, getTTSVoiceLists } = require('../utils/ttsProvider');
router.use(authMiddleware); router.use(authMiddleware);
@ -49,18 +49,14 @@ router.get('/preferences/options', async function(req, res) {
var provider = getSTTProvider(); var provider = getSTTProvider();
var ttsProvider = getTTSProvider(); var ttsProvider = getTTSProvider();
var dbModel = await db.getSetting('tts.model') || '';
var dbVoice = await db.getSetting('tts.voice') || '';
var ttsModel = dbModel || process.env.LITELLM_TTS_MODEL || '';
var sttModels = getSTTModelLists().litellm.map(function(model) { return { value: model, label: model }; }); var sttModels = getSTTModelLists().litellm.map(function(model) { return { value: model, label: model }; });
var ttsVoices = getLiteLLMTTSVoicesForModel(ttsModel, { currentVoice: dbVoice }).map(function(voice) { return { value: voice, label: voice }; }); var ttsVoices = getTTSVoiceLists().litellm.map(function(voice) { return { value: voice, label: voice }; });
res.json({ res.json({
success: true, success: true,
sttProvider: provider || 'none', sttProvider: provider || 'none',
sttModels: sttModels, sttModels: sttModels,
ttsProvider: ttsProvider || 'browser', ttsProvider: ttsProvider || 'browser',
ttsModel: ttsModel || null,
ttsVoices: ttsVoices ttsVoices: ttsVoices
}); });

View file

@ -8,227 +8,6 @@ function clip(text, max) {
return text.length > max ? text.substring(0, max - 1).trimEnd() + '…' : text; return text.length > max ? text.substring(0, max - 1).trimEnd() + '…' : text;
} }
var PEDIATRIC_TAXONOMY = [
{
category: 'neonates', weight: 85, ageBands: ['neonate'],
seeds: [
'febrile neonate sepsis evaluation empiric antibiotics lumbar puncture',
'neonatal jaundice bilirubin phototherapy cholestasis red flags',
'newborn respiratory distress differential management NICU admission',
'neonate poor feeding vomiting bilious emesis congenital obstruction',
'neonatal hypoglycemia seizures lethargy initial management'
]
},
{
category: 'respiratory', weight: 85, ageBands: ['infant', 'toddler', 'school_age', 'adolescent'],
seeds: [
'pediatric asthma exacerbation severity treatment discharge criteria',
'bronchiolitis infant oxygen hydration admission criteria',
'croup stridor epiglottitis bacterial tracheitis airway red flags',
'pediatric pneumonia wheezing foreign body aspiration differential',
'cystic fibrosis respiratory exacerbation airway clearance antibiotics'
]
},
{
category: 'gi', weight: 75, ageBands: ['infant', 'toddler', 'school_age', 'adolescent'],
seeds: [
'pediatric abdominal pain appendicitis intussusception differential',
'acute gastroenteritis dehydration oral rehydration ondansetron',
'bloody diarrhea hemolytic uremic syndrome STEC salmonella',
'pediatric vomiting bilious emesis increased intracranial pressure',
'constipation encopresis functional abdominal pain counseling'
]
},
{
category: 'infectious_disease', weight: 75, ageBands: ['neonate', 'infant', 'toddler', 'school_age', 'adolescent'],
seeds: [
'pediatric fever without source serious bacterial infection workup',
'meningitis encephalitis sepsis child empiric antibiotics',
'pertussis exposure prophylaxis vaccination household contacts',
'skin soft tissue infection cellulitis abscess child antibiotics',
'fever of unknown origin child infectious noninfectious differential'
]
},
{
category: 'emergency', weight: 70, ageBands: ['neonate', 'infant', 'toddler', 'school_age', 'adolescent'],
seeds: [
'pediatric emergency triage red flags resuscitation shock',
'child altered mental status seizure hypoglycemia toxic ingestion',
'pediatric anaphylaxis epinephrine airway hypotension disposition',
'trauma child head injury abdominal injury non accidental trauma',
'pediatric shock dehydration sepsis cardiac emergency management'
]
},
{
category: 'derm', weight: 55, ageBands: ['neonate', 'infant', 'toddler', 'school_age', 'adolescent'],
seeds: [
'pediatric rash fever petechiae purpura emergency differential',
'newborn skin lesions hemangioma melanocytic nevus epidermolysis bullosa',
'eczema atopic dermatitis impetigo cellulitis child management',
'urticaria angioedema anaphylaxis pediatric counseling',
'Kawasaki disease mucocutaneous findings MIS-C differential'
]
},
{
category: 'endocrine', weight: 55, ageBands: ['infant', 'toddler', 'school_age', 'adolescent'],
seeds: [
'diabetic ketoacidosis child fluids insulin cerebral edema',
'pediatric hypoglycemia adrenal insufficiency endocrine emergency',
'short stature delayed puberty precocious puberty evaluation',
'thyroid disease child hyperthyroidism hypothyroidism symptoms',
'polyuria polydipsia diabetes insipidus diabetes mellitus child'
]
},
{
category: 'cardiology', weight: 55, ageBands: ['neonate', 'infant', 'toddler', 'school_age', 'adolescent'],
seeds: [
'pediatric chest pain syncope murmur red flags cardiology referral',
'cyanotic congenital heart disease neonate prostaglandin ductal dependent',
'heart failure infant poor feeding tachypnea hepatomegaly',
'arrhythmia palpitations supraventricular tachycardia child management',
'Kawasaki disease coronary aneurysm aspirin IVIG'
]
},
{
category: 'neurology', weight: 65, ageBands: ['infant', 'toddler', 'school_age', 'adolescent'],
seeds: [
'pediatric headache red flags neuroimaging referral',
'first seizure child febrile seizure epilepsy workup',
'altered mental status child meningitis encephalitis toxic metabolic',
'developmental regression weakness ataxia neurologic emergency',
'migraine child acute treatment prevention counseling'
]
},
{
category: 'renal', weight: 50, ageBands: ['infant', 'toddler', 'school_age', 'adolescent'],
seeds: [
'urinary tract infection child fever pyelonephritis imaging',
'hematuria proteinuria nephrotic nephritic syndrome child',
'acute kidney injury child dehydration sepsis nephrotoxin workup',
'hypertension pediatric evaluation renal endocrine cardiac',
'electrolyte disorders child sodium potassium emergency management'
]
},
{
category: 'rheum', weight: 45, ageBands: ['toddler', 'school_age', 'adolescent'],
seeds: [
'juvenile idiopathic arthritis limp joint swelling fever differential',
'Kawasaki MIS-C fever rash conjunctivitis pediatric differential',
'pediatric lupus vasculitis purpura renal symptoms workup',
'periodic fever syndromes child aphthous pharyngitis adenitis',
'limp child septic arthritis osteomyelitis transient synovitis'
]
},
{
category: 'hematology', weight: 55, ageBands: ['infant', 'toddler', 'school_age', 'adolescent'],
seeds: [
'pediatric anemia microcytic hemolytic iron deficiency workup',
'sickle cell fever pain crisis acute chest child management',
'thrombocytopenia petechiae purpura ITP leukemia child differential',
'bleeding bruising child coagulation disorder non accidental trauma',
'neutropenia fever oncology pediatric emergency antibiotics'
]
},
{
category: 'development', weight: 60, ageBands: ['infant', 'toddler', 'school_age', 'adolescent'],
seeds: [
'developmental milestones delay screening autism speech motor',
'ADHD learning difficulty school performance pediatric assessment',
'autism spectrum toddler screening counseling referral',
'developmental regression child neurologic metabolic genetic evaluation',
'well child developmental behavioral screening anticipatory guidance'
]
},
{
category: 'toxicology', weight: 45, ageBands: ['toddler', 'school_age', 'adolescent'],
seeds: [
'pediatric ingestion acetaminophen iron button battery emergency',
'adolescent overdose suicide risk toxidrome initial management',
'carbon monoxide poisoning child headache altered mental status',
'caustic ingestion child drooling dysphagia endoscopy',
'medication poisoning toddler decontamination antidote observation'
]
},
{
category: 'procedures', weight: 45, ageBands: ['infant', 'toddler', 'school_age', 'adolescent'],
seeds: [
'pediatric lumbar puncture indications contraindications consent',
'laceration repair child sedation analgesia wound infection',
'foreign body removal ear nose airway child management',
'splinting fracture child neurovascular assessment analgesia',
'procedural sedation pediatric fasting monitoring discharge criteria'
]
},
{
category: 'dosing_fluids', weight: 70, ageBands: ['neonate', 'infant', 'toddler', 'school_age', 'adolescent'],
seeds: [
'pediatric maintenance fluids dehydration bolus electrolyte correction',
'weight based medication dosing child safety maximum dose',
'oral rehydration solution dosing gastroenteritis child',
'DKA fluids insulin potassium pediatric cerebral edema',
'antibiotic dosing renal adjustment pediatric infection'
]
},
{
category: 'adolescent', weight: 60, ageBands: ['adolescent'],
seeds: [
'adolescent abdominal pain pregnancy STI pelvic inflammatory disease',
'depression suicide screening adolescent confidentiality safety plan',
'eating disorder adolescent bradycardia weight loss admission criteria',
'substance use adolescent confidential history counseling',
'sports injury concussion return to play adolescent'
]
},
{
category: 'counseling', weight: 55, ageBands: ['neonate', 'infant', 'toddler', 'school_age', 'adolescent'],
seeds: [
'parent counseling fever return precautions child safety net',
'vaccine counseling pediatric hesitancy contraindications',
'asthma action plan inhaler technique parent education',
'gastroenteritis home care hydration return precautions',
'newborn discharge anticipatory guidance feeding jaundice safe sleep'
]
}
];
var VALID_INTENTS = {
diagnosis: true,
management: true,
red_flags: true,
differential: true,
counseling: true,
dosing: true,
admission: true,
discharge: true,
review: true
};
var VALID_AGE_BANDS = {
neonate: true,
infant: true,
toddler: true,
school_age: true,
adolescent: true
};
function taxonomyByCategory() {
var out = {};
PEDIATRIC_TAXONOMY.forEach(function(item) { out[item.category] = item; });
return out;
}
var TAXONOMY_BY_CATEGORY = taxonomyByCategory();
var CATEGORY_ALIASES = {
'infectious disease': 'infectious_disease',
infectious: 'infectious_disease',
'dosing/fluids': 'dosing_fluids',
dosing: 'dosing_fluids',
fluids: 'dosing_fluids',
'red flags': 'emergency',
'admission/discharge': 'emergency',
general: 'counseling'
};
function parseJsonObject(text) { function parseJsonObject(text) {
text = String(text || '').trim().replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/i, ''); text = String(text || '').trim().replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/i, '');
try { return JSON.parse(text); } catch (e) {} try { return JSON.parse(text); } catch (e) {}
@ -243,23 +22,20 @@ function parseJsonObject(text) {
function createClinicalPromptPool(opts) { function createClinicalPromptPool(opts) {
opts = opts || {}; opts = opts || {};
var cacheMs = positiveInt(process.env.CLINICAL_ASSISTANT_EXAMPLE_CACHE_MS, 10 * 60 * 1000); var cacheMs = positiveInt(process.env.CLINICAL_ASSISTANT_EXAMPLE_CACHE_MS, 10 * 60 * 1000);
var refreshMs = nonNegativeInt(process.env.CLINICAL_ASSISTANT_PROMPT_POOL_REFRESH_MS, 7 * 24 * 60 * 60 * 1000); var refreshMs = positiveInt(process.env.CLINICAL_ASSISTANT_PROMPT_POOL_REFRESH_MS, 24 * 60 * 60 * 1000);
var ttlSeconds = refreshMs > 0 ? Math.max(3600, Math.ceil(refreshMs / 1000) * 2) : 0; var ttlSeconds = Math.max(3600, Math.ceil(refreshMs / 1000) * 2);
var target = positiveInt(process.env.CLINICAL_ASSISTANT_PROMPT_POOL_TARGET, 1000); var target = positiveInt(process.env.CLINICAL_ASSISTANT_PROMPT_POOL_TARGET, 200);
var redisBaseKey = String(process.env.CLINICAL_ASSISTANT_PROMPT_POOL_KEY || 'clinical-assistant:prompt-pool:v2').replace(/:all$/, ''); var redisKey = process.env.CLINICAL_ASSISTANT_PROMPT_POOL_KEY || 'clinical-assistant:prompt-pool:v1';
var redisAllKey = redisBaseKey + ':all';
var redisMetaKey = redisBaseKey + ':meta';
var redisLastGoodKey = redisBaseKey + ':last-good';
var memoryCache = { expiresAt: 0, examples: [] }; var memoryCache = { expiresAt: 0, examples: [] };
var refreshPromise = null; var refreshPromise = null;
async function getAvailableExamples() { async function getAvailableExamples() {
var now = Date.now(); var now = Date.now();
if (memoryCache.expiresAt > now && memoryCache.examples.length) return memoryCache.examples; if (memoryCache.expiresAt > now && memoryCache.examples.length) return memoryCache.examples;
var cached = await readPromptPool(); var cached = await opts.redisCache.getJson(redisKey).catch(function() { return null; });
if (cached && Array.isArray(cached.examples) && cached.examples.length) { if (cached && Array.isArray(cached.examples) && cached.examples.length) {
memoryCache = { expiresAt: now + cacheMs, examples: cached.examples }; memoryCache = { expiresAt: now + cacheMs, examples: cached.examples };
if (refreshMs > 0 && (!cached.generatedAt || now - cached.generatedAt > refreshMs)) refreshIfNeeded(false).catch(function() {}); if (!cached.generatedAt || now - cached.generatedAt > refreshMs) refreshIfNeeded(false).catch(function() {});
return cached.examples; return cached.examples;
} }
@ -271,105 +47,82 @@ function createClinicalPromptPool(opts) {
return examples; return examples;
} }
async function refreshIfNeeded(force, context) { async function refreshIfNeeded(force) {
context = context || {};
var now = Date.now(); var now = Date.now();
var cached = await readPromptPool(); var cached = await opts.redisCache.getJson(redisKey).catch(function() { return null; });
if (!force && cached && Array.isArray(cached.examples) && cached.examples.length && (refreshMs === 0 || (cached.generatedAt && now - cached.generatedAt < refreshMs))) { if (!force && cached && Array.isArray(cached.examples) && cached.examples.length && cached.generatedAt && now - cached.generatedAt < refreshMs) {
return cached.examples; return cached.examples;
} }
if (refreshPromise) return refreshPromise; if (refreshPromise) return refreshPromise;
refreshPromise = buildCorpusPromptPool().then(function(examples) { refreshPromise = buildCorpusPromptPool().then(function(examples) {
refreshPromise = null; refreshPromise = null;
if (!examples.length) return []; if (!examples.length) return [];
var payload = { generatedAt: Date.now(), target: target, examples: examples }; var payload = { generatedAt: Date.now(), examples: examples };
memoryCache = { expiresAt: Date.now() + cacheMs, examples: examples }; memoryCache = { expiresAt: Date.now() + cacheMs, examples: examples };
return writePromptPool(payload, context).then(function() { return examples; }); return opts.redisCache.setJson(redisKey, payload, ttlSeconds).then(function() { return examples; });
}).catch(function(e) { }).catch(function(e) {
refreshPromise = null; refreshPromise = null;
console.warn('[clinical-assistant] prompt pool refresh failed:', e.message); console.warn('[clinical-assistant] prompt pool refresh failed:', e.message);
return cached && Array.isArray(cached.examples) ? cached.examples : []; return [];
}); });
return refreshPromise; return refreshPromise;
} }
async function readPromptPool() {
var cached = await opts.redisCache.getJson(redisAllKey).catch(function() { return null; });
if (cached && Array.isArray(cached.examples) && cached.examples.length) return cached;
var lastGood = await opts.redisCache.getJson(redisLastGoodKey).catch(function() { return null; });
if (lastGood && Array.isArray(lastGood.examples) && lastGood.examples.length) return lastGood;
if (typeof opts.loadStoredPromptPool === 'function') {
var stored = await opts.loadStoredPromptPool().catch(function() { return null; });
if (stored && Array.isArray(stored.examples) && stored.examples.length) {
await writePromptPool(stored, { skipStore: true }).catch(function() {});
return stored;
}
}
return null;
}
async function buildCorpusPromptPool() { async function buildCorpusPromptPool() {
var taxonomy = taxonomyWithQuotas(target); var snippets = await collectPromptSeedSnippets();
if (!snippets.length) return fallbackIndexedExamples();
var chatModel = await opts.getSetting('clinical_assistant.prompt_model', '') || process.env.CLINICAL_ASSISTANT_PROMPT_MODEL || await opts.getSetting('clinical_assistant.chat_model', '') || await opts.getSetting('models.default', ''); var chatModel = await opts.getSetting('clinical_assistant.prompt_model', '') || process.env.CLINICAL_ASSISTANT_PROMPT_MODEL || await opts.getSetting('clinical_assistant.chat_model', '') || await opts.getSetting('models.default', '');
var generated = []; var generated = [];
for (var t = 0; t < taxonomy.length && generated.length < target; t++) { var batches = Math.max(1, Math.min(20, Math.ceil(target / 25)));
var item = taxonomy[t]; for (var i = 0; i < batches && generated.length < target; i++) {
var snippets = await collectPromptSeedSnippets(item); var batchSnippets = rotateExamples(snippets, Date.now() + i * 997).slice(0, 24);
if (!snippets.length) continue; var sourceText = batchSnippets.map(function(s, idx) {
var categoryExamples = []; return '[' + (idx + 1) + '] ' + s.title + (s.page ? ', page ' + s.page : '') + '\n' + clip(s.excerpt, 650);
var batches = Math.max(1, Math.min(5, Math.ceil(item.quota / 25))); }).join('\n\n');
for (var i = 0; i < batches && categoryExamples.length < item.quota; i++) { var ai = await opts.callAI([
var batchSnippets = rotateExamples(snippets, i * 18).slice(0, 18); {
var sourceText = batchSnippets.map(function(s, idx) { role: 'system',
return '[' + (idx + 1) + '] ' + s.title + (s.page ? ', page ' + s.page : '') + '\n' + clip(s.excerpt, 700); content: 'Generate realistic pediatric clinical assistant starter questions from indexed source snippets. Use only the provided titles/snippets as inspiration. Do not answer the questions. Do not mention source names. Prefer broad practical clinical questions involving diagnosis, red flags, severity, treatment, dosing, fluids, admission/discharge, counseling, or differential diagnosis. Return strict JSON only: {"questions":[{"label":"2-5 word label","prompt":"question","category":"emergency|infectious disease|neonates|respiratory|gi|neurology|cardiology|endocrine|hematology|development|toxicology|procedures|dosing/fluids|red flags|differential|admission/discharge|counseling|general","intent":"diagnosis|management|red_flags|differential|counseling|dosing|admission|review"}]}.'
}).join('\n\n'); },
var ai = await opts.callAI([ { role: 'user', content: 'Indexed pediatric source snippets:\n\n' + sourceText + '\n\nCreate 35 varied starter questions. Make this batch different from common asthma, croup, and UTI examples when possible. Include a mix of emergencies, outpatient questions, red flags, diagnostics, treatment, counseling, fluids, dosing, admission/discharge, and differentials.' }
{ ], {
role: 'system', model: chatModel || undefined,
content: 'Generate realistic pediatric clinical assistant starter questions from indexed source snippets. Use only the provided titles/snippets as inspiration. Do not answer the questions. Do not mention source names. Every question must be about children, neonates, infants, adolescents, pediatric dosing, pediatric diagnosis, pediatric management, pediatric red flags, pediatric counseling, or pediatric disposition. Return strict JSON only: {"questions":[{"label":"2-5 word label","prompt":"question ending with ?","category":"' + item.category + '","age_band":"' + item.ageBands.join('|') + '","intent":"diagnosis|management|red_flags|differential|counseling|dosing|admission|discharge|review"}]}.' temperature: 0.75,
}, maxTokens: 2600
{ role: 'user', content: 'Category: ' + item.category + '\nAge bands: ' + item.ageBands.join(', ') + '\nIndexed pediatric source snippets:\n\n' + sourceText + '\n\nCreate 30 varied starter questions for this category. Balance emergencies, outpatient decisions, diagnostic workup, red flags, treatment, fluids, dosing, admission/discharge, and family counseling when relevant.' } });
], { var parsed = parseJsonObject(String(ai.content || ''));
model: chatModel || undefined, generated = normalizeGeneratedExamples(generated.concat(parsed.questions || []));
temperature: 0.78,
maxTokens: 2600
});
var parsed = parseJsonObject(String(ai.content || ''));
categoryExamples = normalizeGeneratedExamples(categoryExamples.concat(parsed.questions || []), item).slice(0, item.quota);
}
generated = normalizeGeneratedExamples(generated.concat(categoryExamples));
} }
if (generated.length < 8) return []; if (generated.length < 8) return fallbackIndexedExamples();
return generated.slice(0, target); return generated;
} }
async function collectPromptSeedSnippets(taxonomyItem) { async function collectPromptSeedSnippets() {
var seeds = promptSeedQueries(taxonomyItem); var seeds = promptSeedQueries();
var seen = new Set(); var seen = new Set();
var snippets = []; var snippets = [];
for (var i = 0; i < seeds.length && snippets.length < 36; i++) { for (var i = 0; i < seeds.length && snippets.length < 120; i++) {
try { try {
var searchResponse = await opts.semanticSearch(seeds[i], { limit: 5, includeContext: true, contextChars: 900 }); var searchResponse = await opts.semanticSearch(seeds[i], { limit: 5, includeContext: true, contextChars: 700 });
opts.dedupeSources(opts.normalizeMcpSearchResponse(searchResponse)).forEach(function(source) { opts.dedupeSources(opts.normalizeMcpSearchResponse(searchResponse)).forEach(function(source) {
var key = [source.title, source.page, source.chunk_index].join('|'); var key = [source.title, source.page, source.chunk_index].join('|');
if (seen.has(key) || snippets.length >= 36) return; if (seen.has(key) || snippets.length >= 120) return;
seen.add(key); seen.add(key);
snippets.push(source); snippets.push(source);
}); });
} catch (e) { } catch (e) {
continue; if (snippets.length === 0) throw e;
} }
} }
return snippets; return snippets;
} }
function promptSeedQueries(taxonomyItem) { function promptSeedQueries() {
var seeds = (opts.exampleCandidates || []).map(function(item) { return item.prompt; }); var seeds = (opts.exampleCandidates || []).map(function(item) { return item.prompt; });
Object.keys(opts.topicSuggestions || {}).forEach(function(key) { Object.keys(opts.topicSuggestions || {}).forEach(function(key) {
seeds = seeds.concat(opts.topicSuggestions[key]); seeds = seeds.concat(opts.topicSuggestions[key]);
}); });
seeds = seeds.concat(taxonomyItem.seeds).concat([ seeds = seeds.concat([
taxonomyItem.category + ' pediatric ' + taxonomyItem.seeds.join(' '),
'pediatric emergency red flags admission criteria treatment dosing', 'pediatric emergency red flags admission criteria treatment dosing',
'neonatal fever jaundice respiratory distress vomiting dehydration', 'neonatal fever jaundice respiratory distress vomiting dehydration',
'childhood infectious diseases empiric antibiotics workup disposition', 'childhood infectious diseases empiric antibiotics workup disposition',
@ -377,165 +130,39 @@ function createClinicalPromptPool(opts) {
'pediatric gastrointestinal dehydration abdominal pain bilious vomiting', 'pediatric gastrointestinal dehydration abdominal pain bilious vomiting',
'developmental milestones anemia endocrine neurologic pediatric review' 'developmental milestones anemia endocrine neurologic pediatric review'
]); ]);
return shuffle(seeds).slice(0, 8); return shuffle(seeds).slice(0, 30);
} }
function fallbackIndexedExamples() { function fallbackIndexedExamples() {
return typeof opts.getIndexedTopicExamples === 'function' ? opts.getIndexedTopicExamples().catch(function() { return []; }) : []; return typeof opts.getIndexedTopicExamples === 'function' ? opts.getIndexedTopicExamples().catch(function() { return []; }) : [];
} }
async function getMeta() { function normalizeGeneratedExamples(items) {
return await opts.redisCache.getJson(redisMetaKey).catch(function() { return null; });
}
function normalizeGeneratedExamples(items, defaultTaxonomy) {
var seen = new Set(); var seen = new Set();
var out = []; var out = [];
(Array.isArray(items) ? items : []).forEach(function(item) { (Array.isArray(items) ? items : []).forEach(function(item) {
var prompt = clip(String(item.prompt || item.question || '').replace(/[\r\n\t]+/g, ' ').replace(/\s+/g, ' ').trim(), 220); var prompt = clip(String(item.prompt || item.question || '').replace(/[\r\n\t]+/g, ' ').replace(/\s+/g, ' ').trim(), 220);
if (!isUsefulQuestion(prompt, item)) return; if (!isUsefulQuestion(prompt)) return;
var key = prompt.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim(); var key = prompt.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
if (seen.has(key)) return; if (seen.has(key)) return;
seen.add(key); seen.add(key);
var category = sanitizeCategory(item.category, defaultTaxonomy);
var ageBand = sanitizeAgeBand(item.age_band || item.ageBand || item.age, TAXONOMY_BY_CATEGORY[category] || defaultTaxonomy, prompt);
out.push({ out.push({
label: cleanExampleLabel(item.label || prompt), label: cleanExampleLabel(item.label || prompt),
prompt: prompt, prompt: prompt,
category: category, category: clip(String(item.category || 'general'), 40),
age_band: ageBand, intent: clip(String(item.intent || 'review'), 30)
intent: sanitizeIntent(item.intent)
}); });
}); });
return out.slice(0, target); return out.slice(0, target);
} }
async function writePromptPool(payload, context) { function isUsefulQuestion(prompt) {
context = context || {};
var counts = buildPoolCounts(payload.examples);
var writes = [
opts.redisCache.setJson(redisAllKey, payload, ttlSeconds),
opts.redisCache.setJson(redisLastGoodKey, payload, 0),
opts.redisCache.setJson(redisMetaKey, {
generatedAt: payload.generatedAt,
target: target,
count: payload.examples.length,
categories: counts.categories,
age_bands: counts.age_bands,
intents: counts.intents
}, ttlSeconds)
];
Object.keys(counts.categoryExamples).forEach(function(category) {
writes.push(opts.redisCache.setJson(redisBaseKey + ':category:' + category, {
generatedAt: payload.generatedAt,
category: category,
examples: counts.categoryExamples[category]
}, ttlSeconds));
});
Object.keys(counts.ageExamples).forEach(function(ageBand) {
writes.push(opts.redisCache.setJson(redisBaseKey + ':age:' + ageBand, {
generatedAt: payload.generatedAt,
age_band: ageBand,
examples: counts.ageExamples[ageBand]
}, ttlSeconds));
});
Object.keys(counts.intentExamples).forEach(function(intent) {
writes.push(opts.redisCache.setJson(redisBaseKey + ':intent:' + intent, {
generatedAt: payload.generatedAt,
intent: intent,
examples: counts.intentExamples[intent]
}, ttlSeconds));
});
await Promise.all(writes);
if (!context.skipStore && typeof opts.savePromptPool === 'function') {
await opts.savePromptPool(payload, context).catch(function(e) {
console.warn('[clinical-assistant] prompt pool snapshot save failed:', e.message);
});
}
}
function buildPoolCounts(examples) {
var counts = { categories: {}, age_bands: {}, intents: {}, categoryExamples: {}, ageExamples: {}, intentExamples: {} };
examples.forEach(function(example) {
addCount(counts.categories, example.category);
addCount(counts.age_bands, example.age_band);
addCount(counts.intents, example.intent);
pushGroup(counts.categoryExamples, example.category, example);
pushGroup(counts.ageExamples, example.age_band, example);
pushGroup(counts.intentExamples, example.intent, example);
});
return counts;
}
function addCount(counts, key) {
counts[key] = (counts[key] || 0) + 1;
}
function pushGroup(groups, key, example) {
if (!groups[key]) groups[key] = [];
groups[key].push(example);
}
function taxonomyWithQuotas(total) {
var weightTotal = PEDIATRIC_TAXONOMY.reduce(function(sum, item) { return sum + item.weight; }, 0);
var assigned = 0;
var rows = PEDIATRIC_TAXONOMY.map(function(item) {
var exact = total * item.weight / weightTotal;
var quota = Math.max(10, Math.floor(exact));
assigned += quota;
return Object.assign({}, item, { quota: quota, remainder: exact - Math.floor(exact) });
}).sort(function(a, b) { return b.remainder - a.remainder; });
for (var i = 0; assigned < total; i = (i + 1) % rows.length) {
rows[i].quota += 1;
assigned += 1;
}
for (var j = rows.length - 1; assigned > total && j >= 0; j--) {
if (rows[j].quota <= 10) continue;
rows[j].quota -= 1;
assigned -= 1;
}
return rows.sort(function(a, b) {
return PEDIATRIC_TAXONOMY.indexOf(TAXONOMY_BY_CATEGORY[a.category]) - PEDIATRIC_TAXONOMY.indexOf(TAXONOMY_BY_CATEGORY[b.category]);
});
}
function sanitizeCategory(category, defaultTaxonomy) {
category = String(category || '').trim().toLowerCase().replace(/[\s/-]+/g, '_');
category = CATEGORY_ALIASES[category] || CATEGORY_ALIASES[category.replace(/_/g, ' ')] || category;
if (TAXONOMY_BY_CATEGORY[category]) return category;
return defaultTaxonomy && defaultTaxonomy.category || 'counseling';
}
function sanitizeIntent(intent) {
intent = String(intent || 'review').trim().toLowerCase().replace(/[\s/-]+/g, '_');
return VALID_INTENTS[intent] ? intent : 'review';
}
function sanitizeAgeBand(ageBand, taxonomyItem, prompt) {
ageBand = String(ageBand || '').trim().toLowerCase().replace(/[\s/-]+/g, '_');
if (VALID_AGE_BANDS[ageBand]) return ageBand;
if (/\b(neonate|newborn)\b/i.test(prompt)) return 'neonate';
if (/\b(infant|baby)\b/i.test(prompt)) return 'infant';
if (/\b(toddler)\b/i.test(prompt)) return 'toddler';
if (/\b(adolescent|teen)\b/i.test(prompt)) return 'adolescent';
return taxonomyItem && taxonomyItem.ageBands && taxonomyItem.ageBands[0] || 'school_age';
}
function isUsefulQuestion(prompt, item) {
if (!prompt || prompt.length < 25 || prompt.length > 220) return false; if (!prompt || prompt.length < 25 || prompt.length > 220) return false;
if (prompt.indexOf('?') === -1) return false; if (prompt.indexOf('?') === -1) return false;
if (!hasPediatricSignal(prompt, item)) return false; if (!/\b(child|children|pediatric|paediatric|infant|neonate|newborn|adolescent|teen|toddler|baby)\b/i.test(prompt)) return false;
return !/\b(source|snippet|textbook|chapter|document|database)\b/i.test(prompt); return !/\b(source|snippet|textbook|chapter|document|database)\b/i.test(prompt);
} }
function hasPediatricSignal(prompt, item) {
if (/\b(child|children|pediatric|paediatric|infant|neonate|newborn|adolescent|teen|toddler|baby)\b/i.test(prompt)) return true;
var category = sanitizeCategory(item && item.category, null);
var intent = sanitizeIntent(item && item.intent);
var ageBand = String(item && (item.age_band || item.ageBand || item.age) || '').toLowerCase().replace(/[\s/-]+/g, '_');
return !!TAXONOMY_BY_CATEGORY[category] && !!VALID_INTENTS[intent] && (!ageBand || !!VALID_AGE_BANDS[ageBand]);
}
function cleanExampleLabel(label) { function cleanExampleLabel(label) {
label = String(label || '').replace(/[?!.:,;]+$/g, '').replace(/\s+/g, ' ').trim(); label = String(label || '').replace(/[?!.:,;]+$/g, '').replace(/\s+/g, ' ').trim();
if (!label || label.length > 42) label = labelFromQuestion(label || 'Clinical question'); if (!label || label.length > 42) label = labelFromQuestion(label || 'Clinical question');
@ -554,7 +181,7 @@ function createClinicalPromptPool(opts) {
function rotateExamples(items, seed) { function rotateExamples(items, seed) {
if (!items.length) return []; if (!items.length) return [];
var copy = items.slice(); var copy = items.slice();
var offset = Math.abs(Math.floor(seed)) % copy.length; var offset = Math.floor(seed / cacheMs) % copy.length;
return copy.slice(offset).concat(copy.slice(0, offset)); return copy.slice(offset).concat(copy.slice(0, offset));
} }
@ -571,16 +198,8 @@ function createClinicalPromptPool(opts) {
return { return {
getAvailableExamples: getAvailableExamples, getAvailableExamples: getAvailableExamples,
refreshIfNeeded: refreshIfNeeded, refreshIfNeeded: refreshIfNeeded
getMeta: getMeta,
writePromptPool: writePromptPool
}; };
} }
function nonNegativeInt(value, fallback) {
if (value == null || value === '') return fallback;
var n = Number(value);
return Number.isFinite(n) && n >= 0 ? Math.floor(n) : fallback;
}
module.exports = { createClinicalPromptPool }; module.exports = { createClinicalPromptPool };

View file

@ -34,11 +34,7 @@ async function getJson(key) {
async function setJson(key, value, ttlSeconds) { async function setJson(key, value, ttlSeconds) {
var redis = await getRedis(); var redis = await getRedis();
if (!redis) return false; if (!redis) return false;
if (ttlSeconds && Number(ttlSeconds) > 0) { await redis.set(key, JSON.stringify(value), { EX: ttlSeconds });
await redis.set(key, JSON.stringify(value), { EX: Math.floor(Number(ttlSeconds)) });
} else {
await redis.set(key, JSON.stringify(value));
}
return true; return true;
} }

View file

@ -7,20 +7,6 @@ function parseList(value) {
.filter(Boolean); .filter(Boolean);
} }
var KITTEN_TTS_VOICES = ['Bella', 'Jasper', 'Luna', 'Bruno', 'Rosie', 'Hugo', 'Kiki', 'Leo'];
var SUPERTONIC_TTS_VOICES = ['F1', 'F2', 'F3', 'F4', 'F5', 'M1', 'M2', 'M3', 'M4', 'M5'];
var GROQ_ORPHEUS_ENGLISH_VOICES = ['autumn', 'diana', 'hannah', 'austin', 'daniel', 'troy'];
var GROQ_ORPHEUS_ARABIC_VOICES = ['abdullah', 'fahad', 'sultan', 'lulwa', 'noura', 'aisha'];
function uniqueList(values) {
var seen = new Set();
return (values || []).filter(function(value) {
if (!value || seen.has(value)) return false;
seen.add(value);
return true;
});
}
function getTTSProvider() { function getTTSProvider() {
var env = process.env.TTS_PROVIDER; var env = process.env.TTS_PROVIDER;
if (env === 'litellm') return 'litellm'; if (env === 'litellm') return 'litellm';
@ -34,58 +20,10 @@ function getTTSEnvProvider() {
function getTTSVoiceLists() { function getTTSVoiceLists() {
return { return {
litellm: uniqueList(parseList(process.env.LITELLM_TTS_VOICES).concat(KITTEN_TTS_VOICES, SUPERTONIC_TTS_VOICES, GROQ_ORPHEUS_ENGLISH_VOICES, GROQ_ORPHEUS_ARABIC_VOICES)) litellm: parseList(process.env.LITELLM_TTS_VOICES)
}; };
} }
function getLiteLLMTTSModelFamily(model) {
var id = String(model || '').toLowerCase();
if (id === 'local-kitten-tts') return 'kitten';
if (id === 'local-supertonic-tts') return 'supertonic';
if (id === 'local-kokoro-tts') return 'kokoro';
if (id === 'groq-orpheus-english' || id === 'canopylabs/orpheus-v1-english') return 'groq-orpheus-english';
if (id === 'groq-orpheus-arabic-saudi' || id === 'canopylabs/orpheus-arabic-saudi') return 'groq-orpheus-arabic';
return 'unknown';
}
function getLiteLLMTTSRequestOptions(model) {
var family = getLiteLLMTTSModelFamily(model);
if (family === 'groq-orpheus-english' || family === 'groq-orpheus-arabic') {
return { response_format: 'wav' };
}
return {};
}
function isLiteLLMTTSVoiceCompatible(model, voice) {
if (!voice) return true;
var family = getLiteLLMTTSModelFamily(model);
if (family === 'kitten') return KITTEN_TTS_VOICES.indexOf(voice) !== -1;
if (family === 'supertonic') return SUPERTONIC_TTS_VOICES.indexOf(voice) !== -1;
if (family === 'groq-orpheus-english') return GROQ_ORPHEUS_ENGLISH_VOICES.indexOf(String(voice).toLowerCase()) !== -1;
if (family === 'groq-orpheus-arabic') return GROQ_ORPHEUS_ARABIC_VOICES.indexOf(String(voice).toLowerCase()) !== -1;
if (family === 'kokoro') {
return KITTEN_TTS_VOICES.indexOf(voice) === -1 && SUPERTONIC_TTS_VOICES.indexOf(voice) === -1 &&
GROQ_ORPHEUS_ENGLISH_VOICES.indexOf(String(voice).toLowerCase()) === -1 && GROQ_ORPHEUS_ARABIC_VOICES.indexOf(String(voice).toLowerCase()) === -1;
}
return true;
}
function getLiteLLMTTSVoicesForModel(model, opts) {
opts = opts || {};
var family = getLiteLLMTTSModelFamily(model);
var voices = [];
if (family === 'kitten') voices = KITTEN_TTS_VOICES.slice();
else if (family === 'supertonic') voices = SUPERTONIC_TTS_VOICES.slice();
else if (family === 'groq-orpheus-english') voices = GROQ_ORPHEUS_ENGLISH_VOICES.slice();
else if (family === 'groq-orpheus-arabic') voices = GROQ_ORPHEUS_ARABIC_VOICES.slice();
else voices = parseList(process.env.LITELLM_TTS_VOICES);
[opts.currentVoice, process.env.LITELLM_TTS_VOICE].forEach(function(voice) {
if (isLiteLLMTTSVoiceCompatible(model, voice)) voices.push(voice);
});
return uniqueList(voices.filter(function(voice) { return isLiteLLMTTSVoiceCompatible(model, voice); }));
}
function isLiteLLMTTSModel(model) { function isLiteLLMTTSModel(model) {
var mode = model && model.model_info && model.model_info.mode ? String(model.model_info.mode) : ''; var mode = model && model.model_info && model.model_info.mode ? String(model.model_info.mode) : '';
return mode === 'audio_speech'; return mode === 'audio_speech';
@ -118,26 +56,6 @@ function getLiteLLMTTSDiscoveryItems(models, opts) {
getTTSVoiceLists().litellm.forEach(function(voice) { getTTSVoiceLists().litellm.forEach(function(voice) {
pushUniqueTTSItem(items, { id: voice, name: voice, source: 'configured-voice-list', kind: 'voice' }); pushUniqueTTSItem(items, { id: voice, name: voice, source: 'configured-voice-list', kind: 'voice' });
}); });
if (getLiteLLMTTSModels(models).indexOf('local-kitten-tts') !== -1 || opts.currentModel === 'local-kitten-tts') {
KITTEN_TTS_VOICES.forEach(function(voice) {
pushUniqueTTSItem(items, { id: voice, name: 'Kitten ' + voice, source: 'local-kitten-tts', kind: 'voice' });
});
}
if (getLiteLLMTTSModels(models).indexOf('local-supertonic-tts') !== -1 || opts.currentModel === 'local-supertonic-tts') {
SUPERTONIC_TTS_VOICES.forEach(function(voice) {
pushUniqueTTSItem(items, { id: voice, name: 'Supertonic ' + voice, source: 'local-supertonic-tts', kind: 'voice' });
});
}
if (getLiteLLMTTSModels(models).indexOf('groq-orpheus-english') !== -1 || opts.currentModel === 'groq-orpheus-english') {
GROQ_ORPHEUS_ENGLISH_VOICES.forEach(function(voice) {
pushUniqueTTSItem(items, { id: voice, name: 'Groq Orpheus ' + voice, source: 'groq-orpheus-english', kind: 'voice' });
});
}
if (getLiteLLMTTSModels(models).indexOf('groq-orpheus-arabic-saudi') !== -1 || opts.currentModel === 'groq-orpheus-arabic-saudi') {
GROQ_ORPHEUS_ARABIC_VOICES.forEach(function(voice) {
pushUniqueTTSItem(items, { id: voice, name: 'Groq Orpheus Arabic ' + voice, source: 'groq-orpheus-arabic-saudi', kind: 'voice' });
});
}
return items; return items;
} }
@ -146,10 +64,7 @@ module.exports = {
getLiteLLMTTSDiscoveryItems, getLiteLLMTTSDiscoveryItems,
getLiteLLMHeaders, getLiteLLMHeaders,
getLiteLLMTTSModels, getLiteLLMTTSModels,
getLiteLLMTTSRequestOptions,
getLiteLLMTTSVoicesForModel,
getTTSProvider, getTTSProvider,
getTTSVoiceLists, getTTSVoiceLists,
isLiteLLMTTSVoiceCompatible,
isLiteLLMTTSModel isLiteLLMTTSModel
}; };

View file

@ -244,11 +244,3 @@ test('does not repair malformed tab-separated clinical summary rows into a table
assert.match(html, /Initial Imaging/); assert.match(html, /Initial Imaging/);
assert.match(html, /If you need details/); assert.match(html, /If you need details/);
}); });
test('clinical assistant streams long table answers as lightweight text before final render', async () => {
const source = await fs.readFile(path.join(__dirname, '..', 'public', 'js', 'clinicalAssistant.js'), 'utf8');
assert.match(source, /STREAM_MARKDOWN_LIMIT/);
assert.match(source, /renderStreamingAnswerHtml\(partial, streamSources\)/);
assert.match(source, /assistant-streaming-text/);
assert.match(source, /pipeRows >= 8/);
});

View file

@ -14,35 +14,10 @@ test('clinical assistant starter prompts are Redis or indexed-source backed', ()
const pool = read('src/utils/clinicalPromptPool.js'); const pool = read('src/utils/clinicalPromptPool.js');
assert.doesNotMatch(route, /EXAMPLE_CANDIDATES|TOPIC_SUGGESTIONS|topic_suggestions|buildSuggestionAnswer/); assert.doesNotMatch(route, /EXAMPLE_CANDIDATES|TOPIC_SUGGESTIONS|topic_suggestions|buildSuggestionAnswer/);
assert.match(route, /createClinicalPromptPool\(\{[\s\S]*redisCache: redisCache/); assert.match(route, /createClinicalPromptPool\(\{[\s\S]*redisCache: redisCache/);
assert.match(route, /isUsefulIndexedTopicExample/); assert.match(pool, /opts\.redisCache\.getJson\(redisKey\)/);
assert.match(pool, /clinical-assistant:prompt-pool:v2/); assert.match(pool, /opts\.redisCache\.setJson\(redisKey, payload, ttlSeconds\)/);
assert.match(pool, /redisBaseKey \+ ':all'/); assert.match(pool, /CLINICAL_ASSISTANT_PROMPT_POOL_TARGET, 200/);
assert.match(pool, /redisBaseKey \+ ':last-good'/);
assert.match(pool, /redisBaseKey \+ ':category:' \+ category/);
assert.match(pool, /redisBaseKey \+ ':age:' \+ ageBand/);
assert.match(pool, /redisBaseKey \+ ':intent:' \+ intent/);
assert.match(pool, /CLINICAL_ASSISTANT_PROMPT_POOL_TARGET, 1000/);
assert.match(pool, /CLINICAL_ASSISTANT_PROMPT_POOL_REFRESH_MS, 7 \* 24 \* 60 \* 60 \* 1000/);
assert.match(route, /loadStoredPromptPool: loadLatestPromptPoolSnapshot/);
assert.match(pool, /PEDIATRIC_TAXONOMY/);
assert.match(pool, /taxonomyWithQuotas\(target\)/);
assert.match(pool, /age_band: ageBand/);
assert.match(pool, /function hasPediatricSignal\(prompt, item\)/);
assert.match(pool, /var examples = await fallbackIndexedExamples\(\)/); assert.match(pool, /var examples = await fallbackIndexedExamples\(\)/);
assert.doesNotMatch(pool, /taxonomyFallbackExamples|taxonomySupplementExamples/);
assert.doesNotMatch(pool, /return fallbackIndexedExamples\(\)/);
});
test('clinical assistant prompt pool can be regenerated by admins', () => {
const admin = read('src/routes/adminConfig.js');
const page = read('public/components/admin.html');
const js = read('public/js/admin.js');
assert.match(admin, /\/clinical-assistant\/prompt-pool\/regenerate/);
assert.match(admin, /\/clinical-assistant\/prompt-pool\/restore/);
assert.match(page, /btn-regenerate-assistant-prompt-pool/);
assert.match(page, /btn-restore-assistant-prompt-pool/);
assert.match(js, /regenerateAssistantPromptPool/);
assert.match(js, /restoreAssistantPromptPool/);
}); });
test('clinical assistant prompt forbids relabeling other sources as named sources', () => { test('clinical assistant prompt forbids relabeling other sources as named sources', () => {

View file

@ -1,7 +1,7 @@
const { test } = require('node:test'); const { test } = require('node:test');
const assert = require('node:assert/strict'); const assert = require('node:assert/strict');
const ENV_KEYS = ['TTS_PROVIDER', 'LITELLM_API_BASE', 'LITELLM_API_KEY', 'LITELLM_MASTER_KEY', 'LITELLM_TTS_VOICES', 'LITELLM_TTS_VOICE']; const ENV_KEYS = ['TTS_PROVIDER', 'LITELLM_API_BASE', 'LITELLM_API_KEY', 'LITELLM_MASTER_KEY', 'LITELLM_TTS_VOICES'];
function withEnv(overrides, fn) { function withEnv(overrides, fn) {
const previous = {}; const previous = {};
@ -36,11 +36,10 @@ test('TTS provider ignores non-LiteLLM provider overrides', () => {
test('LiteLLM TTS voice list comes from configured voice catalog', () => { test('LiteLLM TTS voice list comes from configured voice catalog', () => {
const ttsProvider = require('../src/utils/ttsProvider'); const ttsProvider = require('../src/utils/ttsProvider');
withEnv({ LITELLM_TTS_VOICES: 'sherpa/kokoro:am_adam, sherpa/kokoro:af_bella' }, () => { withEnv({ LITELLM_TTS_VOICES: 'sherpa/kokoro:am_adam, sherpa/kokoro:af_bella' }, () => {
const voices = ttsProvider.getTTSVoiceLists().litellm; assert.deepEqual(ttsProvider.getTTSVoiceLists().litellm, [
assert.equal(voices[0], 'sherpa/kokoro:am_adam'); 'sherpa/kokoro:am_adam',
assert.equal(voices[1], 'sherpa/kokoro:af_bella'); 'sherpa/kokoro:af_bella'
assert.equal(voices.includes('Jasper'), true); ]);
assert.equal(voices.includes('F1'), true);
}); });
}); });
@ -93,19 +92,6 @@ test('LiteLLM TTS discovery includes metadata models and configured fallbacks',
}); });
}); });
test('LiteLLM TTS discovery expands Kitten and Supertonic voices', () => {
const ttsProvider = require('../src/utils/ttsProvider');
withEnv({}, () => {
const items = ttsProvider.getLiteLLMTTSDiscoveryItems([
{ model_name: 'local-kitten-tts', model_info: { mode: 'audio_speech' } },
{ model_name: 'local-supertonic-tts', model_info: { mode: 'audio_speech' } }
], {});
assert.equal(items.some(function(item) { return item.id === 'local-kitten-tts' && item.kind === 'model'; }), true);
assert.equal(items.some(function(item) { return item.id === 'Jasper' && item.kind === 'voice'; }), true);
assert.equal(items.some(function(item) { return item.id === 'F1' && item.kind === 'voice'; }), true);
});
});
test('LiteLLM TTS discovery still shows configured model if metadata lookup fails', () => { test('LiteLLM TTS discovery still shows configured model if metadata lookup fails', () => {
const ttsProvider = require('../src/utils/ttsProvider'); const ttsProvider = require('../src/utils/ttsProvider');
assert.deepEqual(ttsProvider.getLiteLLMTTSDiscoveryItems([], { assert.deepEqual(ttsProvider.getLiteLLMTTSDiscoveryItems([], {
@ -117,38 +103,6 @@ test('LiteLLM TTS discovery still shows configured model if metadata lookup fail
]); ]);
}); });
test('LiteLLM TTS voices are scoped to the active local model', () => {
const ttsProvider = require('../src/utils/ttsProvider');
withEnv({ LITELLM_TTS_VOICES: 'sherpa/kokoro:am_adam,sherpa/kokoro:af_bella', LITELLM_TTS_VOICE: 'sherpa/kokoro:am_adam' }, () => {
assert.deepEqual(ttsProvider.getLiteLLMTTSVoicesForModel('local-kitten-tts'), ['Bella', 'Jasper', 'Luna', 'Bruno', 'Rosie', 'Hugo', 'Kiki', 'Leo']);
assert.deepEqual(ttsProvider.getLiteLLMTTSVoicesForModel('local-supertonic-tts'), ['F1', 'F2', 'F3', 'F4', 'F5', 'M1', 'M2', 'M3', 'M4', 'M5']);
assert.deepEqual(ttsProvider.getLiteLLMTTSVoicesForModel('local-kokoro-tts'), ['sherpa/kokoro:am_adam', 'sherpa/kokoro:af_bella']);
assert.deepEqual(ttsProvider.getLiteLLMTTSVoicesForModel('groq-orpheus-english'), ['autumn', 'diana', 'hannah', 'austin', 'daniel', 'troy']);
assert.deepEqual(ttsProvider.getLiteLLMTTSVoicesForModel('canopylabs/orpheus-arabic-saudi'), ['abdullah', 'fahad', 'sultan', 'lulwa', 'noura', 'aisha']);
});
});
test('LiteLLM TTS compatibility rejects cross-model local voices', () => {
const ttsProvider = require('../src/utils/ttsProvider');
assert.equal(ttsProvider.isLiteLLMTTSVoiceCompatible('local-kokoro-tts', 'Bella'), false);
assert.equal(ttsProvider.isLiteLLMTTSVoiceCompatible('local-kokoro-tts', 'M1'), false);
assert.equal(ttsProvider.isLiteLLMTTSVoiceCompatible('local-kitten-tts', 'Bella'), true);
assert.equal(ttsProvider.isLiteLLMTTSVoiceCompatible('local-kitten-tts', 'M1'), false);
assert.equal(ttsProvider.isLiteLLMTTSVoiceCompatible('local-supertonic-tts', 'M1'), true);
assert.equal(ttsProvider.isLiteLLMTTSVoiceCompatible('local-supertonic-tts', 'Bella'), false);
assert.equal(ttsProvider.isLiteLLMTTSVoiceCompatible('groq-orpheus-english', 'hannah'), true);
assert.equal(ttsProvider.isLiteLLMTTSVoiceCompatible('groq-orpheus-english', 'sherpa/kokoro:af_bella'), false);
assert.equal(ttsProvider.isLiteLLMTTSVoiceCompatible('groq-orpheus-arabic-saudi', 'aisha'), true);
assert.equal(ttsProvider.isLiteLLMTTSVoiceCompatible('groq-orpheus-arabic-saudi', 'troy'), false);
});
test('Groq Orpheus TTS requests force wav response format', () => {
const ttsProvider = require('../src/utils/ttsProvider');
assert.deepEqual(ttsProvider.getLiteLLMTTSRequestOptions('groq-orpheus-english'), { response_format: 'wav' });
assert.deepEqual(ttsProvider.getLiteLLMTTSRequestOptions('canopylabs/orpheus-arabic-saudi'), { response_format: 'wav' });
assert.deepEqual(ttsProvider.getLiteLLMTTSRequestOptions('local-kokoro-tts'), {});
});
test('LiteLLM headers include optional content type without exposing key value', () => { test('LiteLLM headers include optional content type without exposing key value', () => {
const ttsProvider = require('../src/utils/ttsProvider'); const ttsProvider = require('../src/utils/ttsProvider');
withEnv({ LITELLM_API_KEY: 'secret-token' }, () => { withEnv({ LITELLM_API_KEY: 'secret-token' }, () => {