Compare commits

..

No commits in common. "main" and "v7.14.5" have entirely different histories.

18 changed files with 93 additions and 844 deletions

View file

@ -15,7 +15,7 @@ services:
LITELLM_TTS_MODEL: local-kokoro-tts
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
CLINICAL_ASSISTANT_PROMPT_POOL_TARGET: 1000
CLINICAL_ASSISTANT_PROMPT_POOL_TARGET: 200
volumes:
- scribe-logs:/app/data/logs
- clinical-assistant-mcp-data:/app/mcp-data:ro
@ -28,9 +28,9 @@ services:
container_name: pediatric-ai-scribe
networks:
- default
- danvics_mcp
- danvics_monitoring
- danvics_speech
- mcp-server_default
- monitoring_default
- speech_net
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000/api/health"]
interval: 30s
@ -46,7 +46,7 @@ services:
environment:
POSTGRES_DB: pedscribe
POSTGRES_USER: pedscribe
POSTGRES_PASSWORD: ${DB_PASSWORD:-pedscribe}
POSTGRES_PASSWORD: ${DB_PASSWORD:?set DB_PASSWORD}
volumes:
- pgdata:/var/lib/postgresql/data
restart: unless-stopped
@ -72,7 +72,7 @@ services:
retries: 5
networks:
- default
- danvics_mcp
- mcp-server_default
volumes:
pgdata:
@ -83,9 +83,9 @@ volumes:
name: mcp-server_mcp-data
networks:
danvics_mcp:
mcp-server_default:
external: true
danvics_monitoring:
monitoring_default:
external: true
danvics_speech:
speech_net:
external: true

View file

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

View file

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

View file

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

4
package-lock.json generated
View file

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

View file

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

View file

@ -318,17 +318,6 @@
<button id="btn-test-assistant-chat-model" class="btn-sm btn-primary" type="button"><i class="fas fa-vial"></i> Test</button>
</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;">
<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>

View file

@ -144,8 +144,6 @@
.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 .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-side { display:grid; gap:12px; }
.assistant-side-body { padding:12px; display:grid; gap:10px; font-size:13px; }
@ -201,7 +199,6 @@
.assistant-composer { position:sticky; bottom:0; z-index:3; }
.assistant-composer-footer { flex-direction:column; align-items:stretch; }
.assistant-composer-footer .btn-generate { width:100%; }
.assistant-composer-footer #btn-assistant-cancel:not([hidden]) { width:100%; justify-content:center; }
.assistant-side { gap:10px; }
}
</style>

View file

@ -143,7 +143,6 @@ body{font-family:'Inter',system-ui,sans-serif;background:var(--g50);color:var(--
.btn-generate-green{background:var(--green);box-shadow:0 3px 10px rgba(16,185,129,0.25);}
.btn-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-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);}
@ -627,7 +626,7 @@ textarea.full-input{resize:vertical;}
.lh-quiz-q-type{font-size:11px;color:var(--g400);background:var(--g100);padding:2px 8px;border-radius:4px;}
.lh-quiz-q-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-option{display:flex;align-items:center;gap:12px;padding:14px 18px;border:2px solid var(--g200);border-radius:10px;cursor:pointer;transition:all 0.2s;font-size:14px;line-height:1.4;background:white;user-select:none;}
.lh-quiz-option{display:flex;align-items:center;gap:12px;padding:14px 18px;border:2px solid var(--g200);border-radius:10px;cursor:pointer;transition:all 0.2s;font-size:14px;line-height:1.4;background:white;}
.lh-quiz-option:hover{border-color:var(--blue);background:var(--blue-light);transform:translateY(-1px);box-shadow:0 2px 8px rgba(37,99,235,0.1);}
.lh-quiz-option input[type="radio"]{accent-color:var(--blue);width:18px;height:18px;flex-shrink:0;}
.lh-quiz-option span{flex:1;}

View file

@ -701,8 +701,6 @@ function adminFlashButtonBackground(btn, color) {
document.addEventListener('click', function(e) {
if (e.target.closest('#btn-save-assistant-config')) saveAssistantAdmin();
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-test-assistant-image-model')) testAssistantImageModel();
if (e.target.closest('#btn-use-custom-assistant-image-model')) useCustomAssistantImageModel();
@ -743,7 +741,6 @@ function adminFlashButtonBackground(btn, color) {
}
window._assistantImageModelValue = cfg['clinical_assistant.image_model'] || '';
loadAssistantImageModels();
loadAssistantPromptPoolStatus();
setValue('assistant-search-limit', cfg['clinical_assistant.search_limit'] || '8');
setValue('assistant-context-chars', cfg['clinical_assistant.context_chars'] || '1400');
setValue('assistant-system-behavior', cfg['clinical_assistant.system_behavior'] || defaults.behavior);
@ -813,91 +810,6 @@ function adminFlashButtonBackground(btn, color) {
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() {
var model = getValue('assistant-image-model') || 'openai-gpt-image-1';
var result = document.getElementById('assistant-image-test-result');

View file

@ -5,9 +5,9 @@ export function createAssistantImageStore() {
var generatedImageSeq = 0;
var previewKeyHandler = null;
function renderGeneratedImage(src, alt, downloadUrl) {
function renderGeneratedImage(src, alt) {
var id = 'img-' + (++generatedImageSeq);
generatedImages[id] = { src: src, downloadUrl: downloadUrl || '' };
generatedImages[id] = src;
return '<div class="assistant-generated-image"><img src="' + escapeAttr(src) + '" alt="' + escapeAttr(alt || 'Generated image') + '">' +
'<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>' +
@ -16,8 +16,7 @@ export function createAssistantImageStore() {
}
function openImagePreview(id) {
var item = generatedImages[id];
var src = item && (item.src || item);
var src = generatedImages[id];
if (!src) return;
closeImagePreview();
var modal = document.createElement('div');
@ -42,25 +41,21 @@ export function createAssistantImageStore() {
}
async function downloadImage(id) {
var item = generatedImages[id];
var src = item && (item.src || item);
var downloadUrl = item && item.downloadUrl;
var src = generatedImages[id];
if (!src) return;
try {
if (downloadUrl) {
await downloadFromServer(downloadUrl);
return;
}
if (await saveWithNativeShare(src)) return;
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');
return;
}
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;
}
await downloadWithBrowser(src);
var a = document.createElement('a');
a.href = src;
a.download = 'clinical-visual.png';
a.rel = 'noopener';
document.body.appendChild(a);
a.click();
a.remove();
} catch (e) {
if (typeof window.showToast === 'function') window.showToast(e.message || 'Image download failed', 'error');
}
@ -134,7 +129,7 @@ async function saveWithCapacitorShare(src) {
if (typeof window.showToast === 'function') window.showToast('Image prepared', 'success');
return true;
} catch (e) {
if (typeof window.showToast === 'function') window.showToast('Could not save with the app. Use Preview and long-press the image to save it.', 'error');
if (typeof window.showToast === 'function') window.showToast('Could not save with the app. Opening browser download instead.', 'error');
return false;
}
}
@ -147,101 +142,7 @@ async function shareWithWebFile(src) {
if (!navigator.canShare({ files: [file] })) return 'unavailable';
await navigator.share({ files: [file], title: 'Clinical visual', text: 'Clinical Assistant generated visual' });
return 'shared';
} 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 || '');
} catch (e) { return isShareCancel(e) ? 'cancelled' : 'unavailable'; }
}
function isNativeApp() {
@ -256,10 +157,6 @@ function isShareCancel(error) {
async function imageSourceToBase64(src) {
if (/^data:image\//i.test(src)) return src.split(',')[1] || '';
var blob = await imageSourceToBlob(src);
return blobToBase64(blob);
}
async function blobToBase64(blob) {
return new Promise(function(resolve, reject) {
var reader = new FileReader();
reader.onload = function() { resolve(String(reader.result || '').split(',')[1] || ''); };

View file

@ -492,11 +492,10 @@ import {
if (!src) throw new Error('No image returned');
lastGeneratedImageSrc = src;
exporter.invalidate();
var downloadUrl = data.downloadUrl || '';
if (out) out.innerHTML = imageStore.renderGeneratedImage(src, 'Generated clinical visual', downloadUrl);
if (out) out.innerHTML = imageStore.renderGeneratedImage(src, 'Generated clinical visual');
if (fromChat) {
setBusy(false, 'Ready');
var html = imageStore.renderGeneratedImage(src, 'Generated clinical visual', downloadUrl);
var html = imageStore.renderGeneratedImage(src, 'Generated clinical visual');
replaceLoadingMessage(loading, html, [], [], true);
}
})
@ -830,10 +829,8 @@ import {
}
if (cancel) {
var canCancel = !!isBusy && !!activeAssistantRequest;
if (canCancel) cancel.removeAttribute('hidden');
else cancel.setAttribute('hidden', '');
cancel.hidden = !canCancel;
cancel.disabled = !canCancel;
cancel.style.display = canCancel ? 'inline-flex' : 'none';
}
if (input) input.disabled = !!isBusy;
}

View file

@ -376,26 +376,6 @@ async function initDatabase() {
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)
try {
var indexExists = await client.query(

View file

@ -91,55 +91,6 @@ router.get('/config', async function(req, res) {
} 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 ───────────────────────────────────────────────────
router.post('/config/test-email', async function(req, res) {
try {

View file

@ -55,9 +55,7 @@ var promptPool = createClinicalPromptPool({
semanticSearch: semanticSearch,
dedupeSources: dedupeSources,
normalizeMcpSearchResponse: normalizeMcpSearchResponse,
getIndexedTopicExamples: getIndexedTopicExamples,
loadStoredPromptPool: loadLatestPromptPoolSnapshot,
savePromptPool: savePromptPoolSnapshot
getIndexedTopicExamples: getIndexedTopicExamples
});
function positiveInt(value, fallback) {
@ -325,7 +323,6 @@ router.get('/clinical-assistant/image/jobs/:id', async function(req, res) {
imageUrl: job.imageUrl || null,
url: job.url || 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
});
} catch (e) {
@ -333,22 +330,6 @@ router.get('/clinical-assistant/image/jobs/:id', async function(req, res) {
}
});
router.get('/clinical-assistant/image/jobs/:id/download', async function(req, res) {
try {
var job = await getImageJob(req.params.id);
if (!job || String(job.userId) !== String(req.user.id) || job.status !== 'done' || !job.base64) {
return res.status(404).json({ error: 'Image job not found' });
}
var buffer = Buffer.from(String(job.base64), 'base64');
res.setHeader('Content-Type', 'image/png');
res.setHeader('Content-Disposition', 'attachment; filename="clinical-visual.png"');
res.setHeader('Cache-Control', 'private, max-age=900');
res.send(buffer);
} catch (e) {
res.status(500).json({ error: 'Image download failed' });
}
});
async function prepareAssistantChat(body) {
body = body || {};
var message = String(body.message || '').trim();
@ -587,49 +568,6 @@ async function 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() {
var response = await indexedTopicSuggestions(12);
var data = response && (response.structuredContent || response.data || response);
@ -652,18 +590,7 @@ async function getIndexedTopicExamples() {
sourceTitle: Array.isArray(item.sample_titles) ? item.sample_titles[0] : '',
category: item.category || ''
};
}).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;
}).filter(function(item) { return item.prompt; });
}
async function generateImage(prompt, model) {

View file

@ -8,227 +8,6 @@ function clip(text, max) {
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) {
text = String(text || '').trim().replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/i, '');
try { return JSON.parse(text); } catch (e) {}
@ -243,23 +22,20 @@ function parseJsonObject(text) {
function createClinicalPromptPool(opts) {
opts = opts || {};
var cacheMs = positiveInt(process.env.CLINICAL_ASSISTANT_EXAMPLE_CACHE_MS, 10 * 60 * 1000);
var refreshMs = nonNegativeInt(process.env.CLINICAL_ASSISTANT_PROMPT_POOL_REFRESH_MS, 7 * 24 * 60 * 60 * 1000);
var ttlSeconds = refreshMs > 0 ? Math.max(3600, Math.ceil(refreshMs / 1000) * 2) : 0;
var target = positiveInt(process.env.CLINICAL_ASSISTANT_PROMPT_POOL_TARGET, 1000);
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 refreshMs = positiveInt(process.env.CLINICAL_ASSISTANT_PROMPT_POOL_REFRESH_MS, 24 * 60 * 60 * 1000);
var ttlSeconds = Math.max(3600, Math.ceil(refreshMs / 1000) * 2);
var target = positiveInt(process.env.CLINICAL_ASSISTANT_PROMPT_POOL_TARGET, 200);
var redisKey = process.env.CLINICAL_ASSISTANT_PROMPT_POOL_KEY || 'clinical-assistant:prompt-pool:v1';
var memoryCache = { expiresAt: 0, examples: [] };
var refreshPromise = null;
async function getAvailableExamples() {
var now = Date.now();
if (memoryCache.expiresAt > now && memoryCache.examples.length) return memoryCache.examples;
var cached = await readPromptPool();
var cached = await opts.redisCache.getJson(redisKey).catch(function() { return null; });
if (cached && Array.isArray(cached.examples) && cached.examples.length) {
memoryCache = { expiresAt: now + cacheMs, examples: cached.examples };
if (refreshMs > 0 && (!cached.generatedAt || now - cached.generatedAt > refreshMs)) refreshIfNeeded(false).catch(function() {});
if (!cached.generatedAt || now - cached.generatedAt > refreshMs) refreshIfNeeded(false).catch(function() {});
return cached.examples;
}
@ -271,105 +47,82 @@ function createClinicalPromptPool(opts) {
return examples;
}
async function refreshIfNeeded(force, context) {
context = context || {};
async function refreshIfNeeded(force) {
var now = Date.now();
var cached = await readPromptPool();
if (!force && cached && Array.isArray(cached.examples) && cached.examples.length && (refreshMs === 0 || (cached.generatedAt && now - cached.generatedAt < refreshMs))) {
var cached = await opts.redisCache.getJson(redisKey).catch(function() { return null; });
if (!force && cached && Array.isArray(cached.examples) && cached.examples.length && cached.generatedAt && now - cached.generatedAt < refreshMs) {
return cached.examples;
}
if (refreshPromise) return refreshPromise;
refreshPromise = buildCorpusPromptPool().then(function(examples) {
refreshPromise = null;
if (!examples.length) return [];
var payload = { generatedAt: Date.now(), target: target, examples: examples };
var payload = { generatedAt: Date.now(), examples: examples };
memoryCache = { expiresAt: Date.now() + cacheMs, examples: examples };
return writePromptPool(payload, context).then(function() { return examples; });
return opts.redisCache.setJson(redisKey, payload, ttlSeconds).then(function() { return examples; });
}).catch(function(e) {
refreshPromise = null;
console.warn('[clinical-assistant] prompt pool refresh failed:', e.message);
return cached && Array.isArray(cached.examples) ? cached.examples : [];
return [];
});
return refreshPromise;
}
async function readPromptPool() {
var cached = await opts.redisCache.getJson(redisAllKey).catch(function() { return null; });
if (cached && Array.isArray(cached.examples) && cached.examples.length) return cached;
var lastGood = await opts.redisCache.getJson(redisLastGoodKey).catch(function() { return null; });
if (lastGood && Array.isArray(lastGood.examples) && lastGood.examples.length) return lastGood;
if (typeof opts.loadStoredPromptPool === 'function') {
var stored = await opts.loadStoredPromptPool().catch(function() { return null; });
if (stored && Array.isArray(stored.examples) && stored.examples.length) {
await writePromptPool(stored, { skipStore: true }).catch(function() {});
return stored;
}
}
return null;
}
async function buildCorpusPromptPool() {
var taxonomy = taxonomyWithQuotas(target);
var snippets = await collectPromptSeedSnippets();
if (!snippets.length) return fallbackIndexedExamples();
var chatModel = await opts.getSetting('clinical_assistant.prompt_model', '') || process.env.CLINICAL_ASSISTANT_PROMPT_MODEL || await opts.getSetting('clinical_assistant.chat_model', '') || await opts.getSetting('models.default', '');
var 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));
var batches = Math.max(1, Math.min(40, Math.ceil(target / 25)));
for (var i = 0; i < batches && generated.length < target; i++) {
var batchSnippets = rotateExamples(snippets, i * 24).slice(0, 24);
var sourceText = batchSnippets.map(function(s, idx) {
return '[' + (idx + 1) + '] ' + s.title + (s.page ? ', page ' + s.page : '') + '\n' + clip(s.excerpt, 650);
}).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 [];
return generated.slice(0, target);
if (generated.length < 8) return fallbackIndexedExamples();
return generated;
}
async function collectPromptSeedSnippets(taxonomyItem) {
var seeds = promptSeedQueries(taxonomyItem);
async function collectPromptSeedSnippets() {
var seeds = promptSeedQueries();
var seen = new Set();
var snippets = [];
for (var i = 0; i < seeds.length && snippets.length < 36; i++) {
for (var i = 0; i < seeds.length && snippets.length < 120; i++) {
try {
var searchResponse = await opts.semanticSearch(seeds[i], { limit: 5, includeContext: true, contextChars: 900 });
var searchResponse = await opts.semanticSearch(seeds[i], { limit: 5, includeContext: true, contextChars: 700 });
opts.dedupeSources(opts.normalizeMcpSearchResponse(searchResponse)).forEach(function(source) {
var key = [source.title, source.page, source.chunk_index].join('|');
if (seen.has(key) || snippets.length >= 36) return;
if (seen.has(key) || snippets.length >= 120) return;
seen.add(key);
snippets.push(source);
});
} catch (e) {
continue;
if (snippets.length === 0) throw e;
}
}
return snippets;
}
function promptSeedQueries(taxonomyItem) {
function promptSeedQueries() {
var seeds = (opts.exampleCandidates || []).map(function(item) { return item.prompt; });
Object.keys(opts.topicSuggestions || {}).forEach(function(key) {
seeds = seeds.concat(opts.topicSuggestions[key]);
});
seeds = seeds.concat(taxonomyItem.seeds).concat([
taxonomyItem.category + ' pediatric ' + taxonomyItem.seeds.join(' '),
seeds = seeds.concat([
'pediatric emergency red flags admission criteria treatment dosing',
'neonatal fever jaundice respiratory distress vomiting dehydration',
'childhood infectious diseases empiric antibiotics workup disposition',
@ -377,18 +130,14 @@ function createClinicalPromptPool(opts) {
'pediatric gastrointestinal dehydration abdominal pain bilious vomiting',
'developmental milestones anemia endocrine neurologic pediatric review'
]);
return shuffle(seeds).slice(0, 8);
return shuffle(seeds).slice(0, 30);
}
function fallbackIndexedExamples() {
return typeof opts.getIndexedTopicExamples === 'function' ? opts.getIndexedTopicExamples().catch(function() { return []; }) : [];
}
async function getMeta() {
return await opts.redisCache.getJson(redisMetaKey).catch(function() { return null; });
}
function normalizeGeneratedExamples(items, defaultTaxonomy) {
function normalizeGeneratedExamples(items) {
var seen = new Set();
var out = [];
(Array.isArray(items) ? items : []).forEach(function(item) {
@ -397,130 +146,16 @@ function createClinicalPromptPool(opts) {
var key = prompt.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
if (seen.has(key)) return;
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({
label: cleanExampleLabel(item.label || prompt),
prompt: prompt,
category: category,
age_band: ageBand,
intent: sanitizeIntent(item.intent)
category: clip(String(item.category || 'general'), 40),
intent: clip(String(item.intent || 'review'), 30)
});
});
return out.slice(0, target);
}
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.indexOf('?') === -1) return false;
@ -530,10 +165,9 @@ function createClinicalPromptPool(opts) {
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]);
var category = String(item && item.category || '').toLowerCase();
var intent = String(item && item.intent || '').toLowerCase();
return /\b(neonates?|development|respiratory|infectious disease|gi|neurology|cardiology|endocrine|hematology|toxicology|procedures|dosing\/fluids|red flags|differential|admission\/discharge|counseling|emergency|general)\b/.test(category) && /\b(diagnosis|management|red_flags|differential|counseling|dosing|admission|review)\b/.test(intent);
}
function cleanExampleLabel(label) {
@ -571,16 +205,8 @@ function createClinicalPromptPool(opts) {
return {
getAvailableExamples: getAvailableExamples,
refreshIfNeeded: refreshIfNeeded,
getMeta: getMeta,
writePromptPool: writePromptPool
refreshIfNeeded: refreshIfNeeded
};
}
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 };

View file

@ -34,11 +34,7 @@ async function getJson(key) {
async function setJson(key, value, ttlSeconds) {
var redis = await getRedis();
if (!redis) return false;
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));
}
await redis.set(key, JSON.stringify(value), { EX: ttlSeconds });
return true;
}

View file

@ -14,35 +14,13 @@ test('clinical assistant starter prompts are Redis or indexed-source backed', ()
const pool = read('src/utils/clinicalPromptPool.js');
assert.doesNotMatch(route, /EXAMPLE_CANDIDATES|TOPIC_SUGGESTIONS|topic_suggestions|buildSuggestionAnswer/);
assert.match(route, /createClinicalPromptPool\(\{[\s\S]*redisCache: redisCache/);
assert.match(route, /isUsefulIndexedTopicExample/);
assert.match(pool, /clinical-assistant:prompt-pool:v2/);
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, /opts\.redisCache\.getJson\(redisKey\)/);
assert.match(pool, /opts\.redisCache\.setJson\(redisKey, payload, ttlSeconds\)/);
assert.match(pool, /CLINICAL_ASSISTANT_PROMPT_POOL_TARGET, 200/);
assert.match(pool, /Math\.min\(40, Math\.ceil\(target \/ 25\)\)/);
assert.match(pool, /rotateExamples\(snippets, i \* 24\)/);
assert.match(pool, /function hasPediatricSignal\(prompt, item\)/);
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', () => {