diff --git a/src/routes/clinicalAssistant.js b/src/routes/clinicalAssistant.js index dc36fd3..230fabc 100644 --- a/src/routes/clinicalAssistant.js +++ b/src/routes/clinicalAssistant.js @@ -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); diff --git a/src/utils/clinicalAnswer.js b/src/utils/clinicalAnswer.js new file mode 100644 index 0000000..ce8cac9 --- /dev/null +++ b/src/utils/clinicalAnswer.js @@ -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 +}; diff --git a/src/utils/clinicalMcpClient.js b/src/utils/clinicalMcpClient.js new file mode 100644 index 0000000..be30a17 --- /dev/null +++ b/src/utils/clinicalMcpClient.js @@ -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 +}; diff --git a/src/utils/clinicalRetrieval.js b/src/utils/clinicalRetrieval.js new file mode 100644 index 0000000..021276d --- /dev/null +++ b/src/utils/clinicalRetrieval.js @@ -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(//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 +};