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
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:
parent
0710c4de8e
commit
96c4565b9c
9 changed files with 1063 additions and 2 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
121
public/components/assistant.html
Normal file
121
public/components/assistant.html
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); }
|
||||
})();
|
||||
|
||||
// ============================================================
|
||||
// ADMIN MODEL MANAGEMENT — Discover, search, enable/disable, custom models
|
||||
// ============================================================
|
||||
|
|
|
|||
316
public/js/clinicalAssistant.js
Normal file
316
public/js/clinicalAssistant.js
Normal 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); }
|
||||
function escapeAttr(s) { return escapeHtml(s).replace(/'/g, '''); }
|
||||
function sanitize(html) { return window.DOMPurify ? window.DOMPurify.sanitize(html) : html; }
|
||||
})();
|
||||
|
|
@ -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'));
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
|
|
|
|||
380
src/routes/clinicalAssistant.js
Normal file
380
src/routes/clinicalAssistant.js
Normal 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;
|
||||
Loading…
Reference in a new issue