fix: polish clinical assistant citations

This commit is contained in:
Daniel 2026-05-06 23:04:27 +02:00
parent b21c921526
commit af7eb3b750
7 changed files with 258 additions and 64 deletions

View file

@ -305,6 +305,47 @@
</div>
</div>
<!-- ── Clinical Assistant ─────────────────────────────────────── -->
<div class="card">
<div class="card-header">
<h3><i class="fas fa-brain"></i> Clinical Assistant</h3>
<span style="font-size:12px;color:var(--g500);">Chat and image model overrides for the assistant</span>
</div>
<div style="padding:16px;display:flex;flex-direction:column;gap:14px;">
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;">
<label style="font-size:13px;font-weight:600;min-width:130px;">Chat model:</label>
<select id="assistant-chat-model" style="font-size:13px;padding:5px 8px;border:1px solid var(--g300);border-radius:6px;flex:1;max-width:420px;"></select>
<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="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>
<button id="btn-refresh-assistant-image-models" class="btn-sm btn-ghost" type="button"><i class="fas fa-rotate"></i> Refresh</button>
<button id="btn-test-assistant-image-model" class="btn-sm btn-primary" type="button"><i class="fas fa-image"></i> Test</button>
</div>
<div id="assistant-image-test-result" style="font-size:12px;color:var(--g500);"></div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;">
<div>
<label style="font-size:12px;font-weight:600;color:var(--g600);display:block;margin-bottom:4px;">Search result limit</label>
<input id="assistant-search-limit" type="number" min="3" max="20" value="8" style="width:100%;font-size:13px;padding:6px 10px;border:1px solid var(--g300);border-radius:6px;">
</div>
<div>
<label style="font-size:12px;font-weight:600;color:var(--g600);display:block;margin-bottom:4px;">Context chars</label>
<input id="assistant-context-chars" type="number" min="300" max="4000" value="1400" style="width:100%;font-size:13px;padding:6px 10px;border:1px solid var(--g300);border-radius:6px;">
</div>
</div>
<div>
<label style="font-size:12px;font-weight:600;color:var(--g600);display:block;margin-bottom:4px;">System behavior</label>
<textarea id="assistant-system-behavior" rows="5" style="width:100%;font-size:12px;font-family:monospace;padding:8px;border:1px solid var(--g300);border-radius:6px;resize:vertical;box-sizing:border-box;" placeholder="Concise clinical assistant behavior..."></textarea>
</div>
<div>
<button id="btn-save-assistant-config" class="btn-sm btn-primary"><i class="fas fa-floppy-disk"></i> Save Assistant Settings</button>
<span id="assistant-admin-status" style="font-size:12px;color:var(--g500);margin-left:8px;"></span>
</div>
</div>
</div>
<!-- ── TTS Model Management ───────────────────────────────────── -->
<div class="card">
<div class="card-header">
@ -342,45 +383,6 @@
</div>
</div>
<!-- ── Clinical Assistant ─────────────────────────────────────── -->
<div class="card">
<div class="card-header">
<h3><i class="fas fa-brain"></i> Clinical Assistant</h3>
<span style="font-size:12px;color:var(--g500);">Uses indexed Nextcloud resources through direct MCP</span>
</div>
<div style="padding:16px;display:flex;flex-direction:column;gap:14px;">
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;">
<label style="font-size:13px;font-weight:600;min-width:130px;">Chat model:</label>
<select id="assistant-chat-model" style="font-size:13px;padding:5px 8px;border:1px solid var(--g300);border-radius:6px;flex:1;max-width:420px;"></select>
</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>
<button id="btn-refresh-assistant-image-models" class="btn-sm btn-ghost" type="button"><i class="fas fa-rotate"></i> Refresh</button>
<button id="btn-test-assistant-image-model" class="btn-sm btn-primary" type="button"><i class="fas fa-image"></i> Test</button>
</div>
<div id="assistant-image-test-result" style="font-size:12px;color:var(--g500);"></div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;">
<div>
<label style="font-size:12px;font-weight:600;color:var(--g600);display:block;margin-bottom:4px;">Search result limit</label>
<input id="assistant-search-limit" type="number" min="3" max="20" value="8" style="width:100%;font-size:13px;padding:6px 10px;border:1px solid var(--g300);border-radius:6px;">
</div>
<div>
<label style="font-size:12px;font-weight:600;color:var(--g600);display:block;margin-bottom:4px;">Context chars</label>
<input id="assistant-context-chars" type="number" min="300" max="4000" value="1400" style="width:100%;font-size:13px;padding:6px 10px;border:1px solid var(--g300);border-radius:6px;">
</div>
</div>
<div>
<label style="font-size:12px;font-weight:600;color:var(--g600);display:block;margin-bottom:4px;">System behavior</label>
<textarea id="assistant-system-behavior" rows="5" style="width:100%;font-size:12px;font-family:monospace;padding:8px;border:1px solid var(--g300);border-radius:6px;resize:vertical;box-sizing:border-box;" placeholder="Concise clinical assistant behavior..."></textarea>
</div>
<div>
<button id="btn-save-assistant-config" class="btn-sm btn-primary"><i class="fas fa-floppy-disk"></i> Save Assistant Settings</button>
<span id="assistant-admin-status" style="font-size:12px;color:var(--g500);margin-left:8px;"></span>
</div>
</div>
</div>
<!-- ── STT Model Management ───────────────────────────────────── -->
<div class="card">
<div class="card-header">

View file

@ -479,7 +479,7 @@
<script defer src="/js/speechRecognition.js"></script>
<script defer src="/js/transcriptionSettings.js"></script>
<script defer src="/js/voicePreferences.js"></script>
<script defer src="/js/app.js"></script>
<script defer src="/js/app.js?v=7.1.3"></script>
<script defer src="/js/ui-state.js"></script>
<script defer src="/js/secureStorage.js"></script>
<script defer src="/js/authFetch.js"></script>
@ -509,7 +509,7 @@
<script defer src="/js/calculators.js"></script>
<script type="module" src="/js/bedside/index.js"></script>
<script defer src="/js/learningHub.js"></script>
<script defer src="/js/admin.js"></script>
<script defer src="/js/admin.js?v=7.1.3"></script>
<!-- ═══════════ IMAGE LIGHTBOX (global overlay — triggered by any
[data-img-src] button in any tab; lives here so it exists in the

View file

@ -676,6 +676,7 @@
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-refresh-assistant-image-models')) loadAssistantImageModels();
if (e.target.closest('#btn-test-assistant-image-model')) testAssistantImageModel();
});
@ -692,14 +693,26 @@
var chatSelect = document.getElementById('assistant-chat-model');
if (chatSelect) {
chatSelect.innerHTML = '<option value="">Use global default</option>';
var savedChatModel = cfg['clinical_assistant.chat_model'] || '';
var defaultLabel = modelsData.defaultModel ? ('Use global default (' + modelsData.defaultModel + ')') : 'Use global default';
chatSelect.innerHTML = '';
var defaultOpt = document.createElement('option');
defaultOpt.value = '';
defaultOpt.textContent = defaultLabel;
chatSelect.appendChild(defaultOpt);
(modelsData.models || []).forEach(function(m) {
var opt = document.createElement('option');
opt.value = m.id;
opt.textContent = m.name || m.id;
chatSelect.appendChild(opt);
});
chatSelect.value = cfg['clinical_assistant.chat_model'] || '';
if (savedChatModel && !Array.prototype.some.call(chatSelect.options, function(o) { return o.value === savedChatModel; })) {
var saved = document.createElement('option');
saved.value = savedChatModel;
saved.textContent = savedChatModel + ' (saved/custom)';
chatSelect.appendChild(saved);
}
chatSelect.value = savedChatModel;
}
window._assistantImageModelValue = cfg['clinical_assistant.image_model'] || '';
loadAssistantImageModels();
@ -743,6 +756,35 @@
});
}
function testAssistantChatModel() {
var model = getValue('assistant-chat-model');
var result = document.getElementById('assistant-chat-test-result');
if (!model) {
var select = document.getElementById('assistant-chat-model');
var selected = select && select.options[select.selectedIndex] ? select.options[select.selectedIndex].textContent : 'global default';
if (result) result.textContent = 'Testing ' + selected + '...';
} else if (result) {
result.textContent = 'Testing ' + model + '...';
}
fetch('/api/admin/config/models/test', {
method: 'POST', headers: getAuthHeaders(), body: JSON.stringify({ modelId: model || getGlobalDefaultFromAssistantSelect() })
}).then(function(r) { return r.json(); }).then(function(data) {
if (!data.success) throw new Error(data.error || 'Chat model test failed');
if (result) result.textContent = 'Chat model OK (' + data.duration + ' ms): ' + (data.response || '').trim();
showToast('Chat model works', 'success');
}).catch(function(err) {
if (result) result.textContent = err.message;
showToast(err.message, 'error');
});
}
function getGlobalDefaultFromAssistantSelect() {
var select = document.getElementById('assistant-chat-model');
if (!select || !select.options[0]) return '';
var match = select.options[0].textContent.match(/\((.+)\)$/);
return match ? match[1] : '';
}
function testAssistantImageModel() {
var model = getValue('assistant-image-model') || 'openai-gpt-image-1';
var result = document.getElementById('assistant-image-test-result');

View file

@ -26,6 +26,7 @@
document.addEventListener('DOMContentLoaded', function() {
// --- COMPONENT LOADER (lazy-load tab HTML from /components/) ---
var COMPONENT_VERSION = '7.1.3';
var _componentCache = {};
var _componentLoading = {};
@ -39,7 +40,7 @@ document.addEventListener('DOMContentLoaded', function() {
}
if (_componentLoading[component]) return _componentLoading[component];
_componentLoading[component] = fetch('/components/' + component + '.html')
_componentLoading[component] = fetch('/components/' + component + '.html?v=' + COMPONENT_VERSION)
.then(function(r) { if (!r.ok) throw new Error(r.status); return r.text(); })
.then(function(html) {
_componentCache[component] = html;

View file

@ -11,6 +11,29 @@
var lastAnswer = '';
var lastSources = [];
var mermaidReady = false;
var dynamicExamples = [];
var EMPTY_PROMPT_SETS = [
[
{ label: 'Status asthma escalation', prompt: 'In a 4-year-old with acute wheeze, when should magnesium sulfate be considered and what dose is recommended?' },
{ label: 'Bilious vomiting', prompt: 'What are the red flags for bilious vomiting in neonates, and what immediate workup is recommended?' },
{ label: 'Bronchiolitis vs asthma', prompt: 'Compare bronchiolitis and asthma management in infants, citing pediatric references.' }
],
[
{ label: 'Febrile neonate', prompt: 'What initial workup and empiric antibiotics are recommended for a well-appearing febrile neonate?' },
{ label: 'DKA fluids', prompt: 'Summarize pediatric DKA fluid strategy and cerebral edema warning signs.' },
{ label: 'Croup escalation', prompt: 'When should a child with croup receive nebulized epinephrine and observation?' }
],
[
{ label: 'Sepsis bolus', prompt: 'In pediatric septic shock, when should fluid boluses be limited and vasoactives started?' },
{ label: 'Head injury CT', prompt: 'Which pediatric head injury features should prompt CT or observation?' },
{ label: 'UTI fever', prompt: 'How should febrile UTI be evaluated and treated in young children?' }
],
[
{ label: 'Sickle pain fever', prompt: 'How should fever and pain crisis be managed in a child with sickle cell disease?' },
{ label: 'Neonatal jaundice', prompt: 'What bilirubin risk factors change phototherapy decisions in a newborn?' },
{ label: 'Status seizure', prompt: 'Outline first- and second-line treatment for pediatric status epilepticus.' }
]
];
document.addEventListener('tabChanged', function (e) {
if (e.detail && e.detail.tab === 'assistant') initIfNeeded();
@ -23,6 +46,7 @@
initialized = true;
bindEvents();
loadStatus();
loadExamples();
}
function bindEvents() {
@ -40,15 +64,7 @@
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') onAsk(e);
});
document.querySelectorAll('[data-assistant-example]').forEach(function (btn) {
btn.addEventListener('click', function () {
var el = document.getElementById('assistant-input');
if (el) {
el.value = btn.getAttribute('data-assistant-example') || '';
el.focus();
}
});
});
bindExampleButtons(document);
}
function loadStatus() {
@ -65,6 +81,21 @@
});
}
function loadExamples() {
fetch('/api/clinical-assistant/examples', { headers: getAuthHeaders(), credentials: 'same-origin' })
.then(function (r) { return r.json(); })
.then(function (data) {
if (!data.success || !Array.isArray(data.examples) || !data.examples.length) return;
dynamicExamples = data.examples;
var wrap = document.getElementById('assistant-messages');
if (wrap && wrap.querySelector('.assistant-empty')) {
wrap.innerHTML = renderEmptyState();
bindExampleButtons(wrap);
}
})
.catch(function () {});
}
function onAsk(e) {
if (e) e.preventDefault();
var input = document.getElementById('assistant-input');
@ -267,7 +298,7 @@
return '<div class="assistant-source" id="assistant-source-' + n + '">' +
'<strong>[' + n + '] ' + escapeHtml(s.title || s.resource || 'Untitled source') + '</strong>' +
'<div class="assistant-source-meta">' + escapeHtml(meta.join(' · ') || 'indexed source') + '</div>' +
(s.excerpt ? '<div class="assistant-source-excerpt">' + renderMarkdown(String(s.excerpt).slice(0, 900), []) + '</div>' : '') +
(s.excerpt ? '<div class="assistant-source-excerpt"><p>' + escapeHtml(String(s.excerpt).slice(0, 900)) + '</p></div>' : '') +
'</div>';
}).join('');
}
@ -349,10 +380,49 @@
lastAnswer = '';
lastSources = [];
var wrap = document.getElementById('assistant-messages');
if (wrap) wrap.innerHTML = '<div class="assistant-empty"><i class="fas fa-book-medical"></i><h3>Evidence-first pediatric assistant</h3><p>Ask a focused clinical question over indexed resources.</p></div>';
if (wrap) {
wrap.innerHTML = renderEmptyState();
bindExampleButtons(wrap);
}
renderSources([]);
}
function renderEmptyState() {
var examples = pickExamples();
return '<div class="assistant-empty"><i class="fas fa-book-medical"></i>' +
'<h3>Evidence-first pediatric assistant</h3>' +
'<p>Ask a real clinical question. Citations stay linked to the source cards on the right.</p>' +
'<div class="assistant-examples">' + examples.map(function (item) {
var source = item.sourceTitle ? (' title="Available from: ' + escapeAttr(item.sourceTitle + (item.page ? ', page ' + item.page : '')) + '"') : '';
return '<button type="button" data-assistant-example="' + escapeAttr(item.prompt) + '"' + source + '>' + escapeHtml(item.label) + '</button>';
}).join('') + '</div></div>';
}
function pickExamples() {
var examples = dynamicExamples.length ? dynamicExamples.slice() : EMPTY_PROMPT_SETS[Math.floor(Math.random() * EMPTY_PROMPT_SETS.length)].slice();
for (var i = examples.length - 1; i > 0; i--) {
var j = Math.floor(Math.random() * (i + 1));
var tmp = examples[i];
examples[i] = examples[j];
examples[j] = tmp;
}
return examples.slice(0, 3);
}
function bindExampleButtons(root) {
(root || document).querySelectorAll('[data-assistant-example]').forEach(function (btn) {
if (btn.getAttribute('data-assistant-bound') === 'true') return;
btn.setAttribute('data-assistant-bound', 'true');
btn.addEventListener('click', function () {
var el = document.getElementById('assistant-input');
if (el) {
el.value = btn.getAttribute('data-assistant-example') || '';
el.focus();
}
});
});
}
function copyLastAnswer() {
if (!lastAnswer) { if (typeof showToast === 'function') showToast('No answer to copy', 'error'); return; }
navigator.clipboard.writeText(lastAnswer).then(function () {

View file

@ -253,6 +253,7 @@ router.put('/config/models/toggle', async function(req, res) {
}
await db.setSetting('models.disabled', JSON.stringify(disabled));
require('../utils/models').invalidateAllowedModelsCache();
logger.audit(req.user.id, 'admin_model_toggle', (enabled ? 'Enabled' : 'Disabled') + ' model: ' + modelId, req, { category: 'admin' });
res.json({ success: true });
} catch (e) { res.status(500).json({ error: 'Request failed' }); }
@ -264,6 +265,7 @@ router.put('/config/models/default', async function(req, res) {
var { modelId } = req.body;
if (!modelId) return res.status(400).json({ error: 'modelId required' });
await db.setSetting('models.default', modelId.trim());
require('../utils/models').invalidateAllowedModelsCache();
logger.audit(req.user.id, 'admin_model_default', 'Set default model: ' + modelId, req, { category: 'admin' });
res.json({ success: true });
} catch (e) { res.status(500).json({ error: 'Request failed' }); }
@ -300,6 +302,7 @@ router.post('/config/models/custom', async function(req, res) {
custom.push({ id: trimmedId, name: name.trim().substring(0, 100), cost: (cost || '?').substring(0, 20), category: cat, tag: 'CUSTOM' });
await db.setSetting('models.custom', JSON.stringify(custom));
require('../utils/models').invalidateAllowedModelsCache();
logger.audit(req.user.id, existing ? 'admin_model_update' : 'admin_model_add', (existing ? 'Updated' : 'Added') + ' custom model: ' + trimmedId, req, { category: 'admin' });
res.json({ success: true });
} catch (e) { res.status(500).json({ error: 'Request failed' }); }
@ -314,6 +317,7 @@ router.delete('/config/models/custom/:modelId(*)', async function(req, res) {
try { custom = JSON.parse(customRaw); } catch(e) { custom = []; }
custom = custom.filter(function(m) { return m.id !== modelId; });
await db.setSetting('models.custom', JSON.stringify(custom));
require('../utils/models').invalidateAllowedModelsCache();
logger.audit(req.user.id, 'admin_model_delete', 'Removed custom model: ' + modelId, req, { category: 'admin' });
res.json({ success: true });
} catch (e) { res.status(500).json({ error: 'Request failed' }); }
@ -325,6 +329,7 @@ router.post('/config/models/clear-all', async function(req, res) {
await db.setSetting('models.custom', '[]');
await db.setSetting('models.disabled', '[]');
await db.setSetting('models.default', '');
require('../utils/models').invalidateAllowedModelsCache();
logger.audit(req.user.id, 'admin_models_clear_all', 'Cleared all custom models and disabled list', req, { category: 'admin' });
res.json({ success: true });
} catch (e) { res.status(500).json({ error: 'Request failed' }); }
@ -365,6 +370,7 @@ router.post('/config/models/add-discovered', async function(req, res) {
custom.push({ id: trimmedId, name: name.trim().substring(0, 100), cost: (cost || '?').substring(0, 20), category: cat, tag: 'DISCOVERED' });
await db.setSetting('models.custom', JSON.stringify(custom));
require('../utils/models').invalidateAllowedModelsCache();
logger.audit(req.user.id, 'admin_model_discover_add', 'Added discovered model: ' + trimmedId, req, { category: 'admin' });
res.json({ success: true, id: trimmedId });
} catch (e) { res.status(500).json({ error: 'Request failed' }); }

View file

@ -20,6 +20,20 @@ var MCP_URLS = buildMcpUrls();
var _lastGoodMcpUrl = MCP_URLS[0];
var DEFAULT_BEHAVIOR = 'You are a concise pediatric clinical assistant. Use retrieved context only for factual claims. If the user input is a greeting or too vague, answer briefly and ask what they want to look up. Synthesize across sources, cite claims with numbered citations like [1], and list sources at the bottom by title/resource and page. Do not invent citations.';
var GREETING_RE = /^(hi|hello|hey|yo|good\s+(morning|afternoon|evening)|thanks|thank you|ok|okay|sup)[\s.!?]*$/i;
var EXAMPLE_CACHE_MS = 10 * 60 * 1000;
var _exampleCache = { expiresAt: 0, examples: [] };
var EXAMPLE_CANDIDATES = [
{ label: 'Status asthma escalation', prompt: 'In a 4-year-old with acute wheeze, when should magnesium sulfate be considered and what dose is recommended?' },
{ label: 'Asthma ED disposition', prompt: 'What clinical features support discharge versus admission after a pediatric asthma exacerbation?' },
{ label: 'Bronchiolitis support', prompt: 'What evidence-based supportive care is recommended for bronchiolitis, and when is admission indicated?' },
{ label: 'Febrile neonate', prompt: 'What initial workup and empiric antibiotics are recommended for a well-appearing febrile neonate?' },
{ label: 'Neonatal resuscitation', prompt: 'Summarize the first minute steps in neonatal resuscitation and when positive pressure ventilation is indicated.' },
{ label: 'DKA cerebral edema', prompt: 'What warning signs suggest cerebral edema during pediatric DKA treatment, and what immediate treatment is recommended?' },
{ label: 'Status epilepticus', prompt: 'Outline first- and second-line treatment for pediatric status epilepticus with typical dosing.' },
{ label: 'Croup escalation', prompt: 'When should a child with croup receive nebulized epinephrine, and how long should they be observed?' },
{ label: 'Sepsis fluids', prompt: 'In pediatric septic shock, when should fluid boluses be limited and vasoactives started?' },
{ label: 'Bilious vomiting', prompt: 'What are the red flags for bilious vomiting in neonates, and what immediate workup is recommended?' }
];
var TOPIC_SUGGESTIONS = {
asthma: [
'Management of acute asthma exacerbation by severity',
@ -85,6 +99,16 @@ router.get('/clinical-assistant/status', async function(req, res) {
}
});
router.get('/clinical-assistant/examples', async function(req, res) {
try {
var examples = await getAvailableExamples();
res.json({ success: true, examples: examples });
} catch (e) {
console.warn('[clinical-assistant] example discovery failed:', e.message);
res.json({ success: true, examples: [] });
}
});
router.post('/clinical-assistant/chat', async function(req, res) {
var started = Date.now();
try {
@ -402,13 +426,14 @@ function normalizeCitationOrder(answer, sources) {
sources = Array.isArray(sources) ? sources : [];
var used = [];
var oldToNew = {};
var re = /\[(\d+)\]/g;
var re = /\[((?:\d+\s*,\s*)*\d+)\]/g;
var m;
while ((m = re.exec(answer))) {
var oldNum = Number(m[1]);
if (!sources[oldNum - 1] || oldToNew[oldNum]) continue;
oldToNew[oldNum] = used.length + 1;
used.push(oldNum);
parseCitationCluster(m[1]).forEach(function(oldNum) {
if (!sources[oldNum - 1] || oldToNew[oldNum]) return;
oldToNew[oldNum] = used.length + 1;
used.push(oldNum);
});
}
var reordered = [];
@ -420,14 +445,54 @@ function normalizeCitationOrder(answer, sources) {
return Object.assign({}, source, { number: idx + 1 });
});
var rewritten = answer.replace(/\[(\d+)\]/g, function(match, n) {
var oldNum = Number(n);
return oldToNew[oldNum] ? '[' + oldToNew[oldNum] + ']' : match;
var rewritten = answer.replace(/\[((?:\d+\s*,\s*)*\d+)\]/g, function(match, cluster) {
var nums = parseCitationCluster(cluster).map(function(oldNum) { return oldToNew[oldNum]; }).filter(Boolean);
if (!nums.length) return match;
nums = Array.from(new Set(nums)).sort(function(a, b) { return a - b; });
return '[' + nums.join(',') + ']';
});
return { answer: rewritten, sources: reordered };
}
function parseCitationCluster(cluster) {
return String(cluster || '').split(',').map(function(n) { return Number(n.trim()); }).filter(function(n) { return Number.isFinite(n); });
}
async function getAvailableExamples() {
var now = Date.now();
if (_exampleCache.expiresAt > now && _exampleCache.examples.length) return _exampleCache.examples;
var examples = [];
for (var i = 0; i < EXAMPLE_CANDIDATES.length && examples.length < 6; i++) {
var item = EXAMPLE_CANDIDATES[i];
try {
var searchResponse = await semanticSearch(item.prompt, {
limit: 2,
includeContext: true,
contextChars: 500
});
var source = dedupeSources(normalizeMcpSearchResponse(searchResponse))[0];
if (!source) continue;
examples.push({
label: item.label,
prompt: item.prompt,
sourceTitle: source.title,
page: source.page || null
});
} catch (e) {
if (examples.length === 0) throw e;
break;
}
}
_exampleCache = {
expiresAt: now + EXAMPLE_CACHE_MS,
examples: examples
};
return examples;
}
async function generateImage(prompt, model) {
if (!process.env.LITELLM_API_BASE) throw new Error('LiteLLM is required for image generation');
var headers = { 'Content-Type': 'application/json' };
@ -455,7 +520,15 @@ function clampInt(value, min, max, fallback) {
}
function clip(s, n) { return String(s || '').replace(/\s+/g, ' ').trim().substring(0, n); }
function cleanTitle(s) { return String(s || '').replace(/\.(pdf|docx?|txt|md)$/i, '').trim(); }
function cleanTitle(s) {
var title = String(s || '').trim();
try { title = decodeURIComponent(title); } catch (e) {}
return title
.replace(/\.(pdf|docx?|txt|md)$/i, '')
.replace(/\s+/g, ' ')
.replace(/\s*\(z-library\.sk,\s*1lib\.sk,\s*z-lib\.sk\)\s*/ig, '')
.trim();
}
function assistantErrorMessage(e) {
var msg = e && e.message ? e.message : String(e);
if (/ECONNREFUSED|fetch failed|Failed to open SSE|MCP/i.test(msg)) return 'Could not reach the MCP search server. Check CLINICAL_ASSISTANT_MCP_URL or the MCP container.';