package database import ( "fmt" "regexp" "sort" "strings" "sync" "github.com/lithammer/fuzzysearch/fuzzy" "github.com/strix-project/strix/internal/models" ) // SearchEngine handles intelligent camera searching type SearchEngine struct { loader *Loader logger interface{ Debug(string, ...any); Error(string, error, ...any) } mu sync.RWMutex } // NewSearchEngine creates a new search engine func NewSearchEngine(loader *Loader, logger interface{ Debug(string, ...any); Error(string, error, ...any) }) *SearchEngine { return &SearchEngine{ loader: loader, logger: logger, } } // SearchResult represents a single search result with score type SearchResult struct { Camera *models.Camera Score float64 } // Search performs intelligent camera search func (s *SearchEngine) Search(query string, limit int) (*models.CameraSearchResponse, error) { if limit <= 0 { limit = 10 } // Normalize query normalizedQuery := s.normalizeQuery(query) tokens := s.tokenizeQuery(normalizedQuery) s.logger.Debug("searching cameras", "query", query, "normalized", normalizedQuery, "tokens", tokens) // Extract potential brand and model brandToken, modelTokens := s.extractBrandModel(tokens) // Perform search results, err := s.performSearch(brandToken, modelTokens, normalizedQuery) if err != nil { return nil, fmt.Errorf("search failed: %w", err) } // Sort by score sort.Slice(results, func(i, j int) bool { return results[i].Score > results[j].Score }) // Apply limit if len(results) > limit { results = results[:limit] } // Convert to response cameras := make([]models.Camera, len(results)) for i, result := range results { cameras[i] = *result.Camera cameras[i].MatchScore = result.Score } return &models.CameraSearchResponse{ Cameras: cameras, Total: len(results), Returned: len(cameras), }, nil } // normalizeQuery normalizes the search query func (s *SearchEngine) normalizeQuery(query string) string { // Convert to lowercase normalized := strings.ToLower(query) // Remove multiple spaces normalized = regexp.MustCompile(`\s+`).ReplaceAllString(normalized, " ") // Remove special characters but keep spaces normalized = regexp.MustCompile(`[^a-z0-9\s\-]`).ReplaceAllString(normalized, " ") // Trim spaces normalized = strings.TrimSpace(normalized) return normalized } // tokenizeQuery splits query into tokens func (s *SearchEngine) tokenizeQuery(query string) []string { // Split by spaces and filter empty tokens tokens := strings.Fields(query) var result []string for _, token := range tokens { if token != "" { result = append(result, token) } } return result } // extractBrandModel attempts to extract brand and model from tokens func (s *SearchEngine) extractBrandModel(tokens []string) (string, []string) { if len(tokens) == 0 { return "", nil } // First token is likely the brand brandToken := tokens[0] // Rest are model tokens var modelTokens []string if len(tokens) > 1 { modelTokens = tokens[1:] } return brandToken, modelTokens } // performSearch executes the actual search func (s *SearchEngine) performSearch(brandToken string, modelTokens []string, fullQuery string) ([]SearchResult, error) { var results []SearchResult var mu sync.Mutex var wg sync.WaitGroup // Get all brands brands, err := s.loader.ListBrands() if err != nil { return nil, err } // Search in parallel with limited concurrency sem := make(chan struct{}, 10) // Limit to 10 concurrent searches for _, brandID := range brands { wg.Add(1) go func(brandID string) { defer wg.Done() sem <- struct{}{} defer func() { <-sem }() // Calculate brand match score brandScore := s.calculateBrandScore(brandID, brandToken) // Skip if brand score is too low if brandScore < 0.3 { return } // Load brand data camera, err := s.loader.LoadBrand(brandID) if err != nil { s.logger.Error("failed to load brand", err, "brand", brandID) return } // Calculate model scores for entries maxModelScore := 0.0 for _, entry := range camera.Entries { for _, model := range entry.Models { modelScore := s.calculateModelScore(model, modelTokens, fullQuery) if modelScore > maxModelScore { maxModelScore = modelScore } } } // Calculate final score finalScore := s.calculateFinalScore(brandScore, maxModelScore) // Add to results if score is high enough if finalScore >= 0.3 { mu.Lock() results = append(results, SearchResult{ Camera: camera, Score: finalScore, }) mu.Unlock() } }(brandID) } wg.Wait() return results, nil } // calculateBrandScore calculates how well a brand matches func (s *SearchEngine) calculateBrandScore(brandID, brandToken string) float64 { brandID = strings.ToLower(brandID) brandToken = strings.ToLower(brandToken) // Exact match if brandID == brandToken { return 1.0 } // Remove hyphens for comparison brandIDClean := strings.ReplaceAll(brandID, "-", "") brandTokenClean := strings.ReplaceAll(brandToken, "-", "") if brandIDClean == brandTokenClean { return 0.95 } // Check if brand starts with token if strings.HasPrefix(brandID, brandToken) || strings.HasPrefix(brandIDClean, brandTokenClean) { return 0.85 } // Check if token is contained in brand if strings.Contains(brandID, brandToken) || strings.Contains(brandIDClean, brandTokenClean) { return 0.75 } // Fuzzy match if fuzzy.Match(brandToken, brandID) { return 0.6 } // Calculate similarity similarity := calculateSimilarity(brandID, brandToken) return similarity * 0.5 } // calculateModelScore calculates how well a model matches func (s *SearchEngine) calculateModelScore(model string, modelTokens []string, fullQuery string) float64 { model = strings.ToLower(model) fullQuery = strings.ToLower(fullQuery) // Check if full query matches the model if model == fullQuery { return 1.0 } // Check if model contains all tokens modelNormalized := s.normalizeQuery(model) allTokensFound := true tokenMatchScore := 0.0 for _, token := range modelTokens { if strings.Contains(modelNormalized, token) { tokenMatchScore += 0.2 } else { allTokensFound = false } } if allTokensFound && len(modelTokens) > 0 { return 0.8 + tokenMatchScore/float64(len(modelTokens))*0.2 } // Fuzzy match on full model modelCombined := strings.Join(modelTokens, "") if fuzzy.Match(modelCombined, modelNormalized) { return 0.6 } // Calculate similarity similarity := calculateSimilarity(modelNormalized, strings.Join(modelTokens, " ")) return similarity * 0.5 } // calculateFinalScore combines brand and model scores func (s *SearchEngine) calculateFinalScore(brandScore, modelScore float64) float64 { // If we have both brand and model matches if brandScore > 0 && modelScore > 0 { // Weighted average: brand 30%, model 70% return brandScore*0.3 + modelScore*0.7 } // If only brand matches if brandScore > 0 { return brandScore * 0.5 } // If only model matches return modelScore * 0.5 } // SearchByModel searches for cameras by model name with fuzzy matching func (s *SearchEngine) SearchByModel(modelName string, similarityThreshold float64, limit int) ([]models.Camera, error) { if similarityThreshold <= 0 { similarityThreshold = 0.8 } if limit <= 0 { limit = 6 } normalizedModel := s.normalizeQuery(modelName) var results []SearchResult // Search through all brands cameras, err := s.loader.StreamingSearch(func(camera *models.Camera) bool { maxScore := 0.0 for _, entry := range camera.Entries { for _, model := range entry.Models { normalizedEntryModel := s.normalizeQuery(model) similarity := calculateSimilarity(normalizedModel, normalizedEntryModel) // Also check fuzzy match if fuzzy.Match(normalizedModel, normalizedEntryModel) { if similarity < 0.7 { similarity = 0.7 } } if similarity > maxScore { maxScore = similarity } } } if maxScore >= similarityThreshold { camera.MatchScore = maxScore return true } return false }) if err != nil { return nil, err } // Convert to SearchResult for sorting for _, camera := range cameras { results = append(results, SearchResult{ Camera: camera, Score: camera.MatchScore, }) } // Sort by score sort.Slice(results, func(i, j int) bool { return results[i].Score > results[j].Score }) // Apply limit if len(results) > limit { results = results[:limit] } // Convert back to Camera slice var finalCameras []models.Camera for _, result := range results { finalCameras = append(finalCameras, *result.Camera) } return finalCameras, nil }