feat: add clinical assistant MCP search
Some checks failed
Build & release Android APK / Build signed APK (push) Has been cancelled
Build TWA APK / build-apk (push) Has been cancelled
Build & Push Docker Image / Build linux/amd64 (push) Has been cancelled
Build & Push Docker Image / Build linux/arm64 (push) Has been cancelled
Build & Push Docker Image / Merge manifests (push) Has been cancelled

This commit is contained in:
Daniel 2026-05-06 07:31:32 +02:00
parent 0710c4de8e
commit 96c4565b9c
9 changed files with 1063 additions and 2 deletions

View file

@ -6,6 +6,8 @@ services:
- "127.0.0.1:3552:3000"
env_file:
- .env
environment:
CLINICAL_ASSISTANT_MCP_URL: http://mcp:8000/mcp
volumes:
- scribe-logs:/app/data/logs
depends_on:
@ -13,6 +15,9 @@ services:
condition: service_healthy
restart: unless-stopped
container_name: pediatric-ai-scribe
networks:
- default
- mcp-server_default
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000/api/health"]
interval: 30s
@ -43,3 +48,7 @@ services:
volumes:
pgdata:
scribe-logs:
networks:
mcp-server_default:
external: true

View file

@ -342,6 +342,45 @@
</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">
@ -419,4 +458,3 @@
</div>
</div>
</div>

View file

@ -0,0 +1,121 @@
<div class="module-header assistant-header">
<div>
<h2><i class="fas fa-brain" style="color:var(--purple);"></i> AI Clinical Assistant</h2>
<p>Ask focused clinical questions over indexed Nextcloud books/resources. Answers should synthesize retrieved evidence and cite book/resource titles with pages.</p>
</div>
<div class="assistant-status" id="assistant-status">
<span class="assistant-dot"></span>
<span id="assistant-status-text">Ready</span>
</div>
</div>
<div class="assistant-layout">
<section class="assistant-main card">
<div class="assistant-toolbar">
<div>
<strong>Clinical Question</strong>
<span id="assistant-model-label" class="model-tag">Admin model</span>
</div>
<div class="assistant-toolbar-actions">
<button id="btn-assistant-clear" class="btn-sm btn-ghost" type="button"><i class="fas fa-rotate-left"></i> Clear</button>
<button id="btn-assistant-copy" class="btn-sm btn-ghost" type="button"><i class="fas fa-copy"></i> Copy answer</button>
</div>
</div>
<div id="assistant-messages" class="assistant-messages" aria-live="polite">
<div class="assistant-empty">
<i class="fas fa-book-medical"></i>
<h3>Evidence-first pediatric assistant</h3>
<p>Ask a real clinical question. Short greetings or vague inputs get a short clarifying response instead of unnecessary textbook output.</p>
<div class="assistant-examples">
<button type="button" data-assistant-example="In a 4-year-old with acute wheeze, when should magnesium sulfate be considered and what dose is recommended?">Status asthma escalation</button>
<button type="button" data-assistant-example="What are the red flags for bilious vomiting in neonates, and what immediate workup is recommended?">Bilious vomiting</button>
<button type="button" data-assistant-example="Compare bronchiolitis and asthma management in infants, citing pediatric references.">Bronchiolitis vs asthma</button>
</div>
</div>
</div>
<form id="assistant-form" class="assistant-composer">
<textarea id="assistant-input" rows="3" placeholder="Ask a focused clinical question..." autocomplete="off"></textarea>
<div class="assistant-composer-footer">
<label class="assistant-check"><input type="checkbox" id="assistant-include-context" checked> retrieve broader context</label>
<button id="btn-assistant-send" class="btn-generate" type="submit"><i class="fas fa-paper-plane"></i> Ask</button>
</div>
</form>
</section>
<aside class="assistant-side">
<div class="card">
<div class="card-header"><h3><i class="fas fa-wand-magic-sparkles"></i> Image / Graph</h3></div>
<div class="assistant-side-body">
<textarea id="assistant-image-prompt" rows="4" placeholder="Generate a teaching image, pathway, or infographic from the current answer..."></textarea>
<button id="btn-assistant-image" class="btn-sm btn-primary" type="button"><i class="fas fa-image"></i> Generate image</button>
<div id="assistant-visual-output" class="assistant-visual-output"></div>
</div>
</div>
<div class="card">
<div class="card-header"><h3><i class="fas fa-quote-right"></i> Sources</h3></div>
<div id="assistant-sources" class="assistant-sources">
<p class="assistant-muted">Citations appear here after an answer.</p>
</div>
</div>
<div class="card">
<div class="card-header"><h3><i class="fas fa-circle-info"></i> Behavior</h3></div>
<div class="assistant-side-body assistant-muted">
<p>Answers should be concise, clinically practical, and citation-grounded.</p>
<p>If the input is only a greeting or too vague, the assistant should ask what you want to look up.</p>
</div>
</div>
</aside>
</div>
<style>
.assistant-header { display:flex; justify-content:space-between; gap:12px; align-items:flex-start; }
.assistant-status { display:flex; align-items:center; gap:6px; font-size:12px; color:var(--g500); background:white; border:1px solid var(--g200); border-radius:999px; padding:5px 10px; box-shadow:var(--shadow); }
.assistant-dot { width:8px; height:8px; border-radius:50%; background:var(--green); display:inline-block; }
.assistant-status.busy .assistant-dot { background:var(--amber); animation:pulse 1.5s infinite; }
.assistant-status.error .assistant-dot { background:var(--red); }
.assistant-layout { display:grid; grid-template-columns:minmax(0,1fr) 330px; gap:14px; align-items:start; }
.assistant-main { display:grid; grid-template-rows:auto minmax(420px,1fr) auto; min-height:calc(100vh - 190px); }
.assistant-toolbar { display:flex; justify-content:space-between; align-items:center; gap:10px; padding:10px 14px; border-bottom:1px solid var(--g200); background:var(--g50); }
.assistant-toolbar-actions { display:flex; gap:6px; flex-wrap:wrap; }
.assistant-messages { padding:16px; overflow-y:auto; background:linear-gradient(180deg,#fff,var(--g50)); }
.assistant-empty { max-width:680px; margin:50px auto; text-align:center; color:var(--g500); }
.assistant-empty i { font-size:34px; color:var(--purple); margin-bottom:10px; }
.assistant-empty h3 { color:var(--g800); font-size:18px; margin-bottom:6px; }
.assistant-examples { display:flex; gap:8px; flex-wrap:wrap; justify-content:center; margin-top:16px; }
.assistant-examples button { border:1px solid var(--g200); background:white; color:var(--blue); border-radius:999px; padding:7px 10px; font-size:12px; cursor:pointer; }
.assistant-suggestion-buttons { display:flex; gap:8px; flex-wrap:wrap; margin-top:12px; }
.assistant-suggestion-buttons button { border:1px solid var(--purple-light); background:#faf5ff; color:var(--purple); border-radius:999px; padding:7px 10px; font-size:12px; cursor:pointer; text-align:left; }
.assistant-suggestion-buttons button:hover { border-color:var(--purple); background:var(--purple-light); }
.assistant-msg { max-width:900px; margin:0 0 14px; display:grid; gap:6px; }
.assistant-msg.user { margin-left:auto; max-width:760px; }
.assistant-msg-label { font-size:11px; font-weight:700; color:var(--g400); text-transform:uppercase; letter-spacing:.04em; }
.assistant-bubble { border:1px solid var(--g200); border-radius:14px; padding:12px 14px; background:white; box-shadow:var(--shadow); font-size:13px; line-height:1.75; }
.assistant-msg.user .assistant-bubble { background:var(--blue); color:white; border-color:var(--blue); }
.assistant-bubble p { margin:0 0 10px; }
.assistant-bubble p:last-child { margin-bottom:0; }
.assistant-bubble ul, .assistant-bubble ol { padding-left:20px; margin:8px 0; }
.assistant-bubble code { background:var(--g100); border-radius:4px; padding:1px 4px; }
.assistant-bubble pre { background:var(--g900); color:white; border-radius:8px; padding:10px; overflow:auto; margin:10px 0; }
.assistant-cite { display:inline-flex; align-items:center; justify-content:center; min-width:20px; height:20px; padding:0 6px; border-radius:999px; background:var(--purple-light); color:var(--purple); font-size:11px; font-weight:700; text-decoration:none; }
.assistant-composer { border-top:1px solid var(--g200); padding:12px; background:white; display:grid; gap:8px; }
.assistant-composer textarea, .assistant-side textarea { width:100%; border:1.5px solid var(--g300); border-radius:10px; padding:10px 12px; resize:vertical; font-family:inherit; font-size:13px; outline:none; }
.assistant-composer textarea:focus, .assistant-side textarea:focus { border-color:var(--blue); box-shadow:0 0 0 3px var(--blue-light); }
.assistant-composer-footer { display:flex; justify-content:space-between; align-items:center; gap:10px; }
.assistant-composer-footer .btn-generate { width:auto; margin:0; padding:9px 18px; }
.assistant-check { font-size:12px; color:var(--g500); display:flex; align-items:center; gap:6px; }
.assistant-side { display:grid; gap:12px; }
.assistant-side-body { padding:12px; display:grid; gap:10px; font-size:13px; }
.assistant-visual-output { display:grid; gap:8px; }
.assistant-visual-output img { width:100%; border-radius:10px; border:1px solid var(--g200); background:white; }
.assistant-sources { padding:10px 12px; display:grid; gap:8px; max-height:360px; overflow-y:auto; }
.assistant-source { border:1px solid var(--g200); border-radius:10px; padding:9px; background:white; font-size:12px; line-height:1.5; }
.assistant-source strong { color:var(--g800); }
.assistant-source-meta { color:var(--g500); font-size:11px; margin-top:3px; }
.assistant-muted { color:var(--g500); font-size:12px; line-height:1.6; }
.assistant-mermaid { background:white; border:1px solid var(--g200); border-radius:10px; padding:10px; margin:10px 0; overflow:auto; }
@media (max-width: 960px) { .assistant-layout { grid-template-columns:1fr; } .assistant-main { min-height:auto; grid-template-rows:auto minmax(320px,1fr) auto; } }
</style>

View file

@ -270,6 +270,10 @@
<i class="fas fa-diagram-project"></i>
<span>Diagrams</span>
</button>
<button class="tab-btn" data-tab="assistant">
<i class="fas fa-brain" style="color:var(--purple);"></i>
<span>AI Assistant</span>
</button>
<button class="tab-btn" data-tab="learning">
<i class="fas fa-graduation-cap"></i>
<span>Learning Hub</span>
@ -329,6 +333,7 @@
<section id="extensions-tab" class="tab-content" data-component="extensions"></section>
<section id="notes-tab" class="tab-content" data-component="notes"></section>
<section id="diagrams-tab" class="tab-content" data-component="diagrams"></section>
<section id="assistant-tab" class="tab-content" data-component="assistant"></section>
<section id="learning-tab" class="tab-content" data-component="learning"></section>
<section id="cms-tab" class="tab-content" data-component="cms"></section>
<section id="docs-tab" class="tab-content" data-component="admin-docs"></section>
@ -486,6 +491,7 @@
<script defer src="/js/extensions.js"></script>
<script defer src="/js/notes.js"></script>
<script defer src="/js/diagrams.js"></script>
<script defer src="/js/clinicalAssistant.js"></script>
<script defer src="/js/nextcloud.js"></script>
<script defer src="/js/wellVisit.js"></script>
<script defer src="/js/shadess.js"></script>

View file

@ -659,6 +659,138 @@
})();
// ============================================================
// ADMIN CLINICAL ASSISTANT SETTINGS
// ============================================================
(function() {
var loaded = false;
var defaults = {
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.'
};
document.addEventListener('tabChanged', function(e) {
if (e.detail && e.detail.tab === 'admin') {
if (!loaded) { loadAssistantAdmin(); loaded = true; }
}
});
document.addEventListener('click', function(e) {
if (e.target.closest('#btn-save-assistant-config')) saveAssistantAdmin();
if (e.target.closest('#btn-refresh-assistant-image-models')) loadAssistantImageModels();
if (e.target.closest('#btn-test-assistant-image-model')) testAssistantImageModel();
});
function loadAssistantAdmin() {
Promise.all([
fetch('/api/models', { headers: getAuthHeaders() }).then(function(r) { return r.json(); }).catch(function() { return {}; }),
fetch('/api/admin/config', { headers: getAuthHeaders() }).then(function(r) { return r.json(); }).catch(function() { return {}; })
]).then(function(results) {
var modelsData = results[0] || {};
var configData = results[1] || {};
var cfg = {};
(configData.config || []).forEach(function(row) { cfg[row.key] = row.value; });
var chatSelect = document.getElementById('assistant-chat-model');
if (chatSelect) {
chatSelect.innerHTML = '<option value="">Use global default</option>';
(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'] || '';
}
window._assistantImageModelValue = cfg['clinical_assistant.image_model'] || '';
loadAssistantImageModels();
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);
});
}
function loadAssistantImageModels() {
var sel = document.getElementById('assistant-image-model');
if (!sel) return;
var current = sel.value || window._assistantImageModelValue || '';
sel.innerHTML = '<option value="">Loading image models...</option>';
fetch('/api/admin/config/image-models/discover', { headers: getAuthHeaders() })
.then(function(r) { return r.json(); })
.then(function(data) {
sel.innerHTML = '<option value="">Use default (openai-gpt-image-1)</option>';
(data.models || []).forEach(function(m) {
var opt = document.createElement('option');
opt.value = m.id;
opt.textContent = m.name || m.id;
sel.appendChild(opt);
});
if (current) {
if (!Array.prototype.some.call(sel.options, function(o) { return o.value === current; })) {
var custom = document.createElement('option');
custom.value = current;
custom.textContent = current + ' (saved/custom)';
sel.appendChild(custom);
}
sel.value = current;
}
})
.catch(function() {
sel.innerHTML = '<option value="">Use default (openai-gpt-image-1)</option>';
['openai-gpt-image-1', 'openai-gpt-image-1-mini', 'openai-gpt-image-1.5', 'openai-dall-e-3'].forEach(function(id) {
var opt = document.createElement('option'); opt.value = id; opt.textContent = id; sel.appendChild(opt);
});
if (current) sel.value = current;
});
}
function testAssistantImageModel() {
var model = getValue('assistant-image-model') || 'openai-gpt-image-1';
var result = document.getElementById('assistant-image-test-result');
if (result) result.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Testing ' + escAssistant(model) + '...';
fetch('/api/admin/config/image-models/test', {
method: 'POST', headers: getAuthHeaders(), body: JSON.stringify({ modelId: model })
}).then(function(r) { return r.json(); }).then(function(data) {
if (!data.success) throw new Error(data.error || 'Image test failed');
var src = data.imageUrl || (data.base64 ? ('data:image/png;base64,' + data.base64) : '');
if (result) result.innerHTML = 'Image model OK (' + data.duration + ' ms)' + (src ? '<div style="margin-top:8px;"><img src="' + src + '" alt="test image" style="max-width:180px;border:1px solid var(--g200);border-radius:8px;"></div>' : '');
showToast('Image model works', 'success');
}).catch(function(err) {
if (result) result.textContent = err.message;
showToast(err.message, 'error');
});
}
function saveAssistantAdmin() {
var status = document.getElementById('assistant-admin-status');
if (status) status.textContent = 'Saving...';
Promise.all([
putAssistantConfig('clinical_assistant.chat_model', getValue('assistant-chat-model')),
putAssistantConfig('clinical_assistant.image_model', getValue('assistant-image-model')),
putAssistantConfig('clinical_assistant.search_limit', getValue('assistant-search-limit') || '8'),
putAssistantConfig('clinical_assistant.context_chars', getValue('assistant-context-chars') || '1400'),
putAssistantConfig('clinical_assistant.system_behavior', getValue('assistant-system-behavior') || defaults.behavior)
]).then(function() {
if (status) status.textContent = '';
showToast('Assistant settings saved', 'success');
}).catch(function(err) {
if (status) status.textContent = '';
showToast(err.message || 'Save failed', 'error');
});
}
function putAssistantConfig(key, value) {
return fetch('/api/admin/config/' + encodeURIComponent(key), {
method: 'PUT', headers: getAuthHeaders(), body: JSON.stringify({ value: value })
}).then(function(r) { return r.json(); }).then(function(data) {
if (!data.success) throw new Error(data.error || 'Save failed');
return data;
});
}
function getValue(id) { var el = document.getElementById(id); return el ? el.value.trim() : ''; }
function setValue(id, value) { var el = document.getElementById(id); if (el) el.value = value; }
function escAssistant(str) { return String(str || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); }
})();
// ============================================================
// ADMIN MODEL MANAGEMENT — Discover, search, enable/disable, custom models
// ============================================================

View file

@ -0,0 +1,316 @@
// ============================================================
// CLINICAL ASSISTANT — evidence-grounded chat UI.
// Backend contract intentionally uses /api/clinical-assistant/* so the
// server can call native MCP directly without routing through mcpo.
// ============================================================
(function () {
'use strict';
var initialized = false;
var messages = [];
var lastAnswer = '';
var lastSources = [];
var mermaidReady = false;
document.addEventListener('tabChanged', function (e) {
if (e.detail && e.detail.tab === 'assistant') initIfNeeded();
});
function initIfNeeded() {
if (initialized) return;
var root = document.getElementById('assistant-tab');
if (!root || !root.querySelector('#assistant-form')) return;
initialized = true;
bindEvents();
loadStatus();
}
function bindEvents() {
var form = document.getElementById('assistant-form');
var clearBtn = document.getElementById('btn-assistant-clear');
var copyBtn = document.getElementById('btn-assistant-copy');
var imageBtn = document.getElementById('btn-assistant-image');
var input = document.getElementById('assistant-input');
if (form) form.addEventListener('submit', onAsk);
if (clearBtn) clearBtn.addEventListener('click', clearConversation);
if (copyBtn) copyBtn.addEventListener('click', copyLastAnswer);
if (imageBtn) imageBtn.addEventListener('click', generateImage);
if (input) input.addEventListener('keydown', function (e) {
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();
}
});
});
}
function loadStatus() {
fetch('/api/clinical-assistant/status', { headers: getAuthHeaders(), credentials: 'same-origin' })
.then(function (r) { return r.json(); })
.then(function (data) {
var label = document.getElementById('assistant-model-label');
if (label && data.success) {
label.textContent = data.chatModel ? ('Chat: ' + data.chatModel) : 'Admin model';
}
})
.catch(function () {
// Endpoint may not exist until backend slice lands; keep UI usable.
});
}
function onAsk(e) {
if (e) e.preventDefault();
var input = document.getElementById('assistant-input');
var includeContext = document.getElementById('assistant-include-context');
var text = input ? input.value.trim() : '';
if (!text) {
if (typeof showToast === 'function') showToast('Enter a clinical question', 'error');
return;
}
appendMessage('user', text);
if (input) input.value = '';
setBusy(true, 'Searching indexed resources...');
fetch('/api/clinical-assistant/chat', {
method: 'POST',
headers: getAuthHeaders(),
credentials: 'same-origin',
body: JSON.stringify({
message: text,
history: messages.slice(-8),
includeContext: !includeContext || includeContext.checked
})
})
.then(function (r) { return r.json().then(function (data) { data._status = r.status; return data; }); })
.then(function (data) {
setBusy(false, 'Ready');
if (!data.success) throw new Error(data.error || ('Request failed (' + data._status + ')'));
var answer = data.answer || data.markdown || '';
lastAnswer = answer;
lastSources = data.sources || data.citations || [];
appendMessage('assistant', answer, lastSources, data.suggestions || []);
renderSources(lastSources);
if (data.model) {
var label = document.getElementById('assistant-model-label');
if (label) label.textContent = 'Chat: ' + data.model;
}
})
.catch(function (err) {
setBusy(false, 'Error', true);
appendMessage('assistant', 'I could not complete the assistant request. ' + err.message + '\n\nIf this is the first run, the backend `/api/clinical-assistant/chat` route still needs to be wired to direct MCP search.');
if (typeof showToast === 'function') showToast(err.message, 'error');
});
}
function appendMessage(role, content, sources, suggestions) {
var wrap = document.getElementById('assistant-messages');
if (!wrap) return;
var empty = wrap.querySelector('.assistant-empty');
if (empty) empty.remove();
messages.push({ role: role, content: content });
var row = document.createElement('div');
row.className = 'assistant-msg ' + role;
var label = document.createElement('div');
label.className = 'assistant-msg-label';
label.textContent = role === 'user' ? 'You' : 'Assistant';
var bubble = document.createElement('div');
bubble.className = 'assistant-bubble';
bubble.innerHTML = role === 'assistant' ? renderMarkdown(content, sources || []) : escapeHtml(content);
if (role === 'assistant' && suggestions && suggestions.length) {
bubble.appendChild(renderSuggestionButtons(suggestions));
}
row.appendChild(label);
row.appendChild(bubble);
wrap.appendChild(row);
renderEmbeddedBlocks(bubble);
wrap.scrollTop = wrap.scrollHeight;
}
function renderSuggestionButtons(suggestions) {
var wrap = document.createElement('div');
wrap.className = 'assistant-suggestion-buttons';
suggestions.forEach(function (s) {
var btn = document.createElement('button');
btn.type = 'button';
btn.textContent = s;
btn.addEventListener('click', function () {
var input = document.getElementById('assistant-input');
if (input) input.value = s;
onAsk(new Event('submit'));
});
wrap.appendChild(btn);
});
return wrap;
}
function renderMarkdown(md, sources) {
var text = String(md || '');
var codeBlocks = [];
text = text.replace(/```(\w+)?\n([\s\S]*?)```/g, function (_, lang, code) {
var idx = codeBlocks.length;
codeBlocks.push({ lang: (lang || '').toLowerCase(), code: code });
return '\n@@CODEBLOCK_' + idx + '@@\n';
});
var html = escapeHtml(text)
.replace(/^### (.*)$/gm, '<h3>$1</h3>')
.replace(/^## (.*)$/gm, '<h2>$1</h2>')
.replace(/^# (.*)$/gm, '<h1>$1</h1>')
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.*?)\*/g, '<em>$1</em>')
.replace(/`([^`]+)`/g, '<code>$1</code>')
.replace(/\[(\d+)\]/g, function (_, n) {
var source = sources[Number(n) - 1];
var title = source ? source.title || source.resource || 'Source' : 'Source';
return '<a class="assistant-cite" href="#assistant-source-' + n + '" title="' + escapeHtml(title) + '">[' + n + ']</a>';
});
html = html.split(/\n{2,}/).map(function (block) {
if (/^\s*<(h\d|ul|ol|pre|div)/.test(block)) return block;
var lines = block.split('\n');
if (lines.every(function (l) { return /^\s*[-*] /.test(l) || !l.trim(); })) {
return '<ul>' + lines.filter(Boolean).map(function (l) { return '<li>' + l.replace(/^\s*[-*] /, '') + '</li>'; }).join('') + '</ul>';
}
if (lines.every(function (l) { return /^\s*\d+\. /.test(l) || !l.trim(); })) {
return '<ol>' + lines.filter(Boolean).map(function (l) { return '<li>' + l.replace(/^\s*\d+\. /, '') + '</li>'; }).join('') + '</ol>';
}
return '<p>' + block.replace(/\n/g, '<br>') + '</p>';
}).join('');
html = html.replace(/@@CODEBLOCK_(\d+)@@/g, function (_, idx) {
var block = codeBlocks[Number(idx)] || { lang: '', code: '' };
if (block.lang === 'mermaid') return '<div class="assistant-mermaid" data-mermaid="' + escapeAttr(block.code) + '">Rendering graph...</div>';
if (block.lang === 'chart' || block.lang === 'chartjs') return '<canvas class="assistant-chart" data-chart="' + escapeAttr(block.code) + '"></canvas>';
return '<pre><code>' + escapeHtml(block.code) + '</code></pre>';
});
return sanitize(html);
}
function renderSources(sources) {
var wrap = document.getElementById('assistant-sources');
if (!wrap) return;
if (!sources || sources.length === 0) {
wrap.innerHTML = '<p class="assistant-muted">No citations returned.</p>';
return;
}
wrap.innerHTML = sources.map(function (s, idx) {
var n = idx + 1;
var page = s.page || s.page_number || s.pageNumber;
var meta = [];
if (page) meta.push('page ' + page);
if (s.doc_type || s.type) meta.push(s.doc_type || s.type);
if (s.score != null) meta.push('score ' + Number(s.score).toFixed(3));
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 style="margin-top:6px;color:var(--g600);">' + escapeHtml(s.excerpt).slice(0, 420) + '</div>' : '') +
'</div>';
}).join('');
}
function renderEmbeddedBlocks(root) {
root.querySelectorAll('[data-mermaid]').forEach(function (el) {
ensureMermaid().then(function () {
if (!window.mermaid) { el.textContent = el.getAttribute('data-mermaid'); return; }
var id = 'assistant-mermaid-' + Math.random().toString(16).slice(2);
window.mermaid.render(id, el.getAttribute('data-mermaid') || '')
.then(function (out) { el.innerHTML = out.svg || ''; })
.catch(function () { el.textContent = el.getAttribute('data-mermaid') || ''; });
});
});
root.querySelectorAll('canvas[data-chart]').forEach(function (canvas) {
if (!window.Chart) return;
try {
var cfg = JSON.parse(canvas.getAttribute('data-chart') || '{}');
new window.Chart(canvas.getContext('2d'), cfg);
} catch (e) {
canvas.replaceWith(document.createTextNode('Invalid chart JSON'));
}
});
}
function ensureMermaid() {
if (window.mermaid && mermaidReady) return Promise.resolve();
return new Promise(function (resolve) {
if (window.mermaid) { configureMermaid(); resolve(); return; }
var script = document.createElement('script');
script.src = '/vendor/mermaid.min.js';
script.onload = function () { configureMermaid(); resolve(); };
script.onerror = resolve;
document.head.appendChild(script);
});
}
function configureMermaid() {
if (!window.mermaid) return;
window.mermaid.initialize({ startOnLoad: false, securityLevel: 'strict', theme: 'default', flowchart: { useMaxWidth: true, htmlLabels: true } });
mermaidReady = true;
}
function generateImage() {
var promptEl = document.getElementById('assistant-image-prompt');
var out = document.getElementById('assistant-visual-output');
var prompt = promptEl ? promptEl.value.trim() : '';
if (!prompt && lastAnswer) prompt = 'Create a concise pediatric clinical teaching visual from this answer:\n\n' + lastAnswer.slice(0, 3000);
if (!prompt) { if (typeof showToast === 'function') showToast('Enter an image prompt or ask a question first', 'error'); return; }
if (out) out.innerHTML = '<p class="assistant-muted"><i class="fas fa-spinner fa-spin"></i> Generating image...</p>';
fetch('/api/clinical-assistant/image', {
method: 'POST', headers: getAuthHeaders(), credentials: 'same-origin',
body: JSON.stringify({ prompt: prompt })
})
.then(function (r) { return r.json(); })
.then(function (data) {
if (!data.success) throw new Error(data.error || 'Image generation failed');
var src = data.imageUrl || data.url || (data.base64 ? ('data:image/png;base64,' + data.base64) : '');
if (!src) throw new Error('No image returned');
if (out) out.innerHTML = '<img src="' + escapeAttr(src) + '" alt="Generated clinical visual"><a class="btn-sm btn-ghost" href="' + escapeAttr(src) + '" target="_blank" rel="noopener"><i class="fas fa-up-right-from-square"></i> Open</a>';
})
.catch(function (err) {
if (out) out.innerHTML = '<p class="assistant-muted">' + escapeHtml(err.message) + '</p>';
if (typeof showToast === 'function') showToast(err.message, 'error');
});
}
function clearConversation() {
messages = [];
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>';
renderSources([]);
}
function copyLastAnswer() {
if (!lastAnswer) { if (typeof showToast === 'function') showToast('No answer to copy', 'error'); return; }
navigator.clipboard.writeText(lastAnswer).then(function () {
if (typeof showToast === 'function') showToast('Copied answer', 'success');
}).catch(function () {
if (typeof showToast === 'function') showToast('Copy failed', 'error');
});
}
function setBusy(isBusy, text, isError) {
var status = document.getElementById('assistant-status');
var label = document.getElementById('assistant-status-text');
var send = document.getElementById('btn-assistant-send');
if (status) {
status.classList.toggle('busy', !!isBusy);
status.classList.toggle('error', !!isError);
}
if (label) label.textContent = text || (isBusy ? 'Working...' : 'Ready');
if (send) {
send.disabled = !!isBusy;
send.innerHTML = isBusy ? '<i class="fas fa-spinner fa-spin"></i> Searching' : '<i class="fas fa-paper-plane"></i> Ask';
}
}
function escapeHtml(s) { return String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); }
function escapeAttr(s) { return escapeHtml(s).replace(/'/g, '&#39;'); }
function sanitize(html) { return window.DOMPurify ? window.DOMPurify.sanitize(html) : html; }
})();

View file

@ -293,6 +293,7 @@ app.use('/api', require('./src/routes/encounters'));
app.use('/api', require('./src/routes/memories'));
app.use('/api', require('./src/routes/notes'));
app.use('/api', require('./src/routes/diagrams'));
app.use('/api', require('./src/routes/clinicalAssistant'));
app.use('/api', require('./src/routes/documents'));
app.use('/api', require('./src/routes/audioBackups'));
app.use('/api', require('./src/routes/billing'));

View file

@ -394,6 +394,64 @@ router.post('/config/models/test', async function(req, res) {
}
});
// ── GET discover image-capable models from provider ───────────────────────
router.get('/config/image-models/discover', async function(req, res) {
try {
var axios = require('axios');
var discovered = [];
if (process.env.LITELLM_API_BASE) {
var headers = {};
if (process.env.LITELLM_API_KEY) headers.Authorization = 'Bearer ' + process.env.LITELLM_API_KEY;
var resp = await axios.get(gatewayUrl('/models'), { headers: headers, timeout: 15000 });
var models = (resp.data && resp.data.data) || [];
models.forEach(function(m) {
var id = m.id || '';
if (/image|dall|gpt-image|imagen|flux|stable|sdxl|recraft|ideogram/i.test(id)) {
discovered.push({ id: id, name: id, source: 'litellm-api' });
}
});
}
var builtins = ['openai-gpt-image-1', 'openai-gpt-image-1-mini', 'openai-gpt-image-1.5', 'openai-dall-e-3'];
builtins.forEach(function(id) {
if (!discovered.find(function(m) { return m.id === id; })) discovered.push({ id: id, name: id, source: 'builtin' });
});
var search = (req.query.q || '').toLowerCase().trim();
if (search) discovered = discovered.filter(function(m) { return m.id.toLowerCase().indexOf(search) !== -1; });
res.json({ success: true, models: discovered, count: discovered.length });
} catch (e) {
res.status(500).json({ error: e.message || 'Request failed' });
}
});
// ── POST test image generation model ──────────────────────────────────────
router.post('/config/image-models/test', async function(req, res) {
try {
if (!process.env.LITELLM_API_BASE) return res.json({ success: false, error: 'LiteLLM not configured' });
var modelId = String(req.body.modelId || '').trim();
if (!modelId) return res.status(400).json({ error: 'modelId required' });
var axios = require('axios');
var headers = { 'Content-Type': 'application/json' };
if (process.env.LITELLM_API_KEY) headers.Authorization = 'Bearer ' + process.env.LITELLM_API_KEY;
var started = Date.now();
var imgResp = await axios.post(gatewayUrl('/images/generations'), {
model: modelId,
prompt: 'A simple child-friendly icon of lungs and an inhaler, flat medical illustration, no text',
size: '1024x1024'
}, { headers: headers, timeout: 120000 });
var item = imgResp.data && imgResp.data.data && imgResp.data.data[0] ? imgResp.data.data[0] : {};
res.json({
success: true,
model: modelId,
duration: Date.now() - started,
imageUrl: item.url || null,
base64: item.b64_json || null
});
} catch (e) {
var detail = e.response && e.response.data ? JSON.stringify(e.response.data).substring(0, 500) : e.message;
res.json({ success: false, error: detail });
}
});
// ── GET TTS provider status, voice list, and DB overrides ────────────────
router.get('/config/tts', async function(req, res) {
try {
@ -964,7 +1022,7 @@ router.put('/config/:key(*)', async function(req, res) {
}
// Security: only allow known key prefixes
var allowed = ['announcement.', 'feature.', 'email.', 'prompt.', 'registration_enabled', 'site.', 'smtp.', 'models.', 'tts.', 'stt.', 'embeddings.'];
var allowed = ['announcement.', 'feature.', 'email.', 'prompt.', 'registration_enabled', 'site.', 'smtp.', 'models.', 'tts.', 'stt.', 'embeddings.', 'clinical_assistant.'];
var isAllowed = allowed.some(function(p) { return key === p || key.startsWith(p); });
if (!isAllowed) {
return res.status(400).json({ error: 'Unknown config key' });

View file

@ -0,0 +1,380 @@
// ============================================================
// CLINICAL ASSISTANT — OpenEvidence-style query surface.
// Auth-gated app route. Retrieves indexed Nextcloud content through
// native MCP, then synthesizes a citation-grounded answer through the
// app's existing AI provider/LiteLLM configuration.
// ============================================================
var express = require('express');
var axios = require('axios');
var router = express.Router();
var db = require('../db/database');
var { authMiddleware } = require('../middleware/auth');
var { callAI } = require('../utils/ai');
var { gatewayUrl } = require('../utils/errors');
var logger = require('../utils/logger');
router.use(authMiddleware);
var MCP_URL = process.env.CLINICAL_ASSISTANT_MCP_URL || process.env.MCP_SERVER_URL || 'http://127.0.0.1:8100/mcp';
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 TOPIC_SUGGESTIONS = {
asthma: [
'Management of acute asthma exacerbation by severity',
'When to give magnesium sulfate in status asthmaticus',
'Bronchiolitis versus asthma in infants with wheeze',
'Discharge criteria after pediatric asthma exacerbation',
'Controller therapy step-up after ED asthma visit'
],
bronchiolitis: [
'Supportive management of bronchiolitis',
'When bronchodilator trial is appropriate in bronchiolitis',
'Admission and oxygen criteria for bronchiolitis',
'Bronchiolitis versus early asthma',
'High-flow nasal cannula use in bronchiolitis'
],
fever: [
'Fever without source by age group',
'Febrile neonate initial workup and antibiotics',
'Risk stratification for serious bacterial infection',
'When to do lumbar puncture in febrile infants',
'Fever in immunocompromised children'
],
seizure: [
'Status epilepticus first-line management',
'Febrile seizure evaluation and counseling',
'Second-line antiseizure medications in children',
'When neuroimaging is indicated after seizure',
'Seizure mimics in pediatrics'
],
croup: [
'Croup severity scoring and treatment',
'When to use nebulized epinephrine for croup',
'Dexamethasone dosing in croup',
'Observation after racemic epinephrine',
'Differential diagnosis for stridor'
],
dehydration: [
'Clinical assessment of pediatric dehydration',
'Oral rehydration therapy protocol',
'IV fluid bolus and maintenance strategy',
'Hypernatremic dehydration precautions',
'Discharge criteria after gastroenteritis'
]
};
router.get('/clinical-assistant/status', async function(req, res) {
try {
var chatModel = await getSetting('clinical_assistant.chat_model', '') || await getSetting('models.default', '');
var imageModel = await getSetting('clinical_assistant.image_model', '');
var searchLimit = clampInt(await getSetting('clinical_assistant.search_limit', '8'), 3, 20, 8);
var contextChars = clampInt(await getSetting('clinical_assistant.context_chars', '1400'), 300, 4000, 1400);
res.json({ success: true, chatModel: chatModel, imageModel: imageModel, searchLimit: searchLimit, contextChars: contextChars, mcpUrl: MCP_URL.replace(/\/mcp$/, '/mcp') });
} catch (e) {
res.status(500).json({ error: 'Request failed' });
}
});
router.post('/clinical-assistant/chat', async function(req, res) {
var started = Date.now();
try {
var message = String(req.body.message || '').trim();
if (!message) return res.status(400).json({ error: 'Question is required' });
if (message.length > 4000) return res.status(400).json({ error: 'Question too long' });
var chatModel = await getSetting('clinical_assistant.chat_model', '') || await getSetting('models.default', '');
var searchLimit = clampInt(await getSetting('clinical_assistant.search_limit', '8'), 3, 20, 8);
var contextChars = clampInt(await getSetting('clinical_assistant.context_chars', '1400'), 300, 4000, 1400);
var behavior = await getSetting('clinical_assistant.system_behavior', DEFAULT_BEHAVIOR) || DEFAULT_BEHAVIOR;
var includeContext = req.body.includeContext !== false;
if (GREETING_RE.test(message)) {
return res.json({
success: true,
answer: 'What clinical question would you like me to look up in the indexed pediatric resources?',
sources: [],
model: null,
search: { skipped: true, reason: 'low_information_input' }
});
}
var suggestions = getTopicSuggestions(message);
if (suggestions.length > 0 && message.split(/\s+/).length < 4) {
return res.json({
success: true,
answer: buildSuggestionAnswer(message, suggestions),
suggestions: suggestions,
sources: [],
model: null,
search: { skipped: true, reason: 'topic_suggestions' }
});
}
var searchResponse = await semanticSearch(message, {
limit: searchLimit,
includeContext: includeContext,
contextChars: contextChars
});
var rawResults = normalizeMcpSearchResponse(searchResponse);
var sources = dedupeSources(rawResults).slice(0, searchLimit);
if (sources.length === 0) {
return res.json({
success: true,
answer: 'I could not find relevant indexed Nextcloud resources for that question. Try a more specific clinical term, diagnosis, medication, age group, or textbook topic.',
sources: [],
model: null,
search: { totalFound: 0 }
});
}
var context = formatSourcesForPrompt(sources);
var history = Array.isArray(req.body.history) ? req.body.history.slice(-6) : [];
var messages = [
{ role: 'system', content: buildSystemPrompt(behavior) },
{ role: 'user', content: buildUserPrompt(message, context, history) }
];
var ai = await callAI(messages, {
model: chatModel || undefined,
temperature: 0.15,
maxTokens: 1800
});
var answer = ensureSourcesSection(String(ai.content || '').trim(), sources);
logger.audit(req.user.id, 'clinical_assistant_query', 'Clinical assistant query', req, {
category: 'clinical', model: ai.model || chatModel, duration: Date.now() - started
});
res.json({
success: true,
answer: answer,
sources: sources,
model: ai.model || chatModel || null,
provider: ai.provider || null,
duration: Date.now() - started,
search: {
totalFound: rawResults.length,
verifiedChunkCount: searchResponse.verified_chunk_count || searchResponse.verifiedChunkCount || 0,
droppedDocumentCount: searchResponse.dropped_document_count || searchResponse.droppedDocumentCount || 0
}
});
} catch (e) {
console.error('[clinical-assistant]', e.message, e.stack || '');
res.status(500).json({ error: assistantErrorMessage(e) });
}
});
router.post('/clinical-assistant/image', async function(req, res) {
try {
var prompt = String(req.body.prompt || '').trim();
if (!prompt) return res.status(400).json({ error: 'Prompt is required' });
if (prompt.length > 5000) prompt = prompt.substring(0, 5000);
var model = await getSetting('clinical_assistant.image_model', '') || process.env.CLINICAL_ASSISTANT_IMAGE_MODEL || 'openai-gpt-image-1';
var image = await generateImage(prompt, model);
logger.audit(req.user.id, 'clinical_assistant_image', 'Generated clinical assistant image', req, { category: 'clinical', model: model });
res.json(Object.assign({ success: true, model: model }, image));
} catch (e) {
var detail = e.response && e.response.data ? JSON.stringify(e.response.data).substring(0, 300) : e.message;
res.status(500).json({ error: detail || 'Image generation failed' });
}
});
async function semanticSearch(query, opts) {
var session = await mcpRequest({
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: {
protocolVersion: '2024-11-05',
capabilities: {},
clientInfo: { name: 'ped-ai-clinical-assistant', version: '1.0.0' }
}
});
var search = await mcpRequest({
jsonrpc: '2.0',
id: 2,
method: 'tools/call',
params: {
name: 'nc_semantic_search',
arguments: {
query: query,
limit: opts.limit,
doc_types: ['file'],
score_threshold: 0,
fusion: 'rrf',
include_context: opts.includeContext,
context_chars: opts.contextChars
}
}
}, session.sessionId);
return search.result || search;
}
async function mcpRequest(payload, sessionId) {
var headers = {
'Accept': 'application/json, text/event-stream',
'Content-Type': 'application/json'
};
if (sessionId) headers['mcp-session-id'] = sessionId;
var resp = await axios.post(MCP_URL, payload, {
headers: headers,
timeout: 90000,
responseType: 'text',
transformResponse: [function(data) { return data; }]
});
var parsed = parseMcpResponse(resp.data);
if (parsed.error) throw new Error(parsed.error.message || JSON.stringify(parsed.error));
parsed.sessionId = resp.headers['mcp-session-id'] || sessionId || null;
return parsed;
}
function parseMcpResponse(body) {
var text = String(body || '').trim();
if (!text) return {};
if (text.charAt(0) === '{') return JSON.parse(text);
var lines = text.split(/\r?\n/);
for (var i = 0; i < lines.length; i++) {
if (lines[i].indexOf('data:') === 0) {
return JSON.parse(lines[i].substring(5).trim());
}
}
throw new Error('Unexpected MCP response format');
}
function normalizeMcpSearchResponse(result) {
var data = result && (result.structuredContent || result.data || result);
if ((!data || !Array.isArray(data.results)) && result && Array.isArray(result.content)) {
for (var i = 0; i < result.content.length; i++) {
var c = result.content[i];
if (c && c.type === 'text' && c.text) {
try {
var parsed = JSON.parse(c.text);
if (parsed && Array.isArray(parsed.results)) data = parsed;
} catch (e) {}
}
}
}
if (!data || !Array.isArray(data.results)) return [];
return data.results.map(function(r, idx) {
var text = [r.before_context, r.excerpt, r.after_context].filter(Boolean).join('\n').trim() || r.marked_text || r.excerpt || '';
return {
number: idx + 1,
id: r.id,
doc_type: r.doc_type,
title: cleanTitle(r.title || 'Untitled resource'),
page: r.page_number || r.pageNumber || null,
page_count: r.page_count || r.pageCount || null,
excerpt: clip(text, 1800),
score: r.score,
chunk_index: r.chunk_index,
total_chunks: r.total_chunks
};
}).filter(function(r) { return r.excerpt; });
}
function dedupeSources(results) {
var seen = new Set();
var out = [];
results.forEach(function(r) {
var key = [r.title, r.page || '', r.chunk_index || ''].join('|');
if (seen.has(key)) return;
seen.add(key);
out.push(Object.assign({}, r, { number: out.length + 1 }));
});
return out;
}
function formatSourcesForPrompt(sources) {
return sources.map(function(s) {
return '[' + s.number + '] ' + s.title + (s.page ? ', page ' + s.page : '') + '\n' + s.excerpt;
}).join('\n\n---\n\n');
}
function buildSystemPrompt(behavior) {
return behavior + '\n\nRules:\n- Answer only the user question; do not dump unrelated textbook content.\n- Use numbered citations [1], [2] for factual claims, matching the provided source numbers.\n- If sources disagree or are insufficient, say so.\n- Do not cite a source number that is not provided.\n- Keep the main answer concise and clinically useful.\n- Include a final "Sources" section listing cited source numbers with title and page.\n- This is clinical decision support, not a substitute for clinician judgment.';
}
function buildUserPrompt(question, context, history) {
var hist = history.filter(function(m) { return m && (m.role === 'user' || m.role === 'assistant') && m.content; })
.map(function(m) { return m.role.toUpperCase() + ': ' + String(m.content).substring(0, 1000); }).join('\n');
return 'Question:\n' + question + '\n\nRecent conversation, if relevant:\n' + (hist || 'None') + '\n\nRetrieved indexed sources:\n' + context + '\n\nWrite the answer now.';
}
function ensureSourcesSection(answer, sources) {
if (/\n\s*#{0,3}\s*Sources\b/i.test(answer)) return answer;
var cited = new Set();
var m;
var re = /\[(\d+)\]/g;
while ((m = re.exec(answer))) cited.add(Number(m[1]));
var list = sources.filter(function(s) { return cited.size === 0 ? s.number <= Math.min(5, sources.length) : cited.has(s.number); })
.map(function(s) { return '[' + s.number + '] ' + s.title + (s.page ? ', p. ' + s.page : ''); })
.join('\n');
return answer + '\n\nSources\n' + list;
}
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' };
if (process.env.LITELLM_API_KEY) headers.Authorization = 'Bearer ' + process.env.LITELLM_API_KEY;
var resp = await axios.post(gatewayUrl('/images/generations'), {
model: model,
prompt: prompt,
size: '1024x1024'
}, { headers: headers, timeout: 120000 });
var item = resp.data && resp.data.data && resp.data.data[0] ? resp.data.data[0] : {};
return { imageUrl: item.url || null, base64: item.b64_json || null, raw: (!item.url && !item.b64_json) ? resp.data : undefined };
}
async function getSetting(key, fallback) {
try {
var val = await db.getSetting(key);
return val == null || val === '' ? fallback : val;
} catch (e) { return fallback; }
}
function clampInt(value, min, max, fallback) {
var n = parseInt(value, 10);
if (!Number.isFinite(n)) return fallback;
return Math.max(min, Math.min(max, n));
}
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 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.';
if (/Model not permitted/i.test(msg)) return 'Configured assistant chat model is not enabled in admin model settings.';
return msg || 'Assistant request failed';
}
function getTopicSuggestions(message) {
var normalized = String(message || '').toLowerCase().replace(/[^a-z0-9\s-]/g, ' ').replace(/\s+/g, ' ').trim();
if (!normalized) return [];
var direct = TOPIC_SUGGESTIONS[normalized];
if (direct) return direct;
var keys = Object.keys(TOPIC_SUGGESTIONS);
for (var i = 0; i < keys.length; i++) {
if (normalized.indexOf(keys[i]) !== -1) return TOPIC_SUGGESTIONS[keys[i]];
}
if (normalized.split(/\s+/).length <= 2) {
return [
'Management of ' + normalized,
'Red flags and differential diagnosis for ' + normalized,
'Initial ED evaluation for ' + normalized,
'Admission versus discharge criteria for ' + normalized,
'Medication dosing and escalation for ' + normalized
];
}
return [];
}
function buildSuggestionAnswer(topic, suggestions) {
return 'I can look up **' + topic + '**. Pick a focused question so I can retrieve the most relevant cited pages:\n\n' +
suggestions.map(function(s) { return '- ' + s; }).join('\n') +
'\n\nOr type your own specific question, for example: "management of acute asthma exacerbation in a 6-year-old".';
}
module.exports = router;