All checks were successful
Forgejo Android APK / Build signed APK (push) Successful in 1m53s
726 lines
29 KiB
JavaScript
726 lines
29 KiB
JavaScript
// ============================================================
|
|
// 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 crypto = require('crypto');
|
|
var router = express.Router();
|
|
var db = require('../db/database');
|
|
var { authMiddleware } = require('../middleware/auth');
|
|
var { callAI, callAIStream } = require('../utils/ai');
|
|
var { gatewayUrl } = require('../utils/errors');
|
|
var { getLiteLLMHeaders } = require('../utils/litellm');
|
|
var logger = require('../utils/logger');
|
|
var cryptoUtil = require('../utils/crypto');
|
|
var redisCache = require('../utils/redis');
|
|
var { createClinicalPromptPool } = require('../utils/clinicalPromptPool');
|
|
var {
|
|
semanticSearch,
|
|
multimodalSearch,
|
|
indexedTopicSuggestions,
|
|
getMcpHealth,
|
|
warmMcpSession
|
|
} = require('../utils/clinicalMcpClient');
|
|
var {
|
|
normalizeMcpSearchResponse,
|
|
normalizeMcpMultimodalResponse,
|
|
dedupeSources,
|
|
isVisualSourceQuery,
|
|
buildMultimodalSearchQuery,
|
|
classifyAndRerankMultimodalResults
|
|
} = require('../utils/clinicalRetrieval');
|
|
var {
|
|
buildSystemPrompt,
|
|
buildUserPrompt,
|
|
finalizeAssistantAnswer
|
|
} = require('../utils/clinicalAnswer');
|
|
|
|
router.use(authMiddleware);
|
|
|
|
var DEFAULT_BEHAVIOR = 'You are a concise pediatric clinical assistant. Use retrieved context only for factual claims. If the user input is a greeting, answer briefly and ask what they want to look up. Synthesize across sources and cite factual claims with the exact provided source numbers like [1]. Do not invent, renumber, merge, or move citations.';
|
|
var GREETING_RE = /^(hi|hello|hey|yo|good\s+(morning|afternoon|evening)|thanks|thank you|ok|okay|sup)[\s.!?]*$/i;
|
|
var MAX_SAVED_CHATS_PER_USER = 100;
|
|
var MAX_SAVED_CHAT_PAYLOAD = 250000;
|
|
var MAX_SAVED_CHAT_TITLE = 160;
|
|
var IMAGE_JOB_TTL_SECONDS = 15 * 60;
|
|
var imageJobs = new Map();
|
|
var promptPool = createClinicalPromptPool({
|
|
redisCache: redisCache,
|
|
callAI: callAI,
|
|
getSetting: getSetting,
|
|
semanticSearch: semanticSearch,
|
|
dedupeSources: dedupeSources,
|
|
normalizeMcpSearchResponse: normalizeMcpSearchResponse,
|
|
getIndexedTopicExamples: getIndexedTopicExamples
|
|
});
|
|
|
|
function positiveInt(value, fallback) {
|
|
var n = Number(value);
|
|
return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback;
|
|
}
|
|
|
|
if (process.env.CLINICAL_ASSISTANT_MCP_WARMUP !== 'false') {
|
|
setTimeout(function() {
|
|
warmMcpSession().catch(function(e) {
|
|
console.warn('[clinical-assistant] MCP warmup skipped:', e.message);
|
|
});
|
|
}, positiveInt(process.env.CLINICAL_ASSISTANT_MCP_WARMUP_DELAY_MS, 5000));
|
|
}
|
|
|
|
setTimeout(function() {
|
|
promptPool.refreshIfNeeded(false).catch(function(e) {
|
|
console.warn('[clinical-assistant] prompt pool warmup skipped:', e.message);
|
|
});
|
|
}, positiveInt(process.env.CLINICAL_ASSISTANT_PROMPT_POOL_WARMUP_DELAY_MS, 15000));
|
|
|
|
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);
|
|
var mcpHealth = await getMcpHealth();
|
|
res.json({
|
|
success: true,
|
|
chatModel: chatModel,
|
|
imageModel: imageModel,
|
|
searchLimit: searchLimit,
|
|
contextChars: contextChars,
|
|
mcp: mcpHealth
|
|
});
|
|
} catch (e) {
|
|
res.status(500).json({ error: 'Request failed' });
|
|
}
|
|
});
|
|
|
|
router.get('/clinical-assistant/examples', async function(req, res) {
|
|
try {
|
|
var examples = await getAvailableExamples();
|
|
res.json({ success: true, examples: examples });
|
|
} catch (e) {
|
|
console.warn('[clinical-assistant] example discovery failed:', e.message);
|
|
res.json({ success: true, examples: [] });
|
|
}
|
|
});
|
|
|
|
router.get('/clinical-assistant/chats', async function(req, res) {
|
|
try {
|
|
var rows = await db.all(
|
|
'SELECT id, title, created_at, updated_at FROM clinical_assistant_chats WHERE user_id = $1 ORDER BY updated_at DESC LIMIT 100',
|
|
[req.user.id]
|
|
);
|
|
rows.forEach(function(row) {
|
|
try { row.title = cryptoUtil.decryptString(row.title); } catch (e) {}
|
|
});
|
|
res.json({ success: true, chats: rows });
|
|
} catch (e) {
|
|
logger.error('GET /clinical-assistant/chats', e.message);
|
|
res.status(500).json({ error: 'Request failed' });
|
|
}
|
|
});
|
|
|
|
router.get('/clinical-assistant/chats/:id', async function(req, res) {
|
|
try {
|
|
var row = await db.get(
|
|
'SELECT id, title, payload, created_at, updated_at FROM clinical_assistant_chats WHERE id = $1 AND user_id = $2',
|
|
[req.params.id, req.user.id]
|
|
);
|
|
if (!row) return res.status(404).json({ error: 'Saved chat not found' });
|
|
try { row.title = cryptoUtil.decryptString(row.title); } catch (e) {}
|
|
try { row.payload = JSON.parse(cryptoUtil.decryptString(row.payload) || '{}'); } catch (e) { row.payload = {}; }
|
|
res.json({ success: true, chat: row });
|
|
} catch (e) {
|
|
logger.error('GET /clinical-assistant/chats/:id', e.message);
|
|
res.status(500).json({ error: 'Request failed' });
|
|
}
|
|
});
|
|
|
|
router.post('/clinical-assistant/chats', async function(req, res) {
|
|
try {
|
|
var title = cleanSavedChatTitle(req.body.title || firstUserMessage(req.body.messages) || 'Clinical assistant chat');
|
|
var payload = buildSavedChatPayload(req.body);
|
|
var payloadText = JSON.stringify(payload);
|
|
if (payloadText.length > MAX_SAVED_CHAT_PAYLOAD) return res.status(400).json({ error: 'Saved chat is too large' });
|
|
|
|
var count = await db.get('SELECT COUNT(*) as cnt FROM clinical_assistant_chats WHERE user_id = $1', [req.user.id]);
|
|
if (count && Number(count.cnt) >= MAX_SAVED_CHATS_PER_USER) {
|
|
return res.status(400).json({ error: 'Maximum ' + MAX_SAVED_CHATS_PER_USER + ' saved chats per user' });
|
|
}
|
|
|
|
var result = await db.run(
|
|
'INSERT INTO clinical_assistant_chats (user_id, title, payload) VALUES ($1, $2, $3) RETURNING id',
|
|
[req.user.id, cryptoUtil.encryptString(title), cryptoUtil.encryptString(payloadText)]
|
|
);
|
|
logger.audit(req.user.id, 'clinical_assistant_chat_save', 'Saved clinical assistant chat', req, { category: 'clinical' });
|
|
res.json({ success: true, id: result.lastInsertRowid, title: title });
|
|
} catch (e) {
|
|
logger.error('POST /clinical-assistant/chats', e.message);
|
|
res.status(500).json({ error: 'Request failed' });
|
|
}
|
|
});
|
|
|
|
router.delete('/clinical-assistant/chats/:id', async function(req, res) {
|
|
try {
|
|
await db.run('DELETE FROM clinical_assistant_chats WHERE id = $1 AND user_id = $2', [req.params.id, req.user.id]);
|
|
logger.audit(req.user.id, 'clinical_assistant_chat_delete', 'Deleted clinical assistant chat', req, { category: 'clinical' });
|
|
res.json({ success: true });
|
|
} catch (e) {
|
|
logger.error('DELETE /clinical-assistant/chats/:id', e.message);
|
|
res.status(500).json({ error: 'Request failed' });
|
|
}
|
|
});
|
|
|
|
router.post('/clinical-assistant/chat', async function(req, res) {
|
|
var started = Date.now();
|
|
try {
|
|
var prepared = await prepareAssistantChat(req.body);
|
|
if (prepared.direct) return res.json(prepared.direct);
|
|
|
|
var ai = await callAI(prepared.messages, {
|
|
model: prepared.chatModel || undefined,
|
|
temperature: 0.15,
|
|
maxTokens: 2600
|
|
});
|
|
var finalized = await finalizeAssistantAnswer(ai, { messages: prepared.messages, chatModel: prepared.chatModel, callAI: callAI });
|
|
var answer = finalized.answer;
|
|
ai = finalized.ai;
|
|
|
|
logger.audit(req.user.id, 'clinical_assistant_query', 'Clinical assistant query', req, {
|
|
category: 'clinical', model: ai.model || prepared.chatModel, duration: Date.now() - started
|
|
});
|
|
|
|
res.json({
|
|
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
|
|
});
|
|
} catch (e) {
|
|
console.error('[clinical-assistant]', e.message, e.stack || '');
|
|
res.status(500).json({ error: assistantErrorMessage(e) });
|
|
}
|
|
});
|
|
|
|
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: 'Looking up sources...' });
|
|
|
|
var prepared = await prepareAssistantChat(req.body);
|
|
if (prepared.direct) {
|
|
sendEvent('done', Object.assign({ duration: Date.now() - started }, prepared.direct));
|
|
return res.end();
|
|
}
|
|
|
|
var safeSources = sanitizeSourcesForClient(prepared.sources);
|
|
sendEvent('sources', { sources: safeSources, search: prepared.search });
|
|
sendEvent('status', { message: 'Generating answer...' });
|
|
|
|
var ai = await callAIStream(prepared.messages, {
|
|
model: prepared.chatModel || undefined,
|
|
temperature: 0.15,
|
|
maxTokens: 2600
|
|
}, function(delta) {
|
|
sendEvent('token', { token: delta });
|
|
});
|
|
|
|
var finalized = await finalizeAssistantAnswer(ai, {
|
|
messages: prepared.messages,
|
|
chatModel: prepared.chatModel,
|
|
callAI: callAI,
|
|
streamed: true,
|
|
onRegenerating: function() { sendEvent('status', { message: 'Completing answer...' }); }
|
|
});
|
|
var answer = finalized.answer;
|
|
ai = finalized.ai;
|
|
|
|
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: safeSources,
|
|
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(e.statusCode || 500).json({ error: assistantErrorMessage(e) });
|
|
sendEvent('error', { error: assistantErrorMessage(e) });
|
|
res.end();
|
|
}
|
|
});
|
|
|
|
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' });
|
|
}
|
|
});
|
|
|
|
router.post('/clinical-assistant/image/jobs', 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 id = crypto.randomBytes(16).toString('hex');
|
|
var job = { id: id, userId: req.user.id, status: 'pending', model: model, createdAt: Date.now(), updatedAt: Date.now() };
|
|
await setImageJob(job);
|
|
res.json({ success: true, jobId: id, status: 'pending' });
|
|
|
|
runImageJob(id, req.user.id, prompt, model, req).catch(function(e) {
|
|
console.warn('[clinical-assistant image job]', e.message);
|
|
});
|
|
} 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 job failed' });
|
|
}
|
|
});
|
|
|
|
router.get('/clinical-assistant/image/jobs/:id', async function(req, res) {
|
|
try {
|
|
var job = await getImageJob(req.params.id);
|
|
if (!job || String(job.userId) !== String(req.user.id)) return res.status(404).json({ error: 'Image job not found' });
|
|
res.json({
|
|
success: true,
|
|
jobId: job.id,
|
|
status: job.status,
|
|
model: job.model || null,
|
|
imageUrl: job.imageUrl || null,
|
|
url: job.url || null,
|
|
base64: job.base64 || null,
|
|
error: job.error || null
|
|
});
|
|
} catch (e) {
|
|
res.status(500).json({ error: 'Image job status failed' });
|
|
}
|
|
});
|
|
|
|
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?',
|
|
sources: [],
|
|
model: null,
|
|
search: { skipped: true, reason: 'low_information_input' }
|
|
} };
|
|
}
|
|
|
|
var history = Array.isArray(body.history) ? body.history.slice(-8) : [];
|
|
|
|
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 a clear match for that question. Please 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
|
|
}
|
|
};
|
|
}
|
|
|
|
async function rewriteSearchQuery(message, history, chatModel) {
|
|
message = String(message || '').trim();
|
|
history = Array.isArray(history) ? history.filter(function(m) { return m && (m.role === 'user' || m.role === 'assistant') && m.content; }).slice(-8) : [];
|
|
if (history.length && history[history.length - 1].role === 'user' && String(history[history.length - 1].content || '').trim() === message) {
|
|
history = history.slice(0, -1);
|
|
}
|
|
if (!history.length || !needsContextualRewrite(message)) return message;
|
|
|
|
var deterministic = deterministicFollowupQuery(message, history);
|
|
if (deterministic) return deterministic;
|
|
|
|
var hist = history.map(function(m) {
|
|
return m.role.toUpperCase() + ': ' + String(m.content).substring(0, 900);
|
|
}).join('\n');
|
|
var ai = await callAI([
|
|
{
|
|
role: 'system',
|
|
content: 'Rewrite short or ambiguous clinical follow-up questions into one standalone search query for medical document retrieval. Preserve the user intent and clinical facts from the conversation. Do not answer. Do not add facts not supported by the conversation. Return only the rewritten query.'
|
|
},
|
|
{
|
|
role: 'user',
|
|
content: 'Conversation:\n' + hist + '\n\nLatest user question:\n' + message + '\n\nStandalone search query:'
|
|
}
|
|
], {
|
|
model: chatModel || undefined,
|
|
temperature: 0,
|
|
maxTokens: 80
|
|
});
|
|
var rewritten = String(ai.content || '').replace(/^['"]|['"]$/g, '').replace(/\s+/g, ' ').trim();
|
|
if (!rewritten || rewritten.length < 6 || rewritten.length > 300) return message;
|
|
if (/^(yes|no|maybe|i don'?t know)$/i.test(rewritten)) return message;
|
|
return rewritten;
|
|
}
|
|
|
|
function needsContextualRewrite(message) {
|
|
var text = String(message || '').trim();
|
|
if (!text) return false;
|
|
var words = text.split(/\s+/).filter(Boolean);
|
|
return words.length <= 8 || /\b(it|this|that|they|them|he|she|dose|dosing|how much|what about|next|admit|discharge|criteria|side effects?|contraindications?|monitor|monitoring)\b/i.test(text);
|
|
}
|
|
|
|
function deterministicFollowupQuery(message, history) {
|
|
var text = String(message || '').trim().toLowerCase();
|
|
if (!/^(dose|dosing|what dose|dose\?|how much|med dose|medication dose)$/i.test(text)) return '';
|
|
var prior = previousUserQuestion(history) || previousAssistantTopic(history);
|
|
if (!prior) return '';
|
|
return 'medication dosing and immediate treatment details for: ' + prior;
|
|
}
|
|
|
|
function previousUserQuestion(history) {
|
|
for (var i = history.length - 1; i >= 0; i--) {
|
|
if (history[i] && history[i].role === 'user' && history[i].content) return clip(history[i].content, 300);
|
|
}
|
|
return '';
|
|
}
|
|
|
|
function previousAssistantTopic(history) {
|
|
for (var i = history.length - 1; i >= 0; i--) {
|
|
if (history[i] && history[i].role === 'assistant' && history[i].content) return clip(history[i].content, 300);
|
|
}
|
|
return '';
|
|
}
|
|
|
|
function formatSourcesForPrompt(sources) {
|
|
return sources.map(function(s) {
|
|
var label = s.source_type === 'multimodal_page' ? ' [visual PDF page match]' : '';
|
|
return '[' + s.number + '] ' + s.title + (s.page ? ', page ' + s.page : '') + label + '\n' + cleanSourceExcerpt(s.excerpt);
|
|
}).join('\n\n---\n\n');
|
|
}
|
|
|
|
function sanitizeSourcesForClient(sources) {
|
|
return (Array.isArray(sources) ? sources : []).map(function(source) {
|
|
var out = Object.assign({}, source);
|
|
delete out.image_path;
|
|
delete out.file_path;
|
|
return out;
|
|
});
|
|
}
|
|
|
|
function buildSavedChatPayload(body) {
|
|
var messages = Array.isArray(body.messages) ? body.messages.slice(-80).map(function(m) {
|
|
return {
|
|
role: m && m.role === 'assistant' ? 'assistant' : 'user',
|
|
content: clip(m && m.content, 12000),
|
|
sources: Array.isArray(m && m.sources) ? m.sources.slice(0, 30).map(function(s, idx) {
|
|
return {
|
|
number: Number(s.number || idx + 1),
|
|
title: clip(s.title || s.resource || 'Source', 500),
|
|
resource: clip(s.resource || '', 500),
|
|
page: s.page || s.page_number || s.pageNumber || null,
|
|
source_type: clip(s.source_type || '', 80),
|
|
doc_type: clip(s.doc_type || s.type || '', 80),
|
|
excerpt: clip(s.excerpt || '', 1800),
|
|
score: s.score == null ? null : Number(s.score)
|
|
};
|
|
}) : []
|
|
};
|
|
}).filter(function(m) { return m.content; }) : [];
|
|
var sources = Array.isArray(body.sources) ? body.sources.slice(0, 30).map(function(s, idx) {
|
|
return {
|
|
number: Number(s.number || idx + 1),
|
|
title: clip(s.title || s.resource || 'Source', 500),
|
|
resource: clip(s.resource || '', 500),
|
|
page: s.page || s.page_number || s.pageNumber || null,
|
|
source_type: clip(s.source_type || '', 80),
|
|
doc_type: clip(s.doc_type || s.type || '', 80),
|
|
excerpt: clip(s.excerpt || '', 1800),
|
|
score: s.score == null ? null : Number(s.score)
|
|
};
|
|
}) : [];
|
|
return {
|
|
version: 1,
|
|
messages: messages,
|
|
sources: sources,
|
|
lastAnswer: clip(body.lastAnswer || '', 30000),
|
|
generatedImage: safeImageForSave(body.generatedImage),
|
|
savedAt: new Date().toISOString()
|
|
};
|
|
}
|
|
|
|
function safeImageForSave(image) {
|
|
image = String(image || '');
|
|
if (!image) return '';
|
|
if (/^https?:\/\//i.test(image)) return image.substring(0, 5000);
|
|
return '';
|
|
}
|
|
|
|
function firstUserMessage(messages) {
|
|
if (!Array.isArray(messages)) return '';
|
|
for (var i = 0; i < messages.length; i++) {
|
|
if (messages[i] && messages[i].role === 'user' && messages[i].content) return messages[i].content;
|
|
}
|
|
return '';
|
|
}
|
|
|
|
function cleanSavedChatTitle(title) {
|
|
return clip(title, MAX_SAVED_CHAT_TITLE).replace(/[\r\n\t]+/g, ' ').replace(/\s+/g, ' ').trim() || 'Clinical assistant chat';
|
|
}
|
|
|
|
async function getAvailableExamples() {
|
|
return promptPool.getAvailableExamples();
|
|
}
|
|
|
|
async function getIndexedTopicExamples() {
|
|
var response = await indexedTopicSuggestions(12);
|
|
var data = response && (response.structuredContent || response.data || response);
|
|
if ((!data || !Array.isArray(data.suggestions)) && response && Array.isArray(response.content)) {
|
|
for (var i = 0; i < response.content.length; i++) {
|
|
var c = response.content[i];
|
|
if (c && c.type === 'text' && c.text) {
|
|
try {
|
|
var parsed = JSON.parse(c.text);
|
|
if (parsed && Array.isArray(parsed.suggestions)) data = parsed;
|
|
} catch (e) {}
|
|
}
|
|
}
|
|
}
|
|
if (!data || !Array.isArray(data.suggestions)) return [];
|
|
return data.suggestions.slice(0, 12).map(function(item) {
|
|
return {
|
|
label: item.label || 'Indexed topic',
|
|
prompt: item.prompt || '',
|
|
sourceTitle: Array.isArray(item.sample_titles) ? item.sample_titles[0] : '',
|
|
category: item.category || ''
|
|
};
|
|
}).filter(function(item) { return item.prompt; });
|
|
}
|
|
|
|
async function generateImage(prompt, model) {
|
|
if (!process.env.LITELLM_API_BASE) throw new Error('LiteLLM is required for image generation');
|
|
var headers = getLiteLLMHeaders('application/json');
|
|
var renderedPrompt = imagePromptForCanvas(prompt);
|
|
var size = process.env.CLINICAL_ASSISTANT_IMAGE_SIZE || 'auto';
|
|
var resp = await generateImageRequest(model, renderedPrompt, size, headers).catch(async function(e) {
|
|
if (!isInvalidImageSizeError(e) || size === '1024x1024') throw e;
|
|
return generateImageRequest(model, renderedPrompt, '1024x1024', headers);
|
|
});
|
|
var item = resp.data && resp.data.data && resp.data.data[0] ? resp.data.data[0] : {};
|
|
var base64 = item.b64_json || null;
|
|
if (!base64 && item.url) base64 = await fetchImageUrlAsBase64(item.url).catch(function() { return null; });
|
|
return { imageUrl: item.url || null, base64: base64, raw: (!item.url && !base64) ? resp.data : undefined };
|
|
}
|
|
|
|
async function fetchImageUrlAsBase64(url) {
|
|
if (!/^https?:\/\//i.test(String(url || ''))) return null;
|
|
var resp = await axios.get(url, { responseType: 'arraybuffer', timeout: 60000 });
|
|
return Buffer.from(resp.data).toString('base64');
|
|
}
|
|
|
|
async function runImageJob(id, userId, prompt, model, req) {
|
|
await updateImageJob(id, { status: 'running', updatedAt: Date.now() });
|
|
try {
|
|
var image = await generateImage(prompt, model);
|
|
await updateImageJob(id, Object.assign({ status: 'done', updatedAt: Date.now() }, image));
|
|
logger.audit(userId, 'clinical_assistant_image', 'Generated clinical assistant image', req, { category: 'clinical', model: model, async: true });
|
|
} catch (e) {
|
|
var detail = e.response && e.response.data ? JSON.stringify(e.response.data).substring(0, 300) : e.message;
|
|
await updateImageJob(id, { status: 'error', error: detail || 'Image generation failed', updatedAt: Date.now() });
|
|
}
|
|
}
|
|
|
|
async function getImageJob(id) {
|
|
var key = imageJobKey(id);
|
|
var job = await redisCache.getJson(key);
|
|
if (job) return job;
|
|
return imageJobs.get(key) || null;
|
|
}
|
|
|
|
async function setImageJob(job) {
|
|
var key = imageJobKey(job.id);
|
|
imageJobs.set(key, job);
|
|
trimImageJobs();
|
|
await redisCache.setJson(key, job, IMAGE_JOB_TTL_SECONDS).catch(function() { return false; });
|
|
}
|
|
|
|
async function updateImageJob(id, patch) {
|
|
var existing = await getImageJob(id);
|
|
if (!existing) return;
|
|
await setImageJob(Object.assign({}, existing, patch));
|
|
}
|
|
|
|
function imageJobKey(id) {
|
|
return 'clinical-assistant:image-job:' + String(id || '').replace(/[^a-f0-9]/g, '').slice(0, 64);
|
|
}
|
|
|
|
function trimImageJobs() {
|
|
var cutoff = Date.now() - IMAGE_JOB_TTL_SECONDS * 1000;
|
|
imageJobs.forEach(function(job, key) {
|
|
if (!job || (job.updatedAt || job.createdAt || 0) < cutoff) imageJobs.delete(key);
|
|
});
|
|
}
|
|
|
|
function generateImageRequest(model, prompt, size, headers) {
|
|
return axios.post(gatewayUrl('/images/generations'), {
|
|
model: model,
|
|
prompt: prompt,
|
|
size: size
|
|
}, { headers: headers, timeout: 120000 });
|
|
}
|
|
|
|
function imagePromptForCanvas(prompt) {
|
|
var text = String(prompt || '');
|
|
var guidance = ' Compose as a single complete medical teaching poster. Keep every element fully inside the canvas with a 10% safe margin on all sides. Do not crop boxes, arrows, labels, legends, or body parts. Use fewer words per box, large readable type, and generous spacing.';
|
|
if (/\b(flow\s*chart|flowchart|algorithm|pathway|timeline|vertical|stepwise|decision\s*tree|age\s*group|0-21|22-28|29-60)\b/i.test(text)) {
|
|
guidance += ' Use a tall portrait layout with top-to-bottom flow, no more than 6-8 main nodes, and ample spacing between decision nodes.';
|
|
}
|
|
if (/\b(table|matrix|comparison|wide|landscape|side-by-side)\b/i.test(text)) {
|
|
guidance += ' Use a wide landscape layout with compact columns, ample horizontal spacing, and no text near the edges.';
|
|
}
|
|
return text.trim() + guidance;
|
|
}
|
|
|
|
function isInvalidImageSizeError(e) {
|
|
var detail = e && e.response && e.response.data ? JSON.stringify(e.response.data) : (e && e.message ? e.message : '');
|
|
return /invalid size|unsupported size|supported sizes/i.test(detail);
|
|
}
|
|
|
|
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) {
|
|
var title = String(s || '').trim();
|
|
try { title = decodeURIComponent(title); } catch (e) {}
|
|
return title
|
|
.replace(/\.(pdf|docx?|txt|md)$/i, '')
|
|
.replace(/\s+/g, ' ')
|
|
.replace(/\s*\(z-library\.sk,\s*1lib\.sk,\s*z-lib\.sk\)\s*/ig, '')
|
|
.trim();
|
|
}
|
|
function cleanSourceExcerpt(text) {
|
|
return String(text || '')
|
|
.replace(/^\[Page-image match\]\s*/i, '')
|
|
.replace(/!\[[^\]]*\]\([^)]*\)/g, ' ')
|
|
.replace(/<br\s*\/?>/gi, ' ')
|
|
.replace(/\*\*/g, '')
|
|
.replace(/\|\s*-{2,}\s*/g, ' ')
|
|
.replace(/\|/g, ' ')
|
|
.replace(/\s+/g, ' ')
|
|
.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';
|
|
}
|
|
|
|
module.exports = router;
|