fix: polish clinical assistant citations
This commit is contained in:
parent
b21c921526
commit
af7eb3b750
7 changed files with 258 additions and 64 deletions
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 () {
|
||||
|
|
|
|||
|
|
@ -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' }); }
|
||||
|
|
|
|||
|
|
@ -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.';
|
||||
|
|
|
|||
Loading…
Reference in a new issue