refactor clinical assistant backend utilities
This commit is contained in:
parent
68a422a7ad
commit
16b4746b0a
4 changed files with 582 additions and 497 deletions
|
|
@ -16,24 +16,34 @@ 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 MCP_URLS = buildMcpUrls();
|
||||
var _lastGoodMcpUrl = MCP_URLS[0];
|
||||
var MCP_INITIALIZE_TIMEOUT_MS = positiveInt(process.env.CLINICAL_ASSISTANT_MCP_INITIALIZE_TIMEOUT_MS, 30000);
|
||||
var MCP_REQUEST_TIMEOUT_MS = positiveInt(process.env.CLINICAL_ASSISTANT_MCP_REQUEST_TIMEOUT_MS, 90000);
|
||||
var MCP_SESSION_TTL_MS = positiveInt(process.env.CLINICAL_ASSISTANT_MCP_SESSION_TTL_MS, 10 * 60 * 1000);
|
||||
var _mcpSession = null;
|
||||
var _mcpSessionPromise = null;
|
||||
var _mcpCallQueue = Promise.resolve();
|
||||
var _mcpRequestId = 1;
|
||||
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 MULTIMODAL_CANDIDATE_LIMIT = 6;
|
||||
var EXAMPLE_CANDIDATES = [
|
||||
{ label: 'Status asthma escalation', prompt: 'In a 4-year-old with acute wheeze, when should magnesium sulfate be considered and what dose is recommended?' },
|
||||
{ label: 'Febrile neonate', prompt: 'What initial workup and empiric antibiotics are recommended for a well-appearing febrile neonate?' },
|
||||
|
|
@ -120,7 +130,7 @@ function positiveInt(value, fallback) {
|
|||
|
||||
if (process.env.CLINICAL_ASSISTANT_MCP_WARMUP !== 'false') {
|
||||
setTimeout(function() {
|
||||
getMcpSession().catch(function(e) {
|
||||
warmMcpSession().catch(function(e) {
|
||||
console.warn('[clinical-assistant] MCP warmup skipped:', e.message);
|
||||
});
|
||||
}, positiveInt(process.env.CLINICAL_ASSISTANT_MCP_WARMUP_DELAY_MS, 5000));
|
||||
|
|
@ -240,19 +250,9 @@ router.post('/clinical-assistant/chat', async function(req, res) {
|
|||
temperature: 0.15,
|
||||
maxTokens: 2600
|
||||
});
|
||||
var answer = stripModelSourcesSection(String(ai.content || '').trim());
|
||||
if (shouldRegenerateTruncatedAnswer(answer, ai.finishReason)) {
|
||||
console.warn('[clinical-assistant] answer looked truncated; regenerating', { finishReason: ai.finishReason, chars: answer.length });
|
||||
var completed = await callAI(prepared.messages, {
|
||||
model: prepared.chatModel || undefined,
|
||||
temperature: 0.15,
|
||||
maxTokens: 5000
|
||||
});
|
||||
answer = stripModelSourcesSection(String(completed.content || '').trim()) || answer;
|
||||
ai.model = completed.model || ai.model;
|
||||
ai.provider = completed.provider || ai.provider;
|
||||
ai.finishReason = completed.finishReason || ai.finishReason;
|
||||
}
|
||||
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
|
||||
|
|
@ -308,20 +308,15 @@ router.post('/clinical-assistant/chat/stream', async function(req, res) {
|
|||
sendEvent('token', { token: delta });
|
||||
});
|
||||
|
||||
var answer = stripModelSourcesSection(String(ai.content || '').trim());
|
||||
if (shouldRegenerateTruncatedAnswer(answer, ai.finishReason)) {
|
||||
console.warn('[clinical-assistant] streamed answer looked truncated; regenerating final answer', { finishReason: ai.finishReason, chars: answer.length });
|
||||
sendEvent('status', { message: 'Completing answer...' });
|
||||
var completed = await callAI(prepared.messages, {
|
||||
model: prepared.chatModel || undefined,
|
||||
temperature: 0.15,
|
||||
maxTokens: 5000
|
||||
});
|
||||
answer = stripModelSourcesSection(String(completed.content || '').trim()) || answer;
|
||||
ai.model = completed.model || ai.model;
|
||||
ai.provider = completed.provider || ai.provider;
|
||||
ai.finishReason = completed.finishReason || ai.finishReason;
|
||||
}
|
||||
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
|
||||
|
|
@ -537,331 +532,6 @@ function previousAssistantTopic(history) {
|
|||
return '';
|
||||
}
|
||||
|
||||
async function semanticSearch(query, opts) {
|
||||
return callMcpTool('nc_semantic_search', {
|
||||
query: query,
|
||||
limit: opts.limit,
|
||||
doc_types: ['file'],
|
||||
score_threshold: 0,
|
||||
fusion: 'rrf',
|
||||
include_context: opts.includeContext,
|
||||
context_chars: opts.contextChars
|
||||
});
|
||||
}
|
||||
|
||||
async function multimodalSearch(query, opts) {
|
||||
return callMcpTool('nc_multimodal_search', {
|
||||
query: query,
|
||||
limit: opts.limit
|
||||
});
|
||||
}
|
||||
|
||||
async function indexedTopicSuggestions(limit) {
|
||||
return callMcpTool('nc_indexed_topic_suggestions', {
|
||||
limit: limit || 12,
|
||||
sample_size: 1500,
|
||||
doc_type: 'file'
|
||||
});
|
||||
}
|
||||
|
||||
async function callMcpTool(name, args) {
|
||||
var queued = _mcpCallQueue.then(function() {
|
||||
return callMcpToolUnlocked(name, args);
|
||||
});
|
||||
_mcpCallQueue = queued.catch(function() {});
|
||||
return queued;
|
||||
}
|
||||
|
||||
async function callMcpToolUnlocked(name, args) {
|
||||
var session = await getMcpSession();
|
||||
var payload = {
|
||||
jsonrpc: '2.0',
|
||||
id: nextMcpRequestId(),
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: name,
|
||||
arguments: args
|
||||
}
|
||||
};
|
||||
var search;
|
||||
try {
|
||||
search = await mcpRequest(payload, session.sessionId, session.mcpUrl);
|
||||
} catch (e) {
|
||||
if (!isInvalidMcpSessionError(e)) throw e;
|
||||
_mcpSession = null;
|
||||
session = await getMcpSession();
|
||||
payload.id = nextMcpRequestId();
|
||||
search = await mcpRequest(payload, session.sessionId, session.mcpUrl);
|
||||
}
|
||||
return search.result || search;
|
||||
}
|
||||
|
||||
function nextMcpRequestId() {
|
||||
_mcpRequestId += 1;
|
||||
if (_mcpRequestId > 1000000000) _mcpRequestId = 2;
|
||||
return _mcpRequestId;
|
||||
}
|
||||
|
||||
async function getMcpSession() {
|
||||
var now = Date.now();
|
||||
if (_mcpSession && _mcpSession.sessionId && _mcpSession.expiresAt > now) return _mcpSession;
|
||||
if (_mcpSessionPromise) return _mcpSessionPromise;
|
||||
_mcpSessionPromise = initializeMcpSession().then(function(session) {
|
||||
_mcpSession = session;
|
||||
return session;
|
||||
}).finally(function() {
|
||||
_mcpSessionPromise = null;
|
||||
});
|
||||
return _mcpSessionPromise;
|
||||
}
|
||||
|
||||
async function initializeMcpSession() {
|
||||
var session = await mcpRequest({
|
||||
jsonrpc: '2.0',
|
||||
id: nextMcpRequestId(),
|
||||
method: 'initialize',
|
||||
params: {
|
||||
protocolVersion: '2024-11-05',
|
||||
capabilities: {},
|
||||
clientInfo: { name: 'ped-ai-clinical-assistant', version: '1.0.0' }
|
||||
}
|
||||
});
|
||||
session.expiresAt = Date.now() + MCP_SESSION_TTL_MS;
|
||||
return session;
|
||||
}
|
||||
|
||||
async function mcpRequest(payload, sessionId, preferredUrl) {
|
||||
var headers = {
|
||||
'Accept': 'application/json, text/event-stream',
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
if (sessionId) headers['mcp-session-id'] = sessionId;
|
||||
var resp = await postMcpWithRetry(payload, headers, preferredUrl);
|
||||
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;
|
||||
parsed.mcpUrl = resp.config && resp.config.url ? resp.config.url : preferredUrl || _lastGoodMcpUrl;
|
||||
return parsed;
|
||||
}
|
||||
|
||||
async function postMcpWithRetry(payload, headers, preferredUrl) {
|
||||
var lastErr = null;
|
||||
var urls = orderedMcpUrls(preferredUrl);
|
||||
var isInitialize = payload && payload.method === 'initialize';
|
||||
for (var round = 0; round < 2; round++) {
|
||||
for (var i = 0; i < urls.length; i++) {
|
||||
var url = urls[i];
|
||||
try {
|
||||
var resp = await axios.post(url, payload, {
|
||||
headers: headers,
|
||||
timeout: isInitialize ? MCP_INITIALIZE_TIMEOUT_MS : MCP_REQUEST_TIMEOUT_MS,
|
||||
responseType: 'text',
|
||||
transformResponse: [function(data) { return data; }]
|
||||
});
|
||||
_lastGoodMcpUrl = url;
|
||||
return resp;
|
||||
} catch (e) {
|
||||
lastErr = e;
|
||||
if (!isTransientMcpError(e)) throw e;
|
||||
console.warn('[clinical-assistant] MCP endpoint unavailable:', url, e.code || e.message);
|
||||
}
|
||||
}
|
||||
await sleep(750 * (round + 1));
|
||||
}
|
||||
throw lastErr;
|
||||
}
|
||||
|
||||
function orderedMcpUrls(preferredUrl) {
|
||||
var candidates = [];
|
||||
[preferredUrl, _lastGoodMcpUrl].concat(MCP_URLS).forEach(function(url) {
|
||||
if (url && candidates.indexOf(url) === -1) candidates.push(url);
|
||||
});
|
||||
return candidates;
|
||||
}
|
||||
|
||||
function isTransientMcpError(e) {
|
||||
var code = e && (e.code || (e.cause && e.cause.code)) || '';
|
||||
var status = e && e.response && e.response.status;
|
||||
return code === 'ECONNREFUSED' || code === 'ECONNRESET' || code === 'ETIMEDOUT' || status === 502 || status === 503 || status === 504;
|
||||
}
|
||||
|
||||
function isInvalidMcpSessionError(e) {
|
||||
var status = e && e.response && e.response.status;
|
||||
var msg = String(e && e.message || '');
|
||||
return status === 400 || status === 404 || status === 410 || /session|mcp-session-id/i.test(msg);
|
||||
}
|
||||
|
||||
async function getMcpHealth() {
|
||||
var results = [];
|
||||
var urls = orderedMcpUrls();
|
||||
for (var i = 0; i < urls.length; i++) {
|
||||
var mcpUrl = urls[i];
|
||||
var healthUrl = mcpUrl.replace(/\/mcp\/?$/, '/health/live');
|
||||
try {
|
||||
var resp = await axios.get(healthUrl, { timeout: 3000 });
|
||||
results.push({ url: mcpUrl, healthUrl: healthUrl, ok: true, status: resp.status, data: resp.data });
|
||||
_lastGoodMcpUrl = mcpUrl;
|
||||
return { ok: true, activeUrl: mcpUrl, checked: results, candidates: MCP_URLS };
|
||||
} catch (e) {
|
||||
results.push({ url: mcpUrl, healthUrl: healthUrl, ok: false, error: e.code || e.message });
|
||||
}
|
||||
}
|
||||
return { ok: false, activeUrl: _lastGoodMcpUrl || null, checked: results, candidates: MCP_URLS };
|
||||
}
|
||||
|
||||
function buildMcpUrls() {
|
||||
var raw = [];
|
||||
if (process.env.CLINICAL_ASSISTANT_MCP_URLS) raw = raw.concat(process.env.CLINICAL_ASSISTANT_MCP_URLS.split(','));
|
||||
raw.push(process.env.CLINICAL_ASSISTANT_MCP_URL || process.env.MCP_SERVER_URL || '');
|
||||
raw.push('http://mcp:8000/mcp');
|
||||
raw.push('http://mcp-server-mcp-1:8000/mcp');
|
||||
raw.push('http://127.0.0.1:8100/mcp');
|
||||
var out = [];
|
||||
raw.forEach(function(url) {
|
||||
url = String(url || '').trim();
|
||||
if (!url) return;
|
||||
if (!/\/mcp\/?$/.test(url)) url = url.replace(/\/$/, '') + '/mcp';
|
||||
if (out.indexOf(url) === -1) out.push(url);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise(function(resolve) { setTimeout(resolve, ms); });
|
||||
}
|
||||
|
||||
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,
|
||||
folder_path: r.folder_path || r.folderPath || '',
|
||||
category: r.category || '',
|
||||
source_priority: r.source_priority || r.sourcePriority || '',
|
||||
source_boost: r.source_boost || r.sourceBoost || null,
|
||||
tags: Array.isArray(r.tags) ? r.tags : [],
|
||||
excerpt: clip(text, 1800),
|
||||
score: r.score,
|
||||
chunk_index: r.chunk_index,
|
||||
total_chunks: r.total_chunks
|
||||
};
|
||||
}).filter(function(r) { return r.excerpt; });
|
||||
}
|
||||
|
||||
function normalizeMcpMultimodalResponse(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 nearby = clip(cleanSourceExcerpt(r.nearby_text || ''), 1400);
|
||||
var caption = clip(cleanSourceExcerpt(r.visual_caption || ''), 900);
|
||||
var labels = Array.isArray(r.visual_labels) ? r.visual_labels.filter(Boolean).slice(0, 20) : [];
|
||||
var excerptParts = [];
|
||||
if (caption) excerptParts.push('[Visual caption] ' + caption);
|
||||
if (labels.length) excerptParts.push('[Visual labels] ' + labels.join(', '));
|
||||
if (nearby) excerptParts.push('[Page text] ' + nearby);
|
||||
return {
|
||||
number: idx + 1,
|
||||
id: r.id,
|
||||
doc_type: r.doc_type || 'file',
|
||||
source_type: 'multimodal_page',
|
||||
title: cleanTitle(r.title || r.file_path || 'PDF page image'),
|
||||
file_path: r.file_path || '',
|
||||
category: r.category || '',
|
||||
subcategory: r.subcategory || '',
|
||||
category_path: r.category_path || '',
|
||||
page: r.page_number || r.pageNumber || null,
|
||||
page_count: r.page_count || r.pageCount || null,
|
||||
visual_caption: caption,
|
||||
visual_labels: labels,
|
||||
visual_caption_source: r.visual_caption_source || '',
|
||||
is_cover_page: Boolean(r.is_cover_page || r.isCoverPage),
|
||||
is_title_page: Boolean(r.is_title_page || r.isTitlePage),
|
||||
reject_for_visual_search: Boolean(r.reject_for_visual_search || r.rejectForVisualSearch),
|
||||
excerpt: excerptParts.length ? '[Page-image match] ' + excerptParts.join('\n') : '[Page-image match] Rendered PDF page matched the visual/text query.',
|
||||
score: r.score,
|
||||
chunk_index: 'page-image-' + (r.page_number || idx + 1),
|
||||
total_chunks: r.page_count || null
|
||||
};
|
||||
}).filter(function(r) { return r.page; });
|
||||
}
|
||||
|
||||
function dedupeSources(results) {
|
||||
var seen = new Map();
|
||||
var out = [];
|
||||
results.forEach(function(r) {
|
||||
var key = sourceDedupeKey(r);
|
||||
var existingIndex = seen.get(key);
|
||||
if (existingIndex != null) {
|
||||
out[existingIndex] = mergeDuplicateSource(out[existingIndex], r);
|
||||
return;
|
||||
}
|
||||
seen.set(key, out.length);
|
||||
out.push(Object.assign({}, r, { number: out.length + 1 }));
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
function sourceDedupeKey(source) {
|
||||
var title = cleanTitle(source && source.title || '').toLowerCase();
|
||||
var page = source && source.page ? String(source.page) : '';
|
||||
var kind = source && source.source_type ? source.source_type : 'text';
|
||||
if (title && page) return [kind, title, page].join('|');
|
||||
return [kind, title, source && source.id || source && source.chunk_index || ''].join('|');
|
||||
}
|
||||
|
||||
function mergeDuplicateSource(existing, duplicate) {
|
||||
var merged = Object.assign({}, existing);
|
||||
var existingExcerpt = cleanSourceExcerpt(existing.excerpt || '');
|
||||
var duplicateExcerpt = cleanSourceExcerpt(duplicate.excerpt || '');
|
||||
if (duplicateExcerpt && existingExcerpt.indexOf(duplicateExcerpt.slice(0, 160)) === -1) {
|
||||
merged.excerpt = clip([existingExcerpt, duplicateExcerpt].filter(Boolean).join('\n'), 1800);
|
||||
}
|
||||
merged.score = Math.max(Number(existing.score) || 0, Number(duplicate.score) || 0) || existing.score || duplicate.score;
|
||||
return merged;
|
||||
}
|
||||
|
||||
function formatSourcesForPrompt(sources) {
|
||||
return sources.map(function(s) {
|
||||
var label = s.source_type === 'multimodal_page' ? ' [visual PDF page match]' : '';
|
||||
|
|
@ -869,140 +539,6 @@ function formatSourcesForPrompt(sources) {
|
|||
}).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- For recognizable medical terms, abbreviations, diseases, and acronyms, answer directly without prefacing with "Assuming you meant".\n- For genuinely misspelled or partial terms, use the retrieved sources to infer the closest medical concept when there is a plausible match, then answer directly. Ask for clarification only when the retrieved sources do not indicate any plausible concept.\n- Use the exact source numbers from the retrieved sources; do not renumber citations for order or style.\n- Cite factual claims immediately with numbered citations like [1] or [1, 3].\n- Every clinical recommendation, dose, threshold, lab value, statistic, comparison, contraindication, red flag, and table row must include its own supporting citation.\n- Do not leave a paragraph, bullet, or table row with multiple factual claims supported only by an uncited heading.\n- If a claim is not directly supported by retrieved sources, omit it or say the available sources are insufficient.\n- If retrieved sources mention the medication/intervention only for other diseases, explicitly say the available sources do not support it for the user\'s requested disease.\n- Do not cite a source number that is not provided.\n- If sources disagree or are insufficient, say so.\n- Keep the main answer concise and clinically useful.\n- Use clear markdown with headings, bullets, and tables when useful.\n- When using a table, output a valid GitHub-flavored markdown table with pipe characters and a separator row. Never output tab-separated tables.\n- Put any summary sentence in a separate paragraph after the table, not as a table row.\n- Do not add a final Sources or References section; the UI displays all retrieved sources separately.\n- Do not add generic disclaimers about clinician judgment.';
|
||||
}
|
||||
|
||||
function buildUserPrompt(question, context, history, searchQuery) {
|
||||
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');
|
||||
var searchNote = searchQuery && searchQuery !== question ? ('\n\nStandalone retrieval query used:\n' + searchQuery) : '';
|
||||
return 'Question:\n' + question + searchNote + '\n\nRecent conversation, if relevant:\n' + (hist || 'None') + '\n\nRetrieved sources:\n' + context + '\n\nWrite the answer now. If the question is a short misspelled or partial term and the sources point to a likely concept, answer the likely concept rather than asking for clarification.';
|
||||
}
|
||||
|
||||
function stripModelSourcesSection(answer) {
|
||||
return cleanDanglingSourceLeadIn(String(answer || '')
|
||||
.replace(/\n\s*(---\s*)?(#{1,3}\s*)?(Sources|References)\s*\n[\s\S]*$/i, '')
|
||||
.replace(/\n\s*>?\s*(⚠️\s*)?Clinical decision support[^\n]*$/gim, '')
|
||||
.trim());
|
||||
}
|
||||
|
||||
function cleanDanglingSourceLeadIn(answer) {
|
||||
answer = String(answer || '').trim();
|
||||
return answer
|
||||
.replace(/([.!?])\s+(?:however,?\s*)?(?:but\s*)?(?:the\s*)?available(?:\s+(?:sources?|retrieved\s+sources?|evidence|material))?\s*$/i, '$1')
|
||||
.replace(/([.!?])\s+(?:however,?\s*)?(?:but\s*)?(?:based\s+on\s+)?(?:the\s*)?available\s*$/i, '$1')
|
||||
.replace(/(?:^|\n\n)(?:however,?\s*)?(?:but\s*)?(?:the\s*)?available(?:\s+(?:sources?|retrieved\s+sources?|evidence|material))?\s*$/i, '')
|
||||
.replace(/(?:^|\n\n)(?:however,?\s*)?(?:but\s*)?(?:based\s+on\s+)?(?:the\s*)?available\s*$/i, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function shouldRegenerateTruncatedAnswer(answer, finishReason) {
|
||||
answer = String(answer || '').trim();
|
||||
if (!answer) return false;
|
||||
if (finishReason === 'length') return true;
|
||||
if (/\b(the|a|an|and|or|but|with|without|for|to|of|in|on|as|by|from|because|therefore|however|some|many|most|few|several|additional|other|further|including|such|available)$/i.test(answer)) return true;
|
||||
if (/[,:;\-(]$/.test(answer)) return true;
|
||||
var lines = answer.split(/\n+/).map(function(line) { return line.trim(); }).filter(Boolean);
|
||||
var last = lines.length ? lines[lines.length - 1] : answer;
|
||||
if (last.length > 24 && /[A-Za-z]$/.test(last) && !/[.!?\])"']$/.test(last)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function isVisualSourceQuery(query) {
|
||||
return visualIntent(query).wanted.length > 0;
|
||||
}
|
||||
|
||||
function isRadiologyQuery(query) {
|
||||
return visualIntent(query).wanted.indexOf('radiology') !== -1;
|
||||
}
|
||||
|
||||
function buildMultimodalSearchQuery(query) {
|
||||
query = String(query || '').trim();
|
||||
var intent = visualIntent(query);
|
||||
if (intent.wanted.indexOf('radiology') !== -1) return query + ' radiograph x-ray chest imaging radiology';
|
||||
if (intent.wanted.indexOf('diagram') !== -1) return query + ' figure diagram pathway flowchart algorithm illustration';
|
||||
if (intent.wanted.indexOf('photo') !== -1) return query + ' clinical photograph image skin lesion pathology microscopy';
|
||||
return query;
|
||||
}
|
||||
|
||||
async function classifyAndRerankMultimodalResults(query, results) {
|
||||
results = Array.isArray(results) ? results : [];
|
||||
if (!results.length) return [];
|
||||
var candidates = results.slice(0, MULTIMODAL_CANDIDATE_LIMIT);
|
||||
return selectMultimodalResults(query, candidates);
|
||||
}
|
||||
|
||||
function selectMultimodalResults(query, results) {
|
||||
results = Array.isArray(results) ? results : [];
|
||||
if (!results.length) return [];
|
||||
results = results.filter(function(r) { return !shouldRejectVisualSource(query, r); });
|
||||
if (!results.length) return [];
|
||||
if (!isRadiologyQuery(query)) return results.filter(function(r) { return !looksLikeTextOnlyPage(r); });
|
||||
var radiologyTerms = /\b(radiology|radiologic|radiograph|x-?ray|cxr|chest\s*(film|x-?ray|radiograph)|imaging|ultrasound|ct|mri|scan|film|pneumonia|bronchiolitis|lung|airway|hyperinflation|infiltrate|consolidation|atelectasis)\b/i;
|
||||
return results.filter(function(r) {
|
||||
return radiologyTerms.test([r.title, r.excerpt, r.file_path, r.category, r.subcategory, r.category_path].filter(Boolean).join(' '));
|
||||
});
|
||||
}
|
||||
|
||||
function visualIntent(query) {
|
||||
var text = String(query || '').toLowerCase();
|
||||
var wanted = [];
|
||||
if (/\b(radiology|radiologic|radiograph|x-?ray|cxr|chest\s*(film|x-?ray|radiograph)|imaging|ultrasound|ct|mri|scan|film)\b/i.test(text)) {
|
||||
wanted.push('radiology');
|
||||
}
|
||||
if (/\b(algorithm|flow\s*chart|flowchart|pathway|diagram|graph|chart|figure|figures|illustration|infographic|schema|schematic|table)\b/i.test(text)) {
|
||||
wanted.push('diagram');
|
||||
}
|
||||
if (/\b(photo|photograph|picture|skin|rash|lesion|wound|gross|microscopy|histology|pathology image|clinical image)\b/i.test(text)) {
|
||||
wanted.push('photo');
|
||||
}
|
||||
if (!wanted.length && /\b(visual|image|images|show|find|display|preview)\b/i.test(text)) {
|
||||
wanted.push('diagram', 'photo');
|
||||
}
|
||||
return { wanted: Array.from(new Set(wanted)) };
|
||||
}
|
||||
|
||||
function visualMetadataScore(query, source) {
|
||||
var haystack = [source.title, source.excerpt, source.visual_caption, (source.visual_labels || []).join(' '), source.file_path, source.category, source.subcategory, source.category_path].filter(Boolean).join(' ');
|
||||
var score = 0;
|
||||
var intent = visualIntent(query);
|
||||
if (isRadiologyQuery(query) && /\b(radiology|radiograph|x-?ray|cxr|imaging|ct|mri|ultrasound|film)\b/i.test(haystack)) score += 0.12;
|
||||
if (intent.wanted.indexOf('diagram') !== -1 && /\b(figure|diagram|algorithm|flowchart|pathway|chart|illustration|infographic)\b/i.test(haystack)) score += 0.12;
|
||||
if (intent.wanted.indexOf('photo') !== -1 && /\b(photo|photograph|skin|rash|lesion|wound|gross|microscopy|histology)\b/i.test(haystack)) score += 0.12;
|
||||
if (/\b(pneumonia|bronchiolitis|lung|airway|hyperinflation|infiltrate|consolidation|atelectasis)\b/i.test(haystack)) score += 0.06;
|
||||
if (looksLikeFrontMatterPage(source)) score -= 0.5;
|
||||
if (looksLikeTextOnlyPage(source)) score -= 0.18;
|
||||
return score;
|
||||
}
|
||||
|
||||
function shouldRejectVisualSource(query, source) {
|
||||
if (!source) return false;
|
||||
if (allowsFrontMatterQuery(query)) return false;
|
||||
return Boolean(source.reject_for_visual_search || source.is_cover_page || source.is_title_page || looksLikeFrontMatterPage(source));
|
||||
}
|
||||
|
||||
function allowsFrontMatterQuery(query) {
|
||||
return /\b(cover|front cover|title page|copyright|table of contents|contents|preface|foreword|publisher|isbn)\b/i.test(String(query || ''));
|
||||
}
|
||||
|
||||
function looksLikeFrontMatterPage(source) {
|
||||
var page = Number(source && source.page) || 0;
|
||||
var pageCount = Number(source && source.page_count) || 0;
|
||||
var earlyLimit = Math.min(8, Math.max(2, pageCount || 8));
|
||||
if (!page || page > earlyLimit) return false;
|
||||
var haystack = [source.title, source.excerpt, source.visual_caption, (source.visual_labels || []).join(' '), source.file_path].filter(Boolean).join(' ');
|
||||
var hasVisualSignal = /\b(figure|fig\.?|radiograph|x-?ray|cxr|ultrasound|ct|mri|photograph|photo|image|diagram|flowchart|algorithm|histology|microscopy|gross pathology)\b/i.test(haystack);
|
||||
if (hasVisualSignal) return false;
|
||||
var frontMatterSignal = /\b(cover|front cover|title page|copyright|all rights reserved|isbn|library of congress|table of contents|contents|preface|foreword|dedication|contributors?|textbook of|manual of|review of|board review|edition|authors?|editor|publisher)\b/i.test(haystack);
|
||||
return frontMatterSignal || page <= 2;
|
||||
}
|
||||
|
||||
function looksLikeTextOnlyPage(source) {
|
||||
var text = String(source && source.excerpt || '');
|
||||
return text.length > 900 && !/\b(figure|image|radiograph|x-?ray|cxr|ultrasound|ct|mri|scan|film|diagram|illustration)\b/i.test(text);
|
||||
}
|
||||
|
||||
function sanitizeSourcesForClient(sources) {
|
||||
return (Array.isArray(sources) ? sources : []).map(function(source) {
|
||||
var out = Object.assign({}, source);
|
||||
|
|
|
|||
66
src/utils/clinicalAnswer.js
Normal file
66
src/utils/clinicalAnswer.js
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
function buildSystemPrompt(behavior) {
|
||||
return behavior + '\n\nRules:\n- Answer only the user question; do not dump unrelated textbook content.\n- For recognizable medical terms, abbreviations, diseases, and acronyms, answer directly without prefacing with "Assuming you meant".\n- For genuinely misspelled or partial terms, use the retrieved sources to infer the closest medical concept when there is a plausible match, then answer directly. Ask for clarification only when the retrieved sources do not indicate any plausible concept.\n- Use the exact source numbers from the retrieved sources; do not renumber citations for order or style.\n- Cite factual claims immediately with numbered citations like [1] or [1, 3].\n- Every clinical recommendation, dose, threshold, lab value, statistic, comparison, contraindication, red flag, and table row must include its own supporting citation.\n- Do not leave a paragraph, bullet, or table row with multiple factual claims supported only by an uncited heading.\n- If a claim is not directly supported by retrieved sources, omit it or say the available sources are insufficient.\n- If retrieved sources mention the medication/intervention only for other diseases, explicitly say the available sources do not support it for the user\'s requested disease.\n- Do not cite a source number that is not provided.\n- If sources disagree or are insufficient, say so.\n- Keep the main answer concise and clinically useful.\n- Use clear markdown with headings, bullets, and tables when useful.\n- When using a table, output a valid GitHub-flavored markdown table with pipe characters and a separator row. Never output tab-separated tables.\n- Put any summary sentence in a separate paragraph after the table, not as a table row.\n- Do not add a final Sources or References section; the UI displays all retrieved sources separately.\n- Do not add generic disclaimers about clinician judgment.';
|
||||
}
|
||||
|
||||
function buildUserPrompt(question, context, history, searchQuery) {
|
||||
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');
|
||||
var searchNote = searchQuery && searchQuery !== question ? ('\n\nStandalone retrieval query used:\n' + searchQuery) : '';
|
||||
return 'Question:\n' + question + searchNote + '\n\nRecent conversation, if relevant:\n' + (hist || 'None') + '\n\nRetrieved sources:\n' + context + '\n\nWrite the answer now. If the question is a short misspelled or partial term and the sources point to a likely concept, answer the likely concept rather than asking for clarification.';
|
||||
}
|
||||
|
||||
async function finalizeAssistantAnswer(ai, options) {
|
||||
options = options || {};
|
||||
var answer = stripModelSourcesSection(String(ai && ai.content || '').trim());
|
||||
if (shouldRegenerateTruncatedAnswer(answer, ai && ai.finishReason) && typeof options.callAI === 'function') {
|
||||
console.warn('[clinical-assistant] answer looked truncated; regenerating final answer', { finishReason: ai && ai.finishReason, chars: answer.length, streamed: Boolean(options.streamed) });
|
||||
if (typeof options.onRegenerating === 'function') options.onRegenerating();
|
||||
var completed = await options.callAI(options.messages, {
|
||||
model: options.chatModel || undefined,
|
||||
temperature: 0.15,
|
||||
maxTokens: 5000
|
||||
});
|
||||
answer = stripModelSourcesSection(String(completed.content || '').trim()) || answer;
|
||||
ai.model = completed.model || ai.model;
|
||||
ai.provider = completed.provider || ai.provider;
|
||||
ai.finishReason = completed.finishReason || ai.finishReason;
|
||||
}
|
||||
return { answer: answer, ai: ai };
|
||||
}
|
||||
|
||||
function stripModelSourcesSection(answer) {
|
||||
return cleanDanglingSourceLeadIn(String(answer || '')
|
||||
.replace(/\n\s*(---\s*)?(#{1,3}\s*)?(Sources|References)\s*\n[\s\S]*$/i, '')
|
||||
.replace(/\n\s*>?\s*(⚠️\s*)?Clinical decision support[^\n]*$/gim, '')
|
||||
.trim());
|
||||
}
|
||||
|
||||
function cleanDanglingSourceLeadIn(answer) {
|
||||
answer = String(answer || '').trim();
|
||||
return answer
|
||||
.replace(/([.!?])\s+(?:however,?\s*)?(?:but\s*)?(?:the\s*)?available(?:\s+(?:sources?|retrieved\s+sources?|evidence|material))?\s*$/i, '$1')
|
||||
.replace(/([.!?])\s+(?:however,?\s*)?(?:but\s*)?(?:based\s+on\s+)?(?:the\s*)?available\s*$/i, '$1')
|
||||
.replace(/(?:^|\n\n)(?:however,?\s*)?(?:but\s*)?(?:the\s*)?available(?:\s+(?:sources?|retrieved\s+sources?|evidence|material))?\s*$/i, '')
|
||||
.replace(/(?:^|\n\n)(?:however,?\s*)?(?:but\s*)?(?:based\s+on\s+)?(?:the\s*)?available\s*$/i, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function shouldRegenerateTruncatedAnswer(answer, finishReason) {
|
||||
answer = String(answer || '').trim();
|
||||
if (!answer) return false;
|
||||
if (finishReason === 'length') return true;
|
||||
if (/\b(the|a|an|and|or|but|with|without|for|to|of|in|on|as|by|from|because|therefore|however|some|many|most|few|several|additional|other|further|including|such|available)$/i.test(answer)) return true;
|
||||
if (/[,:;\-(]$/.test(answer)) return true;
|
||||
var lines = answer.split(/\n+/).map(function(line) { return line.trim(); }).filter(Boolean);
|
||||
var last = lines.length ? lines[lines.length - 1] : answer;
|
||||
if (last.length > 24 && /[A-Za-z]$/.test(last) && !/[.!?\])"']$/.test(last)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildSystemPrompt: buildSystemPrompt,
|
||||
buildUserPrompt: buildUserPrompt,
|
||||
finalizeAssistantAnswer: finalizeAssistantAnswer,
|
||||
stripModelSourcesSection: stripModelSourcesSection,
|
||||
shouldRegenerateTruncatedAnswer: shouldRegenerateTruncatedAnswer
|
||||
};
|
||||
236
src/utils/clinicalMcpClient.js
Normal file
236
src/utils/clinicalMcpClient.js
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
var axios = require('axios');
|
||||
|
||||
var MCP_URLS = buildMcpUrls();
|
||||
var _lastGoodMcpUrl = MCP_URLS[0];
|
||||
var MCP_INITIALIZE_TIMEOUT_MS = positiveInt(process.env.CLINICAL_ASSISTANT_MCP_INITIALIZE_TIMEOUT_MS, 30000);
|
||||
var MCP_REQUEST_TIMEOUT_MS = positiveInt(process.env.CLINICAL_ASSISTANT_MCP_REQUEST_TIMEOUT_MS, 90000);
|
||||
var MCP_SESSION_TTL_MS = positiveInt(process.env.CLINICAL_ASSISTANT_MCP_SESSION_TTL_MS, 10 * 60 * 1000);
|
||||
var _mcpSession = null;
|
||||
var _mcpSessionPromise = null;
|
||||
var _mcpCallQueue = Promise.resolve();
|
||||
var _mcpRequestId = 1;
|
||||
|
||||
function positiveInt(value, fallback) {
|
||||
var n = Number(value);
|
||||
return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback;
|
||||
}
|
||||
|
||||
async function semanticSearch(query, opts) {
|
||||
opts = opts || {};
|
||||
return callMcpTool('nc_semantic_search', {
|
||||
query: query,
|
||||
limit: opts.limit,
|
||||
doc_types: ['file'],
|
||||
score_threshold: 0,
|
||||
fusion: 'rrf',
|
||||
include_context: opts.includeContext,
|
||||
context_chars: opts.contextChars
|
||||
});
|
||||
}
|
||||
|
||||
async function multimodalSearch(query, opts) {
|
||||
opts = opts || {};
|
||||
return callMcpTool('nc_multimodal_search', {
|
||||
query: query,
|
||||
limit: opts.limit
|
||||
});
|
||||
}
|
||||
|
||||
async function indexedTopicSuggestions(limit) {
|
||||
return callMcpTool('nc_indexed_topic_suggestions', {
|
||||
limit: limit || 12,
|
||||
sample_size: 1500,
|
||||
doc_type: 'file'
|
||||
});
|
||||
}
|
||||
|
||||
function warmMcpSession() {
|
||||
return getMcpSession();
|
||||
}
|
||||
|
||||
async function callMcpTool(name, args) {
|
||||
var queued = _mcpCallQueue.then(function() {
|
||||
return callMcpToolUnlocked(name, args);
|
||||
});
|
||||
_mcpCallQueue = queued.catch(function() {});
|
||||
return queued;
|
||||
}
|
||||
|
||||
async function callMcpToolUnlocked(name, args) {
|
||||
var session = await getMcpSession();
|
||||
var payload = {
|
||||
jsonrpc: '2.0',
|
||||
id: nextMcpRequestId(),
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: name,
|
||||
arguments: args
|
||||
}
|
||||
};
|
||||
var search;
|
||||
try {
|
||||
search = await mcpRequest(payload, session.sessionId, session.mcpUrl);
|
||||
} catch (e) {
|
||||
if (!isInvalidMcpSessionError(e)) throw e;
|
||||
_mcpSession = null;
|
||||
session = await getMcpSession();
|
||||
payload.id = nextMcpRequestId();
|
||||
search = await mcpRequest(payload, session.sessionId, session.mcpUrl);
|
||||
}
|
||||
return search.result || search;
|
||||
}
|
||||
|
||||
function nextMcpRequestId() {
|
||||
_mcpRequestId += 1;
|
||||
if (_mcpRequestId > 1000000000) _mcpRequestId = 2;
|
||||
return _mcpRequestId;
|
||||
}
|
||||
|
||||
async function getMcpSession() {
|
||||
var now = Date.now();
|
||||
if (_mcpSession && _mcpSession.sessionId && _mcpSession.expiresAt > now) return _mcpSession;
|
||||
if (_mcpSessionPromise) return _mcpSessionPromise;
|
||||
_mcpSessionPromise = initializeMcpSession().then(function(session) {
|
||||
_mcpSession = session;
|
||||
return session;
|
||||
}).finally(function() {
|
||||
_mcpSessionPromise = null;
|
||||
});
|
||||
return _mcpSessionPromise;
|
||||
}
|
||||
|
||||
async function initializeMcpSession() {
|
||||
var session = await mcpRequest({
|
||||
jsonrpc: '2.0',
|
||||
id: nextMcpRequestId(),
|
||||
method: 'initialize',
|
||||
params: {
|
||||
protocolVersion: '2024-11-05',
|
||||
capabilities: {},
|
||||
clientInfo: { name: 'ped-ai-clinical-assistant', version: '1.0.0' }
|
||||
}
|
||||
});
|
||||
session.expiresAt = Date.now() + MCP_SESSION_TTL_MS;
|
||||
return session;
|
||||
}
|
||||
|
||||
async function mcpRequest(payload, sessionId, preferredUrl) {
|
||||
var headers = {
|
||||
'Accept': 'application/json, text/event-stream',
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
if (sessionId) headers['mcp-session-id'] = sessionId;
|
||||
var resp = await postMcpWithRetry(payload, headers, preferredUrl);
|
||||
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;
|
||||
parsed.mcpUrl = resp.config && resp.config.url ? resp.config.url : preferredUrl || _lastGoodMcpUrl;
|
||||
return parsed;
|
||||
}
|
||||
|
||||
async function postMcpWithRetry(payload, headers, preferredUrl) {
|
||||
var lastErr = null;
|
||||
var urls = orderedMcpUrls(preferredUrl);
|
||||
var isInitialize = payload && payload.method === 'initialize';
|
||||
for (var round = 0; round < 2; round++) {
|
||||
for (var i = 0; i < urls.length; i++) {
|
||||
var url = urls[i];
|
||||
try {
|
||||
var resp = await axios.post(url, payload, {
|
||||
headers: headers,
|
||||
timeout: isInitialize ? MCP_INITIALIZE_TIMEOUT_MS : MCP_REQUEST_TIMEOUT_MS,
|
||||
responseType: 'text',
|
||||
transformResponse: [function(data) { return data; }]
|
||||
});
|
||||
_lastGoodMcpUrl = url;
|
||||
return resp;
|
||||
} catch (e) {
|
||||
lastErr = e;
|
||||
if (!isTransientMcpError(e)) throw e;
|
||||
console.warn('[clinical-assistant] MCP endpoint unavailable:', url, e.code || e.message);
|
||||
}
|
||||
}
|
||||
await sleep(750 * (round + 1));
|
||||
}
|
||||
throw lastErr;
|
||||
}
|
||||
|
||||
function orderedMcpUrls(preferredUrl) {
|
||||
var candidates = [];
|
||||
[preferredUrl, _lastGoodMcpUrl].concat(MCP_URLS).forEach(function(url) {
|
||||
if (url && candidates.indexOf(url) === -1) candidates.push(url);
|
||||
});
|
||||
return candidates;
|
||||
}
|
||||
|
||||
function isTransientMcpError(e) {
|
||||
var code = e && (e.code || (e.cause && e.cause.code)) || '';
|
||||
var status = e && e.response && e.response.status;
|
||||
return code === 'ECONNREFUSED' || code === 'ECONNRESET' || code === 'ETIMEDOUT' || status === 502 || status === 503 || status === 504;
|
||||
}
|
||||
|
||||
function isInvalidMcpSessionError(e) {
|
||||
var status = e && e.response && e.response.status;
|
||||
var msg = String(e && e.message || '');
|
||||
return status === 400 || status === 404 || status === 410 || /session|mcp-session-id/i.test(msg);
|
||||
}
|
||||
|
||||
async function getMcpHealth() {
|
||||
var results = [];
|
||||
var urls = orderedMcpUrls();
|
||||
for (var i = 0; i < urls.length; i++) {
|
||||
var mcpUrl = urls[i];
|
||||
var healthUrl = mcpUrl.replace(/\/mcp\/?$/, '/health/live');
|
||||
try {
|
||||
var resp = await axios.get(healthUrl, { timeout: 3000 });
|
||||
results.push({ url: mcpUrl, healthUrl: healthUrl, ok: true, status: resp.status, data: resp.data });
|
||||
_lastGoodMcpUrl = mcpUrl;
|
||||
return { ok: true, activeUrl: mcpUrl, checked: results, candidates: MCP_URLS };
|
||||
} catch (e) {
|
||||
results.push({ url: mcpUrl, healthUrl: healthUrl, ok: false, error: e.code || e.message });
|
||||
}
|
||||
}
|
||||
return { ok: false, activeUrl: _lastGoodMcpUrl || null, checked: results, candidates: MCP_URLS };
|
||||
}
|
||||
|
||||
function buildMcpUrls() {
|
||||
var raw = [];
|
||||
if (process.env.CLINICAL_ASSISTANT_MCP_URLS) raw = raw.concat(process.env.CLINICAL_ASSISTANT_MCP_URLS.split(','));
|
||||
raw.push(process.env.CLINICAL_ASSISTANT_MCP_URL || process.env.MCP_SERVER_URL || '');
|
||||
raw.push('http://mcp:8000/mcp');
|
||||
raw.push('http://mcp-server-mcp-1:8000/mcp');
|
||||
raw.push('http://127.0.0.1:8100/mcp');
|
||||
var out = [];
|
||||
raw.forEach(function(url) {
|
||||
url = String(url || '').trim();
|
||||
if (!url) return;
|
||||
if (!/\/mcp\/?$/.test(url)) url = url.replace(/\/$/, '') + '/mcp';
|
||||
if (out.indexOf(url) === -1) out.push(url);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise(function(resolve) { setTimeout(resolve, ms); });
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
semanticSearch: semanticSearch,
|
||||
multimodalSearch: multimodalSearch,
|
||||
indexedTopicSuggestions: indexedTopicSuggestions,
|
||||
getMcpHealth: getMcpHealth,
|
||||
warmMcpSession: warmMcpSession
|
||||
};
|
||||
247
src/utils/clinicalRetrieval.js
Normal file
247
src/utils/clinicalRetrieval.js
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
var MULTIMODAL_CANDIDATE_LIMIT = 6;
|
||||
|
||||
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,
|
||||
folder_path: r.folder_path || r.folderPath || '',
|
||||
category: r.category || '',
|
||||
source_priority: r.source_priority || r.sourcePriority || '',
|
||||
source_boost: r.source_boost || r.sourceBoost || null,
|
||||
tags: Array.isArray(r.tags) ? r.tags : [],
|
||||
excerpt: clip(text, 1800),
|
||||
score: r.score,
|
||||
chunk_index: r.chunk_index,
|
||||
total_chunks: r.total_chunks
|
||||
};
|
||||
}).filter(function(r) { return r.excerpt; });
|
||||
}
|
||||
|
||||
function normalizeMcpMultimodalResponse(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 nearby = clip(cleanSourceExcerpt(r.nearby_text || ''), 1400);
|
||||
var caption = clip(cleanSourceExcerpt(r.visual_caption || ''), 900);
|
||||
var labels = Array.isArray(r.visual_labels) ? r.visual_labels.filter(Boolean).slice(0, 20) : [];
|
||||
var excerptParts = [];
|
||||
if (caption) excerptParts.push('[Visual caption] ' + caption);
|
||||
if (labels.length) excerptParts.push('[Visual labels] ' + labels.join(', '));
|
||||
if (nearby) excerptParts.push('[Page text] ' + nearby);
|
||||
return {
|
||||
number: idx + 1,
|
||||
id: r.id,
|
||||
doc_type: r.doc_type || 'file',
|
||||
source_type: 'multimodal_page',
|
||||
title: cleanTitle(r.title || r.file_path || 'PDF page image'),
|
||||
file_path: r.file_path || '',
|
||||
category: r.category || '',
|
||||
subcategory: r.subcategory || '',
|
||||
category_path: r.category_path || '',
|
||||
page: r.page_number || r.pageNumber || null,
|
||||
page_count: r.page_count || r.pageCount || null,
|
||||
visual_caption: caption,
|
||||
visual_labels: labels,
|
||||
visual_caption_source: r.visual_caption_source || '',
|
||||
is_cover_page: Boolean(r.is_cover_page || r.isCoverPage),
|
||||
is_title_page: Boolean(r.is_title_page || r.isTitlePage),
|
||||
reject_for_visual_search: Boolean(r.reject_for_visual_search || r.rejectForVisualSearch),
|
||||
excerpt: excerptParts.length ? '[Page-image match] ' + excerptParts.join('\n') : '[Page-image match] Rendered PDF page matched the visual/text query.',
|
||||
score: r.score,
|
||||
chunk_index: 'page-image-' + (r.page_number || idx + 1),
|
||||
total_chunks: r.page_count || null
|
||||
};
|
||||
}).filter(function(r) { return r.page; });
|
||||
}
|
||||
|
||||
function dedupeSources(results) {
|
||||
var seen = new Map();
|
||||
var out = [];
|
||||
results.forEach(function(r) {
|
||||
var key = sourceDedupeKey(r);
|
||||
var existingIndex = seen.get(key);
|
||||
if (existingIndex != null) {
|
||||
out[existingIndex] = mergeDuplicateSource(out[existingIndex], r);
|
||||
return;
|
||||
}
|
||||
seen.set(key, out.length);
|
||||
out.push(Object.assign({}, r, { number: out.length + 1 }));
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
function sourceDedupeKey(source) {
|
||||
var title = cleanTitle(source && source.title || '').toLowerCase();
|
||||
var page = source && source.page ? String(source.page) : '';
|
||||
var kind = source && source.source_type ? source.source_type : 'text';
|
||||
if (title && page) return [kind, title, page].join('|');
|
||||
return [kind, title, source && source.id || source && source.chunk_index || ''].join('|');
|
||||
}
|
||||
|
||||
function mergeDuplicateSource(existing, duplicate) {
|
||||
var merged = Object.assign({}, existing);
|
||||
var existingExcerpt = cleanSourceExcerpt(existing.excerpt || '');
|
||||
var duplicateExcerpt = cleanSourceExcerpt(duplicate.excerpt || '');
|
||||
if (duplicateExcerpt && existingExcerpt.indexOf(duplicateExcerpt.slice(0, 160)) === -1) {
|
||||
merged.excerpt = clip([existingExcerpt, duplicateExcerpt].filter(Boolean).join('\n'), 1800);
|
||||
}
|
||||
merged.score = Math.max(Number(existing.score) || 0, Number(duplicate.score) || 0) || existing.score || duplicate.score;
|
||||
return merged;
|
||||
}
|
||||
|
||||
function isVisualSourceQuery(query) {
|
||||
return visualIntent(query).wanted.length > 0;
|
||||
}
|
||||
|
||||
function isRadiologyQuery(query) {
|
||||
return visualIntent(query).wanted.indexOf('radiology') !== -1;
|
||||
}
|
||||
|
||||
function buildMultimodalSearchQuery(query) {
|
||||
query = String(query || '').trim();
|
||||
var intent = visualIntent(query);
|
||||
if (intent.wanted.indexOf('radiology') !== -1) return query + ' radiograph x-ray chest imaging radiology';
|
||||
if (intent.wanted.indexOf('diagram') !== -1) return query + ' figure diagram pathway flowchart algorithm illustration';
|
||||
if (intent.wanted.indexOf('photo') !== -1) return query + ' clinical photograph image skin lesion pathology microscopy';
|
||||
return query;
|
||||
}
|
||||
|
||||
async function classifyAndRerankMultimodalResults(query, results) {
|
||||
results = Array.isArray(results) ? results : [];
|
||||
if (!results.length) return [];
|
||||
var candidates = results.slice(0, MULTIMODAL_CANDIDATE_LIMIT);
|
||||
return selectMultimodalResults(query, candidates);
|
||||
}
|
||||
|
||||
function selectMultimodalResults(query, results) {
|
||||
results = Array.isArray(results) ? results : [];
|
||||
if (!results.length) return [];
|
||||
results = results.filter(function(r) { return !shouldRejectVisualSource(query, r); });
|
||||
if (!results.length) return [];
|
||||
if (!isRadiologyQuery(query)) return results.filter(function(r) { return !looksLikeTextOnlyPage(r); });
|
||||
var radiologyTerms = /\b(radiology|radiologic|radiograph|x-?ray|cxr|chest\s*(film|x-?ray|radiograph)|imaging|ultrasound|ct|mri|scan|film|pneumonia|bronchiolitis|lung|airway|hyperinflation|infiltrate|consolidation|atelectasis)\b/i;
|
||||
return results.filter(function(r) {
|
||||
return radiologyTerms.test([r.title, r.excerpt, r.file_path, r.category, r.subcategory, r.category_path].filter(Boolean).join(' '));
|
||||
});
|
||||
}
|
||||
|
||||
function visualIntent(query) {
|
||||
var text = String(query || '').toLowerCase();
|
||||
var wanted = [];
|
||||
if (/\b(radiology|radiologic|radiograph|x-?ray|cxr|chest\s*(film|x-?ray|radiograph)|imaging|ultrasound|ct|mri|scan|film)\b/i.test(text)) {
|
||||
wanted.push('radiology');
|
||||
}
|
||||
if (/\b(algorithm|flow\s*chart|flowchart|pathway|diagram|graph|chart|figure|figures|illustration|infographic|schema|schematic|table)\b/i.test(text)) {
|
||||
wanted.push('diagram');
|
||||
}
|
||||
if (/\b(photo|photograph|picture|skin|rash|lesion|wound|gross|microscopy|histology|pathology image|clinical image)\b/i.test(text)) {
|
||||
wanted.push('photo');
|
||||
}
|
||||
if (!wanted.length && /\b(visual|image|images|show|find|display|preview)\b/i.test(text)) {
|
||||
wanted.push('diagram', 'photo');
|
||||
}
|
||||
return { wanted: Array.from(new Set(wanted)) };
|
||||
}
|
||||
|
||||
function visualMetadataScore(query, source) {
|
||||
var haystack = [source.title, source.excerpt, source.visual_caption, (source.visual_labels || []).join(' '), source.file_path, source.category, source.subcategory, source.category_path].filter(Boolean).join(' ');
|
||||
var score = 0;
|
||||
var intent = visualIntent(query);
|
||||
if (isRadiologyQuery(query) && /\b(radiology|radiograph|x-?ray|cxr|imaging|ct|mri|ultrasound|film)\b/i.test(haystack)) score += 0.12;
|
||||
if (intent.wanted.indexOf('diagram') !== -1 && /\b(figure|diagram|algorithm|flowchart|pathway|chart|illustration|infographic)\b/i.test(haystack)) score += 0.12;
|
||||
if (intent.wanted.indexOf('photo') !== -1 && /\b(photo|photograph|skin|rash|lesion|wound|gross|microscopy|histology)\b/i.test(haystack)) score += 0.12;
|
||||
if (/\b(pneumonia|bronchiolitis|lung|airway|hyperinflation|infiltrate|consolidation|atelectasis)\b/i.test(haystack)) score += 0.06;
|
||||
if (looksLikeFrontMatterPage(source)) score -= 0.5;
|
||||
if (looksLikeTextOnlyPage(source)) score -= 0.18;
|
||||
return score;
|
||||
}
|
||||
|
||||
function shouldRejectVisualSource(query, source) {
|
||||
if (!source) return false;
|
||||
if (allowsFrontMatterQuery(query)) return false;
|
||||
return Boolean(source.reject_for_visual_search || source.is_cover_page || source.is_title_page || looksLikeFrontMatterPage(source));
|
||||
}
|
||||
|
||||
function allowsFrontMatterQuery(query) {
|
||||
return /\b(cover|front cover|title page|copyright|table of contents|contents|preface|foreword|publisher|isbn)\b/i.test(String(query || ''));
|
||||
}
|
||||
|
||||
function looksLikeFrontMatterPage(source) {
|
||||
var page = Number(source && source.page) || 0;
|
||||
var pageCount = Number(source && source.page_count) || 0;
|
||||
var earlyLimit = Math.min(8, Math.max(2, pageCount || 8));
|
||||
if (!page || page > earlyLimit) return false;
|
||||
var haystack = [source.title, source.excerpt, source.visual_caption, (source.visual_labels || []).join(' '), source.file_path].filter(Boolean).join(' ');
|
||||
var hasVisualSignal = /\b(figure|fig\.?|radiograph|x-?ray|cxr|ultrasound|ct|mri|photograph|photo|image|diagram|flowchart|algorithm|histology|microscopy|gross pathology)\b/i.test(haystack);
|
||||
if (hasVisualSignal) return false;
|
||||
var frontMatterSignal = /\b(cover|front cover|title page|copyright|all rights reserved|isbn|library of congress|table of contents|contents|preface|foreword|dedication|contributors?|textbook of|manual of|review of|board review|edition|authors?|editor|publisher)\b/i.test(haystack);
|
||||
return frontMatterSignal || page <= 2;
|
||||
}
|
||||
|
||||
function looksLikeTextOnlyPage(source) {
|
||||
var text = String(source && source.excerpt || '');
|
||||
return text.length > 900 && !/\b(figure|image|radiograph|x-?ray|cxr|ultrasound|ct|mri|scan|film|diagram|illustration)\b/i.test(text);
|
||||
}
|
||||
|
||||
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(/<br\s*\/?>/gi, ' ')
|
||||
.replace(/\*\*/g, '')
|
||||
.replace(/\|\s*-{2,}\s*/g, ' ')
|
||||
.replace(/\|/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
normalizeMcpSearchResponse: normalizeMcpSearchResponse,
|
||||
normalizeMcpMultimodalResponse: normalizeMcpMultimodalResponse,
|
||||
dedupeSources: dedupeSources,
|
||||
isVisualSourceQuery: isVisualSourceQuery,
|
||||
buildMultimodalSearchQuery: buildMultimodalSearchQuery,
|
||||
classifyAndRerankMultimodalResults: classifyAndRerankMultimodalResults,
|
||||
visualMetadataScore: visualMetadataScore
|
||||
};
|
||||
Loading…
Reference in a new issue