diff --git a/public/components/admin.html b/public/components/admin.html index 0ef4245..4b0ac4d 100644 --- a/public/components/admin.html +++ b/public/components/admin.html @@ -318,6 +318,17 @@
+
+
+
Starter prompt pool
+
Checking prompt pool...
+
+ +
+ + +
+
diff --git a/public/js/admin.js b/public/js/admin.js index a8ad23f..48ecab8 100644 --- a/public/js/admin.js +++ b/public/js/admin.js @@ -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'); diff --git a/src/db/database.js b/src/db/database.js index cf56f52..c889a0b 100644 --- a/src/db/database.js +++ b/src/db/database.js @@ -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( diff --git a/src/routes/adminConfig.js b/src/routes/adminConfig.js index 8dc589b..d15f2b8 100644 --- a/src/routes/adminConfig.js +++ b/src/routes/adminConfig.js @@ -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 { diff --git a/src/routes/clinicalAssistant.js b/src/routes/clinicalAssistant.js index 2d02a84..5dc3ceb 100644 --- a/src/routes/clinicalAssistant.js +++ b/src/routes/clinicalAssistant.js @@ -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) { @@ -568,6 +570,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 +635,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) { diff --git a/src/utils/clinicalPromptPool.js b/src/utils/clinicalPromptPool.js index 4f85001..025d041 100644 --- a/src/utils/clinicalPromptPool.js +++ b/src/utils/clinicalPromptPool.js @@ -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', ''); @@ -367,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 = []; @@ -389,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, @@ -424,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) { @@ -543,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 }; diff --git a/src/utils/redis.js b/src/utils/redis.js index e6702ef..332e5a6 100644 --- a/src/utils/redis.js +++ b/src/utils/redis.js @@ -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; } diff --git a/test/clinical-assistant-prompt-pool.test.js b/test/clinical-assistant-prompt-pool.test.js index 475e083..6c16ef8 100644 --- a/test/clinical-assistant-prompt-pool.test.js +++ b/test/clinical-assistant-prompt-pool.test.js @@ -14,12 +14,16 @@ 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/); @@ -29,6 +33,18 @@ test('clinical assistant starter prompts are Redis or indexed-source backed', () 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', () => { const answer = read('src/utils/clinicalAnswer.js'); assert.match(answer, /If the user names a specific source, textbook, guideline, or table/);