UNPKG

@bottobot/td-mcp

Version:

TouchDesigner MCP Server v2.8.0 - 21 MCP tools, 629 operators with clean parameter data, 69 Python API classes, 14 tutorials, 32 workflow patterns. Includes version system, experimental techniques KB, core tool enhancements, and experimental build support

990 lines (850 loc) 38 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;