Compare commits
22 commits
v7.14.4-rc
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f556d50a09 | ||
|
|
3fb4c10f2b | ||
|
|
4613a27879 | ||
|
|
f31afcdbf4 | ||
|
|
a814d2a2c2 | ||
|
|
bca107846e | ||
|
|
524ad40d49 | ||
|
|
82ed46d01b | ||
|
|
bee9361c1d | ||
|
|
2ca969e099 | ||
|
|
80139d9a82 | ||
|
|
f7cfc6695d | ||
|
|
b0e1f4969a | ||
|
|
d02b9e2771 | ||
|
|
c7921ab822 | ||
|
|
fb3e4d4135 | ||
|
|
cb63729656 | ||
|
|
2cef65fb1f | ||
|
|
629dea808e | ||
|
|
8dca18292a | ||
|
|
1f5e4aabac | ||
|
|
97ddd87449 |
48 changed files with 1497 additions and 229 deletions
|
|
@ -15,6 +15,7 @@ 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
|
||||||
|
|
@ -27,9 +28,9 @@ services:
|
||||||
container_name: pediatric-ai-scribe
|
container_name: pediatric-ai-scribe
|
||||||
networks:
|
networks:
|
||||||
- default
|
- default
|
||||||
- mcp-server_default
|
- danvics_mcp
|
||||||
- monitoring_default
|
- danvics_monitoring
|
||||||
- speech_net
|
- danvics_speech
|
||||||
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
|
||||||
|
|
@ -45,7 +46,7 @@ services:
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: pedscribe
|
POSTGRES_DB: pedscribe
|
||||||
POSTGRES_USER: pedscribe
|
POSTGRES_USER: pedscribe
|
||||||
POSTGRES_PASSWORD: ${DB_PASSWORD:?set DB_PASSWORD}
|
POSTGRES_PASSWORD: ${DB_PASSWORD:-pedscribe}
|
||||||
volumes:
|
volumes:
|
||||||
- pgdata:/var/lib/postgresql/data
|
- pgdata:/var/lib/postgresql/data
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
@ -71,7 +72,7 @@ services:
|
||||||
retries: 5
|
retries: 5
|
||||||
networks:
|
networks:
|
||||||
- default
|
- default
|
||||||
- mcp-server_default
|
- danvics_mcp
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
pgdata:
|
pgdata:
|
||||||
|
|
@ -82,9 +83,9 @@ volumes:
|
||||||
name: mcp-server_mcp-data
|
name: mcp-server_mcp-data
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
mcp-server_default:
|
danvics_mcp:
|
||||||
external: true
|
external: true
|
||||||
monitoring_default:
|
danvics_monitoring:
|
||||||
external: true
|
external: true
|
||||||
speech_net:
|
danvics_speech:
|
||||||
external: true
|
external: true
|
||||||
|
|
|
||||||
|
|
@ -103,8 +103,7 @@ 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:**
|
||||||
|
|
|
||||||
|
|
@ -123,9 +123,23 @@ necessary UX tradeoff over perfect indistinguishability.
|
||||||
|
|
||||||
## Turnstile (Cloudflare bot protection)
|
## Turnstile (Cloudflare bot protection)
|
||||||
|
|
||||||
Applied to `/api/auth/login`, `/register`, `/forgot-password` when
|
Applied to `/api/auth/register` and `/api/auth/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
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,15 @@
|
||||||
// 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 UI's login form is gated by a Cloudflare Turnstile token
|
// The test does a programmatic logout (clear the ped_auth cookie,
|
||||||
// whose site key is hardcoded in index.html, which can't be
|
// same effect server-side as clicking Logout) followed by a fresh
|
||||||
// completed in the e2e container (Turnstile rejects the non-prod
|
// programmatic login. This exercises the same localStorage
|
||||||
// origin). So the test does a programmatic logout (clear the
|
// persistence path a real logout/login would.
|
||||||
// ped_auth cookie, same effect server-side as clicking Logout)
|
//
|
||||||
// followed by a fresh programmatic login — this exercises the
|
// (Historically this was a workaround for the Turnstile challenge on
|
||||||
// same localStorage persistence path a real logout/login would,
|
// the login form, which could not be completed in the e2e container.
|
||||||
// without depending on the bot challenge.
|
// Login is no longer gated, but driving it programmatically keeps
|
||||||
|
// 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');
|
||||||
|
|
|
||||||
|
|
@ -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 714003
|
versionCode 714016
|
||||||
versionName "7.14.3"
|
versionName "7.14.16"
|
||||||
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.
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,8 @@ 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;
|
||||||
|
|
@ -36,6 +38,11 @@ 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);
|
||||||
|
|
@ -47,6 +54,9 @@ 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();
|
||||||
|
|
||||||
|
|
@ -60,6 +70,79 @@ 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() {
|
||||||
|
|
@ -124,6 +207,7 @@ 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
|
||||||
|
|
@ -131,6 +215,17 @@ 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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">@null</item>
|
<item name="android:background">@color/colorPrimary</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>
|
||||||
|
|
|
||||||
4
mobile/package-lock.json
generated
4
mobile/package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
||||||
{
|
{
|
||||||
"name": "pedscribe-mobile",
|
"name": "pedscribe-mobile",
|
||||||
"version": "7.14.3",
|
"version": "7.14.14",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "pedscribe-mobile",
|
"name": "pedscribe-mobile",
|
||||||
"version": "7.14.3",
|
"version": "7.14.14",
|
||||||
"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",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "pedscribe-mobile",
|
"name": "pedscribe-mobile",
|
||||||
"version": "7.14.3",
|
"version": "7.14.16",
|
||||||
"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
4
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
||||||
{
|
{
|
||||||
"name": "pediatric-ai-scribe",
|
"name": "pediatric-ai-scribe",
|
||||||
"version": "7.14.3",
|
"version": "7.14.14",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "pediatric-ai-scribe",
|
"name": "pediatric-ai-scribe",
|
||||||
"version": "7.14.3",
|
"version": "7.14.14",
|
||||||
"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",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "pediatric-ai-scribe",
|
"name": "pediatric-ai-scribe",
|
||||||
"version": "7.14.3",
|
"version": "7.14.16",
|
||||||
"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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -318,6 +318,17 @@
|
||||||
<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>
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@
|
||||||
<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>
|
||||||
|
|
@ -143,6 +144,8 @@
|
||||||
.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; }
|
||||||
|
|
@ -198,6 +201,7 @@
|
||||||
.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>
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,9 @@
|
||||||
<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>
|
||||||
|
|
@ -119,6 +122,65 @@
|
||||||
|
|
||||||
</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 ≥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;">
|
||||||
|
|
@ -305,4 +367,3 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -143,6 +143,7 @@ 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);}
|
||||||
|
|
@ -396,6 +397,21 @@ 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;}
|
||||||
|
|
@ -626,7 +642,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;}
|
.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: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;}
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,11 @@
|
||||||
<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">
|
||||||
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
|
<!-- Explicit render mode: the register/forgot widgets live inside forms that
|
||||||
|
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>
|
||||||
|
|
@ -70,7 +74,6 @@
|
||||||
<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>
|
||||||
|
|
@ -104,7 +107,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 class="cf-turnstile" data-sitekey="0x4AAAAAAC0VtKAhC8rzpMx6" data-theme="light"></div>
|
<div id="turnstile-register" data-sitekey="0x4AAAAAAC0VtKAhC8rzpMx6"></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>
|
||||||
|
|
@ -118,7 +121,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 class="cf-turnstile" id="turnstile-forgot" data-sitekey="0x4AAAAAAC0VtKAhC8rzpMx6" data-theme="light"></div>
|
<div id="turnstile-forgot" data-sitekey="0x4AAAAAAC0VtKAhC8rzpMx6"></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>
|
||||||
|
|
@ -131,7 +134,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://github.com/ifedan-ed/pediatric-ai-scribe-v3/releases/latest" target="_blank" rel="noopener" style="color:#2563eb;text-decoration:none;font-weight:500;">
|
<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;">
|
||||||
<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>
|
||||||
|
|
|
||||||
|
|
@ -701,6 +701,8 @@ 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();
|
||||||
|
|
@ -741,6 +743,7 @@ 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);
|
||||||
|
|
@ -810,6 +813,91 @@ 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');
|
||||||
|
|
|
||||||
|
|
@ -600,9 +600,19 @@ 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 (Capacitor KeepAwake or InsomniaCap)
|
// Keep screen awake during recording.
|
||||||
|
//
|
||||||
|
// 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();
|
||||||
|
|
|
||||||
|
|
@ -19,20 +19,24 @@ export function fetchAssistantExamples() {
|
||||||
.then(function(r) { return r.json(); });
|
.then(function(r) { return r.json(); });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function openAssistantStream(payload) {
|
export function openAssistantStream(payload, options) {
|
||||||
|
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) {
|
export function fetchAssistantChat(payload, options) {
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,9 @@ export function createAssistantImageStore() {
|
||||||
var generatedImageSeq = 0;
|
var generatedImageSeq = 0;
|
||||||
var previewKeyHandler = null;
|
var previewKeyHandler = null;
|
||||||
|
|
||||||
function renderGeneratedImage(src, alt) {
|
function renderGeneratedImage(src, alt, downloadUrl) {
|
||||||
var id = 'img-' + (++generatedImageSeq);
|
var id = 'img-' + (++generatedImageSeq);
|
||||||
generatedImages[id] = src;
|
generatedImages[id] = { src: src, downloadUrl: downloadUrl || '' };
|
||||||
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,7 +16,8 @@ export function createAssistantImageStore() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function openImagePreview(id) {
|
function openImagePreview(id) {
|
||||||
var src = generatedImages[id];
|
var item = 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');
|
||||||
|
|
@ -41,21 +42,25 @@ export function createAssistantImageStore() {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function downloadImage(id) {
|
async function downloadImage(id) {
|
||||||
var src = generatedImages[id];
|
var item = 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;
|
||||||
}
|
}
|
||||||
var a = document.createElement('a');
|
if (isMobileBrowser()) {
|
||||||
a.href = src;
|
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.download = 'clinical-visual.png';
|
return;
|
||||||
a.rel = 'noopener';
|
}
|
||||||
document.body.appendChild(a);
|
await downloadWithBrowser(src);
|
||||||
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');
|
||||||
}
|
}
|
||||||
|
|
@ -129,7 +134,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. Opening browser download instead.', 'error');
|
if (typeof window.showToast === 'function') window.showToast('Could not save with the app. Use Preview and long-press the image to save it.', 'error');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -142,7 +147,101 @@ 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) ? 'cancelled' : 'unavailable'; }
|
} catch (e) { return isShareCancel(e) ? 'unavailable' : '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() {
|
||||||
|
|
@ -157,6 +256,10 @@ 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] || ''); };
|
||||||
|
|
|
||||||
|
|
@ -47,50 +47,86 @@ 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.
|
||||||
var BIO_SERVER = 'pedscribe-bio'; // namespace for the keychain/keystore item
|
// Two plugins cooperate here, because the one that does the biometric
|
||||||
|
// 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.NativeBiometric;
|
var p = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.BiometricAuthNative;
|
||||||
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.isAvailable()
|
return p.checkBiometry()
|
||||||
.then(function (r) { return { ok: !!(r && r.isAvailable), type: r && r.biometryType }; })
|
.then(function (r) {
|
||||||
|
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
|
||||||
// verifyIdentity prompt path entirely so we don't rattle the user.
|
// 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) {
|
||||||
var p = bioPlugin();
|
if (!bioPlugin()) 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.setCredentials({ username: email, password: password, server: BIO_SERVER })
|
return Promise.resolve(
|
||||||
.then(function () { try { localStorage.setItem(BIO_ENABLED_KEY, '1'); } catch (e) {} });
|
window.SecureStorage.set(BIO_CREDS_KEY, JSON.stringify({ username: email, password: password }))
|
||||||
|
).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'));
|
||||||
return p.verifyIdentity({
|
if (!window.SecureStorage) return Promise.reject(new Error('Secure storage unavailable'));
|
||||||
|
// 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',
|
||||||
title: 'PedScribe',
|
androidTitle: 'PedScribe',
|
||||||
subtitle: 'Use biometric to sign in',
|
androidSubtitle: 'Use biometric to sign in',
|
||||||
description: 'Confirm your identity to continue.'
|
cancelTitle: 'Use password',
|
||||||
|
allowDeviceCredential: false
|
||||||
}).then(function () {
|
}).then(function () {
|
||||||
return p.getCredentials({ server: BIO_SERVER });
|
return window.SecureStorage.get(BIO_CREDS_KEY);
|
||||||
|
}).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 (!p) return Promise.resolve();
|
if (!window.SecureStorage) return Promise.resolve();
|
||||||
return p.deleteCredentials({ server: BIO_SERVER }).catch(function () { /* fine if missing */ });
|
return Promise.resolve(window.SecureStorage.remove(BIO_CREDS_KEY)).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 };
|
||||||
|
|
@ -113,9 +149,8 @@ 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.type) {
|
if (label && 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 = 'Sign in with ' + s.typeName;
|
||||||
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 = ''; }
|
||||||
|
|
@ -214,6 +249,71 @@ 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() {
|
||||||
|
|
@ -226,12 +326,14 @@ 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) {
|
||||||
|
|
@ -527,7 +629,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 (turnstile, 2FA prompt, error handling,
|
// form so all the existing flow (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');
|
||||||
|
|
@ -544,10 +646,7 @@ 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 (turnstile token, 2FA, session storage, etc.)
|
// existing handling (2FA, session storage, etc.) runs unchanged.
|
||||||
// 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 }));
|
||||||
})
|
})
|
||||||
|
|
@ -577,17 +676,9 @@ 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, turnstileToken: loginToken };
|
var body = { email: email, password: password };
|
||||||
if (totpCode) body.totpCode = totpCode;
|
if (totpCode) body.totpCode = totpCode;
|
||||||
|
|
||||||
fetch('/api/auth/login', {
|
fetch('/api/auth/login', {
|
||||||
|
|
@ -625,7 +716,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 = ({ 'FACE_ID': 'Face ID', 'TOUCH_ID': 'Touch ID', 'FACE_AUTHENTICATION': 'face recognition', 'FINGERPRINT': 'fingerprint' })[s.type] || 'biometric';
|
var typeName = s.typeName || '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)
|
||||||
|
|
@ -637,14 +728,12 @@ 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;
|
||||||
|
|
@ -703,9 +792,8 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cloudflare Turnstile verification
|
// Cloudflare Turnstile verification
|
||||||
var turnstileResponse = document.querySelector('[name="cf-turnstile-response"]');
|
var regToken = turnstileToken('register');
|
||||||
var turnstileToken = turnstileResponse ? turnstileResponse.value : '';
|
if (!regToken) {
|
||||||
if (!turnstileToken) {
|
|
||||||
showToast('Please complete the verification challenge', 'error');
|
showToast('Please complete the verification challenge', 'error');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
@ -715,7 +803,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: turnstileToken })
|
body: JSON.stringify({ name: name, email: email, password: password, turnstileToken: regToken })
|
||||||
})
|
})
|
||||||
.then(function(r) { return r.json(); })
|
.then(function(r) { return r.json(); })
|
||||||
.then(function(data) {
|
.then(function(data) {
|
||||||
|
|
@ -729,17 +817,20 @@ 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');
|
||||||
if (window.turnstile) turnstile.reset();
|
resetTurnstile('register');
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.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');
|
||||||
if (window.turnstile) turnstile.reset();
|
resetTurnstile('register');
|
||||||
});
|
});
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -756,8 +847,7 @@ 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 forgotTurnstile = document.querySelector('#forgot-form [name="cf-turnstile-response"]');
|
var forgotToken = turnstileToken('forgot');
|
||||||
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;
|
||||||
|
|
@ -774,12 +864,13 @@ 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');
|
||||||
if (window.turnstile) turnstile.reset('#turnstile-forgot');
|
resetTurnstile('forgot');
|
||||||
});
|
});
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,8 @@ 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();
|
||||||
|
|
||||||
|
|
@ -49,6 +51,7 @@ 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');
|
||||||
|
|
@ -60,6 +63,7 @@ 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);
|
||||||
|
|
@ -126,21 +130,40 @@ 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)
|
}, loading, request)
|
||||||
.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);
|
||||||
|
|
@ -155,8 +178,8 @@ import {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function streamAssistantResponse(payload, loading) {
|
async function streamAssistantResponse(payload, loading, request) {
|
||||||
var response = await openAssistantStream(payload);
|
var response = await openAssistantStream(payload, { signal: request ? request.signal : undefined });
|
||||||
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 + ')'));
|
||||||
|
|
@ -177,7 +200,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 ? renderAssistantBubbleHtml(partial, streamSources, false) : '<p class="assistant-muted">Generating answer...</p>';
|
bubble.innerHTML = partial ? renderStreamingAnswerHtml(partial, streamSources) : '<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;
|
||||||
|
|
@ -207,6 +230,7 @@ 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 });
|
||||||
|
|
@ -224,9 +248,11 @@ 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);
|
finalData = await fetchAssistantFallback(payload, request);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
@ -238,6 +264,20 @@ 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 = '';
|
||||||
|
|
@ -250,8 +290,15 @@ import {
|
||||||
catch (e) { return null; }
|
catch (e) { return null; }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchAssistantFallback(payload) {
|
async function fetchAssistantFallback(payload, request) {
|
||||||
var data = await fetchAssistantChat(payload);
|
var data;
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|
@ -445,10 +492,11 @@ 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();
|
||||||
if (out) out.innerHTML = imageStore.renderGeneratedImage(src, 'Generated clinical visual');
|
var downloadUrl = data.downloadUrl || '';
|
||||||
|
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');
|
var html = imageStore.renderGeneratedImage(src, 'Generated clinical visual', downloadUrl);
|
||||||
replaceLoadingMessage(loading, html, [], [], true);
|
replaceLoadingMessage(loading, html, [], [], true);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
@ -547,6 +595,17 @@ 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>' +
|
||||||
|
|
@ -758,6 +817,7 @@ 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);
|
||||||
|
|
@ -768,6 +828,13 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@
|
||||||
# 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
|
||||||
|
|
@ -13,31 +12,32 @@
|
||||||
# 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 (run `cd mobile/android && ./gradlew ...`
|
# - Build the Android APK itself. Forgejo CI does that
|
||||||
# yourself). This script just marks the version so the build tags
|
# (.forgejo/workflows/android-apk.yml): any branch push builds a signed
|
||||||
# correctly.
|
# APK as a 30-day workflow artifact, and a v* tag push additionally
|
||||||
|
# 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] [--gh]"
|
echo "usage: $0 <version> [--push]"
|
||||||
echo " example: $0 6.1.1 --push --gh"
|
echo " example: $0 6.1.1 --push"
|
||||||
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,30 +90,25 @@ git tag -a "v${VERSION}" -m "Release v${VERSION}"
|
||||||
echo " tagged v${VERSION}"
|
echo " tagged v${VERSION}"
|
||||||
|
|
||||||
if $PUSH; then
|
if $PUSH; then
|
||||||
git push origin HEAD
|
# This repo's remote is "forgejo", not "origin". Prefer forgejo, fall back
|
||||||
git push origin "v${VERSION}"
|
# to origin, otherwise use the only remote there is — so this keeps working
|
||||||
echo " pushed to origin"
|
# if the remote is ever renamed. Pushing the tag is what makes CI attach the
|
||||||
fi
|
# signed APK to a Forgejo release for Obtainium; the branch push alone only
|
||||||
|
# builds it as a 30-day workflow artifact.
|
||||||
if $DO_RELEASE; then
|
REMOTE=""
|
||||||
if ! command -v gh >/dev/null; then
|
for candidate in forgejo origin; do
|
||||||
echo "WARN: gh CLI not installed, skipping GitHub release creation" >&2
|
if git remote get-url "$candidate" >/dev/null 2>&1; then REMOTE="$candidate"; break; fi
|
||||||
else
|
done
|
||||||
APK="mobile/android/app/build/outputs/apk/release/app-release.apk"
|
if [[ -z "$REMOTE" ]]; then
|
||||||
if [[ -f "$APK" ]]; then
|
REMOTE=$(git remote | head -1)
|
||||||
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."
|
||||||
|
|
|
||||||
|
|
@ -376,6 +376,26 @@ 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(
|
||||||
|
|
|
||||||
|
|
@ -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, getTTSProvider, getTTSVoiceLists } = require('../utils/ttsProvider');
|
var { getTTSEnvProvider, getLiteLLMTTSDiscoveryItems, getLiteLLMTTSRequestOptions, getLiteLLMTTSVoicesForModel, isLiteLLMTTSVoiceCompatible, getTTSProvider } = 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,6 +91,55 @@ 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 {
|
||||||
|
|
@ -501,12 +550,17 @@ 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: dbVoice || envVoice,
|
currentVoice: currentVoice,
|
||||||
currentModel: dbModel || envModel,
|
currentModel: currentModel,
|
||||||
dbVoice: dbVoice,
|
dbVoice: dbVoice,
|
||||||
dbModel: dbModel,
|
dbModel: dbModel,
|
||||||
envVoice: envVoice,
|
envVoice: envVoice,
|
||||||
|
|
@ -514,7 +568,9 @@ router.get('/config/tts', async function(req, res) {
|
||||||
configured: {
|
configured: {
|
||||||
litellm: !!process.env.LITELLM_API_BASE
|
litellm: !!process.env.LITELLM_API_BASE
|
||||||
},
|
},
|
||||||
voices: getTTSVoiceLists()
|
voices: {
|
||||||
|
litellm: voices
|
||||||
|
}
|
||||||
});
|
});
|
||||||
} catch (e) { res.status(500).json({ error: 'Request failed' }); }
|
} catch (e) { res.status(500).json({ error: 'Request failed' }); }
|
||||||
});
|
});
|
||||||
|
|
@ -567,11 +623,15 @@ 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 usedVoice = voice || adminVoice || process.env.LITELLM_TTS_VOICE || '';
|
var defaultVoices = getLiteLLMTTSVoicesForModel(ttsModel, { currentVoice: adminVoice });
|
||||||
|
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'),
|
||||||
{ model: ttsModel, voice: usedVoice, input: text },
|
payload,
|
||||||
{ 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);
|
||||||
|
|
|
||||||
|
|
@ -299,19 +299,15 @@ 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, turnstileToken } = req.body;
|
var { email, password, totpCode } = 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' });
|
||||||
|
|
||||||
// Cloudflare Turnstile verification
|
// No Turnstile on login. The widget could not reliably complete a
|
||||||
if (process.env.TURNSTILE_SECRET_KEY) {
|
// challenge inside the Capacitor WebView, which locked mobile users out.
|
||||||
if (!turnstileToken) return res.status(400).json({ error: 'Please complete the verification' });
|
// Brute-force cover here comes from the 10-per-15-min per-IP rate limit
|
||||||
var tsRes = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
|
// (server.js), the constant-time bcrypt comparison below, and TOTP 2FA.
|
||||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
// Registration and password reset — the endpoints that actually attract
|
||||||
body: JSON.stringify({ secret: process.env.TURNSTILE_SECRET_KEY, response: turnstileToken, remoteip: req.ip })
|
// bots — are still gated.
|
||||||
});
|
|
||||||
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
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,9 @@ 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) {
|
||||||
|
|
@ -323,6 +325,7 @@ 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) {
|
||||||
|
|
@ -330,6 +333,22 @@ 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();
|
||||||
|
|
@ -568,6 +587,49 @@ 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);
|
||||||
|
|
@ -590,7 +652,18 @@ 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(function(item) { return item.prompt; });
|
}).filter(isUsefulIndexedTopicExample);
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
|
|
||||||
|
|
@ -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 { getTTSProvider } = require('../utils/ttsProvider');
|
var { getLiteLLMTTSRequestOptions, getLiteLLMTTSVoicesForModel, isLiteLLMTTSVoiceCompatible, 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,19 +22,22 @@ 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 ttsVoice = resolvedVoice || process.env.LITELLM_TTS_VOICE || '';
|
var defaultVoices = getLiteLLMTTSVoicesForModel(ttsModel, { currentVoice: adminVoice });
|
||||||
|
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({ model: ttsModel, input: text, voice: ttsVoice })
|
body: JSON.stringify(payload)
|
||||||
});
|
});
|
||||||
if (!ttsResp.ok) {
|
if (!ttsResp.ok) {
|
||||||
var errBody = await ttsResp.text();
|
var errBody = await ttsResp.text();
|
||||||
|
|
|
||||||
|
|
@ -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 { getTTSProvider, getTTSVoiceLists } = require('../utils/ttsProvider');
|
var { getLiteLLMTTSVoicesForModel, getTTSProvider } = require('../utils/ttsProvider');
|
||||||
|
|
||||||
router.use(authMiddleware);
|
router.use(authMiddleware);
|
||||||
|
|
||||||
|
|
@ -49,14 +49,18 @@ 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 = getTTSVoiceLists().litellm.map(function(voice) { return { value: voice, label: voice }; });
|
var ttsVoices = getLiteLLMTTSVoicesForModel(ttsModel, { currentVoice: dbVoice }).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
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,227 @@ 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) {}
|
||||||
|
|
@ -22,20 +243,23 @@ 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 = positiveInt(process.env.CLINICAL_ASSISTANT_PROMPT_POOL_REFRESH_MS, 24 * 60 * 60 * 1000);
|
var refreshMs = nonNegativeInt(process.env.CLINICAL_ASSISTANT_PROMPT_POOL_REFRESH_MS, 7 * 24 * 60 * 60 * 1000);
|
||||||
var ttlSeconds = Math.max(3600, Math.ceil(refreshMs / 1000) * 2);
|
var ttlSeconds = refreshMs > 0 ? Math.max(3600, Math.ceil(refreshMs / 1000) * 2) : 0;
|
||||||
var target = positiveInt(process.env.CLINICAL_ASSISTANT_PROMPT_POOL_TARGET, 200);
|
var target = positiveInt(process.env.CLINICAL_ASSISTANT_PROMPT_POOL_TARGET, 1000);
|
||||||
var redisKey = process.env.CLINICAL_ASSISTANT_PROMPT_POOL_KEY || 'clinical-assistant:prompt-pool:v1';
|
var redisBaseKey = String(process.env.CLINICAL_ASSISTANT_PROMPT_POOL_KEY || 'clinical-assistant:prompt-pool:v2').replace(/:all$/, '');
|
||||||
|
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 opts.redisCache.getJson(redisKey).catch(function() { return null; });
|
var cached = await readPromptPool();
|
||||||
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 (!cached.generatedAt || now - cached.generatedAt > refreshMs) refreshIfNeeded(false).catch(function() {});
|
if (refreshMs > 0 && (!cached.generatedAt || now - cached.generatedAt > refreshMs)) refreshIfNeeded(false).catch(function() {});
|
||||||
return cached.examples;
|
return cached.examples;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -47,82 +271,105 @@ function createClinicalPromptPool(opts) {
|
||||||
return examples;
|
return examples;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshIfNeeded(force) {
|
async function refreshIfNeeded(force, context) {
|
||||||
|
context = context || {};
|
||||||
var now = Date.now();
|
var now = Date.now();
|
||||||
var cached = await opts.redisCache.getJson(redisKey).catch(function() { return null; });
|
var cached = await readPromptPool();
|
||||||
if (!force && cached && Array.isArray(cached.examples) && cached.examples.length && cached.generatedAt && now - cached.generatedAt < refreshMs) {
|
if (!force && cached && Array.isArray(cached.examples) && cached.examples.length && (refreshMs === 0 || (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(), examples: examples };
|
var payload = { generatedAt: Date.now(), target: target, examples: examples };
|
||||||
memoryCache = { expiresAt: Date.now() + cacheMs, examples: examples };
|
memoryCache = { expiresAt: Date.now() + cacheMs, examples: examples };
|
||||||
return opts.redisCache.setJson(redisKey, payload, ttlSeconds).then(function() { return examples; });
|
return writePromptPool(payload, context).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 [];
|
return cached && Array.isArray(cached.examples) ? cached.examples : [];
|
||||||
});
|
});
|
||||||
return refreshPromise;
|
return refreshPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function buildCorpusPromptPool() {
|
async function readPromptPool() {
|
||||||
var snippets = await collectPromptSeedSnippets();
|
var cached = await opts.redisCache.getJson(redisAllKey).catch(function() { return null; });
|
||||||
if (!snippets.length) return fallbackIndexedExamples();
|
if (cached && Array.isArray(cached.examples) && cached.examples.length) return cached;
|
||||||
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 lastGood = await opts.redisCache.getJson(redisLastGoodKey).catch(function() { return null; });
|
||||||
var generated = [];
|
if (lastGood && Array.isArray(lastGood.examples) && lastGood.examples.length) return lastGood;
|
||||||
var batches = Math.max(1, Math.min(20, Math.ceil(target / 25)));
|
if (typeof opts.loadStoredPromptPool === 'function') {
|
||||||
for (var i = 0; i < batches && generated.length < target; i++) {
|
var stored = await opts.loadStoredPromptPool().catch(function() { return null; });
|
||||||
var batchSnippets = rotateExamples(snippets, Date.now() + i * 997).slice(0, 24);
|
if (stored && Array.isArray(stored.examples) && stored.examples.length) {
|
||||||
var sourceText = batchSnippets.map(function(s, idx) {
|
await writePromptPool(stored, { skipStore: true }).catch(function() {});
|
||||||
return '[' + (idx + 1) + '] ' + s.title + (s.page ? ', page ' + s.page : '') + '\n' + clip(s.excerpt, 650);
|
return stored;
|
||||||
}).join('\n\n');
|
}
|
||||||
var ai = await opts.callAI([
|
|
||||||
{
|
|
||||||
role: 'system',
|
|
||||||
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"}]}.'
|
|
||||||
},
|
|
||||||
{ 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.' }
|
|
||||||
], {
|
|
||||||
model: chatModel || undefined,
|
|
||||||
temperature: 0.75,
|
|
||||||
maxTokens: 2600
|
|
||||||
});
|
|
||||||
var parsed = parseJsonObject(String(ai.content || ''));
|
|
||||||
generated = normalizeGeneratedExamples(generated.concat(parsed.questions || []));
|
|
||||||
}
|
}
|
||||||
if (generated.length < 8) return fallbackIndexedExamples();
|
return null;
|
||||||
return generated;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function collectPromptSeedSnippets() {
|
async function buildCorpusPromptPool() {
|
||||||
var seeds = promptSeedQueries();
|
var taxonomy = taxonomyWithQuotas(target);
|
||||||
|
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 = [];
|
||||||
|
for (var t = 0; t < taxonomy.length && generated.length < target; t++) {
|
||||||
|
var item = taxonomy[t];
|
||||||
|
var snippets = await collectPromptSeedSnippets(item);
|
||||||
|
if (!snippets.length) continue;
|
||||||
|
var categoryExamples = [];
|
||||||
|
var batches = Math.max(1, Math.min(5, Math.ceil(item.quota / 25)));
|
||||||
|
for (var i = 0; i < batches && categoryExamples.length < item.quota; i++) {
|
||||||
|
var batchSnippets = rotateExamples(snippets, i * 18).slice(0, 18);
|
||||||
|
var sourceText = batchSnippets.map(function(s, idx) {
|
||||||
|
return '[' + (idx + 1) + '] ' + s.title + (s.page ? ', page ' + s.page : '') + '\n' + clip(s.excerpt, 700);
|
||||||
|
}).join('\n\n');
|
||||||
|
var ai = await opts.callAI([
|
||||||
|
{
|
||||||
|
role: 'system',
|
||||||
|
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"}]}.'
|
||||||
|
},
|
||||||
|
{ 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.' }
|
||||||
|
], {
|
||||||
|
model: chatModel || undefined,
|
||||||
|
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 [];
|
||||||
|
return generated.slice(0, target);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function collectPromptSeedSnippets(taxonomyItem) {
|
||||||
|
var seeds = promptSeedQueries(taxonomyItem);
|
||||||
var seen = new Set();
|
var seen = new Set();
|
||||||
var snippets = [];
|
var snippets = [];
|
||||||
for (var i = 0; i < seeds.length && snippets.length < 120; i++) {
|
for (var i = 0; i < seeds.length && snippets.length < 36; i++) {
|
||||||
try {
|
try {
|
||||||
var searchResponse = await opts.semanticSearch(seeds[i], { limit: 5, includeContext: true, contextChars: 700 });
|
var searchResponse = await opts.semanticSearch(seeds[i], { limit: 5, includeContext: true, contextChars: 900 });
|
||||||
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 >= 120) return;
|
if (seen.has(key) || snippets.length >= 36) return;
|
||||||
seen.add(key);
|
seen.add(key);
|
||||||
snippets.push(source);
|
snippets.push(source);
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (snippets.length === 0) throw e;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return snippets;
|
return snippets;
|
||||||
}
|
}
|
||||||
|
|
||||||
function promptSeedQueries() {
|
function promptSeedQueries(taxonomyItem) {
|
||||||
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([
|
seeds = seeds.concat(taxonomyItem.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',
|
||||||
|
|
@ -130,39 +377,165 @@ 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, 30);
|
return shuffle(seeds).slice(0, 8);
|
||||||
}
|
}
|
||||||
|
|
||||||
function fallbackIndexedExamples() {
|
function fallbackIndexedExamples() {
|
||||||
return typeof opts.getIndexedTopicExamples === 'function' ? opts.getIndexedTopicExamples().catch(function() { return []; }) : [];
|
return typeof opts.getIndexedTopicExamples === 'function' ? opts.getIndexedTopicExamples().catch(function() { return []; }) : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeGeneratedExamples(items) {
|
async function getMeta() {
|
||||||
|
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)) return;
|
if (!isUsefulQuestion(prompt, item)) 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: clip(String(item.category || 'general'), 40),
|
category: category,
|
||||||
intent: clip(String(item.intent || 'review'), 30)
|
age_band: ageBand,
|
||||||
|
intent: sanitizeIntent(item.intent)
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
return out.slice(0, target);
|
return out.slice(0, target);
|
||||||
}
|
}
|
||||||
|
|
||||||
function isUsefulQuestion(prompt) {
|
async function writePromptPool(payload, context) {
|
||||||
|
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 (!/\b(child|children|pediatric|paediatric|infant|neonate|newborn|adolescent|teen|toddler|baby)\b/i.test(prompt)) return false;
|
if (!hasPediatricSignal(prompt, item)) 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');
|
||||||
|
|
@ -181,7 +554,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.floor(seed / cacheMs) % copy.length;
|
var offset = Math.abs(Math.floor(seed)) % copy.length;
|
||||||
return copy.slice(offset).concat(copy.slice(0, offset));
|
return copy.slice(offset).concat(copy.slice(0, offset));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -198,8 +571,16 @@ 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 };
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,11 @@ 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;
|
||||||
await redis.set(key, JSON.stringify(value), { EX: ttlSeconds });
|
if (ttlSeconds && Number(ttlSeconds) > 0) {
|
||||||
|
await redis.set(key, JSON.stringify(value), { EX: Math.floor(Number(ttlSeconds)) });
|
||||||
|
} else {
|
||||||
|
await redis.set(key, JSON.stringify(value));
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,20 @@ 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';
|
||||||
|
|
@ -20,10 +34,58 @@ function getTTSEnvProvider() {
|
||||||
|
|
||||||
function getTTSVoiceLists() {
|
function getTTSVoiceLists() {
|
||||||
return {
|
return {
|
||||||
litellm: parseList(process.env.LITELLM_TTS_VOICES)
|
litellm: uniqueList(parseList(process.env.LITELLM_TTS_VOICES).concat(KITTEN_TTS_VOICES, SUPERTONIC_TTS_VOICES, GROQ_ORPHEUS_ENGLISH_VOICES, GROQ_ORPHEUS_ARABIC_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';
|
||||||
|
|
@ -56,6 +118,26 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -64,7 +146,10 @@ module.exports = {
|
||||||
getLiteLLMTTSDiscoveryItems,
|
getLiteLLMTTSDiscoveryItems,
|
||||||
getLiteLLMHeaders,
|
getLiteLLMHeaders,
|
||||||
getLiteLLMTTSModels,
|
getLiteLLMTTSModels,
|
||||||
|
getLiteLLMTTSRequestOptions,
|
||||||
|
getLiteLLMTTSVoicesForModel,
|
||||||
getTTSProvider,
|
getTTSProvider,
|
||||||
getTTSVoiceLists,
|
getTTSVoiceLists,
|
||||||
|
isLiteLLMTTSVoiceCompatible,
|
||||||
isLiteLLMTTSModel
|
isLiteLLMTTSModel
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -244,3 +244,11 @@ 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/);
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -14,10 +14,35 @@ 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(pool, /opts\.redisCache\.getJson\(redisKey\)/);
|
assert.match(route, /isUsefulIndexedTopicExample/);
|
||||||
assert.match(pool, /opts\.redisCache\.setJson\(redisKey, payload, ttlSeconds\)/);
|
assert.match(pool, /clinical-assistant:prompt-pool:v2/);
|
||||||
assert.match(pool, /CLINICAL_ASSISTANT_PROMPT_POOL_TARGET, 200/);
|
assert.match(pool, /redisBaseKey \+ ':all'/);
|
||||||
|
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', () => {
|
||||||
|
|
|
||||||
|
|
@ -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'];
|
const ENV_KEYS = ['TTS_PROVIDER', 'LITELLM_API_BASE', 'LITELLM_API_KEY', 'LITELLM_MASTER_KEY', 'LITELLM_TTS_VOICES', 'LITELLM_TTS_VOICE'];
|
||||||
|
|
||||||
function withEnv(overrides, fn) {
|
function withEnv(overrides, fn) {
|
||||||
const previous = {};
|
const previous = {};
|
||||||
|
|
@ -36,10 +36,11 @@ 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' }, () => {
|
||||||
assert.deepEqual(ttsProvider.getTTSVoiceLists().litellm, [
|
const voices = ttsProvider.getTTSVoiceLists().litellm;
|
||||||
'sherpa/kokoro:am_adam',
|
assert.equal(voices[0], 'sherpa/kokoro:am_adam');
|
||||||
'sherpa/kokoro:af_bella'
|
assert.equal(voices[1], 'sherpa/kokoro:af_bella');
|
||||||
]);
|
assert.equal(voices.includes('Jasper'), true);
|
||||||
|
assert.equal(voices.includes('F1'), true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -92,6 +93,19 @@ 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([], {
|
||||||
|
|
@ -103,6 +117,38 @@ 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' }, () => {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue