From 795ad9ffaeb887ec871b15d3390b64c30e73666e Mon Sep 17 00:00:00 2001 From: Daniel Date: Sat, 9 May 2026 19:59:04 +0200 Subject: [PATCH] harden clinical assistant source handling --- public/js/admin.js | 2 +- public/js/app.js | 12 +++- public/js/assistant/images.js | 3 +- public/js/assistant/sources.js | 1 + public/js/shadess.js | 2 +- public/sw.js | 4 +- server.js | 2 +- src/routes/adminDocs.js | 11 +++- src/routes/clinicalAssistant.js | 5 +- src/utils/ai.js | 12 ++-- src/utils/clinicalAnswer.js | 2 +- src/utils/clinicalRetrieval.js | 37 +++++++++++- src/utils/models.js | 6 +- test/assistant-image-intent.test.js | 20 +++++++ test/assistant-sources-cleaning.test.js | 16 ++++++ test/clinical-assistant-prompt-pool.test.js | 7 +++ test/clinical-retrieval-title.test.js | 62 +++++++++++++++++++++ 17 files changed, 181 insertions(+), 23 deletions(-) create mode 100644 test/assistant-image-intent.test.js create mode 100644 test/assistant-sources-cleaning.test.js create mode 100644 test/clinical-retrieval-title.test.js diff --git a/public/js/admin.js b/public/js/admin.js index 52a63c3..8082128 100644 --- a/public/js/admin.js +++ b/public/js/admin.js @@ -819,7 +819,7 @@ function adminFlashButtonBackground(btn, color) { }).then(function(r) { return r.json(); }).then(function(data) { if (!data.success) throw new Error(data.error || 'Image test failed'); var src = data.imageUrl || (data.base64 ? ('data:image/png;base64,' + data.base64) : ''); - if (result) result.innerHTML = 'Image model OK (' + data.duration + ' ms)' + (src ? '
test image
' : ''); + if (result) result.innerHTML = 'Image model OK (' + data.duration + ' ms)' + (src ? '
test image
' : ''); showToast('Image model works', 'success'); }).catch(function(err) { if (result) result.textContent = err.message; diff --git a/public/js/app.js b/public/js/app.js index 0731d83..ed92a67 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -26,7 +26,15 @@ window.addEventListener('unhandledrejection', function(e) { document.addEventListener('DOMContentLoaded', function() { // --- COMPONENT LOADER (lazy-load tab HTML from /components/) --- - var COMPONENT_VERSION = '7.1.3'; + function getComponentVersion() { + try { + var script = document.currentScript || document.querySelector('script[src^="/js/app.js"]'); + var version = script ? new URL(script.src, window.location.href).searchParams.get('v') : ''; + return version || 'dev'; + } catch(e) { return 'dev'; } + } + var COMPONENT_VERSION = getComponentVersion(); + window.PEDSCRIBE_COMPONENT_VERSION = COMPONENT_VERSION; var _componentCache = {}; var _componentLoading = {}; @@ -1020,7 +1028,7 @@ function deduplicateFinal(newText, existingText) { // PWA Service Worker if ('serviceWorker' in navigator) { - navigator.serviceWorker.register('/sw.js').catch(function() {}); + navigator.serviceWorker.register('/sw.js?v=' + encodeURIComponent(window.PEDSCRIBE_COMPONENT_VERSION || 'dev')).catch(function() {}); } console.log('✅ App.js loaded'); diff --git a/public/js/assistant/images.js b/public/js/assistant/images.js index 6351a46..b8423f1 100644 --- a/public/js/assistant/images.js +++ b/public/js/assistant/images.js @@ -52,8 +52,9 @@ export function buildContextualImagePrompt(request, lastAnswer, lastSources) { export function isImageRequest(text) { text = String(text || '').trim(); + var visualNoun = /\b(image|photo|picture|visual|illustration|diagram|figure|flowchart|infographic)\b/i; return /^(image|photo|picture|visual|illustration|diagram|figure|flowchart|infographic)$/i.test(text) || - /\b(show|see|display|view|image|photo|picture|visual|illustration|diagram|figure)\b/i.test(text) && text.split(/\s+/).length <= 6 || + (/\b(show|see|display|view)\b/i.test(text) && visualNoun.test(text) && text.split(/\s+/).length <= 8) || /\b(generate|create|draw|make|render)\b[\s\S]{0,80}\b(image|picture|illustration|visual|infographic|diagram|flowchart)\b/i.test(text) || /\b(generate|create|draw|make|render)\b[\s\S]{0,80}\b(an?|the)?\s*(algorithm|pathway|poster|teaching visual)\b/i.test(text); } diff --git a/public/js/assistant/sources.js b/public/js/assistant/sources.js index 832f38a..93f27ab 100644 --- a/public/js/assistant/sources.js +++ b/public/js/assistant/sources.js @@ -38,6 +38,7 @@ function renderSourceBadges(source) { function cleanSourceExcerpt(text) { return String(text || '') .replace(/^\[Page-image match\]\s*/i, '') + .replace(/!\[[^\]]*\]\([^)]*\)/g, ' ') .replace(//gi, ' ') .replace(/\*\*/g, '') .replace(/\|\s*-{2,}\s*/g, ' ') diff --git a/public/js/shadess.js b/public/js/shadess.js index 8ad5c57..2620f98 100644 --- a/public/js/shadess.js +++ b/public/js/shadess.js @@ -726,7 +726,7 @@ function setupShadessModule() { if (e.results[i].isFinal) _wvTranscript += e.results[i][0].transcript + ' '; else interim = e.results[i][0].transcript; } - if (transcriptEl) transcriptEl.innerHTML = _wvTranscript + (interim ? '' + interim + '' : ''); + if (transcriptEl) transcriptEl.innerHTML = esc(_wvTranscript) + (interim ? '' + esc(interim) + '' : ''); }; _wvRecognition.onend = function() { if (_wvRecording && !_wvPaused) try { _wvRecognition.start(); } catch(e) {} }; } diff --git a/public/sw.js b/public/sw.js index cc5c573..7fca878 100644 --- a/public/sw.js +++ b/public/sw.js @@ -4,7 +4,9 @@ // API calls always fresh (critical for medical data accuracy) // ============================================================ -var CACHE_NAME = 'pedscribe-v12-notes7'; +var CACHE_VERSION = 'dev'; +try { CACHE_VERSION = new URL(self.location.href).searchParams.get('v') || CACHE_VERSION; } catch(e) {} +var CACHE_NAME = 'pedscribe-' + CACHE_VERSION; var SHELL_ASSETS = [ '/', '/index.html', diff --git a/server.js b/server.js index 41247cb..8ee838a 100644 --- a/server.js +++ b/server.js @@ -176,7 +176,7 @@ function getTemplatedIndex() { if (st.mtimeMs === _indexCached.mtime && _indexCached.html) return _indexCached.html; var raw = fs.readFileSync(INDEX_PATH, 'utf8'); _indexCached.html = raw.replace( - /(<(?:script|link)[^>]+(?:src|href)=["'])(\/(?:js|css)\/[^"'?]+)(["'])/g, + /(<(?:script|link)[^>]+(?:src|href)=["'])(\/(?:js|css)\/[^"'?]+)(?:\?v=[^"']*)?(["'])/g, '$1$2?v=' + BUILD_ID + '$3' ); _indexCached.mtime = st.mtimeMs; diff --git a/src/routes/adminDocs.js b/src/routes/adminDocs.js index e52b5d7..e0b1d37 100644 --- a/src/routes/adminDocs.js +++ b/src/routes/adminDocs.js @@ -35,6 +35,15 @@ function requireAdmin(req, res, next) { // at the repo root and __dirname here is src/routes, so go up two. var DOCS_ROOT = path.resolve(__dirname, '..', '..', 'docs'); +function sanitizeRenderedHtml(html) { + return String(html || '') + .replace(/<\s*(script|style|iframe|object|embed)\b[\s\S]*?<\s*\/\s*\1\s*>/gi, '') + .replace(/<\s*(iframe|object|embed)\b[^>]*\/?>/gi, '') + .replace(/\s+on[a-z]+\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, '') + .replace(/\s+(href|src)\s*=\s*(["'])\s*javascript:[\s\S]*?\2/gi, '') + .replace(/\s+(href|src)\s*=\s*javascript:[^\s>]+/gi, ''); +} + // Resolve a user-supplied path safely. Returns null if the path tries // to escape DOCS_ROOT or doesn't exist. function safeResolve(relPath) { @@ -113,7 +122,7 @@ router.get('/file', requireAdmin, function (req, res) { // marked: GitHub-flavoured rendering with automatic line breaks off // (so authors can wrap prose without inserting
everywhere) and // header IDs on so the client can deep-link via #anchor. - var html = marked.parse(src, { gfm: true, breaks: false, headerIds: true }); + var html = sanitizeRenderedHtml(marked.parse(src, { gfm: true, breaks: false, headerIds: true })); res.json({ success: true, html: html, path: raw, bytes: stat.size }); } catch (e) { logger.error('[adminDocs] file failed', e.message); diff --git a/src/routes/clinicalAssistant.js b/src/routes/clinicalAssistant.js index 10e3ab6..d9d7ccd 100644 --- a/src/routes/clinicalAssistant.js +++ b/src/routes/clinicalAssistant.js @@ -12,6 +12,7 @@ var db = require('../db/database'); var { authMiddleware } = require('../middleware/auth'); var { callAI, callAIStream } = require('../utils/ai'); var { gatewayUrl } = require('../utils/errors'); +var { getLiteLLMHeaders } = require('../utils/litellm'); var logger = require('../utils/logger'); var cryptoUtil = require('../utils/crypto'); var redisCache = require('../utils/redis'); @@ -551,8 +552,7 @@ async function getIndexedTopicExamples() { async function generateImage(prompt, model) { if (!process.env.LITELLM_API_BASE) throw new Error('LiteLLM is required for image generation'); - var headers = { 'Content-Type': 'application/json' }; - if (process.env.LITELLM_API_KEY) headers.Authorization = 'Bearer ' + process.env.LITELLM_API_KEY; + var headers = getLiteLLMHeaders('application/json'); var renderedPrompt = imagePromptForCanvas(prompt); var size = process.env.CLINICAL_ASSISTANT_IMAGE_SIZE || 'auto'; var resp = await generateImageRequest(model, renderedPrompt, size, headers).catch(async function(e) { @@ -614,6 +614,7 @@ function cleanTitle(s) { function cleanSourceExcerpt(text) { return String(text || '') .replace(/^\[Page-image match\]\s*/i, '') + .replace(/!\[[^\]]*\]\([^)]*\)/g, ' ') .replace(//gi, ' ') .replace(/\*\*/g, '') .replace(/\|\s*-{2,}\s*/g, ' ') diff --git a/src/utils/ai.js b/src/utils/ai.js index eebce26..7557c89 100644 --- a/src/utils/ai.js +++ b/src/utils/ai.js @@ -1,14 +1,14 @@ // ============================================================ -// AI.JS — Multi-provider AI client -// Supports: OpenRouter, AWS Bedrock, Azure OpenAI, Google Vertex AI, LiteLLM -// Default: OpenRouter (set AI_PROVIDER in .env to switch) +// AI.JS — AI client +// Primary path: LiteLLM / OpenAI-compatible base URL via LITELLM_API_BASE. +// Legacy direct providers remain available only when explicitly selected. // ============================================================ const { OpenAI } = require('openai'); const { DEFAULT_MODEL, FALLBACK_MODEL, getBedrockModelId, getBedrockMaxOut } = require('./models'); const logger = require('./logger'); -var activeProvider = process.env.AI_PROVIDER || 'openrouter'; +var activeProvider = process.env.AI_PROVIDER || (process.env.LITELLM_API_BASE ? 'litellm' : 'openrouter'); // ============================================================ // OPENROUTER CLIENT (default) @@ -99,7 +99,7 @@ if (process.env.LITELLM_API_BASE) { try { litellmClient = new OpenAI({ baseURL: process.env.LITELLM_API_BASE.replace(/\/+$/, ''), - apiKey: process.env.LITELLM_API_KEY || 'sk-litellm' + apiKey: process.env.LITELLM_API_KEY || process.env.LITELLM_MASTER_KEY || process.env.OPENAI_API_KEY || 'sk-litellm' }); activeProvider = 'litellm'; console.log('✅ LiteLLM: configured (base: ' + process.env.LITELLM_API_BASE + ')'); @@ -496,7 +496,7 @@ async function callAI(messages, options) { } else if (openrouter) { result = await callOpenRouter(messages, model, temperature, maxTokens); } else { - throw new Error('No AI provider configured. Set OPENROUTER_API_KEY, AWS_BEDROCK_REGION, AZURE_OPENAI_ENDPOINT, GOOGLE_VERTEX_PROJECT, or LITELLM_API_BASE in .env'); + throw new Error('No AI provider configured. Set LITELLM_API_BASE plus an API key, or explicitly configure a legacy direct provider.'); } var duration = Date.now() - startTime; diff --git a/src/utils/clinicalAnswer.js b/src/utils/clinicalAnswer.js index ce8cac9..50b0432 100644 --- a/src/utils/clinicalAnswer.js +++ b/src/utils/clinicalAnswer.js @@ -1,5 +1,5 @@ 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.'; + 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 the user names a specific source, textbook, guideline, or table, do not claim that another source is from the named source. If the named source is absent from the retrieved sources, say that explicitly before using other sources.\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) { diff --git a/src/utils/clinicalRetrieval.js b/src/utils/clinicalRetrieval.js index 021276d..b640041 100644 --- a/src/utils/clinicalRetrieval.js +++ b/src/utils/clinicalRetrieval.js @@ -1,4 +1,8 @@ var MULTIMODAL_CANDIDATE_LIMIT = 6; +var INTERNAL_TITLE_FALLBACKS = { + '1586022': 'Respiratory Disease', + 'FABK010-fm[i-xiv].qxd': 'Respiratory Disease' +}; function normalizeMcpSearchResponse(result) { var data = result && (result.structuredContent || result.data || result); @@ -20,15 +24,16 @@ function normalizeMcpSearchResponse(result) { number: idx + 1, id: r.id, doc_type: r.doc_type, - title: cleanTitle(r.title || 'Untitled resource'), + title: displayTitleForSource(r, 'Untitled resource'), page: r.page_number || r.pageNumber || null, page_count: r.page_count || r.pageCount || null, + file_path: r.file_path || r.filePath || r.path || '', 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), + excerpt: clip(cleanSourceExcerpt(text), 1800), score: r.score, chunk_index: r.chunk_index, total_chunks: r.total_chunks @@ -63,7 +68,7 @@ function normalizeMcpMultimodalResponse(result) { id: r.id, doc_type: r.doc_type || 'file', source_type: 'multimodal_page', - title: cleanTitle(r.title || r.file_path || 'PDF page image'), + title: displayTitleForSource(r, 'PDF page image'), file_path: r.file_path || '', category: r.category || '', subcategory: r.subcategory || '', @@ -215,6 +220,31 @@ function looksLikeTextOnlyPage(source) { function clip(s, n) { return String(s || '').replace(/\s+/g, ' ').trim().substring(0, n); } +function displayTitleForSource(source, fallback) { + source = source || {}; + var rawTitle = String(source.title || '').trim(); + var filePath = source.file_path || source.filePath || source.path || ''; + if (looksLikeInternalPdfTitle(rawTitle)) { + var fromPath = titleFromPath(filePath); + if (fromPath) return cleanTitle(fromPath); + var mapped = INTERNAL_TITLE_FALLBACKS[String(source.id || '')] || INTERNAL_TITLE_FALLBACKS[rawTitle]; + if (mapped) return mapped; + } + return cleanTitle(rawTitle || filePath || fallback || 'Untitled resource'); +} + +function looksLikeInternalPdfTitle(title) { + return /\.qxd$/i.test(String(title || '').trim()); +} + +function titleFromPath(filePath) { + var text = String(filePath || '').trim(); + if (!text) return ''; + try { text = decodeURIComponent(text); } catch (e) {} + var parts = text.split(/[\\/]+/).filter(Boolean); + return parts.length ? parts[parts.length - 1] : text; +} + function cleanTitle(s) { var title = String(s || '').trim(); try { title = decodeURIComponent(title); } catch (e) {} @@ -228,6 +258,7 @@ function cleanTitle(s) { function cleanSourceExcerpt(text) { return String(text || '') .replace(/^\[Page-image match\]\s*/i, '') + .replace(/!\[[^\]]*\]\([^)]*\)/g, ' ') .replace(//gi, ' ') .replace(/\*\*/g, '') .replace(/\|\s*-{2,}\s*/g, ' ') diff --git a/src/utils/models.js b/src/utils/models.js index 16ed694..9cda6fd 100644 --- a/src/utils/models.js +++ b/src/utils/models.js @@ -3,7 +3,7 @@ // Supports: OpenRouter, AWS Bedrock, Azure OpenAI, Google Vertex AI, LiteLLM // ============================================================ -var activeProvider = process.env.AI_PROVIDER || 'openrouter'; +var activeProvider = process.env.AI_PROVIDER || (process.env.LITELLM_API_BASE ? 'litellm' : 'openrouter'); var OPENROUTER_MODELS = [ { id: 'google/gemini-2.5-flash', name: 'Gemini Flash 2.5', cost: '~$0.001', tag: 'BEST VALUE', category: 'fast' }, @@ -133,7 +133,7 @@ function getDefaultModel() { case 'bedrock': return 'amazon/nova-pro'; case 'azure': return process.env.AZURE_DEPLOYMENT_NAME || 'gpt-4o-mini'; case 'vertex': return 'gemini-2.5-flash'; - case 'litellm': return ''; // No default — set via admin panel or LITELLM_DEFAULT_MODEL env + case 'litellm': return process.env.LITELLM_DEFAULT_MODEL || process.env.OPENAI_MODEL || ''; // OpenAI-compatible base: set admin default or env. case 'openrouter': default: return 'google/gemini-2.5-flash'; } @@ -144,7 +144,7 @@ function getFallbackModel() { case 'bedrock': return 'amazon/nova-lite'; case 'azure': return process.env.AZURE_DEPLOYMENT_NAME || 'gpt-4o-mini'; case 'vertex': return 'gemini-2.0-flash'; - case 'litellm': return ''; // No fallback — use whatever the user has configured + case 'litellm': return process.env.LITELLM_FALLBACK_MODEL || ''; // No implicit cross-provider fallback. case 'openrouter': default: return 'deepseek/deepseek-chat-v3-0324'; } diff --git a/test/assistant-image-intent.test.js b/test/assistant-image-intent.test.js new file mode 100644 index 0000000..e61bae4 --- /dev/null +++ b/test/assistant-image-intent.test.js @@ -0,0 +1,20 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); +const { pathToFileURL } = require('node:url'); + +async function imagesModule() { + return import(pathToFileURL(path.join(__dirname, '..', 'public/js/assistant/images.js')).href); +} + +test('assistant image intent does not intercept table retrieval requests', async () => { + const { isImageRequest } = await imagesModule(); + assert.equal(isImageRequest('can you show me the table?'), false); + assert.equal(isImageRequest('show me Table 13.1'), false); +}); + +test('assistant image intent still handles explicit visual requests', async () => { + const { isImageRequest } = await imagesModule(); + assert.equal(isImageRequest('show me the diagram'), true); + assert.equal(isImageRequest('create an infographic for asthma'), true); +}); diff --git a/test/assistant-sources-cleaning.test.js b/test/assistant-sources-cleaning.test.js new file mode 100644 index 0000000..06c0695 --- /dev/null +++ b/test/assistant-sources-cleaning.test.js @@ -0,0 +1,16 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); +const { pathToFileURL } = require('node:url'); + +async function sourcesModule() { + return import(pathToFileURL(path.join(__dirname, '..', 'public/js/assistant/sources.js')).href); +} + +test('assistant source excerpts hide image markdown but keep OCR text', async () => { + const { renderSourcesList } = await sourcesModule(); + const html = renderSourcesList([{ title: 'Nelson', excerpt: '![](/tmp/pdf-images/pdf-0132-02.png) AGE STREAMS OF DEVELOPMENT' }]); + + assert.match(html, /AGE STREAMS OF DEVELOPMENT/); + assert.doesNotMatch(html, /tmp\/pdf-images|!\[\]/); +}); diff --git a/test/clinical-assistant-prompt-pool.test.js b/test/clinical-assistant-prompt-pool.test.js index feb8503..07ef6a3 100644 --- a/test/clinical-assistant-prompt-pool.test.js +++ b/test/clinical-assistant-prompt-pool.test.js @@ -19,3 +19,10 @@ test('clinical assistant starter prompts are Redis or indexed-source backed', () assert.match(pool, /CLINICAL_ASSISTANT_PROMPT_POOL_TARGET, 200/); assert.match(pool, /var examples = await fallbackIndexedExamples\(\)/); }); + +test('clinical assistant prompt forbids relabeling other sources as named sources', () => { + const answer = read('src/utils/clinicalAnswer.js'); + assert.match(answer, /If the user names a specific source, textbook, guideline, or table/); + assert.match(answer, /do not claim that another source is from the named source/); + assert.match(answer, /If the named source is absent from the retrieved sources, say that explicitly/); +}); diff --git a/test/clinical-retrieval-title.test.js b/test/clinical-retrieval-title.test.js new file mode 100644 index 0000000..e19f032 --- /dev/null +++ b/test/clinical-retrieval-title.test.js @@ -0,0 +1,62 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); + +const { normalizeMcpSearchResponse, normalizeMcpMultimodalResponse } = require('../src/utils/clinicalRetrieval'); + +test('clinical retrieval replaces known internal PDF title with document title', () => { + const results = normalizeMcpSearchResponse({ + results: [{ + id: 1586022, + doc_type: 'file', + title: 'FABK010-fm[i-xiv].qxd', + excerpt: 'Respiratory disease text', + page_number: 320 + }] + }); + + assert.equal(results[0].title, 'Respiratory Disease'); + assert.equal(results[0].page, 320); +}); + +test('clinical retrieval prefers file basename for internal PDF production titles', () => { + const results = normalizeMcpMultimodalResponse({ + results: [{ + id: 99, + doc_type: 'file', + title: 'chapter-layout.qxd', + file_path: 'Medical Library/Critical%20care%20%26%20Respiratory/Respiratory%20Disease.pdf', + visual_caption: 'Airway diagram', + page_number: 12 + }] + }); + + assert.equal(results[0].title, 'Respiratory Disease'); +}); + +test('clinical retrieval leaves normal source titles unchanged', () => { + const results = normalizeMcpSearchResponse({ + results: [{ + id: 123, + doc_type: 'file', + title: 'Nelson Textbook of Pediatrics.pdf', + excerpt: 'Clinical text' + }] + }); + + assert.equal(results[0].title, 'Nelson Textbook of Pediatrics'); +}); + +test('clinical retrieval strips image markdown while preserving OCR table text', () => { + const results = normalizeMcpSearchResponse({ + results: [{ + id: 1585684, + doc_type: 'file', + title: 'Nelson Textbook of Pediatrics.pdf', + excerpt: '~~Table 13.1~~\n\n![](/tmp/pdf-images/pdf-0132-02.png)\n\nAGE STREAMS OF DEVELOPMENT
2 mo row' + }] + }); + + assert.match(results[0].excerpt, /Table 13\.1/); + assert.match(results[0].excerpt, /AGE STREAMS OF DEVELOPMENT/); + assert.doesNotMatch(results[0].excerpt, /pdf-images|!\[\]/); +});