UNPKG

@bottobot/td-mcp

Version:

TouchDesigner Documentation MCP Server v2.3 - Pure MCP server for VS Code/Codium with comprehensive operator documentation for 629 operators + 7 tutorials across all categories (TOP, CHOP, SOP, DAT, MAT, COMP, POP). Features experimental POP operators wit

990 lines (850 loc) 39 kB
/** * Search Indexer - Advanced indexing and search processing for TouchDesigner wiki content * Handles building search indices, relevance scoring, and query processing */ import { promises as fs } from 'fs'; import { join } from 'path'; import SearchIndex from '../models/search-index.js'; import WikiEntry from '../models/wiki-entry.js'; export class SearchIndexer { /** * Create a new search indexer instance * @param {Object} options - Indexer configuration options */ constructor(options = {}) { this.options = { indexPath: options.indexPath || './wiki/data/search-index', enablePersistence: options.enablePersistence !== undefined ? options.enablePersistence : true, autoSave: options.autoSave !== undefined ? options.autoSave : true, autoSaveInterval: options.autoSaveInterval || 30000, // 30 seconds enableFuzzySearch: options.enableFuzzySearch !== undefined ? options.enableFuzzySearch : true, enableStemming: options.enableStemming !== undefined ? options.enableStemming : true, customStopWords: options.customStopWords || [], relevanceWeights: { nameMatch: options.nameMatch || 10, categoryMatch: options.categoryMatch || 8, descriptionMatch: options.descriptionMatch || 5, parameterMatch: options.parameterMatch || 3, keywordMatch: options.keywordMatch || 4, contentMatch: options.contentMatch || 2, ...options.relevanceWeights }, ...options }; // Initialize search index this.searchIndex = new SearchIndex({ stemming: this.options.enableStemming, stopWords: true, caseSensitive: false, minQueryLength: 2, maxResults: this.options.maxResults || 100 }); // Add custom stop words if (this.options.customStopWords.length > 0) { this.options.customStopWords.forEach(word => { this.searchIndex.stopWords.add(word.toLowerCase()); }); } // Processing statistics this.stats = { totalEntries: 0, totalParameters: 0, indexSize: 0, lastIndexTime: null, searchQueries: 0, averageQueryTime: 0, popularQueries: new Map(), categoryDistribution: new Map(), processingErrors: [] }; // Auto-save interval this.autoSaveTimer = null; if (this.options.autoSave) { this.startAutoSave(); } // Query processing cache this.queryCache = new Map(); this.maxCacheSize = options.maxCacheSize || 1000; // Specialized TouchDesigner search patterns this.tdPatterns = { operatorTypes: /\b(top|chop|sop|dat|mat|comp|pop)\b/gi, parameterNames: /\b(translate|rotate|scale|resolution|format|active|cooking)\b/gi, commonTerms: /\b(noise|feedback|transform|render|audio|video|geometry|shader|texture|animation)\b/gi, expressions: /\b(me\.|op\(|abs\(|sin\(|cos\(|time\.|frame\.|fps)\b/gi, pythonKeywords: /\b(import|def|class|for|while|if|else|try|except|return)\b/gi }; } /** * Index a single WikiEntry * @param {WikiEntry} entry - Wiki entry to index * @returns {Promise<void>} */ async indexEntry(entry) { try { if (!entry || !entry.id) { console.warn('[Search Indexer] Skipping invalid entry: missing ID'); throw new Error('Invalid entry: missing ID'); } console.log(`[Search Indexer] Processing: ${entry.name} (${entry.category}) - ID: ${entry.id}`); // Add to search index this.searchIndex.addEntry(entry); // Update statistics this.updateIndexStats(entry); console.log(`[Search Indexer] ✓ Indexed: ${entry.name} (${entry.category}) - Parameters: ${entry.parameters?.length || 0}`); } catch (error) { console.error(`[Search Indexer] Error indexing entry ${entry?.id}:`, error); this.stats.processingErrors.push({ entryId: entry?.id, error: error.message, timestamp: new Date().toISOString() }); throw error; } } /** * Index multiple WikiEntry objects * @param {Array} entries - Array of wiki entries to index * @param {Object} options - Indexing options * @returns {Promise<Object>} Indexing results */ async indexEntries(entries, options = {}) { const results = { indexed: 0, errors: 0, skipped: 0, totalTime: 0, startTime: Date.now() }; console.log(`[Search Indexer] ========================================`); console.log(`[Search Indexer] Starting bulk indexing operation`); console.log(`[Search Indexer] Total entries to process: ${entries.length}`); console.log(`[Search Indexer] ========================================`); const batchSize = options.batchSize || 50; const batches = this.createBatches(entries, batchSize); console.log(`[Search Indexer] Created ${batches.length} batches of size ${batchSize}`); for (let i = 0; i < batches.length; i++) { const batch = batches[i]; const batchStart = Date.now(); console.log(`[Search Indexer] `); console.log(`[Search Indexer] >>> Batch ${i + 1}/${batches.length} (${batch.length} entries)`); const batchPromises = batch.map(async (entry) => { try { await this.indexEntry(entry); results.indexed++; } catch (error) { results.errors++; console.error(`[Search Indexer] ✗ Failed to index ${entry?.name || entry?.id || 'unknown'}:`, error.message); } }); await Promise.all(batchPromises); const batchTime = Date.now() - batchStart; console.log(`[Search Indexer] <<< Batch ${i + 1} complete in ${batchTime}ms`); console.log(`[Search Indexer] Progress: ${results.indexed + results.errors}/${entries.length} (${Math.round(((results.indexed + results.errors) / entries.length) * 100)}%)`); // Optional progress callback if (options.onProgress) { options.onProgress({ batch: i + 1, totalBatches: batches.length, processed: results.indexed + results.errors, total: entries.length }); } } results.totalTime = Date.now() - results.startTime; this.stats.lastIndexTime = new Date().toISOString(); console.log(`[Search Indexer] ========================================`); console.log(`[Search Indexer] Indexing Complete!`); console.log(`[Search Indexer] Successfully indexed: ${results.indexed}`); console.log(`[Search Indexer] Failed: ${results.errors}`); console.log(`[Search Indexer] Total time: ${results.totalTime}ms`); console.log(`[Search Indexer] Average per entry: ${(results.totalTime / entries.length).toFixed(2)}ms`); console.log(`[Search Indexer] ========================================`); // Save index if persistence is enabled if (this.options.enablePersistence && results.indexed > 0) { console.log(`[Search Indexer] Saving index to disk...`); await this.saveIndex(); } return results; } /** * Perform advanced search with relevance scoring * @param {string} query - Search query * @param {Object} options - Search options * @returns {Promise<Object>} Search results with metadata */ async search(query, options = {}) { const searchStart = Date.now(); try { // Validate query if (!query || typeof query !== 'string' || query.trim().length < 2) { return { results: [], query, totalResults: 0, searchTime: 0, suggestions: await this.getSuggestions(query) }; } const normalizedQuery = query.trim(); // Check cache first const cacheKey = this.createCacheKey(normalizedQuery, options); if (this.queryCache.has(cacheKey)) { const cached = this.queryCache.get(cacheKey); cached.searchTime = Date.now() - searchStart; cached.cached = true; return cached; } // Enhance query with TouchDesigner-specific processing const enhancedOptions = this.enhanceSearchOptions(normalizedQuery, options); // Perform search using the SearchIndex const rawResults = this.searchIndex.search(normalizedQuery, enhancedOptions); // Post-process and enhance results const enhancedResults = await this.enhanceSearchResults(rawResults, normalizedQuery, enhancedOptions); // Apply additional TouchDesigner-specific scoring const scoredResults = this.applyTouchDesignerScoring(enhancedResults, normalizedQuery); // Sort by final relevance score scoredResults.sort((a, b) => b.relevanceScore - a.relevanceScore); // Prepare response const searchResults = { results: scoredResults.slice(0, enhancedOptions.limit || 50), query: normalizedQuery, totalResults: scoredResults.length, searchTime: Date.now() - searchStart, facets: this.searchIndex.getFacets(), suggestions: scoredResults.length === 0 ? await this.getSuggestions(normalizedQuery) : [], queryEnhancements: this.analyzeQuery(normalizedQuery), cached: false }; // Cache results this.cacheSearchResults(cacheKey, searchResults); // Update statistics this.updateSearchStats(normalizedQuery, searchResults); return searchResults; } catch (error) { console.error('[Search Indexer] Search error:', error); return { results: [], query, totalResults: 0, searchTime: Date.now() - searchStart, error: error.message, suggestions: await this.getSuggestions(query) }; } } /** * Get contextual search suggestions * @param {string} partialQuery - Partial or failed query * @param {Object} options - Suggestion options * @returns {Promise<Array>} Array of suggestions */ async getSuggestions(partialQuery, options = {}) { if (!partialQuery || partialQuery.length < 2) { return this.getPopularQueries(10); } const suggestions = new Set(); const limit = options.limit || 10; // Get suggestions from search index const indexSuggestions = this.searchIndex.getSuggestions(partialQuery, limit); indexSuggestions.forEach(suggestion => suggestions.add(suggestion)); // Add TouchDesigner-specific suggestions const tdSuggestions = this.getTouchDesignerSuggestions(partialQuery); tdSuggestions.forEach(suggestion => suggestions.add(suggestion)); // Add popular queries that match const popularSuggestions = this.getPopularQueries(20).filter(query => query.toLowerCase().includes(partialQuery.toLowerCase()) ); popularSuggestions.forEach(suggestion => suggestions.add(suggestion)); return Array.from(suggestions).slice(0, limit); } /** * Get search statistics and analytics * @returns {Object} Comprehensive search statistics */ getSearchStats() { // Completely bulletproof Map handling let topQueries = []; let categoryDistribution = []; try { // Handle popularQueries if (this.stats.popularQueries instanceof Map) { topQueries = Array.from(this.stats.popularQueries.entries()) .sort((a, b) => b[1] - a[1]) .slice(0, 10); } else if (this.stats.popularQueries && typeof this.stats.popularQueries === 'object') { // Convert from object to array format topQueries = Object.entries(this.stats.popularQueries) .sort((a, b) => b[1] - a[1]) .slice(0, 10); // Reinitialize as Map this.stats.popularQueries = new Map(Object.entries(this.stats.popularQueries)); } else { this.stats.popularQueries = new Map(); } } catch (error) { console.warn('[Search Indexer] Error processing popularQueries:', error.message); this.stats.popularQueries = new Map(); topQueries = []; } try { // Handle categoryDistribution if (this.stats.categoryDistribution instanceof Map) { categoryDistribution = Array.from(this.stats.categoryDistribution.entries()); } else if (this.stats.categoryDistribution && typeof this.stats.categoryDistribution === 'object') { // Convert from object to array format categoryDistribution = Object.entries(this.stats.categoryDistribution); // Reinitialize as Map this.stats.categoryDistribution = new Map(Object.entries(this.stats.categoryDistribution)); } else { this.stats.categoryDistribution = new Map(); } } catch (error) { console.warn('[Search Indexer] Error processing categoryDistribution:', error.message); this.stats.categoryDistribution = new Map(); categoryDistribution = []; } return { ...this.stats, indexStats: this.searchIndex ? this.searchIndex.getStats() : {}, cacheStats: { size: this.queryCache ? this.queryCache.size : 0, hitRate: this.calculateCacheHitRate() }, topQueries, categoryDistribution }; } /** * Save search index to disk * @returns {Promise<void>} */ async saveIndex() { if (!this.options.enablePersistence) return; try { const indexData = this.searchIndex.exportData(); // Prepare stats data with proper Map serialization const serializedStats = { ...this.stats, popularQueries: Array.from(this.stats.popularQueries.entries()), categoryDistribution: Array.from(this.stats.categoryDistribution.entries()) }; const statsData = { stats: serializedStats, timestamp: new Date().toISOString(), version: '1.0.0' }; // Ensure directory exists await fs.mkdir(this.options.indexPath, { recursive: true }); // Save index data await fs.writeFile( join(this.options.indexPath, 'search-index.json'), JSON.stringify(indexData, null, 2) ); // Save statistics await fs.writeFile( join(this.options.indexPath, 'search-stats.json'), JSON.stringify(statsData, null, 2) ); const indexFile = join(this.options.indexPath, 'search-index.json'); const statsFile = join(this.options.indexPath, 'search-stats.json'); console.log(`[Search Indexer] ✓ Index saved to ${indexFile}`); console.log(`[Search Indexer] ✓ Stats saved to ${statsFile}`); console.log(`[Search Indexer] Index contains ${this.stats.totalEntries} entries with ${this.stats.totalParameters} total parameters`); } catch (error) { console.error('[Search Indexer] Error saving index:', error); throw error; } } /** * Load search index from disk * @returns {Promise<void>} */ async loadIndex() { if (!this.options.enablePersistence) return; try { const indexPath = join(this.options.indexPath, 'search-index.json'); const statsPath = join(this.options.indexPath, 'search-stats.json'); // Load index data try { console.log(`[Search Indexer] Loading index from ${indexPath}...`); const indexData = JSON.parse(await fs.readFile(indexPath, 'utf-8')); this.searchIndex.importData(indexData); console.log(`[Search Indexer] ✓ Index loaded successfully`); // Log some basic info about the loaded index const stats = this.searchIndex.getStats(); console.log(`[Search Indexer] Loaded ${stats.totalEntries || 0} entries`); console.log(`[Search Indexer] Index size: ${stats.indexSize || 0} bytes`); } catch (error) { console.log('[Search Indexer] No existing index found, will create new index'); } // Load statistics try { const statsData = JSON.parse(await fs.readFile(statsPath, 'utf-8')); const loadedStats = statsData.stats; // Reconstruct Map objects - handle all possible formats this.stats.popularQueries = new Map(); if (loadedStats.popularQueries) { if (Array.isArray(loadedStats.popularQueries)) { // Saved as array of [key, value] pairs this.stats.popularQueries = new Map(loadedStats.popularQueries); } else if (typeof loadedStats.popularQueries === 'object' && loadedStats.popularQueries !== null) { // Saved as plain object this.stats.popularQueries = new Map(Object.entries(loadedStats.popularQueries)); } } this.stats.categoryDistribution = new Map(); if (loadedStats.categoryDistribution) { if (Array.isArray(loadedStats.categoryDistribution)) { // Saved as array of [key, value] pairs this.stats.categoryDistribution = new Map(loadedStats.categoryDistribution); } else if (typeof loadedStats.categoryDistribution === 'object' && loadedStats.categoryDistribution !== null) { // Saved as plain object this.stats.categoryDistribution = new Map(Object.entries(loadedStats.categoryDistribution)); } } // Merge other stats (excluding the Map objects we just handled) const { popularQueries, categoryDistribution, ...otherStats } = loadedStats; this.stats = { ...this.stats, ...otherStats }; console.log(`[Search Indexer] ✓ Statistics loaded from ${statsPath}`); console.log(`[Search Indexer] Total entries: ${this.stats.totalEntries}`); console.log(`[Search Indexer] Total parameters: ${this.stats.totalParameters}`); console.log(`[Search Indexer] Last indexed: ${this.stats.lastIndexTime || 'never'}`); } catch (error) { console.log('[Search Indexer] No existing statistics found, starting fresh'); // Ensure Maps are initialized even if loading fails this.stats.popularQueries = new Map(); this.stats.categoryDistribution = new Map(); } } catch (error) { console.warn('[Search Indexer] Error loading index:', error); // Continue with empty index } } /** * Enhance search options with TouchDesigner-specific settings * @param {string} query - Search query * @param {Object} options - Original options * @returns {Object} Enhanced options */ enhanceSearchOptions(query, options) { const enhanced = { ...options }; // Detect query type and adjust settings const queryAnalysis = this.analyzeQuery(query); if (queryAnalysis.isOperatorQuery) { enhanced.fields = ['name', 'displayName', 'category', 'description']; enhanced.threshold = 0.3; } if (queryAnalysis.isParameterQuery) { enhanced.fields = ['parameters', 'name', 'description']; enhanced.parameterSearch = true; } if (queryAnalysis.isTechnicalQuery) { enhanced.fields = ['description', 'details', 'usage', 'keywords']; enhanced.threshold = 0.2; } // Set default limits enhanced.limit = enhanced.limit || 50; enhanced.fuzzy = enhanced.fuzzy !== undefined ? enhanced.fuzzy : this.options.enableFuzzySearch; return enhanced; } /** * Enhance search results with additional metadata * @param {Array} results - Raw search results * @param {string} query - Original query * @param {Object} options - Search options * @returns {Promise<Array>} Enhanced results */ async enhanceSearchResults(results, query, options) { return results.map(result => { const enhanced = { ...result, queryMatch: this.analyzeQueryMatch(result, query), relevanceFactors: this.calculateRelevanceFactors(result, query), snippet: this.generateSnippet(result.entry, query), highlightTerms: this.extractHighlightTerms(query) }; return enhanced; }); } /** * Apply TouchDesigner-specific scoring adjustments * @param {Array} results - Search results to score * @param {string} query - Search query * @returns {Array} Results with updated scores */ applyTouchDesignerScoring(results, query) { const queryLower = query.toLowerCase(); return results.map(result => { let bonusScore = 0; const entry = result.entry; // Category-specific bonuses if (this.tdPatterns.operatorTypes.test(queryLower)) { const categoryMatch = queryLower.match(this.tdPatterns.operatorTypes); if (categoryMatch && entry.category.toLowerCase() === categoryMatch[0]) { bonusScore += this.options.relevanceWeights.categoryMatch; } } // Parameter-specific bonuses if (entry.parameters && entry.parameters.length > 0) { const parameterMatches = entry.parameters.filter(param => param.name.toLowerCase().includes(queryLower) || param.description.toLowerCase().includes(queryLower) ); bonusScore += parameterMatches.length * this.options.relevanceWeights.parameterMatch; } // Common term bonuses const commonTermMatches = (entry.description + ' ' + entry.details).match(this.tdPatterns.commonTerms); if (commonTermMatches) { bonusScore += commonTermMatches.length * this.options.relevanceWeights.contentMatch; } // Keyword exact match bonuses const keywordMatches = entry.keywords.filter(keyword => keyword.toLowerCase() === queryLower || queryLower.includes(keyword.toLowerCase()) ); bonusScore += keywordMatches.length * this.options.relevanceWeights.keywordMatch; // Name similarity bonus if (entry.name.toLowerCase().includes(queryLower)) { bonusScore += this.options.relevanceWeights.nameMatch; } // Calculate final relevance score result.relevanceScore = (result.score || 0) + bonusScore; result.tdScore = bonusScore; return result; }); } /** * Analyze query to determine type and characteristics * @param {string} query - Query to analyze * @returns {Object} Query analysis */ analyzeQuery(query) { const analysis = { isOperatorQuery: false, isParameterQuery: false, isTechnicalQuery: false, isCategoryQuery: false, isExpressionQuery: false, isPythonQuery: false, detectedPatterns: [], suggestedEnhancements: [] }; const queryLower = query.toLowerCase(); // Check for operator type queries if (this.tdPatterns.operatorTypes.test(queryLower)) { analysis.isOperatorQuery = true; analysis.isCategoryQuery = true; analysis.detectedPatterns.push('operator_type'); } // Check for parameter queries if (this.tdPatterns.parameterNames.test(queryLower) || queryLower.includes('parameter') || queryLower.includes('param')) { analysis.isParameterQuery = true; analysis.detectedPatterns.push('parameter'); } // Check for technical/common term queries if (this.tdPatterns.commonTerms.test(queryLower)) { analysis.isTechnicalQuery = true; analysis.detectedPatterns.push('technical_term'); } // Check for expression queries if (this.tdPatterns.expressions.test(query)) { analysis.isExpressionQuery = true; analysis.detectedPatterns.push('expression'); } // Check for Python queries if (this.tdPatterns.pythonKeywords.test(queryLower)) { analysis.isPythonQuery = true; analysis.detectedPatterns.push('python'); } return analysis; } /** * Generate a snippet from entry content highlighting query terms * @param {Object} entry - Wiki entry * @param {string} query - Search query * @returns {string} Generated snippet */ generateSnippet(entry, query) { const queryTerms = query.toLowerCase().split(/\s+/); const content = entry.description || entry.summary || ''; if (!content) return ''; // Find the best sentence that contains query terms const sentences = content.split(/[.!?]+/); let bestSentence = sentences[0] || ''; let maxMatches = 0; for (const sentence of sentences) { const sentenceLower = sentence.toLowerCase(); const matches = queryTerms.filter(term => sentenceLower.includes(term)).length; if (matches > maxMatches) { maxMatches = matches; bestSentence = sentence; } } // Truncate and highlight let snippet = bestSentence.trim().substring(0, 200); if (bestSentence.length > 200) { snippet += '...'; } return snippet; } /** * Extract highlight terms from query * @param {string} query - Search query * @returns {Array} Terms to highlight */ extractHighlightTerms(query) { return query.toLowerCase() .split(/\s+/) .filter(term => term.length > 2) .filter(term => !this.searchIndex.stopWords.has(term)); } /** * Get TouchDesigner-specific suggestions * @param {string} partialQuery - Partial query * @returns {Array} TD-specific suggestions */ getTouchDesignerSuggestions(partialQuery) { const suggestions = []; const queryLower = partialQuery.toLowerCase(); // Operator type suggestions const operatorTypes = ['TOP', 'CHOP', 'SOP', 'DAT', 'MAT', 'COMP', 'POP']; operatorTypes.forEach(type => { if (type.toLowerCase().startsWith(queryLower)) { suggestions.push(type); } }); // Common operator names const commonOperators = [ 'Noise', 'Transform', 'Movie File In', 'Render', 'Composite', 'Feedback', 'Audio File In', 'Constant', 'Math', 'Logic', 'Switch', 'Merge', 'Geometry', 'Text', 'Rectangle', 'Circle', 'Line', 'Grid' ]; commonOperators.forEach(op => { if (op.toLowerCase().includes(queryLower)) { suggestions.push(op); } }); return suggestions; } /** * Create cache key for search results * @param {string} query - Search query * @param {Object} options - Search options * @returns {string} Cache key */ createCacheKey(query, options) { const optionsKey = JSON.stringify({ category: options.category, tags: options.tags, limit: options.limit, fuzzy: options.fuzzy }); return `${query.toLowerCase()}:${optionsKey}`; } /** * Cache search results * @param {string} cacheKey - Cache key * @param {Object} results - Search results */ cacheSearchResults(cacheKey, results) { // Remove oldest entries if cache is full if (this.queryCache.size >= this.maxCacheSize) { const firstKey = this.queryCache.keys().next().value; this.queryCache.delete(firstKey); } this.queryCache.set(cacheKey, results); } /** * Calculate relevance factors for a search result * @param {Object} result - Search result * @param {string} query - Search query * @returns {Object} Relevance factors */ calculateRelevanceFactors(result, query) { const factors = { nameMatch: 0, descriptionMatch: 0, parameterMatch: 0, keywordMatch: 0, categoryMatch: 0 }; const queryLower = query.toLowerCase(); const entry = result.entry; if (entry.name.toLowerCase().includes(queryLower)) { factors.nameMatch = 1; } if (entry.description && entry.description.toLowerCase().includes(queryLower)) { factors.descriptionMatch = 1; } if (entry.category && entry.category.toLowerCase().includes(queryLower)) { factors.categoryMatch = 1; } return factors; } /** * Analyze how well a query matches a search result * @param {Object} result - Search result * @param {string} query - Search query * @returns {Object} Match analysis */ analyzeQueryMatch(result, query) { const queryTerms = query.toLowerCase().split(/\s+/); const entry = result.entry; const matches = { exactNameMatch: entry.name.toLowerCase() === query.toLowerCase(), partialNameMatch: entry.name.toLowerCase().includes(query.toLowerCase()), categoryMatch: entry.category.toLowerCase().includes(query.toLowerCase()), termMatches: 0, totalTerms: queryTerms.length }; // Count matching terms queryTerms.forEach(term => { if (entry.name.toLowerCase().includes(term) || entry.description.toLowerCase().includes(term) || entry.keywords.some(keyword => keyword.toLowerCase().includes(term))) { matches.termMatches++; } }); matches.matchRatio = matches.termMatches / matches.totalTerms; return matches; } /** * Update indexing statistics * @param {WikiEntry} entry - Indexed entry */ updateIndexStats(entry) { this.stats.totalEntries++; const paramCount = entry.parameters?.length || 0; this.stats.totalParameters += paramCount; if (this.stats.totalEntries % 100 === 0) { console.log(`[Search Indexer] Milestone: ${this.stats.totalEntries} entries indexed`); } // Update category distribution - ensure it's a Map if (!(this.stats.categoryDistribution instanceof Map)) { this.stats.categoryDistribution = new Map(); } const category = entry.category || 'Unknown'; this.stats.categoryDistribution.set( category, (this.stats.categoryDistribution.get(category) || 0) + 1 ); this.stats.indexSize = this.searchIndex.getStats().indexSize; } /** * Update search statistics * @param {string} query - Search query * @param {Object} results - Search results */ updateSearchStats(query, results) { this.stats.searchQueries++; // Update average query time const currentAvg = this.stats.averageQueryTime; const newTime = results.searchTime; this.stats.averageQueryTime = ((currentAvg * (this.stats.searchQueries - 1)) + newTime) / this.stats.searchQueries; // Track popular queries - ensure it's a Map if (!(this.stats.popularQueries instanceof Map)) { this.stats.popularQueries = new Map(); } const count = this.stats.popularQueries.get(query) || 0; this.stats.popularQueries.set(query, count + 1); // Limit popular queries map size if (this.stats.popularQueries.size > 1000) { const entries = Array.from(this.stats.popularQueries.entries()) .sort((a, b) => b[1] - a[1]) .slice(0, 500); this.stats.popularQueries = new Map(entries); } } /** * Get popular queries for suggestions * @param {number} limit - Number of queries to return * @returns {Array} Popular queries */ getPopularQueries(limit = 10) { if (!(this.stats.popularQueries instanceof Map)) { return []; } return Array.from(this.stats.popularQueries.entries()) .sort((a, b) => b[1] - a[1]) .slice(0, limit) .map(entry => entry[0]); } /** * Calculate cache hit rate * @returns {number} Hit rate percentage */ calculateCacheHitRate() { // This would require tracking cache hits/misses // For now, return a placeholder return 0.0; } /** * Create batches for processing * @param {Array} items - Items to batch * @param {number} batchSize - Size of each batch * @returns {Array} Array of batches */ createBatches(items, batchSize) { const batches = []; for (let i = 0; i < items.length; i += batchSize) { batches.push(items.slice(i, i + batchSize)); } return batches; } /** * Start auto-save timer */ startAutoSave() { if (this.autoSaveTimer) { clearInterval(this.autoSaveTimer); } console.log(`[Search Indexer] Auto-save enabled (interval: ${this.options.autoSaveInterval}ms)`); this.autoSaveTimer = setInterval(async () => { try { console.log(`[Search Indexer] Auto-save triggered at ${new Date().toISOString()}`); await this.saveIndex(); console.log(`[Search Indexer] Auto-save completed`); } catch (error) { console.error('[Search Indexer] Auto-save failed:', error.message); } }, this.options.autoSaveInterval); } /** * Stop auto-save timer */ stopAutoSave() { if (this.autoSaveTimer) { clearInterval(this.autoSaveTimer); this.autoSaveTimer = null; } } /** * Clear search index and reset statistics */ clear() { console.log('[Search Indexer] Clearing index and resetting statistics...'); this.searchIndex.clear(); this.queryCache.clear(); this.stats = { totalEntries: 0, totalParameters: 0, indexSize: 0, lastIndexTime: null, searchQueries: 0, averageQueryTime: 0, popularQueries: new Map(), categoryDistribution: new Map(), processingErrors: [] }; console.log('[Search Indexer] Index cleared'); } /** * Cleanup resources */ destroy() { this.stopAutoSave(); this.queryCache.clear(); } } export default SearchIndexer;