86a8fb36d5
- Fix critical scanner.go bug: ineffective break in select (SA4011) Use labeled break to properly exit loop on context cancellation - Add error checking for all file.Close() and resp.Body.Close() Prevent resource leaks in loader, onvif_simple, and tester - Add error checking for fmt.Sscanf() calls in tester.go Prevent silent parse failures for FPS and bitrate extraction - Add error checking for all SSE streamWriter calls Explicit ignore with _ = for SendJSON and SendError - Remove unused sync.RWMutex field from SearchEngine - Refactor if/else to switch for CodecType (staticcheck QF1003) More idiomatic Go code in stream tester All 20 linter issues resolved. Code compiles and runs correctly.
408 lines
9.9 KiB
Go
408 lines
9.9 KiB
Go
package database
|
|
|
|
import (
|
|
"fmt"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
|
|
"github.com/lithammer/fuzzysearch/fuzzy"
|
|
"github.com/eduard256/Strix/internal/models"
|
|
)
|
|
|
|
// SearchEngine handles intelligent camera searching
|
|
type SearchEngine struct {
|
|
loader *Loader
|
|
logger interface{ Debug(string, ...any); Error(string, error, ...any) }
|
|
}
|
|
|
|
// 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
|
|
})
|
|
|
|
// Expand each camera into individual model entries with model-specific scores
|
|
type ModelResult struct {
|
|
Camera models.Camera
|
|
Score float64
|
|
}
|
|
var modelResults []ModelResult
|
|
|
|
for _, result := range results {
|
|
camera := result.Camera
|
|
|
|
// Collect unique models with their scores
|
|
modelScores := make(map[string]float64)
|
|
for _, entry := range camera.Entries {
|
|
for _, model := range entry.Models {
|
|
if model != "" && model != "Other" {
|
|
// Calculate model-specific score
|
|
modelScore := s.calculateModelScore(model, modelTokens, normalizedQuery)
|
|
if modelScore > modelScores[model] {
|
|
modelScores[model] = modelScore
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Create a separate camera entry for each unique model
|
|
for model, modelScore := range modelScores {
|
|
// Combine brand score with model score
|
|
finalScore := result.Score*0.3 + modelScore*0.7
|
|
|
|
expandedCamera := models.Camera{
|
|
Brand: camera.Brand,
|
|
BrandID: camera.BrandID,
|
|
Model: model,
|
|
LastUpdated: camera.LastUpdated,
|
|
Source: camera.Source,
|
|
Website: camera.Website,
|
|
Entries: camera.Entries,
|
|
MatchScore: finalScore,
|
|
}
|
|
modelResults = append(modelResults, ModelResult{
|
|
Camera: expandedCamera,
|
|
Score: finalScore,
|
|
})
|
|
}
|
|
}
|
|
|
|
// Sort by final score (best matches first)
|
|
sort.Slice(modelResults, func(i, j int) bool {
|
|
return modelResults[i].Score > modelResults[j].Score
|
|
})
|
|
|
|
// Apply limit
|
|
if len(modelResults) > limit {
|
|
modelResults = modelResults[:limit]
|
|
}
|
|
|
|
// Convert to camera slice
|
|
cameras := make([]models.Camera, len(modelResults))
|
|
for i, result := range modelResults {
|
|
cameras[i] = result.Camera
|
|
}
|
|
|
|
return &models.CameraSearchResponse{
|
|
Cameras: cameras,
|
|
Total: len(cameras),
|
|
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
|
|
} |