Compare commits

..

6 commits

Author SHA1 Message Date
Daniel
bee9361c1d Fix authenticated mobile image downloads
All checks were successful
Forgejo Android APK / Build signed APK (push) Successful in 2m39s
2026-06-09 16:02:11 +02:00
Daniel
2ca969e099 Fix generated image mobile downloads
All checks were successful
Forgejo Android APK / Build signed APK (push) Successful in 2m21s
2026-06-09 15:20:06 +02:00
Daniel
80139d9a82 Prevent mobile image download preview fallback
All checks were successful
Forgejo Android APK / Build signed APK (push) Successful in 1m58s
2026-06-09 03:11:35 +02:00
Daniel
f7cfc6695d Fix clinical assistant image downloads
All checks were successful
Forgejo Android APK / Build signed APK (push) Successful in 2m3s
2026-06-08 22:15:34 +02:00
Daniel
b0e1f4969a feat: improve clinical assistant prompts
All checks were successful
Forgejo Android APK / Build signed APK (push) Successful in 2m15s
2026-06-06 00:01:07 +02:00
Daniel
d02b9e2771 Fix clinical prompt pool fallback seeding
All checks were successful
Forgejo Android APK / Build signed APK (push) Successful in 2m14s
2026-05-22 16:53:53 +02:00
15 changed files with 420 additions and 108 deletions

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 714009
versionName "7.14.9"
versionCode 714014
versionName "7.14.14"
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.9",
"version": "7.14.14",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pedscribe-mobile",
"version": "7.14.9",
"version": "7.14.14",
"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.9",
"version": "7.14.14",
"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.9",
"version": "7.14.14",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pediatric-ai-scribe",
"version": "7.14.9",
"version": "7.14.14",
"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.9",
"version": "7.14.14",
"description": "AI-powered pediatric clinical documentation platform",
"main": "server.js",
"scripts": {

View file

@ -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>
</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

@ -701,6 +701,8 @@ 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();
@ -741,6 +743,7 @@ 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);
@ -810,6 +813,91 @@ 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) {
function renderGeneratedImage(src, alt, downloadUrl) {
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') + '">' +
'<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,7 +16,8 @@ export function createAssistantImageStore() {
}
function openImagePreview(id) {
var src = generatedImages[id];
var item = generatedImages[id];
var src = item && (item.src || item);
if (!src) return;
closeImagePreview();
var modal = document.createElement('div');
@ -41,14 +42,24 @@ export function createAssistantImageStore() {
}
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;
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);
} catch (e) {
if (typeof window.showToast === 'function') window.showToast(e.message || 'Image download failed', 'error');
@ -123,7 +134,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. 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;
}
}
@ -136,10 +147,14 @@ 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) ? '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');
@ -150,15 +165,79 @@ async function downloadWithBrowser(src) {
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()) {
setTimeout(function() {
var opened = window.open(objectUrl, '_blank', 'noopener');
if (opened && typeof window.showToast === 'function') {
window.showToast('If the download did not start, long-press the opened image and save it.', 'success');
}
}, 300);
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() {
@ -177,6 +256,10 @@ 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,10 +492,11 @@ import {
if (!src) throw new Error('No image returned');
lastGeneratedImageSrc = src;
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) {
setBusy(false, 'Ready');
var html = imageStore.renderGeneratedImage(src, 'Generated clinical visual');
var html = imageStore.renderGeneratedImage(src, 'Generated clinical visual', downloadUrl);
replaceLoadingMessage(loading, html, [], [], true);
}
})

View file

@ -376,6 +376,26 @@ 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,6 +91,55 @@ 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,7 +55,9 @@ var promptPool = createClinicalPromptPool({
semanticSearch: semanticSearch,
dedupeSources: dedupeSources,
normalizeMcpSearchResponse: normalizeMcpSearchResponse,
getIndexedTopicExamples: getIndexedTopicExamples
getIndexedTopicExamples: getIndexedTopicExamples,
loadStoredPromptPool: loadLatestPromptPoolSnapshot,
savePromptPool: savePromptPoolSnapshot
});
function positiveInt(value, fallback) {
@ -323,6 +325,7 @@ 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) {
@ -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) {
body = body || {};
var message = String(body.message || '').trim();
@ -568,6 +587,49 @@ 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);
@ -590,7 +652,18 @@ async function getIndexedTopicExamples() {
sourceTitle: Array.isArray(item.sample_titles) ? item.sample_titles[0] : '',
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) {

View file

@ -243,22 +243,23 @@ function parseJsonObject(text) {
function createClinicalPromptPool(opts) {
opts = opts || {};
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 ttlSeconds = Math.max(3600, Math.ceil(refreshMs / 1000) * 2);
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 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 opts.redisCache.getJson(redisAllKey).catch(function() { return null; });
var cached = await readPromptPool();
if (cached && Array.isArray(cached.examples) && cached.examples.length) {
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;
}
@ -270,10 +271,11 @@ function createClinicalPromptPool(opts) {
return examples;
}
async function refreshIfNeeded(force) {
async function refreshIfNeeded(force, context) {
context = context || {};
var now = Date.now();
var cached = await opts.redisCache.getJson(redisAllKey).catch(function() { return null; });
if (!force && cached && Array.isArray(cached.examples) && cached.examples.length && cached.generatedAt && now - cached.generatedAt < refreshMs) {
var cached = await readPromptPool();
if (!force && cached && Array.isArray(cached.examples) && cached.examples.length && (refreshMs === 0 || (cached.generatedAt && now - cached.generatedAt < refreshMs))) {
return cached.examples;
}
if (refreshPromise) return refreshPromise;
@ -282,15 +284,30 @@ function createClinicalPromptPool(opts) {
if (!examples.length) return [];
var payload = { generatedAt: Date.now(), target: target, examples: examples };
memoryCache = { expiresAt: Date.now() + cacheMs, examples: examples };
return writePromptPool(payload).then(function() { return examples; });
return writePromptPool(payload, context).then(function() { return examples; });
}).catch(function(e) {
refreshPromise = null;
console.warn('[clinical-assistant] prompt pool refresh failed:', e.message);
return [];
return cached && Array.isArray(cached.examples) ? cached.examples : [];
});
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 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', '');
@ -322,11 +339,7 @@ function createClinicalPromptPool(opts) {
}
generated = normalizeGeneratedExamples(generated.concat(categoryExamples));
}
generated = normalizeGeneratedExamples(generated.concat(taxonomyFallbackExamples(taxonomy, generated.length)));
if (generated.length < target) {
generated = normalizeGeneratedExamples(generated.concat(taxonomySupplementExamples(taxonomy, target - generated.length)));
}
if (generated.length < 8) return fallbackIndexedExamples();
if (generated.length < 8) return [];
return generated.slice(0, target);
}
@ -371,6 +384,10 @@ function createClinicalPromptPool(opts) {
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) {
var seen = new Set();
var out = [];
@ -393,10 +410,12 @@ function createClinicalPromptPool(opts) {
return out.slice(0, target);
}
async function writePromptPool(payload) {
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,
@ -428,6 +447,11 @@ function createClinicalPromptPool(opts) {
}, 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) {
@ -475,73 +499,6 @@ function createClinicalPromptPool(opts) {
});
}
function taxonomyFallbackExamples(taxonomy, existingCount) {
var templates = [
'How should I evaluate a {age} with {seed}, focusing on {focus}?',
'What red flags change disposition for a {age} with {seed} and {focus}?',
'What pediatric differential fits {seed} in a {age} with {focus}?',
'What initial management is recommended for a {age} with {seed} and {focus}?',
'When should a {age} with {seed} be admitted because of {focus}?',
'What return precautions fit {seed} in a {age} with {focus}?'
];
var focusAreas = [
'severity assessment',
'diagnostic workup',
'age-specific risks',
'medication dosing',
'fluid management',
'family safety netting',
'follow-up timing',
'comorbid conditions',
'initial stabilization',
'consultation thresholds'
];
var examples = [];
taxonomy.forEach(function(item) {
var needed = Math.max(0, item.quota - Math.floor(existingCount / taxonomy.length));
var itemExamples = [];
var seen = new Set();
for (var i = 0; i < needed * 3 && itemExamples.length < needed; i++) {
var seed = item.seeds[i % item.seeds.length];
var seedPhrase = seed.split(/\s+/).slice(0, 5).join(' ');
var ageBand = item.ageBands[i % item.ageBands.length];
var template = templates[i % templates.length];
var focus = focusAreas[Math.floor(i / (templates.length * item.seeds.length)) % focusAreas.length];
var example = {
label: cleanExampleLabel(seedPhrase.split(/\s+/).slice(0, 4).join(' ')),
prompt: template.replace('{age}', ageBand.replace('_', ' ') + ' patient').replace('{seed}', seedPhrase).replace('{focus}', focus),
category: item.category,
age_band: ageBand,
intent: ['diagnosis', 'red_flags', 'differential', 'management', 'admission', 'counseling'][i % 6]
};
var key = example.prompt.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
if (seen.has(key)) continue;
seen.add(key);
itemExamples.push(example);
}
examples = examples.concat(itemExamples);
});
return examples;
}
function taxonomySupplementExamples(taxonomy, needed) {
var examples = [];
for (var i = 0; i < needed * 2 && examples.length < needed; i++) {
var item = taxonomy[i % taxonomy.length];
var seedPhrase = item.seeds[i % item.seeds.length].split(/\s+/).slice(0, 5).join(' ');
var ageBand = item.ageBands[i % item.ageBands.length];
var categoryLabel = item.category.replace(/_/g, ' ');
examples.push({
label: cleanExampleLabel(categoryLabel + ' review'),
prompt: 'For pediatric ' + categoryLabel + ' cases, how should I handle a ' + ageBand.replace('_', ' ') + ' patient with ' + seedPhrase + '?',
category: item.category,
age_band: ageBand,
intent: ['diagnosis', 'management', 'red_flags', 'differential', 'counseling', 'dosing', 'admission', 'review'][i % 8]
});
}
return examples;
}
function sanitizeCategory(category, defaultTaxonomy) {
category = String(category || '').trim().toLowerCase().replace(/[\s/-]+/g, '_');
category = CATEGORY_ALIASES[category] || CATEGORY_ALIASES[category.replace(/_/g, ' ')] || category;
@ -614,8 +571,16 @@ function createClinicalPromptPool(opts) {
return {
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 };

View file

@ -34,7 +34,11 @@ async function getJson(key) {
async function setJson(key, value, ttlSeconds) {
var redis = await getRedis();
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;
}

View file

@ -14,17 +14,35 @@ 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, /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', () => {