feat: stream clinical assistant responses
This commit is contained in:
parent
c355409fc4
commit
60d726a293
3 changed files with 340 additions and 140 deletions
|
|
@ -118,30 +118,11 @@ import { escapeAttr, escapeHtml, renderAssistantMarkdown, stripSourcesSection }
|
||||||
setBusy(true, 'Searching indexed resources...');
|
setBusy(true, 'Searching indexed resources...');
|
||||||
var loading = appendLoadingMessage('Searching indexed resources', 'Retrieving and synthesizing cited references...');
|
var loading = appendLoadingMessage('Searching indexed resources', 'Retrieving and synthesizing cited references...');
|
||||||
|
|
||||||
fetch('/api/clinical-assistant/chat', {
|
streamAssistantResponse({
|
||||||
method: 'POST',
|
message: text,
|
||||||
headers: getAuthHeaders(),
|
history: messages.slice(-8),
|
||||||
credentials: 'same-origin',
|
includeContext: !includeContext || includeContext.checked
|
||||||
body: JSON.stringify({
|
}, loading)
|
||||||
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 || [];
|
|
||||||
replaceLoadingMessage(loading, 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) {
|
.catch(function (err) {
|
||||||
setBusy(false, 'Error', true);
|
setBusy(false, 'Error', true);
|
||||||
replaceLoadingMessage(loading, 'I could not complete the assistant request. ' + err.message + '\n\nThe indexed search service may be busy or temporarily unavailable. Please try again in a moment.');
|
replaceLoadingMessage(loading, 'I could not complete the assistant request. ' + err.message + '\n\nThe indexed search service may be busy or temporarily unavailable. Please try again in a moment.');
|
||||||
|
|
@ -149,6 +130,109 @@ import { escapeAttr, escapeHtml, renderAssistantMarkdown, stripSourcesSection }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function streamAssistantResponse(payload, loading) {
|
||||||
|
var response = await fetch('/api/clinical-assistant/chat/stream', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
credentials: 'same-origin',
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
if (!response.ok || !response.body) {
|
||||||
|
var fallback = await response.json().catch(function () { return {}; });
|
||||||
|
throw new Error(fallback.error || ('Request failed (' + response.status + ')'));
|
||||||
|
}
|
||||||
|
|
||||||
|
var partial = '';
|
||||||
|
var streamSources = [];
|
||||||
|
var streamSuggestions = [];
|
||||||
|
var lastRender = 0;
|
||||||
|
var bubble = loading ? loading.querySelector('.assistant-bubble') : null;
|
||||||
|
var decoder = new TextDecoder();
|
||||||
|
var buffer = '';
|
||||||
|
var doneData = null;
|
||||||
|
|
||||||
|
function renderPartial(force) {
|
||||||
|
var now = Date.now();
|
||||||
|
if (!force && now - lastRender < 180) return;
|
||||||
|
lastRender = now;
|
||||||
|
if (!bubble) return;
|
||||||
|
loading.classList.remove('assistant-loading-msg');
|
||||||
|
bubble.classList.remove('assistant-thinking');
|
||||||
|
bubble.innerHTML = partial ? renderMarkdown(partial, streamSources) : '<p class="assistant-muted">Generating answer...</p>';
|
||||||
|
var wrap = document.getElementById('assistant-messages');
|
||||||
|
if (wrap) wrap.scrollTop = wrap.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEvent(type, data) {
|
||||||
|
if (type === 'status') {
|
||||||
|
updateLoadingMessage(loading, data.message || 'Working...');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (type === 'sources') {
|
||||||
|
streamSources = data.sources || [];
|
||||||
|
renderSources(streamSources);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (type === 'token') {
|
||||||
|
partial += data.token || '';
|
||||||
|
renderPartial(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (type === 'done') {
|
||||||
|
doneData = data || {};
|
||||||
|
streamSuggestions = doneData.suggestions || [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (type === 'error') throw new Error(data.error || 'Assistant stream failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
var reader = response.body.getReader();
|
||||||
|
while (true) {
|
||||||
|
var chunk = await reader.read();
|
||||||
|
if (chunk.done) break;
|
||||||
|
buffer += decoder.decode(chunk.value, { stream: true });
|
||||||
|
var parts = buffer.split('\n\n');
|
||||||
|
buffer = parts.pop() || '';
|
||||||
|
parts.forEach(function (part) {
|
||||||
|
var parsed = parseSseEvent(part);
|
||||||
|
if (parsed) handleEvent(parsed.type, parsed.data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (buffer.trim()) {
|
||||||
|
var tail = parseSseEvent(buffer);
|
||||||
|
if (tail) handleEvent(tail.type, tail.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
setBusy(false, 'Ready');
|
||||||
|
var answer = (doneData && (doneData.answer || doneData.markdown)) || partial;
|
||||||
|
lastAnswer = answer || '';
|
||||||
|
lastSources = (doneData && (doneData.sources || doneData.citations)) || streamSources;
|
||||||
|
replaceLoadingMessage(loading, lastAnswer, lastSources, streamSuggestions);
|
||||||
|
renderSources(lastSources);
|
||||||
|
if (doneData && doneData.model) {
|
||||||
|
var label = document.getElementById('assistant-model-label');
|
||||||
|
if (label) label.textContent = 'Chat: ' + doneData.model;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSseEvent(block) {
|
||||||
|
var type = 'message';
|
||||||
|
var data = '';
|
||||||
|
String(block || '').split(/\r?\n/).forEach(function (line) {
|
||||||
|
if (line.indexOf('event:') === 0) type = line.substring(6).trim();
|
||||||
|
if (line.indexOf('data:') === 0) data += line.substring(5).trim();
|
||||||
|
});
|
||||||
|
if (!data) return null;
|
||||||
|
try { return { type: type, data: JSON.parse(data) }; }
|
||||||
|
catch (e) { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateLoadingMessage(row, detail) {
|
||||||
|
if (!row) return;
|
||||||
|
var el = row.querySelector('.assistant-thinking-detail');
|
||||||
|
if (el) el.textContent = detail;
|
||||||
|
}
|
||||||
|
|
||||||
function appendLoadingMessage(title, detail) {
|
function appendLoadingMessage(title, detail) {
|
||||||
var wrap = document.getElementById('assistant-messages');
|
var wrap = document.getElementById('assistant-messages');
|
||||||
if (!wrap) return null;
|
if (!wrap) return null;
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ var { S3Client, GetObjectCommand } = require('@aws-sdk/client-s3');
|
||||||
var router = express.Router();
|
var router = express.Router();
|
||||||
var db = require('../db/database');
|
var db = require('../db/database');
|
||||||
var { authMiddleware } = require('../middleware/auth');
|
var { authMiddleware } = require('../middleware/auth');
|
||||||
var { callAI } = require('../utils/ai');
|
var { callAI, callAIStream } = require('../utils/ai');
|
||||||
var { gatewayUrl } = require('../utils/errors');
|
var { gatewayUrl } = require('../utils/errors');
|
||||||
var logger = require('../utils/logger');
|
var logger = require('../utils/logger');
|
||||||
var cryptoUtil = require('../utils/crypto');
|
var cryptoUtil = require('../utils/crypto');
|
||||||
|
|
@ -249,93 +249,11 @@ router.delete('/clinical-assistant/chats/:id', async function(req, res) {
|
||||||
router.post('/clinical-assistant/chat', async function(req, res) {
|
router.post('/clinical-assistant/chat', async function(req, res) {
|
||||||
var started = Date.now();
|
var started = Date.now();
|
||||||
try {
|
try {
|
||||||
var message = String(req.body.message || '').trim();
|
var prepared = await prepareAssistantChat(req.body);
|
||||||
if (!message) return res.status(400).json({ error: 'Question is required' });
|
if (prepared.direct) return res.json(prepared.direct);
|
||||||
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 ai = await callAI(prepared.messages, {
|
||||||
var searchLimit = clampInt(await getSetting('clinical_assistant.search_limit', '8'), 3, 20, 8);
|
model: prepared.chatModel || undefined,
|
||||||
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 history = Array.isArray(req.body.history) ? req.body.history.slice(-8) : [];
|
|
||||||
var contextualHistory = priorClinicalHistory(history, message);
|
|
||||||
|
|
||||||
var suggestions = getTopicSuggestions(message);
|
|
||||||
if (!contextualHistory.length && 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 searchQuery = await rewriteSearchQuery(message, history, chatModel).catch(function(e) {
|
|
||||||
console.warn('[clinical-assistant] query rewrite skipped:', e.message);
|
|
||||||
return message;
|
|
||||||
});
|
|
||||||
|
|
||||||
var searchResponse = await semanticSearch(searchQuery, {
|
|
||||||
limit: searchLimit,
|
|
||||||
includeContext: includeContext,
|
|
||||||
contextChars: contextChars
|
|
||||||
});
|
|
||||||
var visualQuery = isVisualSourceQuery(message) || isVisualSourceQuery(searchQuery);
|
|
||||||
var multimodalResponse = visualQuery ? await multimodalSearch(buildMultimodalSearchQuery(searchQuery), { limit: 8 }).catch(function(e) {
|
|
||||||
console.warn('[clinical-assistant] multimodal search skipped:', e.message);
|
|
||||||
return null;
|
|
||||||
}) : null;
|
|
||||||
var rawTextResults = normalizeMcpSearchResponse(searchResponse);
|
|
||||||
var rawMultimodalResults = await classifyAndRerankMultimodalResults(
|
|
||||||
message + ' ' + searchQuery,
|
|
||||||
normalizeMcpMultimodalResponse(multimodalResponse)
|
|
||||||
);
|
|
||||||
var rawResults = rawTextResults.concat(rawMultimodalResults);
|
|
||||||
console.info('[clinical-assistant] retrieval counts:', {
|
|
||||||
text: rawTextResults.length,
|
|
||||||
multimodal: rawMultimodalResults.length,
|
|
||||||
query: searchQuery
|
|
||||||
});
|
|
||||||
var visualSlots = rawMultimodalResults.length ? Math.min(2, Math.max(1, Math.floor(searchLimit / 4))) : 0;
|
|
||||||
var textSlots = searchLimit - visualSlots;
|
|
||||||
var sources = dedupeSources(
|
|
||||||
dedupeSources(rawTextResults).slice(0, textSlots).concat(
|
|
||||||
dedupeSources(rawMultimodalResults).slice(0, visualSlots)
|
|
||||||
)
|
|
||||||
).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 messages = [
|
|
||||||
{ role: 'system', content: buildSystemPrompt(behavior) },
|
|
||||||
{ role: 'user', content: buildUserPrompt(message, context, history, searchQuery) }
|
|
||||||
];
|
|
||||||
|
|
||||||
var ai = await callAI(messages, {
|
|
||||||
model: chatModel || undefined,
|
|
||||||
temperature: 0.15,
|
temperature: 0.15,
|
||||||
maxTokens: 1800
|
maxTokens: 1800
|
||||||
});
|
});
|
||||||
|
|
@ -348,18 +266,11 @@ router.post('/clinical-assistant/chat', async function(req, res) {
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
answer: answer,
|
answer: answer,
|
||||||
sources: sanitizeSourcesForClient(sources),
|
sources: sanitizeSourcesForClient(prepared.sources),
|
||||||
model: ai.model || chatModel || null,
|
model: ai.model || prepared.chatModel || null,
|
||||||
provider: ai.provider || null,
|
provider: ai.provider || null,
|
||||||
duration: Date.now() - started,
|
duration: Date.now() - started,
|
||||||
search: {
|
search: prepared.search
|
||||||
totalFound: rawResults.length,
|
|
||||||
multimodalFound: rawMultimodalResults.length,
|
|
||||||
query: searchQuery,
|
|
||||||
rewritten: searchQuery !== message,
|
|
||||||
verifiedChunkCount: searchResponse.verified_chunk_count || searchResponse.verifiedChunkCount || 0,
|
|
||||||
droppedDocumentCount: searchResponse.dropped_document_count || searchResponse.droppedDocumentCount || 0
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[clinical-assistant]', e.message, e.stack || '');
|
console.error('[clinical-assistant]', e.message, e.stack || '');
|
||||||
|
|
@ -367,6 +278,59 @@ router.post('/clinical-assistant/chat', async function(req, res) {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.post('/clinical-assistant/chat/stream', async function(req, res) {
|
||||||
|
var started = Date.now();
|
||||||
|
var streamOpen = false;
|
||||||
|
function sendEvent(type, data) {
|
||||||
|
if (!streamOpen) return;
|
||||||
|
res.write('event: ' + type + '\n');
|
||||||
|
res.write('data: ' + JSON.stringify(data || {}) + '\n\n');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
|
||||||
|
res.setHeader('Cache-Control', 'no-cache, no-transform');
|
||||||
|
res.setHeader('Connection', 'keep-alive');
|
||||||
|
if (typeof res.flushHeaders === 'function') res.flushHeaders();
|
||||||
|
streamOpen = true;
|
||||||
|
sendEvent('status', { message: 'Searching indexed resources...' });
|
||||||
|
|
||||||
|
var prepared = await prepareAssistantChat(req.body);
|
||||||
|
if (prepared.direct) {
|
||||||
|
sendEvent('done', Object.assign({ duration: Date.now() - started }, prepared.direct));
|
||||||
|
return res.end();
|
||||||
|
}
|
||||||
|
sendEvent('sources', { sources: sanitizeSourcesForClient(prepared.sources), search: prepared.search });
|
||||||
|
sendEvent('status', { message: 'Generating answer...' });
|
||||||
|
|
||||||
|
var ai = await callAIStream(prepared.messages, {
|
||||||
|
model: prepared.chatModel || undefined,
|
||||||
|
temperature: 0.15,
|
||||||
|
maxTokens: 1800
|
||||||
|
}, function(delta) {
|
||||||
|
sendEvent('token', { token: delta });
|
||||||
|
});
|
||||||
|
var answer = stripModelSourcesSection(String(ai.content || '').trim());
|
||||||
|
logger.audit(req.user.id, 'clinical_assistant_query', 'Clinical assistant streaming query', req, {
|
||||||
|
category: 'clinical', model: ai.model || prepared.chatModel, duration: Date.now() - started
|
||||||
|
});
|
||||||
|
sendEvent('done', {
|
||||||
|
success: true,
|
||||||
|
answer: answer,
|
||||||
|
sources: sanitizeSourcesForClient(prepared.sources),
|
||||||
|
model: ai.model || prepared.chatModel || null,
|
||||||
|
provider: ai.provider || null,
|
||||||
|
duration: Date.now() - started,
|
||||||
|
search: prepared.search
|
||||||
|
});
|
||||||
|
res.end();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[clinical-assistant stream]', e.message, e.stack || '');
|
||||||
|
if (!streamOpen) return res.status(500).json({ error: assistantErrorMessage(e) });
|
||||||
|
sendEvent('error', { error: assistantErrorMessage(e) });
|
||||||
|
res.end();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
router.post('/clinical-assistant/image', async function(req, res) {
|
router.post('/clinical-assistant/image', async function(req, res) {
|
||||||
try {
|
try {
|
||||||
var prompt = String(req.body.prompt || '').trim();
|
var prompt = String(req.body.prompt || '').trim();
|
||||||
|
|
@ -383,6 +347,113 @@ router.post('/clinical-assistant/image', async function(req, res) {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function prepareAssistantChat(body) {
|
||||||
|
body = body || {};
|
||||||
|
var message = String(body.message || '').trim();
|
||||||
|
if (!message) {
|
||||||
|
var emptyErr = new Error('Question is required');
|
||||||
|
emptyErr.statusCode = 400;
|
||||||
|
throw emptyErr;
|
||||||
|
}
|
||||||
|
if (message.length > 4000) {
|
||||||
|
var longErr = new Error('Question too long');
|
||||||
|
longErr.statusCode = 400;
|
||||||
|
throw longErr;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = body.includeContext !== false;
|
||||||
|
|
||||||
|
if (GREETING_RE.test(message)) {
|
||||||
|
return { direct: {
|
||||||
|
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 history = Array.isArray(body.history) ? body.history.slice(-8) : [];
|
||||||
|
var contextualHistory = priorClinicalHistory(history, message);
|
||||||
|
var suggestions = getTopicSuggestions(message);
|
||||||
|
if (!contextualHistory.length && suggestions.length > 0 && message.split(/\s+/).length < 4) {
|
||||||
|
return { direct: {
|
||||||
|
success: true,
|
||||||
|
answer: buildSuggestionAnswer(message, suggestions),
|
||||||
|
suggestions: suggestions,
|
||||||
|
sources: [],
|
||||||
|
model: null,
|
||||||
|
search: { skipped: true, reason: 'topic_suggestions' }
|
||||||
|
} };
|
||||||
|
}
|
||||||
|
|
||||||
|
var searchQuery = await rewriteSearchQuery(message, history, chatModel).catch(function(e) {
|
||||||
|
console.warn('[clinical-assistant] query rewrite skipped:', e.message);
|
||||||
|
return message;
|
||||||
|
});
|
||||||
|
var searchResponse = await semanticSearch(searchQuery, {
|
||||||
|
limit: searchLimit,
|
||||||
|
includeContext: includeContext,
|
||||||
|
contextChars: contextChars
|
||||||
|
});
|
||||||
|
var visualQuery = isVisualSourceQuery(message) || isVisualSourceQuery(searchQuery);
|
||||||
|
var multimodalResponse = visualQuery ? await multimodalSearch(buildMultimodalSearchQuery(searchQuery), { limit: 8 }).catch(function(e) {
|
||||||
|
console.warn('[clinical-assistant] multimodal search skipped:', e.message);
|
||||||
|
return null;
|
||||||
|
}) : null;
|
||||||
|
var rawTextResults = normalizeMcpSearchResponse(searchResponse);
|
||||||
|
var rawMultimodalResults = await classifyAndRerankMultimodalResults(
|
||||||
|
message + ' ' + searchQuery,
|
||||||
|
normalizeMcpMultimodalResponse(multimodalResponse)
|
||||||
|
);
|
||||||
|
var rawResults = rawTextResults.concat(rawMultimodalResults);
|
||||||
|
console.info('[clinical-assistant] retrieval counts:', {
|
||||||
|
text: rawTextResults.length,
|
||||||
|
multimodal: rawMultimodalResults.length,
|
||||||
|
query: searchQuery
|
||||||
|
});
|
||||||
|
var visualSlots = rawMultimodalResults.length ? Math.min(2, Math.max(1, Math.floor(searchLimit / 4))) : 0;
|
||||||
|
var textSlots = searchLimit - visualSlots;
|
||||||
|
var sources = dedupeSources(
|
||||||
|
dedupeSources(rawTextResults).slice(0, textSlots).concat(
|
||||||
|
dedupeSources(rawMultimodalResults).slice(0, visualSlots)
|
||||||
|
)
|
||||||
|
).slice(0, searchLimit);
|
||||||
|
|
||||||
|
if (sources.length === 0) {
|
||||||
|
return { direct: {
|
||||||
|
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);
|
||||||
|
return {
|
||||||
|
message: message,
|
||||||
|
chatModel: chatModel,
|
||||||
|
sources: sources,
|
||||||
|
messages: [
|
||||||
|
{ role: 'system', content: buildSystemPrompt(behavior) },
|
||||||
|
{ role: 'user', content: buildUserPrompt(message, context, history, searchQuery) }
|
||||||
|
],
|
||||||
|
search: {
|
||||||
|
totalFound: rawResults.length,
|
||||||
|
multimodalFound: rawMultimodalResults.length,
|
||||||
|
query: searchQuery,
|
||||||
|
rewritten: searchQuery !== message,
|
||||||
|
verifiedChunkCount: searchResponse.verified_chunk_count || searchResponse.verifiedChunkCount || 0,
|
||||||
|
droppedDocumentCount: searchResponse.dropped_document_count || searchResponse.droppedDocumentCount || 0
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
router.post('/clinical-assistant/export-summary', async function(req, res) {
|
router.post('/clinical-assistant/export-summary', async function(req, res) {
|
||||||
try {
|
try {
|
||||||
if (Array.isArray(req.body.items) && req.body.items.length) {
|
if (Array.isArray(req.body.items) && req.body.items.length) {
|
||||||
|
|
|
||||||
|
|
@ -395,6 +395,68 @@ async function callLiteLLM(messages, model, temperature, maxTokens) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function assertModelAllowed(requestedModel, options) {
|
||||||
|
options = options || {};
|
||||||
|
if (!requestedModel || options.skipAllowlistCheck) return;
|
||||||
|
try {
|
||||||
|
var db = require('../db/database');
|
||||||
|
var { getAllowedModelIds } = require('./models');
|
||||||
|
var allowed = await getAllowedModelIds(db);
|
||||||
|
if (allowed && allowed.size > 0 && !allowed.has(requestedModel)) {
|
||||||
|
var err = new Error('Model not permitted');
|
||||||
|
err.code = 'model_not_permitted';
|
||||||
|
err.model = requestedModel;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (e && e.code === 'model_not_permitted') throw e;
|
||||||
|
console.warn('[callAI] Allow-list check skipped:', e && e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function callAIStream(messages, options, onToken) {
|
||||||
|
options = options || {};
|
||||||
|
var requestedModel = options.model;
|
||||||
|
var model = requestedModel || DEFAULT_MODEL;
|
||||||
|
var temperature = options.temperature || 0.3;
|
||||||
|
var maxTokens = options.maxTokens || 4000;
|
||||||
|
var startTime = Date.now();
|
||||||
|
await assertModelAllowed(requestedModel, options);
|
||||||
|
|
||||||
|
var client = null;
|
||||||
|
var provider = null;
|
||||||
|
if (activeProvider === 'litellm' && litellmClient) {
|
||||||
|
client = litellmClient;
|
||||||
|
provider = 'litellm';
|
||||||
|
} else if (activeProvider === 'openrouter' && openrouter) {
|
||||||
|
client = openrouter;
|
||||||
|
provider = 'openrouter';
|
||||||
|
} else if (activeProvider === 'azure' && azureClient) {
|
||||||
|
client = azureClient;
|
||||||
|
provider = 'azure';
|
||||||
|
model = process.env.AZURE_DEPLOYMENT_NAME || model;
|
||||||
|
}
|
||||||
|
if (!client) throw new Error('Streaming is only configured for OpenAI-compatible providers');
|
||||||
|
|
||||||
|
var content = '';
|
||||||
|
var stream = await client.chat.completions.create({
|
||||||
|
model: model,
|
||||||
|
messages: messages,
|
||||||
|
temperature: temperature,
|
||||||
|
max_tokens: maxTokens,
|
||||||
|
stream: true
|
||||||
|
});
|
||||||
|
for await (var part of stream) {
|
||||||
|
var delta = part && part.choices && part.choices[0] && part.choices[0].delta ? part.choices[0].delta.content : '';
|
||||||
|
if (!delta) continue;
|
||||||
|
content += delta;
|
||||||
|
if (typeof onToken === 'function') onToken(delta);
|
||||||
|
}
|
||||||
|
var duration = Date.now() - startTime;
|
||||||
|
logger.apiCall(null, provider + '/' + model, { model: model, duration: duration, statusCode: 200 });
|
||||||
|
return { success: true, content: content, model: model, provider: provider, duration: duration };
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// MAIN CALL AI FUNCTION — Routes to correct provider
|
// MAIN CALL AI FUNCTION — Routes to correct provider
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
@ -411,24 +473,7 @@ async function callAI(messages, options) {
|
||||||
// the budget on a reasoning model outside the configured roster.
|
// the budget on a reasoning model outside the configured roster.
|
||||||
// Falls back silently to DEFAULT_MODEL when no model was provided.
|
// Falls back silently to DEFAULT_MODEL when no model was provided.
|
||||||
// Admin test endpoints pass skipAllowlistCheck to test before adding.
|
// Admin test endpoints pass skipAllowlistCheck to test before adding.
|
||||||
if (requestedModel && !options.skipAllowlistCheck) {
|
await assertModelAllowed(requestedModel, options);
|
||||||
try {
|
|
||||||
var db = require('../db/database');
|
|
||||||
var { getAllowedModelIds } = require('./models');
|
|
||||||
var allowed = await getAllowedModelIds(db);
|
|
||||||
if (allowed && allowed.size > 0 && !allowed.has(requestedModel)) {
|
|
||||||
var err = new Error('Model not permitted');
|
|
||||||
err.code = 'model_not_permitted';
|
|
||||||
err.model = requestedModel;
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
if (e && e.code === 'model_not_permitted') throw e;
|
|
||||||
// DB lookup failure — fall through, let the provider reject if it
|
|
||||||
// doesn't recognize the model. Log for visibility.
|
|
||||||
console.warn('[callAI] Allow-list check skipped:', e && e.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
var result;
|
var result;
|
||||||
|
|
@ -632,4 +677,4 @@ async function discoverModels() {
|
||||||
return discovered;
|
return discovered;
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { callAI, whisperClient, activeProvider, discoverModels, vertexClient, litellmClient };
|
module.exports = { callAI, callAIStream, whisperClient, activeProvider, discoverModels, vertexClient, litellmClient };
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue