Spaces:
Configuration error
Configuration error
| /** | |
| * SearchEngine Module | |
| * Handles Lunr.js integration and search logic with filtering and grouping | |
| */ | |
| class SearchEngine { | |
| constructor(utils) { | |
| this.utils = utils; | |
| this.index = null; | |
| this.documents = {}; | |
| this.isInitialized = false; | |
| // Support both new schema (topics, audience) and legacy (categories, personas) | |
| this.topics = new Set(); | |
| this.tags = new Set(); | |
| this.documentTypes = new Set(); | |
| this.audience = new Set(); | |
| this.difficulties = new Set(); | |
| // Dynamic facets - discovered from documents, not predefined | |
| this.facets = {}; // { facetKey: Set of values } | |
| } | |
| /** | |
| * Initialize the search engine with documents | |
| */ | |
| async initialize(documents) { | |
| try { | |
| await this.loadLunr(); | |
| this.documents = documents; | |
| this.collectMetadata(); | |
| this.buildIndex(); | |
| this.isInitialized = true; | |
| } catch (error) { | |
| throw error; | |
| } | |
| } | |
| /** | |
| * Collect metadata for filtering using actual frontmatter values | |
| * Supports both new schema (topics, audience) and legacy (categories, personas) | |
| * Dynamically discovers all facet keys from documents | |
| */ | |
| collectMetadata() { | |
| // Clear existing sets | |
| this.topics = new Set(); | |
| this.tags = new Set(); | |
| this.documentTypes = new Set(); | |
| this.audience = new Set(); | |
| this.difficulties = new Set(); | |
| this.facets = {}; // Reset dynamic facets | |
| Object.values(this.documents).forEach(doc => { | |
| // Collect topics (new schema) or categories (legacy) | |
| const topicsField = doc.topics || doc.categories; | |
| if (topicsField) { | |
| if (Array.isArray(topicsField)) { | |
| topicsField.forEach(topic => this.topics.add(topic)); | |
| } else if (typeof topicsField === 'string') { | |
| topicsField.split(',').forEach(topic => this.topics.add(topic.trim())); | |
| } | |
| } | |
| // Collect actual frontmatter tags | |
| if (doc.tags) { | |
| if (Array.isArray(doc.tags)) { | |
| doc.tags.forEach(tag => { | |
| // Split space-separated tags and add individually | |
| if (typeof tag === 'string' && tag.includes(' ')) { | |
| tag.split(' ').forEach(individualTag => { | |
| if (individualTag.trim()) { | |
| this.tags.add(individualTag.trim()); | |
| } | |
| }); | |
| } else if (tag && tag.trim()) { | |
| this.tags.add(tag.trim()); | |
| } | |
| }); | |
| } else if (typeof doc.tags === 'string') { | |
| // Handle both comma-separated and space-separated tags | |
| const allTags = doc.tags.includes(',') | |
| ? doc.tags.split(',') | |
| : doc.tags.split(' '); | |
| allTags.forEach(tag => { | |
| if (tag && tag.trim()) { | |
| this.tags.add(tag.trim()); | |
| } | |
| }); | |
| } | |
| } | |
| // Use actual content_type from frontmatter (not calculated doc_type) | |
| if (doc.content_type) { | |
| this.documentTypes.add(doc.content_type); | |
| } | |
| // Collect audience (new schema) or personas (legacy) | |
| const audienceField = doc.audience || doc.personas; | |
| if (audienceField) { | |
| if (Array.isArray(audienceField)) { | |
| audienceField.forEach(aud => this.audience.add(aud)); | |
| } else if (typeof audienceField === 'string') { | |
| this.audience.add(audienceField); | |
| } | |
| } | |
| if (doc.difficulty) { | |
| this.difficulties.add(doc.difficulty); | |
| } | |
| // Dynamically discover all facets from documents | |
| if (doc.facets && typeof doc.facets === 'object') { | |
| Object.entries(doc.facets).forEach(([facetKey, facetValue]) => { | |
| // Initialize Set for this facet if not exists | |
| if (!this.facets[facetKey]) { | |
| this.facets[facetKey] = new Set(); | |
| } | |
| // Add value(s) to the facet Set | |
| if (Array.isArray(facetValue)) { | |
| facetValue.forEach(v => this.facets[facetKey].add(v)); | |
| } else if (facetValue) { | |
| this.facets[facetKey].add(facetValue); | |
| } | |
| }); | |
| } | |
| // Also check for flat facet fields (legacy modality, etc.) | |
| // These get added to facets dynamically | |
| if (doc.modality && !this.facets.modality) { | |
| this.facets.modality = new Set(); | |
| } | |
| if (doc.modality) { | |
| this.facets.modality.add(doc.modality); | |
| } | |
| }); | |
| } | |
| /** | |
| * Get available filter options using actual frontmatter taxonomy | |
| * Returns both new field names and legacy names for backwards compatibility | |
| * Includes dynamically discovered facets | |
| */ | |
| getFilterOptions() { | |
| // Convert dynamic facets from Sets to sorted arrays | |
| const facetOptions = {}; | |
| Object.entries(this.facets).forEach(([facetKey, facetSet]) => { | |
| facetOptions[facetKey] = Array.from(facetSet).sort(); | |
| }); | |
| return { | |
| // New schema names | |
| topics: Array.from(this.topics).sort(), | |
| audience: Array.from(this.audience).sort(), | |
| // Legacy names (aliases for backwards compatibility) | |
| categories: Array.from(this.topics).sort(), | |
| personas: Array.from(this.audience).sort(), | |
| // Common fields | |
| tags: Array.from(this.tags).sort(), | |
| documentTypes: Array.from(this.documentTypes).sort(), | |
| difficulties: Array.from(this.difficulties).sort(), | |
| // Dynamic facets (user-defined, discovered from documents) | |
| facets: facetOptions | |
| }; | |
| } | |
| /** | |
| * Load Lunr.js library if not already loaded | |
| */ | |
| async loadLunr() { | |
| if (typeof lunr === 'undefined') { | |
| await this.utils.loadScript('https://unpkg.com/lunr@2.3.9/lunr.min.js'); | |
| } | |
| } | |
| /** | |
| * Build the Lunr search index | |
| * Supports both new schema (topics, audience) and legacy (categories, personas) | |
| * | |
| * Field boosting rationale: | |
| * - Title matches are almost always what users want (highest boost) | |
| * - Description (from frontmatter) is hand-crafted summary (high boost) | |
| * - Headings provide structural relevance (medium-high boost) | |
| * - Content gets lowest boost to prevent long documents from dominating | |
| * - Hierarchy: title > description > headings/keywords > tags > content | |
| */ | |
| buildIndex() { | |
| const documentsArray = Object.values(this.documents); | |
| const self = this; | |
| this.index = lunr(function() { | |
| // Define fields with optimized boosting for documentation search patterns | |
| this.ref('id'); | |
| // Primary fields - highest relevance | |
| this.field('title', { boost: 10 }); // Title matches most important | |
| this.field('description', { boost: 8 }); // Frontmatter description (hand-crafted) | |
| // Secondary fields - structural relevance | |
| this.field('keywords', { boost: 7 }); // Explicit keywords | |
| this.field('headings_text', { boost: 5 }); // Section headings | |
| this.field('headings', { boost: 5 }); // Section headings (legacy format) | |
| this.field('tags', { boost: 4 }); // Taxonomy tags | |
| // Tertiary fields - content matching | |
| this.field('summary', { boost: 3 }); // Summary field | |
| this.field('topics', { boost: 2 }); // Topic categorization | |
| this.field('content', { boost: 1 }); // Full content (low to prevent long docs dominating) | |
| // Metadata fields - filtering support | |
| this.field('content_type', { boost: 1 }); | |
| this.field('audience', { boost: 1 }); | |
| this.field('difficulty', { boost: 1 }); | |
| this.field('modality', { boost: 1 }); | |
| this.field('section_path', { boost: 1 }); | |
| this.field('author', { boost: 1 }); | |
| // Add documents to index | |
| documentsArray.forEach((doc) => { | |
| try { | |
| this.add({ | |
| id: doc.id, | |
| title: doc.title || '', | |
| description: doc.description || '', // NEW: separate indexed field | |
| content: (doc.content || '').substring(0, 5000), // Limit content length | |
| summary: doc.summary || '', | |
| headings: self.extractHeadingsText(doc.headings), | |
| headings_text: doc.headings_text || '', | |
| keywords: self.arrayToString(doc.keywords), | |
| tags: self.arrayToString(doc.tags), | |
| // Support both topics (new) and categories (legacy) | |
| topics: self.arrayToString(doc.topics || doc.categories), | |
| content_type: doc.content_type || '', | |
| // Support both audience (new) and personas (legacy) | |
| audience: self.arrayToString(doc.audience || doc.personas), | |
| difficulty: doc.difficulty || '', | |
| modality: doc.modality || '', | |
| section_path: self.arrayToString(doc.section_path), | |
| author: doc.author || '' | |
| }); | |
| } catch (docError) { | |
| // Skip documents that fail to index | |
| } | |
| }, this); | |
| }); | |
| } | |
| /** | |
| * Convert array to string for indexing | |
| */ | |
| arrayToString(arr) { | |
| if (Array.isArray(arr)) { | |
| return arr.join(' '); | |
| } | |
| return arr || ''; | |
| } | |
| /** | |
| * Extract text from headings array | |
| */ | |
| extractHeadingsText(headings) { | |
| if (!Array.isArray(headings)) return ''; | |
| return headings.map(h => h.text || '').join(' '); | |
| } | |
| /** | |
| * Perform search with query and optional filters | |
| */ | |
| search(query, filters = {}, maxResults = 20) { | |
| if (!this.isInitialized || !this.index) { | |
| return []; | |
| } | |
| if (!query || query.trim().length < 2) { | |
| return []; | |
| } | |
| try { | |
| // Enhanced search with multiple strategies | |
| const results = this.performMultiStrategySearch(query); | |
| // Process and enhance results | |
| const enhancedResults = this.enhanceResults(results, query); | |
| // Apply filters | |
| const filteredResults = this.applyFilters(enhancedResults, filters); | |
| // Group and rank results | |
| const groupedResults = this.groupResultsByDocument(filteredResults, query); | |
| return groupedResults.slice(0, maxResults); | |
| } catch (error) { | |
| return []; | |
| } | |
| } | |
| /** | |
| * Apply filters to search results | |
| * Supports both new schema (topic, audience) and legacy (category, persona) filter names | |
| * Handles dynamic facet filters | |
| */ | |
| applyFilters(results, filters) { | |
| return results.filter(result => { | |
| // Topic filter (new) or category filter (legacy) | |
| const topicFilter = filters.topic || filters.category; | |
| if (topicFilter && topicFilter !== '') { | |
| const docTopics = this.getDocumentTopics(result); | |
| if (!docTopics.includes(topicFilter)) { | |
| return false; | |
| } | |
| } | |
| // Tag filter | |
| if (filters.tag && filters.tag !== '') { | |
| const docTags = this.getDocumentTags(result); | |
| if (!docTags.includes(filters.tag)) { | |
| return false; | |
| } | |
| } | |
| // Document type filter (using actual frontmatter content_type) | |
| if (filters.type && filters.type !== '') { | |
| if (result.content_type !== filters.type) { | |
| return false; | |
| } | |
| } | |
| // Audience filter (new) or persona filter (legacy) | |
| const audienceFilter = filters.audience || filters.persona; | |
| if (audienceFilter && audienceFilter !== '') { | |
| const docAudience = this.getDocumentAudience(result); | |
| if (!docAudience.includes(audienceFilter)) { | |
| return false; | |
| } | |
| } | |
| // Difficulty filter | |
| if (filters.difficulty && filters.difficulty !== '') { | |
| if (result.difficulty !== filters.difficulty) { | |
| return false; | |
| } | |
| } | |
| // Dynamic facet filters (e.g., filters.facets = { modality: 'text-only', framework: 'pytorch' }) | |
| if (filters.facets && typeof filters.facets === 'object') { | |
| for (const [facetKey, facetValue] of Object.entries(filters.facets)) { | |
| if (facetValue && facetValue !== '') { | |
| const docFacetValue = this.getDocumentFacet(result, facetKey); | |
| if (!docFacetValue.includes(facetValue)) { | |
| return false; | |
| } | |
| } | |
| } | |
| } | |
| // Legacy flat facet filters (e.g., filters.modality directly) | |
| // Check for any filter key that matches a known facet | |
| for (const facetKey of Object.keys(this.facets)) { | |
| if (filters[facetKey] && filters[facetKey] !== '') { | |
| const docFacetValue = this.getDocumentFacet(result, facetKey); | |
| if (!docFacetValue.includes(filters[facetKey])) { | |
| return false; | |
| } | |
| } | |
| } | |
| return true; | |
| }); | |
| } | |
| /** | |
| * Get a specific facet value for a document | |
| */ | |
| getDocumentFacet(doc, facetKey) { | |
| // Check nested facets object first | |
| if (doc.facets && doc.facets[facetKey]) { | |
| const value = doc.facets[facetKey]; | |
| return Array.isArray(value) ? value : [value]; | |
| } | |
| // Check flat field (legacy) | |
| if (doc[facetKey]) { | |
| const value = doc[facetKey]; | |
| return Array.isArray(value) ? value : [value]; | |
| } | |
| return []; | |
| } | |
| /** | |
| * Get topics for a document (supports new schema and legacy categories) | |
| */ | |
| getDocumentTopics(doc) { | |
| const topics = []; | |
| // From explicit topics (new schema) or categories (legacy) | |
| const topicsField = doc.topics || doc.categories; | |
| if (topicsField) { | |
| if (Array.isArray(topicsField)) { | |
| topics.push(...topicsField); | |
| } else { | |
| topics.push(...topicsField.split(',').map(t => t.trim())); | |
| } | |
| } | |
| // From section path | |
| if (doc.section_path && Array.isArray(doc.section_path)) { | |
| topics.push(...doc.section_path); | |
| } | |
| // From document ID path | |
| if (doc.id) { | |
| const pathParts = doc.id.split('/').filter(part => part && part !== 'index'); | |
| topics.push(...pathParts); | |
| } | |
| return [...new Set(topics)]; // Remove duplicates | |
| } | |
| /** | |
| * Get categories for a document (legacy alias for getDocumentTopics) | |
| */ | |
| getDocumentCategories(doc) { | |
| return this.getDocumentTopics(doc); | |
| } | |
| /** | |
| * Get tags for a document | |
| */ | |
| getDocumentTags(doc) { | |
| if (!doc.tags) return []; | |
| if (Array.isArray(doc.tags)) { | |
| // Handle array of tags that might contain space-separated strings | |
| const flatTags = []; | |
| doc.tags.forEach(tag => { | |
| if (typeof tag === 'string' && tag.includes(' ')) { | |
| // Split space-separated tags | |
| tag.split(' ').forEach(individualTag => { | |
| if (individualTag.trim()) { | |
| flatTags.push(individualTag.trim()); | |
| } | |
| }); | |
| } else if (tag && tag.trim()) { | |
| flatTags.push(tag.trim()); | |
| } | |
| }); | |
| return flatTags; | |
| } | |
| // Handle string tags - check for both comma and space separation | |
| if (typeof doc.tags === 'string') { | |
| const allTags = []; | |
| const tagString = doc.tags.trim(); | |
| if (tagString.includes(',')) { | |
| // Comma-separated tags | |
| tagString.split(',').forEach(tag => { | |
| if (tag.trim()) { | |
| allTags.push(tag.trim()); | |
| } | |
| }); | |
| } else { | |
| // Space-separated tags | |
| tagString.split(' ').forEach(tag => { | |
| if (tag.trim()) { | |
| allTags.push(tag.trim()); | |
| } | |
| }); | |
| } | |
| return allTags; | |
| } | |
| return []; | |
| } | |
| /** | |
| * Get audience for a document (supports new schema and legacy personas) | |
| */ | |
| getDocumentAudience(doc) { | |
| // Support both audience (new) and personas (legacy) | |
| const audienceField = doc.audience || doc.personas; | |
| if (!audienceField) return []; | |
| if (Array.isArray(audienceField)) { | |
| return audienceField; | |
| } | |
| return [audienceField]; | |
| } | |
| /** | |
| * Get personas for a document (legacy alias for getDocumentAudience) | |
| */ | |
| getDocumentPersonas(doc) { | |
| return this.getDocumentAudience(doc); | |
| } | |
| /** | |
| * Perform search with multiple strategies | |
| */ | |
| performMultiStrategySearch(query) { | |
| const strategies = [ | |
| // Exact phrase search with wildcards | |
| `"${query}" ${query}*`, | |
| // Fuzzy search with wildcards | |
| `${query}* ${query}~2`, | |
| // Individual terms with boost | |
| query.split(/\s+/).map(term => `${term}*`).join(' '), | |
| // Fallback: just the query | |
| query | |
| ]; | |
| let allResults = []; | |
| const seenIds = new Set(); | |
| for (const strategy of strategies) { | |
| try { | |
| const results = this.index.search(strategy); | |
| // Add new results (avoid duplicates) | |
| results.forEach(result => { | |
| if (!seenIds.has(result.ref)) { | |
| seenIds.add(result.ref); | |
| allResults.push({ | |
| ...result, | |
| strategy: strategy | |
| }); | |
| } | |
| }); | |
| // If we have enough good results, stop | |
| if (allResults.length >= 30) break; | |
| } catch (strategyError) { | |
| console.warn(`Search strategy failed: ${strategy}`, strategyError); | |
| } | |
| } | |
| return allResults; | |
| } | |
| /** | |
| * Enhance search results with document data and apply re-ranking | |
| */ | |
| enhanceResults(results, query) { | |
| const queryLower = query.toLowerCase().trim(); | |
| const queryTerms = queryLower.split(/\s+/); | |
| return results.map(result => { | |
| const doc = this.documents[result.ref]; | |
| if (!doc) { | |
| console.warn(`Document not found: ${result.ref}`); | |
| return null; | |
| } | |
| // Calculate additional relevance boost for title matches | |
| const titleBoost = this.calculateTitleBoost(doc, queryLower, queryTerms); | |
| const keywordBoost = this.calculateKeywordBoost(doc, queryTerms); | |
| const descriptionBoost = this.calculateDescriptionBoost(doc, queryTerms); | |
| // Apply boosts to base score | |
| const enhancedScore = result.score * (1 + titleBoost + keywordBoost + descriptionBoost); | |
| return { | |
| ...doc, | |
| score: enhancedScore, | |
| baseScore: result.score, | |
| titleBoost, | |
| keywordBoost, | |
| descriptionBoost, | |
| matchedTerms: Object.keys(result.matchData?.metadata || {}), | |
| matchData: result.matchData, | |
| strategy: result.strategy | |
| }; | |
| }).filter(Boolean); // Remove null results | |
| } | |
| /** | |
| * Calculate boost for title matches | |
| * Heavily rewards exact and partial title matches | |
| */ | |
| calculateTitleBoost(doc, queryLower, queryTerms) { | |
| if (!doc.title) return 0; | |
| const titleLower = doc.title.toLowerCase(); | |
| let boost = 0; | |
| // Exact title match (highest boost) | |
| if (titleLower === queryLower) { | |
| boost += 10; | |
| } | |
| // Title starts with query | |
| else if (titleLower.startsWith(queryLower)) { | |
| boost += 8; | |
| } | |
| // Query is a significant part of title (e.g., "audit" in "Documentation Audit Guide") | |
| else if (titleLower.includes(queryLower)) { | |
| // Boost more if query is a larger portion of the title | |
| const ratio = queryLower.length / titleLower.length; | |
| boost += 5 * ratio + 3; | |
| } | |
| // All query terms appear in title | |
| else if (queryTerms.every(term => titleLower.includes(term))) { | |
| boost += 4; | |
| } | |
| // Some query terms appear in title | |
| else { | |
| const matchingTerms = queryTerms.filter(term => titleLower.includes(term)); | |
| if (matchingTerms.length > 0) { | |
| boost += 2 * (matchingTerms.length / queryTerms.length); | |
| } | |
| } | |
| // Additional boost if title contains query as a distinct word | |
| const titleWords = titleLower.split(/[\s\-_:]+/); | |
| if (titleWords.some(word => word === queryLower || word.startsWith(queryLower))) { | |
| boost += 2; | |
| } | |
| return boost; | |
| } | |
| /** | |
| * Calculate boost for keyword matches | |
| */ | |
| calculateKeywordBoost(doc, queryTerms) { | |
| if (!doc.keywords) return 0; | |
| const keywords = Array.isArray(doc.keywords) | |
| ? doc.keywords.map(k => k.toLowerCase()) | |
| : doc.keywords.toLowerCase().split(/[\s,]+/); | |
| let boost = 0; | |
| queryTerms.forEach(term => { | |
| if (keywords.some(kw => kw === term || kw.startsWith(term))) { | |
| boost += 1.5; | |
| } | |
| }); | |
| return boost; | |
| } | |
| /** | |
| * Calculate boost for description matches | |
| */ | |
| calculateDescriptionBoost(doc, queryTerms) { | |
| if (!doc.description) return 0; | |
| const descLower = doc.description.toLowerCase(); | |
| let boost = 0; | |
| // Check if query terms appear early in description | |
| queryTerms.forEach(term => { | |
| const pos = descLower.indexOf(term); | |
| if (pos !== -1) { | |
| // Boost more if term appears early | |
| boost += pos < 50 ? 1 : 0.5; | |
| } | |
| }); | |
| return boost; | |
| } | |
| /** | |
| * Group results by document and find matching sections | |
| */ | |
| groupResultsByDocument(results, query) { | |
| const grouped = new Map(); | |
| results.forEach(result => { | |
| const docId = result.id; | |
| if (!grouped.has(docId)) { | |
| // Find matching sections within this document | |
| const matchingSections = this.findMatchingSections(result, query); | |
| grouped.set(docId, { | |
| ...result, | |
| matchingSections, | |
| totalMatches: 1, | |
| combinedScore: result.score | |
| }); | |
| } else { | |
| // Document already exists, combine scores and sections | |
| const existing = grouped.get(docId); | |
| const additionalSections = this.findMatchingSections(result, query); | |
| existing.matchingSections = this.mergeSections(existing.matchingSections, additionalSections); | |
| existing.totalMatches += 1; | |
| existing.combinedScore = Math.max(existing.combinedScore, result.score); | |
| } | |
| }); | |
| // Convert map to array and sort by combined score | |
| return Array.from(grouped.values()) | |
| .sort((a, b) => b.combinedScore - a.combinedScore); | |
| } | |
| /** | |
| * Find matching sections within a document | |
| */ | |
| findMatchingSections(result, query) { | |
| const matchingSections = []; | |
| const queryTerms = query.toLowerCase().split(/\s+/); | |
| // Check if title matches | |
| if (result.title) { | |
| const titleText = result.title.toLowerCase(); | |
| const hasMatch = queryTerms.some(term => titleText.includes(term)); | |
| if (hasMatch) { | |
| matchingSections.push({ | |
| type: 'title', | |
| text: result.title, | |
| level: 1, | |
| anchor: '' | |
| }); | |
| } | |
| } | |
| // Check headings for matches | |
| if (result.headings && Array.isArray(result.headings)) { | |
| result.headings.forEach(heading => { | |
| const headingText = heading.text?.toLowerCase() || ''; | |
| const hasMatch = queryTerms.some(term => headingText.includes(term)); | |
| if (hasMatch) { | |
| matchingSections.push({ | |
| type: 'heading', | |
| text: heading.text, | |
| level: heading.level || 2, | |
| anchor: this.generateAnchor(heading.text) | |
| }); | |
| } | |
| }); | |
| } | |
| // If no specific sections found, add a general content match | |
| if (matchingSections.length === 0) { | |
| matchingSections.push({ | |
| type: 'content', | |
| text: 'Content match', | |
| level: 0, | |
| anchor: '' | |
| }); | |
| } | |
| return matchingSections; | |
| } | |
| /** | |
| * Generate anchor link similar to how Sphinx does it | |
| */ | |
| generateAnchor(headingText) { | |
| if (!headingText) return ''; | |
| return headingText | |
| .toLowerCase() | |
| .replace(/[^\w\s-]/g, '') // Remove special chars | |
| .replace(/\s+/g, '-') // Replace spaces with hyphens | |
| .trim(); | |
| } | |
| /** | |
| * Merge sections, avoiding duplicates | |
| */ | |
| mergeSections(existing, additional) { | |
| const merged = [...existing]; | |
| additional.forEach(section => { | |
| const isDuplicate = existing.some(existingSection => | |
| existingSection.text === section.text && | |
| existingSection.type === section.type | |
| ); | |
| if (!isDuplicate) { | |
| merged.push(section); | |
| } | |
| }); | |
| return merged; | |
| } | |
| /** | |
| * Get search statistics | |
| */ | |
| getStatistics() { | |
| // Count facet keys and total values | |
| const facetStats = {}; | |
| Object.entries(this.facets).forEach(([key, valueSet]) => { | |
| facetStats[key] = valueSet.size; | |
| }); | |
| return { | |
| documentsIndexed: Object.keys(this.documents).length, | |
| topicsAvailable: this.topics.size, | |
| tagsAvailable: this.tags.size, | |
| documentTypesAvailable: this.documentTypes.size, | |
| audienceAvailable: this.audience.size, | |
| difficultiesAvailable: this.difficulties.size, | |
| facetsDiscovered: Object.keys(this.facets).length, | |
| facetStats: facetStats, | |
| isInitialized: this.isInitialized | |
| }; | |
| } | |
| /** | |
| * Check if the search engine is ready | |
| */ | |
| isReady() { | |
| return this.isInitialized && this.index !== null; | |
| } | |
| } | |
| // Make SearchEngine available globally | |
| window.SearchEngine = SearchEngine; | |