247 lines
11 KiB
JavaScript
247 lines
11 KiB
JavaScript
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
|
|
};
|