feat: improve clinical assistant prompts
All checks were successful
Forgejo Android APK / Build signed APK (push) Successful in 2m15s
All checks were successful
Forgejo Android APK / Build signed APK (push) Successful in 2m15s
This commit is contained in:
parent
d02b9e2771
commit
b0e1f4969a
8 changed files with 294 additions and 14 deletions
|
|
@ -318,6 +318,17 @@
|
||||||
<button id="btn-test-assistant-chat-model" class="btn-sm btn-primary" type="button"><i class="fas fa-vial"></i> Test</button>
|
<button id="btn-test-assistant-chat-model" class="btn-sm btn-primary" type="button"><i class="fas fa-vial"></i> Test</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="assistant-chat-test-result" style="font-size:12px;color:var(--g500);"></div>
|
<div id="assistant-chat-test-result" style="font-size:12px;color:var(--g500);"></div>
|
||||||
|
<div style="border:1px solid var(--g100);border-radius:8px;padding:10px;display:flex;gap:10px;align-items:center;justify-content:space-between;flex-wrap:wrap;">
|
||||||
|
<div>
|
||||||
|
<div style="font-size:13px;font-weight:600;color:var(--g700);">Starter prompt pool</div>
|
||||||
|
<div id="assistant-prompt-pool-status" style="font-size:12px;color:var(--g500);margin-top:3px;">Checking prompt pool...</div>
|
||||||
|
</div>
|
||||||
|
<button id="btn-regenerate-assistant-prompt-pool" class="btn-sm btn-ghost" type="button"><i class="fas fa-rotate"></i> Regenerate Pool</button>
|
||||||
|
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;width:100%;">
|
||||||
|
<select id="assistant-prompt-pool-snapshots" style="font-size:12px;padding:5px 8px;border:1px solid var(--g300);border-radius:6px;flex:1;min-width:220px;"><option value="">Loading snapshots...</option></select>
|
||||||
|
<button id="btn-restore-assistant-prompt-pool" class="btn-sm btn-ghost" type="button"><i class="fas fa-clock-rotate-left"></i> Restore Snapshot</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;">
|
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;">
|
||||||
<label style="font-size:13px;font-weight:600;min-width:130px;">Image model:</label>
|
<label style="font-size:13px;font-weight:600;min-width:130px;">Image model:</label>
|
||||||
<select id="assistant-image-model" style="font-size:13px;padding:5px 8px;border:1px solid var(--g300);border-radius:6px;flex:1;max-width:420px;"></select>
|
<select id="assistant-image-model" style="font-size:13px;padding:5px 8px;border:1px solid var(--g300);border-radius:6px;flex:1;max-width:420px;"></select>
|
||||||
|
|
|
||||||
|
|
@ -701,6 +701,8 @@ function adminFlashButtonBackground(btn, color) {
|
||||||
document.addEventListener('click', function(e) {
|
document.addEventListener('click', function(e) {
|
||||||
if (e.target.closest('#btn-save-assistant-config')) saveAssistantAdmin();
|
if (e.target.closest('#btn-save-assistant-config')) saveAssistantAdmin();
|
||||||
if (e.target.closest('#btn-test-assistant-chat-model')) testAssistantChatModel();
|
if (e.target.closest('#btn-test-assistant-chat-model')) testAssistantChatModel();
|
||||||
|
if (e.target.closest('#btn-regenerate-assistant-prompt-pool')) regenerateAssistantPromptPool();
|
||||||
|
if (e.target.closest('#btn-restore-assistant-prompt-pool')) restoreAssistantPromptPool();
|
||||||
if (e.target.closest('#btn-refresh-assistant-image-models')) loadAssistantImageModels();
|
if (e.target.closest('#btn-refresh-assistant-image-models')) loadAssistantImageModels();
|
||||||
if (e.target.closest('#btn-test-assistant-image-model')) testAssistantImageModel();
|
if (e.target.closest('#btn-test-assistant-image-model')) testAssistantImageModel();
|
||||||
if (e.target.closest('#btn-use-custom-assistant-image-model')) useCustomAssistantImageModel();
|
if (e.target.closest('#btn-use-custom-assistant-image-model')) useCustomAssistantImageModel();
|
||||||
|
|
@ -741,6 +743,7 @@ function adminFlashButtonBackground(btn, color) {
|
||||||
}
|
}
|
||||||
window._assistantImageModelValue = cfg['clinical_assistant.image_model'] || '';
|
window._assistantImageModelValue = cfg['clinical_assistant.image_model'] || '';
|
||||||
loadAssistantImageModels();
|
loadAssistantImageModels();
|
||||||
|
loadAssistantPromptPoolStatus();
|
||||||
setValue('assistant-search-limit', cfg['clinical_assistant.search_limit'] || '8');
|
setValue('assistant-search-limit', cfg['clinical_assistant.search_limit'] || '8');
|
||||||
setValue('assistant-context-chars', cfg['clinical_assistant.context_chars'] || '1400');
|
setValue('assistant-context-chars', cfg['clinical_assistant.context_chars'] || '1400');
|
||||||
setValue('assistant-system-behavior', cfg['clinical_assistant.system_behavior'] || defaults.behavior);
|
setValue('assistant-system-behavior', cfg['clinical_assistant.system_behavior'] || defaults.behavior);
|
||||||
|
|
@ -810,6 +813,91 @@ function adminFlashButtonBackground(btn, color) {
|
||||||
return match ? match[1] : '';
|
return match ? match[1] : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function loadAssistantPromptPoolStatus() {
|
||||||
|
var result = document.getElementById('assistant-prompt-pool-status');
|
||||||
|
if (result) result.textContent = 'Checking prompt pool...';
|
||||||
|
fetch('/api/admin/clinical-assistant/prompt-pool', { headers: getAuthHeaders() })
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(data) {
|
||||||
|
if (!data.success) throw new Error(data.error || 'Prompt pool status failed');
|
||||||
|
renderAssistantPromptPoolStatus(data.meta);
|
||||||
|
renderAssistantPromptPoolSnapshots(data.snapshots || []);
|
||||||
|
})
|
||||||
|
.catch(function(err) {
|
||||||
|
if (result) result.textContent = err.message;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function regenerateAssistantPromptPool() {
|
||||||
|
var result = document.getElementById('assistant-prompt-pool-status');
|
||||||
|
var btn = document.getElementById('btn-regenerate-assistant-prompt-pool');
|
||||||
|
if (btn) btn.disabled = true;
|
||||||
|
if (result) result.textContent = 'Regenerating prompt pool. This can take several minutes...';
|
||||||
|
fetch('/api/admin/clinical-assistant/prompt-pool/regenerate', {
|
||||||
|
method: 'POST', headers: getAuthHeaders(), body: JSON.stringify({})
|
||||||
|
}).then(function(r) { return r.json(); }).then(function(data) {
|
||||||
|
if (!data.success) throw new Error(data.error || 'Prompt pool regeneration failed');
|
||||||
|
renderAssistantPromptPoolStatus(data.meta || { count: data.count, generatedAt: Date.now() });
|
||||||
|
renderAssistantPromptPoolSnapshots(data.snapshots || []);
|
||||||
|
showToast('Prompt pool regenerated', 'success');
|
||||||
|
}).catch(function(err) {
|
||||||
|
if (result) result.textContent = err.message;
|
||||||
|
showToast(err.message, 'error');
|
||||||
|
}).finally(function() {
|
||||||
|
if (btn) btn.disabled = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAssistantPromptPoolStatus(meta) {
|
||||||
|
var result = document.getElementById('assistant-prompt-pool-status');
|
||||||
|
if (!result) return;
|
||||||
|
if (!meta) {
|
||||||
|
result.textContent = 'No generated pool found. The assistant will use indexed-topic fallback until you generate one.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var generated = meta.generatedAt ? new Date(meta.generatedAt).toLocaleString() : 'unknown time';
|
||||||
|
result.textContent = 'Generated pool: ' + (meta.count || 0) + ' prompts, target ' + (meta.target || '?') + ', generated ' + generated + (meta.restoredFrom ? ', restored from snapshot #' + meta.restoredFrom : '') + '.';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAssistantPromptPoolSnapshots(snapshots) {
|
||||||
|
var select = document.getElementById('assistant-prompt-pool-snapshots');
|
||||||
|
if (!select) return;
|
||||||
|
select.innerHTML = '';
|
||||||
|
if (!snapshots.length) {
|
||||||
|
var empty = document.createElement('option');
|
||||||
|
empty.value = '';
|
||||||
|
empty.textContent = 'No saved snapshots';
|
||||||
|
select.appendChild(empty);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
snapshots.forEach(function(s) {
|
||||||
|
var opt = document.createElement('option');
|
||||||
|
opt.value = s.id;
|
||||||
|
var date = s.created_at ? new Date(s.created_at).toLocaleString() : 'unknown time';
|
||||||
|
opt.textContent = '#' + s.id + ' - ' + (s.count || 0) + ' prompts - ' + date + (s.restored_from ? ' (restored from #' + s.restored_from + ')' : '');
|
||||||
|
select.appendChild(opt);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreAssistantPromptPool() {
|
||||||
|
var select = document.getElementById('assistant-prompt-pool-snapshots');
|
||||||
|
var id = select ? select.value : '';
|
||||||
|
var result = document.getElementById('assistant-prompt-pool-status');
|
||||||
|
if (!id) { showToast('Select a prompt pool snapshot', 'error'); return; }
|
||||||
|
if (result) result.textContent = 'Restoring prompt pool snapshot #' + id + '...';
|
||||||
|
fetch('/api/admin/clinical-assistant/prompt-pool/restore', {
|
||||||
|
method: 'POST', headers: getAuthHeaders(), body: JSON.stringify({ id: Number(id) })
|
||||||
|
}).then(function(r) { return r.json(); }).then(function(data) {
|
||||||
|
if (!data.success) throw new Error(data.error || 'Prompt pool restore failed');
|
||||||
|
renderAssistantPromptPoolStatus(data.meta || { count: data.count, generatedAt: Date.now(), restoredFrom: id });
|
||||||
|
renderAssistantPromptPoolSnapshots(data.snapshots || []);
|
||||||
|
showToast('Prompt pool restored', 'success');
|
||||||
|
}).catch(function(err) {
|
||||||
|
if (result) result.textContent = err.message;
|
||||||
|
showToast(err.message, 'error');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function testAssistantImageModel() {
|
function testAssistantImageModel() {
|
||||||
var model = getValue('assistant-image-model') || 'openai-gpt-image-1';
|
var model = getValue('assistant-image-model') || 'openai-gpt-image-1';
|
||||||
var result = document.getElementById('assistant-image-test-result');
|
var result = document.getElementById('assistant-image-test-result');
|
||||||
|
|
|
||||||
|
|
@ -376,6 +376,26 @@ async function initDatabase() {
|
||||||
console.warn('⚠️ Could not create milestones table:', e.message);
|
console.warn('⚠️ Could not create milestones table:', e.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Durable history for generated clinical assistant starter prompts.
|
||||||
|
// Redis serves the active pool; Postgres keeps snapshots for rollback.
|
||||||
|
try { await client.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS clinical_prompt_pool_snapshots (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
generated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
target INTEGER DEFAULT 0,
|
||||||
|
count INTEGER DEFAULT 0,
|
||||||
|
payload JSONB NOT NULL,
|
||||||
|
restored_from INTEGER REFERENCES clinical_prompt_pool_snapshots(id) ON DELETE SET NULL,
|
||||||
|
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_prompt_pool_snapshots_created ON clinical_prompt_pool_snapshots(created_at DESC);
|
||||||
|
`);
|
||||||
|
console.log('✅ clinical_prompt_pool_snapshots: table ready');
|
||||||
|
} catch(e) {
|
||||||
|
console.warn('⚠️ Could not create clinical prompt pool snapshots table:', e.message);
|
||||||
|
}
|
||||||
|
|
||||||
// Create IVFFLAT index for fast similarity search (after data is populated)
|
// Create IVFFLAT index for fast similarity search (after data is populated)
|
||||||
try {
|
try {
|
||||||
var indexExists = await client.query(
|
var indexExists = await client.query(
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,55 @@ router.get('/config', async function(req, res) {
|
||||||
} catch (e) { res.status(500).json({ error: 'Request failed' }); }
|
} catch (e) { res.status(500).json({ error: 'Request failed' }); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Clinical assistant starter prompt pool ─────────────────────────────────
|
||||||
|
router.get('/clinical-assistant/prompt-pool', async function(req, res) {
|
||||||
|
try {
|
||||||
|
var clinicalAssistant = require('./clinicalAssistant');
|
||||||
|
var meta = await clinicalAssistant.getPromptPoolMeta();
|
||||||
|
var snapshots = await clinicalAssistant.getPromptPoolSnapshots(20);
|
||||||
|
res.json({ success: true, meta: meta || null, snapshots: snapshots });
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ error: e.message || 'Prompt pool status failed' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/clinical-assistant/prompt-pool/snapshots', async function(req, res) {
|
||||||
|
try {
|
||||||
|
var clinicalAssistant = require('./clinicalAssistant');
|
||||||
|
var snapshots = await clinicalAssistant.getPromptPoolSnapshots(req.query.limit || 20);
|
||||||
|
res.json({ success: true, snapshots: snapshots });
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ error: e.message || 'Prompt pool snapshot listing failed' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/clinical-assistant/prompt-pool/regenerate', async function(req, res) {
|
||||||
|
try {
|
||||||
|
var clinicalAssistant = require('./clinicalAssistant');
|
||||||
|
var examples = await clinicalAssistant.refreshPromptPool(true, req.user && req.user.id);
|
||||||
|
var meta = await clinicalAssistant.getPromptPoolMeta();
|
||||||
|
var snapshots = await clinicalAssistant.getPromptPoolSnapshots(20);
|
||||||
|
res.json({ success: true, count: examples.length, meta: meta || null, snapshots: snapshots });
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ error: e.message || 'Prompt pool regeneration failed' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/clinical-assistant/prompt-pool/restore', async function(req, res) {
|
||||||
|
try {
|
||||||
|
var clinicalAssistant = require('./clinicalAssistant');
|
||||||
|
var id = Number(req.body && req.body.id);
|
||||||
|
if (!Number.isFinite(id) || id <= 0) return res.status(400).json({ error: 'Snapshot id is required' });
|
||||||
|
var payload = await clinicalAssistant.restorePromptPoolSnapshot(id, req.user && req.user.id);
|
||||||
|
if (!payload) return res.status(404).json({ error: 'Snapshot not found' });
|
||||||
|
var meta = await clinicalAssistant.getPromptPoolMeta();
|
||||||
|
var snapshots = await clinicalAssistant.getPromptPoolSnapshots(20);
|
||||||
|
res.json({ success: true, count: Array.isArray(payload.examples) ? payload.examples.length : 0, meta: meta || null, snapshots: snapshots });
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ error: e.message || 'Prompt pool restore failed' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ── POST send test email ───────────────────────────────────────────────────
|
// ── POST send test email ───────────────────────────────────────────────────
|
||||||
router.post('/config/test-email', async function(req, res) {
|
router.post('/config/test-email', async function(req, res) {
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,9 @@ var promptPool = createClinicalPromptPool({
|
||||||
semanticSearch: semanticSearch,
|
semanticSearch: semanticSearch,
|
||||||
dedupeSources: dedupeSources,
|
dedupeSources: dedupeSources,
|
||||||
normalizeMcpSearchResponse: normalizeMcpSearchResponse,
|
normalizeMcpSearchResponse: normalizeMcpSearchResponse,
|
||||||
getIndexedTopicExamples: getIndexedTopicExamples
|
getIndexedTopicExamples: getIndexedTopicExamples,
|
||||||
|
loadStoredPromptPool: loadLatestPromptPoolSnapshot,
|
||||||
|
savePromptPool: savePromptPoolSnapshot
|
||||||
});
|
});
|
||||||
|
|
||||||
function positiveInt(value, fallback) {
|
function positiveInt(value, fallback) {
|
||||||
|
|
@ -568,6 +570,49 @@ async function getAvailableExamples() {
|
||||||
return promptPool.getAvailableExamples();
|
return promptPool.getAvailableExamples();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
router.refreshPromptPool = function(force, userId) {
|
||||||
|
return promptPool.refreshIfNeeded(force !== false, { userId: userId || null });
|
||||||
|
};
|
||||||
|
|
||||||
|
router.getPromptPoolMeta = function() {
|
||||||
|
return promptPool.getMeta();
|
||||||
|
};
|
||||||
|
|
||||||
|
router.getPromptPoolSnapshots = getPromptPoolSnapshots;
|
||||||
|
router.restorePromptPoolSnapshot = restorePromptPoolSnapshot;
|
||||||
|
|
||||||
|
async function savePromptPoolSnapshot(payload, context) {
|
||||||
|
context = context || {};
|
||||||
|
var result = await db.run(
|
||||||
|
'INSERT INTO clinical_prompt_pool_snapshots (generated_at, target, count, payload, restored_from, created_by) VALUES (to_timestamp($1 / 1000.0), $2, $3, $4::jsonb, $5, $6) RETURNING id',
|
||||||
|
[payload.generatedAt || Date.now(), payload.target || 0, Array.isArray(payload.examples) ? payload.examples.length : 0, JSON.stringify(payload), context.restoredFrom || null, context.userId || null]
|
||||||
|
);
|
||||||
|
return result.lastInsertRowid;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadLatestPromptPoolSnapshot() {
|
||||||
|
var row = await db.get('SELECT payload FROM clinical_prompt_pool_snapshots ORDER BY created_at DESC, id DESC LIMIT 1', []);
|
||||||
|
if (!row || !row.payload) return null;
|
||||||
|
return typeof row.payload === 'string' ? JSON.parse(row.payload) : row.payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getPromptPoolSnapshots(limit) {
|
||||||
|
limit = clampInt(limit || 20, 1, 50, 20);
|
||||||
|
return db.all(
|
||||||
|
'SELECT id, generated_at, target, count, restored_from, created_at FROM clinical_prompt_pool_snapshots ORDER BY created_at DESC, id DESC LIMIT $1',
|
||||||
|
[limit]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restorePromptPoolSnapshot(id, userId) {
|
||||||
|
var row = await db.get('SELECT id, payload FROM clinical_prompt_pool_snapshots WHERE id = $1', [id]);
|
||||||
|
if (!row || !row.payload) return null;
|
||||||
|
var original = typeof row.payload === 'string' ? JSON.parse(row.payload) : row.payload;
|
||||||
|
var payload = Object.assign({}, original, { generatedAt: Date.now(), restoredFrom: Number(row.id) });
|
||||||
|
await promptPool.writePromptPool(payload, { userId: userId || null, restoredFrom: Number(row.id) });
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
async function getIndexedTopicExamples() {
|
async function getIndexedTopicExamples() {
|
||||||
var response = await indexedTopicSuggestions(12);
|
var response = await indexedTopicSuggestions(12);
|
||||||
var data = response && (response.structuredContent || response.data || response);
|
var data = response && (response.structuredContent || response.data || response);
|
||||||
|
|
@ -590,7 +635,18 @@ async function getIndexedTopicExamples() {
|
||||||
sourceTitle: Array.isArray(item.sample_titles) ? item.sample_titles[0] : '',
|
sourceTitle: Array.isArray(item.sample_titles) ? item.sample_titles[0] : '',
|
||||||
category: item.category || ''
|
category: item.category || ''
|
||||||
};
|
};
|
||||||
}).filter(function(item) { return item.prompt; });
|
}).filter(isUsefulIndexedTopicExample);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isUsefulIndexedTopicExample(item) {
|
||||||
|
var label = String(item && item.label || '');
|
||||||
|
var prompt = String(item && item.prompt || '');
|
||||||
|
if (!prompt || prompt.indexOf('?') === -1) return false;
|
||||||
|
var haystack = (label + ' ' + prompt + ' ' + String(item.sourceTitle || '')).toLowerCase();
|
||||||
|
if (/\b(start|end) of picture text\b/.test(haystack)) return false;
|
||||||
|
if (/\b(comparative|cross[-\s]?sectional|retrospective|prospective) study\b/.test(label.toLowerCase())) return false;
|
||||||
|
if (/\b(prevalence|correlation|association) of\b/.test(label.toLowerCase())) return false;
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function generateImage(prompt, model) {
|
async function generateImage(prompt, model) {
|
||||||
|
|
|
||||||
|
|
@ -243,22 +243,23 @@ function parseJsonObject(text) {
|
||||||
function createClinicalPromptPool(opts) {
|
function createClinicalPromptPool(opts) {
|
||||||
opts = opts || {};
|
opts = opts || {};
|
||||||
var cacheMs = positiveInt(process.env.CLINICAL_ASSISTANT_EXAMPLE_CACHE_MS, 10 * 60 * 1000);
|
var cacheMs = positiveInt(process.env.CLINICAL_ASSISTANT_EXAMPLE_CACHE_MS, 10 * 60 * 1000);
|
||||||
var refreshMs = positiveInt(process.env.CLINICAL_ASSISTANT_PROMPT_POOL_REFRESH_MS, 24 * 60 * 60 * 1000);
|
var refreshMs = nonNegativeInt(process.env.CLINICAL_ASSISTANT_PROMPT_POOL_REFRESH_MS, 7 * 24 * 60 * 60 * 1000);
|
||||||
var ttlSeconds = Math.max(3600, Math.ceil(refreshMs / 1000) * 2);
|
var ttlSeconds = refreshMs > 0 ? Math.max(3600, Math.ceil(refreshMs / 1000) * 2) : 0;
|
||||||
var target = positiveInt(process.env.CLINICAL_ASSISTANT_PROMPT_POOL_TARGET, 1000);
|
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 redisBaseKey = String(process.env.CLINICAL_ASSISTANT_PROMPT_POOL_KEY || 'clinical-assistant:prompt-pool:v2').replace(/:all$/, '');
|
||||||
var redisAllKey = redisBaseKey + ':all';
|
var redisAllKey = redisBaseKey + ':all';
|
||||||
var redisMetaKey = redisBaseKey + ':meta';
|
var redisMetaKey = redisBaseKey + ':meta';
|
||||||
|
var redisLastGoodKey = redisBaseKey + ':last-good';
|
||||||
var memoryCache = { expiresAt: 0, examples: [] };
|
var memoryCache = { expiresAt: 0, examples: [] };
|
||||||
var refreshPromise = null;
|
var refreshPromise = null;
|
||||||
|
|
||||||
async function getAvailableExamples() {
|
async function getAvailableExamples() {
|
||||||
var now = Date.now();
|
var now = Date.now();
|
||||||
if (memoryCache.expiresAt > now && memoryCache.examples.length) return memoryCache.examples;
|
if (memoryCache.expiresAt > now && memoryCache.examples.length) return memoryCache.examples;
|
||||||
var cached = await opts.redisCache.getJson(redisAllKey).catch(function() { return null; });
|
var cached = await readPromptPool();
|
||||||
if (cached && Array.isArray(cached.examples) && cached.examples.length) {
|
if (cached && Array.isArray(cached.examples) && cached.examples.length) {
|
||||||
memoryCache = { expiresAt: now + cacheMs, examples: cached.examples };
|
memoryCache = { expiresAt: now + cacheMs, examples: cached.examples };
|
||||||
if (!cached.generatedAt || now - cached.generatedAt > refreshMs) refreshIfNeeded(false).catch(function() {});
|
if (refreshMs > 0 && (!cached.generatedAt || now - cached.generatedAt > refreshMs)) refreshIfNeeded(false).catch(function() {});
|
||||||
return cached.examples;
|
return cached.examples;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -270,10 +271,11 @@ function createClinicalPromptPool(opts) {
|
||||||
return examples;
|
return examples;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshIfNeeded(force) {
|
async function refreshIfNeeded(force, context) {
|
||||||
|
context = context || {};
|
||||||
var now = Date.now();
|
var now = Date.now();
|
||||||
var cached = await opts.redisCache.getJson(redisAllKey).catch(function() { return null; });
|
var cached = await readPromptPool();
|
||||||
if (!force && cached && Array.isArray(cached.examples) && cached.examples.length && cached.generatedAt && now - cached.generatedAt < refreshMs) {
|
if (!force && cached && Array.isArray(cached.examples) && cached.examples.length && (refreshMs === 0 || (cached.generatedAt && now - cached.generatedAt < refreshMs))) {
|
||||||
return cached.examples;
|
return cached.examples;
|
||||||
}
|
}
|
||||||
if (refreshPromise) return refreshPromise;
|
if (refreshPromise) return refreshPromise;
|
||||||
|
|
@ -282,15 +284,30 @@ function createClinicalPromptPool(opts) {
|
||||||
if (!examples.length) return [];
|
if (!examples.length) return [];
|
||||||
var payload = { generatedAt: Date.now(), target: target, examples: examples };
|
var payload = { generatedAt: Date.now(), target: target, examples: examples };
|
||||||
memoryCache = { expiresAt: Date.now() + cacheMs, examples: examples };
|
memoryCache = { expiresAt: Date.now() + cacheMs, examples: examples };
|
||||||
return writePromptPool(payload).then(function() { return examples; });
|
return writePromptPool(payload, context).then(function() { return examples; });
|
||||||
}).catch(function(e) {
|
}).catch(function(e) {
|
||||||
refreshPromise = null;
|
refreshPromise = null;
|
||||||
console.warn('[clinical-assistant] prompt pool refresh failed:', e.message);
|
console.warn('[clinical-assistant] prompt pool refresh failed:', e.message);
|
||||||
return [];
|
return cached && Array.isArray(cached.examples) ? cached.examples : [];
|
||||||
});
|
});
|
||||||
return refreshPromise;
|
return refreshPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function readPromptPool() {
|
||||||
|
var cached = await opts.redisCache.getJson(redisAllKey).catch(function() { return null; });
|
||||||
|
if (cached && Array.isArray(cached.examples) && cached.examples.length) return cached;
|
||||||
|
var lastGood = await opts.redisCache.getJson(redisLastGoodKey).catch(function() { return null; });
|
||||||
|
if (lastGood && Array.isArray(lastGood.examples) && lastGood.examples.length) return lastGood;
|
||||||
|
if (typeof opts.loadStoredPromptPool === 'function') {
|
||||||
|
var stored = await opts.loadStoredPromptPool().catch(function() { return null; });
|
||||||
|
if (stored && Array.isArray(stored.examples) && stored.examples.length) {
|
||||||
|
await writePromptPool(stored, { skipStore: true }).catch(function() {});
|
||||||
|
return stored;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
async function buildCorpusPromptPool() {
|
async function buildCorpusPromptPool() {
|
||||||
var taxonomy = taxonomyWithQuotas(target);
|
var taxonomy = taxonomyWithQuotas(target);
|
||||||
var chatModel = await opts.getSetting('clinical_assistant.prompt_model', '') || process.env.CLINICAL_ASSISTANT_PROMPT_MODEL || await opts.getSetting('clinical_assistant.chat_model', '') || await opts.getSetting('models.default', '');
|
var 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 []; }) : [];
|
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, defaultTaxonomy) {
|
||||||
var seen = new Set();
|
var seen = new Set();
|
||||||
var out = [];
|
var out = [];
|
||||||
|
|
@ -389,10 +410,12 @@ function createClinicalPromptPool(opts) {
|
||||||
return out.slice(0, target);
|
return out.slice(0, target);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function writePromptPool(payload) {
|
async function writePromptPool(payload, context) {
|
||||||
|
context = context || {};
|
||||||
var counts = buildPoolCounts(payload.examples);
|
var counts = buildPoolCounts(payload.examples);
|
||||||
var writes = [
|
var writes = [
|
||||||
opts.redisCache.setJson(redisAllKey, payload, ttlSeconds),
|
opts.redisCache.setJson(redisAllKey, payload, ttlSeconds),
|
||||||
|
opts.redisCache.setJson(redisLastGoodKey, payload, 0),
|
||||||
opts.redisCache.setJson(redisMetaKey, {
|
opts.redisCache.setJson(redisMetaKey, {
|
||||||
generatedAt: payload.generatedAt,
|
generatedAt: payload.generatedAt,
|
||||||
target: target,
|
target: target,
|
||||||
|
|
@ -424,6 +447,11 @@ function createClinicalPromptPool(opts) {
|
||||||
}, ttlSeconds));
|
}, ttlSeconds));
|
||||||
});
|
});
|
||||||
await Promise.all(writes);
|
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) {
|
function buildPoolCounts(examples) {
|
||||||
|
|
@ -543,8 +571,16 @@ function createClinicalPromptPool(opts) {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
getAvailableExamples: getAvailableExamples,
|
getAvailableExamples: getAvailableExamples,
|
||||||
refreshIfNeeded: refreshIfNeeded
|
refreshIfNeeded: refreshIfNeeded,
|
||||||
|
getMeta: getMeta,
|
||||||
|
writePromptPool: writePromptPool
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function nonNegativeInt(value, fallback) {
|
||||||
|
if (value == null || value === '') return fallback;
|
||||||
|
var n = Number(value);
|
||||||
|
return Number.isFinite(n) && n >= 0 ? Math.floor(n) : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = { createClinicalPromptPool };
|
module.exports = { createClinicalPromptPool };
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,11 @@ async function getJson(key) {
|
||||||
async function setJson(key, value, ttlSeconds) {
|
async function setJson(key, value, ttlSeconds) {
|
||||||
var redis = await getRedis();
|
var redis = await getRedis();
|
||||||
if (!redis) return false;
|
if (!redis) return false;
|
||||||
await redis.set(key, JSON.stringify(value), { EX: ttlSeconds });
|
if (ttlSeconds && Number(ttlSeconds) > 0) {
|
||||||
|
await redis.set(key, JSON.stringify(value), { EX: Math.floor(Number(ttlSeconds)) });
|
||||||
|
} else {
|
||||||
|
await redis.set(key, JSON.stringify(value));
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,12 +14,16 @@ test('clinical assistant starter prompts are Redis or indexed-source backed', ()
|
||||||
const pool = read('src/utils/clinicalPromptPool.js');
|
const pool = read('src/utils/clinicalPromptPool.js');
|
||||||
assert.doesNotMatch(route, /EXAMPLE_CANDIDATES|TOPIC_SUGGESTIONS|topic_suggestions|buildSuggestionAnswer/);
|
assert.doesNotMatch(route, /EXAMPLE_CANDIDATES|TOPIC_SUGGESTIONS|topic_suggestions|buildSuggestionAnswer/);
|
||||||
assert.match(route, /createClinicalPromptPool\(\{[\s\S]*redisCache: redisCache/);
|
assert.match(route, /createClinicalPromptPool\(\{[\s\S]*redisCache: redisCache/);
|
||||||
|
assert.match(route, /isUsefulIndexedTopicExample/);
|
||||||
assert.match(pool, /clinical-assistant:prompt-pool:v2/);
|
assert.match(pool, /clinical-assistant:prompt-pool:v2/);
|
||||||
assert.match(pool, /redisBaseKey \+ ':all'/);
|
assert.match(pool, /redisBaseKey \+ ':all'/);
|
||||||
|
assert.match(pool, /redisBaseKey \+ ':last-good'/);
|
||||||
assert.match(pool, /redisBaseKey \+ ':category:' \+ category/);
|
assert.match(pool, /redisBaseKey \+ ':category:' \+ category/);
|
||||||
assert.match(pool, /redisBaseKey \+ ':age:' \+ ageBand/);
|
assert.match(pool, /redisBaseKey \+ ':age:' \+ ageBand/);
|
||||||
assert.match(pool, /redisBaseKey \+ ':intent:' \+ intent/);
|
assert.match(pool, /redisBaseKey \+ ':intent:' \+ intent/);
|
||||||
assert.match(pool, /CLINICAL_ASSISTANT_PROMPT_POOL_TARGET, 1000/);
|
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, /PEDIATRIC_TAXONOMY/);
|
||||||
assert.match(pool, /taxonomyWithQuotas\(target\)/);
|
assert.match(pool, /taxonomyWithQuotas\(target\)/);
|
||||||
assert.match(pool, /age_band: ageBand/);
|
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\(\)/);
|
assert.doesNotMatch(pool, /return fallbackIndexedExamples\(\)/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('clinical assistant prompt pool can be regenerated by admins', () => {
|
||||||
|
const admin = read('src/routes/adminConfig.js');
|
||||||
|
const page = read('public/components/admin.html');
|
||||||
|
const js = read('public/js/admin.js');
|
||||||
|
assert.match(admin, /\/clinical-assistant\/prompt-pool\/regenerate/);
|
||||||
|
assert.match(admin, /\/clinical-assistant\/prompt-pool\/restore/);
|
||||||
|
assert.match(page, /btn-regenerate-assistant-prompt-pool/);
|
||||||
|
assert.match(page, /btn-restore-assistant-prompt-pool/);
|
||||||
|
assert.match(js, /regenerateAssistantPromptPool/);
|
||||||
|
assert.match(js, /restoreAssistantPromptPool/);
|
||||||
|
});
|
||||||
|
|
||||||
test('clinical assistant prompt forbids relabeling other sources as named sources', () => {
|
test('clinical assistant prompt forbids relabeling other sources as named sources', () => {
|
||||||
const answer = read('src/utils/clinicalAnswer.js');
|
const answer = read('src/utils/clinicalAnswer.js');
|
||||||
assert.match(answer, /If the user names a specific source, textbook, guideline, or table/);
|
assert.match(answer, /If the user names a specific source, textbook, guideline, or table/);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue