File size: 28,493 Bytes
0722e92
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
/**
 * 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;