feat(search): implement fuzzy matching on history title and URL
- commit
- 6990cc0a84ffe5a1246958b44346fff10337930b
- author
- brice · · 2025-12-16 18:24 UTC
- parent
- 83d87fb
- signature
- unsigned
1 file · +47 -3
M internal/application/usecase/search_history.go +47 -3
| @@ -8,6 +8,7 @@ import ( | |||
| 8 | 8 | "github.com/bnema/dumber/internal/domain/entity" | |
| 9 | 9 | "github.com/bnema/dumber/internal/domain/repository" | |
| 10 | 10 | "github.com/bnema/dumber/internal/logging" | |
| 11 | + | "github.com/sahilm/fuzzy" | |
| 11 | 12 | ) | |
| 12 | 13 | ||
| 13 | 14 | // SearchHistoryUseCase handles history search and retrieval operations. | |
| @@ -34,7 +35,10 @@ type SearchOutput struct { | |||
| 34 | 35 | } | |
| 35 | 36 | ||
| 36 | 37 | // Search performs a fuzzy search on history entries. | |
| 38 | + | // Uses in-memory fuzzy matching on title + URL for token-based matching in any order. | |
| 37 | 39 | func (uc *SearchHistoryUseCase) Search(ctx context.Context, input SearchInput) (*SearchOutput, error) { | |
| 40 | + | log := logging.FromContext(ctx) | |
| 41 | + | ||
| 38 | 42 | if input.Query == "" { | |
| 39 | 43 | return &SearchOutput{Matches: []entity.HistoryMatch{}}, nil | |
| 40 | 44 | } | |
| @@ -44,12 +48,52 @@ func (uc *SearchHistoryUseCase) Search(ctx context.Context, input SearchInput) ( | |||
| 44 | 48 | limit = 20 // Default limit | |
| 45 | 49 | } | |
| 46 | 50 | ||
| 47 | - | matches, err := uc.historyRepo.Search(ctx, input.Query, limit) | |
| 51 | + | // Fetch more candidates for fuzzy filtering (10x the limit) | |
| 52 | + | candidateLimit := limit * 10 | |
| 53 | + | if candidateLimit < 100 { | |
| 54 | + | candidateLimit = 100 | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | candidates, err := uc.historyRepo.GetRecent(ctx, candidateLimit, 0) | |
| 48 | 58 | if err != nil { | |
| 49 | - | return nil, fmt.Errorf("failed to search history: %w", err) | |
| 59 | + | return nil, fmt.Errorf("failed to get history candidates: %w", err) | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | if len(candidates) == 0 { | |
| 63 | + | return &SearchOutput{Matches: []entity.HistoryMatch{}}, nil | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | // Build searchable strings: "title url" for each entry | |
| 67 | + | searchStrings := make([]string, len(candidates)) | |
| 68 | + | for i, e := range candidates { | |
| 69 | + | searchStrings[i] = e.Title + " " + e.URL | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | // Fuzzy match | |
| 73 | + | fuzzyMatches := fuzzy.Find(input.Query, searchStrings) | |
| 74 | + | ||
| 75 | + | log.Debug(). | |
| 76 | + | Str("query", input.Query). | |
| 77 | + | Int("candidates", len(candidates)). | |
| 78 | + | Int("matches", len(fuzzyMatches)). | |
| 79 | + | Msg("fuzzy search completed") | |
| 80 | + | ||
| 81 | + | // Convert to HistoryMatch with scores, limited to requested limit | |
| 82 | + | resultLimit := limit | |
| 83 | + | if len(fuzzyMatches) < resultLimit { | |
| 84 | + | resultLimit = len(fuzzyMatches) | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | results := make([]entity.HistoryMatch, 0, resultLimit) | |
| 88 | + | for i := 0; i < resultLimit; i++ { | |
| 89 | + | m := fuzzyMatches[i] | |
| 90 | + | results = append(results, entity.HistoryMatch{ | |
| 91 | + | Entry: candidates[m.Index], | |
| 92 | + | Score: float64(m.Score), | |
| 93 | + | }) | |
| 50 | 94 | } | |
| 51 | 95 | ||
| 52 | - | return &SearchOutput{Matches: matches}, nil | |
| 96 | + | return &SearchOutput{Matches: results}, nil | |
| 53 | 97 | } | |
| 54 | 98 | ||
| 55 | 99 | // GetRecent retrieves recent history entries with pagination. | |
| @@ -8,6 +8,7 @@ import ( | |||||
| 8 | "github.com/bnema/dumber/internal/domain/entity" | 8 | "github.com/bnema/dumber/internal/domain/entity" | ||
| 9 | "github.com/bnema/dumber/internal/domain/repository" | 9 | "github.com/bnema/dumber/internal/domain/repository" | ||
| 10 | "github.com/bnema/dumber/internal/logging" | 10 | "github.com/bnema/dumber/internal/logging" | ||
| 11 | + | "github.com/sahilm/fuzzy" | |||
| 11 | ) | 12 | ) | ||
| 12 | 13 | ||||
| 13 | // SearchHistoryUseCase handles history search and retrieval operations. | 14 | // SearchHistoryUseCase handles history search and retrieval operations. | ||
| @@ -34,7 +35,10 @@ type SearchOutput struct { | |||||
| 34 | } | 35 | } | ||
| 35 | 36 | ||||
| 36 | // Search performs a fuzzy search on history entries. | 37 | // Search performs a fuzzy search on history entries. | ||
| 38 | + | // Uses in-memory fuzzy matching on title + URL for token-based matching in any order. | |||
| 37 | func (uc *SearchHistoryUseCase) Search(ctx context.Context, input SearchInput) (*SearchOutput, error) { | 39 | func (uc *SearchHistoryUseCase) Search(ctx context.Context, input SearchInput) (*SearchOutput, error) { | ||
| 40 | + | log := logging.FromContext(ctx) | |||
| 41 | + | ||||
| 38 | if input.Query == "" { | 42 | if input.Query == "" { | ||
| 39 | return &SearchOutput{Matches: []entity.HistoryMatch{}}, nil | 43 | return &SearchOutput{Matches: []entity.HistoryMatch{}}, nil | ||
| 40 | } | 44 | } | ||
| @@ -44,12 +48,52 @@ func (uc *SearchHistoryUseCase) Search(ctx context.Context, input SearchInput) ( | |||||
| 44 | limit = 20 // Default limit | 48 | limit = 20 // Default limit | ||
| 45 | } | 49 | } | ||
| 46 | 50 | ||||
| 47 | - | matches, err := uc.historyRepo.Search(ctx, input.Query, limit) | 51 | + | // Fetch more candidates for fuzzy filtering (10x the limit) |
| 52 | + | candidateLimit := limit * 10 | |||
| 53 | + | if candidateLimit < 100 { | |||
| 54 | + | candidateLimit = 100 | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | candidates, err := uc.historyRepo.GetRecent(ctx, candidateLimit, 0) | |||
| 48 | if err != nil { | 58 | if err != nil { | ||
| 49 | - | return nil, fmt.Errorf("failed to search history: %w", err) | 59 | + | return nil, fmt.Errorf("failed to get history candidates: %w", err) |
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | if len(candidates) == 0 { | |||
| 63 | + | return &SearchOutput{Matches: []entity.HistoryMatch{}}, nil | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | // Build searchable strings: "title url" for each entry | |||
| 67 | + | searchStrings := make([]string, len(candidates)) | |||
| 68 | + | for i, e := range candidates { | |||
| 69 | + | searchStrings[i] = e.Title + " " + e.URL | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | // Fuzzy match | |||
| 73 | + | fuzzyMatches := fuzzy.Find(input.Query, searchStrings) | |||
| 74 | + | ||||
| 75 | + | log.Debug(). | |||
| 76 | + | Str("query", input.Query). | |||
| 77 | + | Int("candidates", len(candidates)). | |||
| 78 | + | Int("matches", len(fuzzyMatches)). | |||
| 79 | + | Msg("fuzzy search completed") | |||
| 80 | + | ||||
| 81 | + | // Convert to HistoryMatch with scores, limited to requested limit | |||
| 82 | + | resultLimit := limit | |||
| 83 | + | if len(fuzzyMatches) < resultLimit { | |||
| 84 | + | resultLimit = len(fuzzyMatches) | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | results := make([]entity.HistoryMatch, 0, resultLimit) | |||
| 88 | + | for i := 0; i < resultLimit; i++ { | |||
| 89 | + | m := fuzzyMatches[i] | |||
| 90 | + | results = append(results, entity.HistoryMatch{ | |||
| 91 | + | Entry: candidates[m.Index], | |||
| 92 | + | Score: float64(m.Score), | |||
| 93 | + | }) | |||
| 50 | } | 94 | } | ||
| 51 | 95 | ||||
| 52 | - | return &SearchOutput{Matches: matches}, nil | 96 | + | return &SearchOutput{Matches: results}, nil |
| 53 | } | 97 | } | ||
| 54 | 98 | ||||
| 55 | // GetRecent retrieves recent history entries with pagination. | 99 | // GetRecent retrieves recent history entries with pagination. | ||