From d930514bea66dde0cbe35e5eec7dd463614823a1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Jan 2026 20:01:41 +0000 Subject: [PATCH] fix: incorporate Fuse search scores into template sorting When searching templates, the Fuse.js relevance scores were being discarded when any sort option other than 'default' was selected. This caused templates with better search matches to be ranked lower than templates with higher usage/popularity but worse search relevance. Changes: - Store Fuse search scores in a Map for use during sorting - For 'recommended' sort with active search: weight search relevance at 60% and base recommendation score at 40% - For 'popular' sort with active search: weight both equally at 50% - For VRAM/size sorts: use search relevance as tiebreaker - 'default' sort preserves Fuse's original relevance order --- src/composables/useTemplateFiltering.test.ts | 104 +++++++++++++++++++ src/composables/useTemplateFiltering.ts | 94 ++++++++++++++--- 2 files changed, 185 insertions(+), 13 deletions(-) diff --git a/src/composables/useTemplateFiltering.test.ts b/src/composables/useTemplateFiltering.test.ts index 5f30e5ec1..dffcb2a0a 100644 --- a/src/composables/useTemplateFiltering.test.ts +++ b/src/composables/useTemplateFiltering.test.ts @@ -272,4 +272,108 @@ describe('useTemplateFiltering', () => { 'beta-pro' ]) }) + + it('incorporates search relevance into recommended sorting', async () => { + vi.useFakeTimers() + + const templates = ref([ + { + name: 'wan-video-exact', + title: 'Wan Video Template', + description: 'A template with Wan in title', + mediaType: 'image', + mediaSubtype: 'png', + date: '2024-01-01', + usage: 10 + }, + { + name: 'qwen-image-partial', + title: 'Qwen Image Editor', + description: 'A template that contains w, a, n scattered', + mediaType: 'image', + mediaSubtype: 'png', + date: '2024-01-01', + usage: 1000 // Higher usage but worse search match + }, + { + name: 'wan-text-exact', + title: 'Wan2.5: Text to Image', + description: 'Another exact match for Wan', + mediaType: 'image', + mediaSubtype: 'png', + date: '2024-01-01', + usage: 50 + } + ]) + + const { searchQuery, sortBy, filteredTemplates } = + useTemplateFiltering(templates) + + // Search for "Wan" + searchQuery.value = 'Wan' + sortBy.value = 'recommended' + await nextTick() + await vi.runOnlyPendingTimersAsync() + await nextTick() + + // Templates with "Wan" in title should rank higher than Qwen despite lower usage + // because search relevance is now factored into the recommended sort + const results = filteredTemplates.value.map((t) => t.name) + + // Verify exact matches appear (Qwen might be filtered out by threshold) + expect(results).toContain('wan-video-exact') + expect(results).toContain('wan-text-exact') + + // If Qwen appears, it should be ranked lower than exact matches + if (results.includes('qwen-image-partial')) { + const wanIndex = results.indexOf('wan-video-exact') + const qwenIndex = results.indexOf('qwen-image-partial') + expect(wanIndex).toBeLessThan(qwenIndex) + } + + vi.useRealTimers() + }) + + it('preserves Fuse search order when using default sort', async () => { + vi.useFakeTimers() + + const templates = ref([ + { + name: 'portrait-basic', + title: 'Basic Portrait', + description: 'A basic template', + mediaType: 'image', + mediaSubtype: 'png' + }, + { + name: 'portrait-pro', + title: 'Portrait Pro Edition', + description: 'Advanced portrait features', + mediaType: 'image', + mediaSubtype: 'png' + }, + { + name: 'landscape-view', + title: 'Landscape Generator', + description: 'Generate landscapes', + mediaType: 'image', + mediaSubtype: 'png' + } + ]) + + const { searchQuery, sortBy, filteredTemplates } = + useTemplateFiltering(templates) + + searchQuery.value = 'Portrait Pro' + sortBy.value = 'default' + await nextTick() + await vi.runOnlyPendingTimersAsync() + await nextTick() + + const results = filteredTemplates.value.map((t) => t.name) + + // With default sort, Fuse's relevance ordering is preserved + // "Portrait Pro Edition" should be first as it's the best match + expect(results[0]).toBe('portrait-pro') + }) }) diff --git a/src/composables/useTemplateFiltering.ts b/src/composables/useTemplateFiltering.ts index fdb892e07..f6c8d7857 100644 --- a/src/composables/useTemplateFiltering.ts +++ b/src/composables/useTemplateFiltering.ts @@ -82,13 +82,31 @@ export function useTemplateFiltering( const debouncedSearchQuery = refDebounced(searchQuery, 50) - const filteredBySearch = computed(() => { + // Store Fuse search results with scores for use in sorting + const fuseSearchResults = computed(() => { if (!debouncedSearchQuery.value.trim()) { + return null + } + return fuse.value.search(debouncedSearchQuery.value) + }) + + // Map of template name to search score (lower is better in Fuse, 0 = perfect match) + const searchScoreMap = computed(() => { + const map = new Map() + if (fuseSearchResults.value) { + fuseSearchResults.value.forEach((result) => { + // Store the score (0 = perfect match, 1 = worst match) + map.set(result.item.name, result.score ?? 1) + }) + } + return map + }) + + const filteredBySearch = computed(() => { + if (!fuseSearchResults.value) { return templatesArray.value } - - const results = fuse.value.search(debouncedSearchQuery.value) - return results.map((result) => result.item) + return fuseSearchResults.value.map((result) => result.item) }) const filteredByModels = computed(() => { @@ -165,31 +183,66 @@ export function useTemplateFiltering( { immediate: true } ) + // Helper to get search relevance score (higher is better, 0-1 range) + // Fuse returns scores where 0 = perfect match, 1 = worst match + // We invert it so higher = better for combining with other scores + const getSearchRelevance = (template: TemplateInfo): number => { + const fuseScore = searchScoreMap.value.get(template.name) + if (fuseScore === undefined) return 0 // Not in search results or no search + return 1 - fuseScore // Invert: 0 (worst) -> 1 (best) + } + + const hasActiveSearch = computed( + () => debouncedSearchQuery.value.trim() !== '' + ) + const sortedTemplates = computed(() => { const templates = [...filteredByRunsOn.value] switch (sortBy.value) { case 'recommended': - // Curated: usage × 0.5 + internal × 0.3 + freshness × 0.2 + // When searching, heavily weight search relevance + // Formula with search: searchRelevance × 0.6 + (usage × 0.5 + internal × 0.3 + freshness × 0.2) × 0.4 + // Formula without search: usage × 0.5 + internal × 0.3 + freshness × 0.2 return templates.sort((a, b) => { - const scoreA = rankingStore.computeDefaultScore( + const baseScoreA = rankingStore.computeDefaultScore( a.date, a.searchRank, a.usage ) - const scoreB = rankingStore.computeDefaultScore( + const baseScoreB = rankingStore.computeDefaultScore( b.date, b.searchRank, b.usage ) - return scoreB - scoreA + + if (hasActiveSearch.value) { + const searchA = getSearchRelevance(a) + const searchB = getSearchRelevance(b) + const finalA = searchA * 0.6 + baseScoreA * 0.4 + const finalB = searchB * 0.6 + baseScoreB * 0.4 + return finalB - finalA + } + + return baseScoreB - baseScoreA }) case 'popular': - // User-driven: usage × 0.9 + freshness × 0.1 + // When searching, include search relevance + // Formula with search: searchRelevance × 0.5 + (usage × 0.9 + freshness × 0.1) × 0.5 + // Formula without search: usage × 0.9 + freshness × 0.1 return templates.sort((a, b) => { - const scoreA = rankingStore.computePopularScore(a.date, a.usage) - const scoreB = rankingStore.computePopularScore(b.date, b.usage) - return scoreB - scoreA + const baseScoreA = rankingStore.computePopularScore(a.date, a.usage) + const baseScoreB = rankingStore.computePopularScore(b.date, b.usage) + + if (hasActiveSearch.value) { + const searchA = getSearchRelevance(a) + const searchB = getSearchRelevance(b) + const finalA = searchA * 0.5 + baseScoreA * 0.5 + const finalB = searchB * 0.5 + baseScoreB * 0.5 + return finalB - finalA + } + + return baseScoreB - baseScoreA }) case 'alphabetical': return templates.sort((a, b) => { @@ -209,6 +262,12 @@ export function useTemplateFiltering( const vramB = getVramMetric(b) if (vramA === vramB) { + // Use search relevance as tiebreaker when searching + if (hasActiveSearch.value) { + const searchA = getSearchRelevance(a) + const searchB = getSearchRelevance(b) + if (searchA !== searchB) return searchB - searchA + } const nameA = a.title || a.name || '' const nameB = b.title || b.name || '' return nameA.localeCompare(nameB) @@ -225,11 +284,20 @@ export function useTemplateFiltering( typeof a.size === 'number' ? a.size : Number.POSITIVE_INFINITY const sizeB = typeof b.size === 'number' ? b.size : Number.POSITIVE_INFINITY - if (sizeA === sizeB) return 0 + if (sizeA === sizeB) { + // Use search relevance as tiebreaker when searching + if (hasActiveSearch.value) { + const searchA = getSearchRelevance(a) + const searchB = getSearchRelevance(b) + if (searchA !== searchB) return searchB - searchA + } + return 0 + } return sizeA - sizeB }) case 'default': default: + // 'default' preserves Fuse's search order (already sorted by relevance) return templates } })