/**
* @file loda_search_handler.js
* @description Main LODA search orchestrator
* @atomic COMPOUND (full orchestration, managed state)
* @component LODA-MCP-COMP-02
*/
const fs = require('fs');
const { LodaIndex } = require('./loda_index');
const { SectionBloomFilters } = require('./bloom_filter');
const { calculateRelevance } = require('./relevance_scorer');
const { selectWithinBudget } = require('./budget_manager');
const { estimateTokens } = require('./token_estimator');
class LodaSearchHandler {
constructor(parseFunc, options = {}) {
this.parseFunc = parseFunc;
this.index = new LodaIndex(options);
this.bloomFilters = new SectionBloomFilters();
this.options = {
useBloomFilter: true,
bloomFilterFallback: true, // Retry without if 0 results
...options
};
}
/**
* Execute LODA-optimized search
* @param {string} documentPath - Document to search
* @param {string} query - Search query
* @param {Object} options - Search options
* @returns {Object} Search results with metadata
*/
search(documentPath, query, options = {}) {
const {
contextBudget = null,
maxSections = 5,
includeContext = true,
contextLines = 10
} = options;
// 1. Get cached structure
const structure = this.index.getStructure(documentPath, this.parseFunc);
const cacheHit = this.index.cache.has(documentPath);
// 2. Build Bloom filters if not exists
if (this.options.useBloomFilter && !this.bloomFilters.filters.has(documentPath)) {
this.bloomFilters.buildForDocument(documentPath, structure.sections);
}
// 3. Bloom filter elimination (if enabled)
let candidates = structure.sections;
if (this.options.useBloomFilter) {
candidates = structure.sections.filter(section =>
this.bloomFilters.mightContain(documentPath, section.id, query)
);
}
// 3b. Load section content for scoring (sections from parseFunc only have line ranges)
const fileContent = fs.readFileSync(documentPath, 'utf8');
const lines = fileContent.split('\n');
const candidatesWithContent = candidates.map(section => ({
...section,
content: lines.slice(section.startLine - 1, section.endLine).join('\n')
}));
// 4. Score candidates (now with content)
const scored = candidatesWithContent.map(section => ({
...section,
score: calculateRelevance(section, query)
})).filter(s => s.score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, maxSections);
// 4b. Bloom filter fallback if 0 results
if (scored.length === 0 && this.options.bloomFilterFallback && this.options.useBloomFilter) {
// Retry without bloom filter - load content for all sections
const allWithContent = structure.sections.map(section => ({
...section,
content: lines.slice(section.startLine - 1, section.endLine).join('\n')
}));
const allScored = allWithContent.map(section => ({
...section,
score: calculateRelevance(section, query)
})).filter(s => s.score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, maxSections);
if (allScored.length > 0) {
// Bloom filter was too aggressive, use fallback results
scored.push(...allScored);
}
}
// 5. Select within budget
const budgetResult = selectWithinBudget(scored, contextBudget);
// 6. Build response
return {
query,
documentPath,
sections: budgetResult.selected.map(s => ({
id: s.id,
header: s.header,
level: s.level,
score: s.score,
lineRange: [s.startLine, s.endLine],
tokenEstimate: estimateTokens(s.content)
})),
metadata: {
totalSections: structure.sections.length,
candidatesAfterBloom: candidates.length,
scoredAboveZero: scored.length,
returnedSections: budgetResult.selected.length,
totalTokens: budgetResult.totalTokens,
budgetStatus: budgetResult.status,
truncated: budgetResult.truncated,
cacheHit
}
};
}
/**
* Get full section content (for follow-up)
* @param {string} documentPath - Document path
* @param {string} sectionId - Section ID to retrieve
* @param {Object} fs - File system module
* @param {number} contextLines - Context lines (unused, for compatibility)
* @returns {Object} Section with full content
*/
getSectionContent(documentPath, sectionId, fs, contextLines = 10) {
const structure = this.index.getStructure(documentPath, this.parseFunc);
const section = structure.sections.find(s => s.id === sectionId);
if (!section) {
throw new Error(`Section ${sectionId} not found`);
}
const content = fs.readFileSync(documentPath, 'utf8');
const lines = content.split('\n');
const sectionContent = lines.slice(section.startLine - 1, section.endLine).join('\n');
return {
section: {
...section,
content: sectionContent,
tokenEstimate: estimateTokens(sectionContent)
}
};
}
/**
* Invalidate cache for a document
* @param {string} documentPath - Path to invalidate
*/
invalidateCache(documentPath) {
this.index.invalidate(documentPath);
this.bloomFilters.filters.delete(documentPath);
}
/**
* Get cache statistics
* @returns {Object} Cache stats
*/
getStats() {
return {
indexStats: this.index.getStats(),
bloomFilterDocs: this.bloomFilters.filters.size
};
}
}
module.exports = { LodaSearchHandler };