diff --git a/docker-compose.yml b/docker-compose.yml index 35458aee..c2c3dfcc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,6 +8,7 @@ services: - .env environment: CLINICAL_ASSISTANT_MCP_URL: http://mcp:8000/mcp + CLINICAL_ASSISTANT_SOURCE_FILE_PATH: Medical Library/Pediatrics/Kliegman%20R.%20Nelson%20Textbook%20of%20Pediatrics%20%202-Volume%20Set%2022ed%202024.pdf REDIS_URL: redis://ped-ai-redis:6379 LOKI_URL: http://monitoring-loki:3100 LITELLM_API_BASE: http://litellm:4000 diff --git a/src/routes/clinicalAssistant.js b/src/routes/clinicalAssistant.js index 9e45e658..602fe33b 100644 --- a/src/routes/clinicalAssistant.js +++ b/src/routes/clinicalAssistant.js @@ -28,6 +28,7 @@ var { var { normalizeMcpSearchResponse, normalizeMcpMultimodalResponse, + filterSourcesByFilePath, dedupeSources, isVisualSourceQuery, buildMultimodalSearchQuery, @@ -48,6 +49,7 @@ var MAX_SAVED_CHATS_PER_USER = 100; var MAX_SAVED_CHAT_PAYLOAD = 250000; var MAX_SAVED_CHAT_TITLE = 160; var IMAGE_JOB_TTL_SECONDS = 15 * 60; +var SOURCE_LOCK_SEARCH_LIMIT = 50; var imageJobs = new Map(); var promptPool = createClinicalPromptPool({ redisCache: redisCache, @@ -375,6 +377,7 @@ async function prepareAssistantChat(body) { var contextChars = clampInt(await getSetting('clinical_assistant.context_chars', '1400'), 300, 4000, 1400); var behavior = await getSetting('clinical_assistant.system_behavior', DEFAULT_BEHAVIOR) || DEFAULT_BEHAVIOR; var includeContext = body.includeContext !== false; + var sourceFilePath = String(process.env.CLINICAL_ASSISTANT_SOURCE_FILE_PATH || '').trim(); if (GREETING_RE.test(message)) { return { direct: { @@ -393,7 +396,8 @@ async function prepareAssistantChat(body) { return message; }); var searchResponse = await semanticSearch(searchQuery, { - limit: searchLimit, + // MCP has no source-path filter. Retrieve a larger candidate set, then enforce the lock locally. + limit: sourceFilePath ? SOURCE_LOCK_SEARCH_LIMIT : searchLimit, includeContext: includeContext, contextChars: contextChars }); @@ -402,10 +406,10 @@ async function prepareAssistantChat(body) { console.warn('[clinical-assistant] multimodal search skipped:', e.message); return null; }) : null; - var rawTextResults = normalizeMcpSearchResponse(searchResponse); + var rawTextResults = filterSourcesByFilePath(normalizeMcpSearchResponse(searchResponse), sourceFilePath); var rawMultimodalResults = await classifyAndRerankMultimodalResults( message + ' ' + searchQuery, - normalizeMcpMultimodalResponse(multimodalResponse) + filterSourcesByFilePath(normalizeMcpMultimodalResponse(multimodalResponse), sourceFilePath) ); var rawResults = rawTextResults.concat(rawMultimodalResults); console.info('[clinical-assistant] retrieval counts:', { @@ -446,7 +450,8 @@ async function prepareAssistantChat(body) { query: searchQuery, rewritten: searchQuery !== message, verifiedChunkCount: searchResponse.verified_chunk_count || searchResponse.verifiedChunkCount || 0, - droppedDocumentCount: searchResponse.dropped_document_count || searchResponse.droppedDocumentCount || 0 + droppedDocumentCount: searchResponse.dropped_document_count || searchResponse.droppedDocumentCount || 0, + sourceLocked: Boolean(sourceFilePath) } }; } diff --git a/src/utils/clinicalRetrieval.js b/src/utils/clinicalRetrieval.js index 6eedf1a4..eea2e521 100644 --- a/src/utils/clinicalRetrieval.js +++ b/src/utils/clinicalRetrieval.js @@ -89,6 +89,18 @@ function normalizeMcpMultimodalResponse(result) { }).filter(function(r) { return r.page; }); } +function filterSourcesByFilePath(results, filePath) { + if (!filePath) return results; + var wanted = normalizeFilePath(filePath); + return results.filter(function(result) { + return normalizeFilePath(result && result.file_path) === wanted; + }); +} + +function normalizeFilePath(filePath) { + try { return decodeURIComponent(String(filePath || '')).replace(/^\/+/, ''); } catch (e) { return String(filePath || '').replace(/^\/+/, ''); } +} + function dedupeSources(results) { var seen = new Map(); var out = []; @@ -271,6 +283,7 @@ function cleanSourceExcerpt(text) { module.exports = { normalizeMcpSearchResponse: normalizeMcpSearchResponse, normalizeMcpMultimodalResponse: normalizeMcpMultimodalResponse, + filterSourcesByFilePath: filterSourcesByFilePath, dedupeSources: dedupeSources, isVisualSourceQuery: isVisualSourceQuery, buildMultimodalSearchQuery: buildMultimodalSearchQuery, diff --git a/test/clinical-retrieval-source-lock.test.js b/test/clinical-retrieval-source-lock.test.js new file mode 100644 index 00000000..342fffe7 --- /dev/null +++ b/test/clinical-retrieval-source-lock.test.js @@ -0,0 +1,13 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { filterSourcesByFilePath } = require('../src/utils/clinicalRetrieval'); + +test('source lock keeps only the selected book regardless of URL encoding', () => { + const path = 'Medical Library/Pediatrics/Kliegman R. Nelson Textbook of Pediatrics 2-Volume Set 22ed 2024.pdf'; + const sources = [ + { title: 'Nelson', file_path: 'Medical Library/Pediatrics/Kliegman%20R.%20Nelson%20Textbook%20of%20Pediatrics%20%202-Volume%20Set%2022ed%202024.pdf' }, + { title: 'Other book', file_path: 'Medical Library/Pediatrics/Other.pdf' } + ]; + assert.deepEqual(filterSourcesByFilePath(sources, path), [sources[0]]); +});