package matching import ( "math" "strings" "bugulma/backend/internal/domain" "bugulma/backend/internal/geospatial" ) // DiscoveryMatch represents a soft match for products/services (not ResourceFlows) type DiscoveryMatch struct { Product *domain.Product `json:"product,omitempty"` Service *domain.Service `json:"service,omitempty"` CommunityListing *domain.CommunityListing `json:"community_listing,omitempty"` MatchType string `json:"match_type"` // "product", "service", "community" RelevanceScore float64 `json:"relevance_score"` // 0-1 overall relevance TextMatchScore float64 `json:"text_match_score"` // 0-1 text similarity CategoryMatchScore float64 `json:"category_match_score"` // 0-1 category match DistanceScore float64 `json:"distance_score"` // 0-1 distance (closer = higher) PriceMatchScore float64 `json:"price_match_score"` // 0-1 price compatibility AvailabilityScore float64 `json:"availability_score"` // 0-1 availability match DistanceKm float64 `json:"distance_km"` Organization *domain.Organization `json:"organization,omitempty"` Site *domain.Site `json:"site,omitempty"` } // DiscoveryQuery represents a search query for products/services type DiscoveryQuery struct { Query string `json:"query"` // Natural language search Categories []string `json:"categories,omitempty"` // Filter by categories Location *geospatial.Point `json:"location,omitempty"` // Search center point RadiusKm float64 `json:"radius_km"` // Search radius MaxPrice *float64 `json:"max_price,omitempty"` // Maximum price filter MinPrice *float64 `json:"min_price,omitempty"` // Minimum price filter AvailabilityStatus string `json:"availability_status,omitempty"` // Filter by availability Tags []string `json:"tags,omitempty"` // Filter by tags Limit int `json:"limit"` // Max results Offset int `json:"offset"` // Pagination offset } // DiscoveryMatcher handles soft matching for products/services type DiscoveryMatcher struct { geoCalc geospatial.Calculator } // NewDiscoveryMatcher creates a new discovery matcher func NewDiscoveryMatcher() *DiscoveryMatcher { return &DiscoveryMatcher{ geoCalc: geospatial.NewCalculatorWithDefaults(), } } // ScoreProductMatch calculates relevance score for a product match // Formula: soft_match_score = 0.3*text_match + 0.2*category_match + 0.2*distance_score // - 0.15*price_match + 0.15*availability_score func (dm *DiscoveryMatcher) ScoreProductMatch( product *domain.Product, query DiscoveryQuery, org *domain.Organization, site *domain.Site, ) (*DiscoveryMatch, error) { match := &DiscoveryMatch{ Product: product, MatchType: "product", } // 1. Text match (30% weight) textScore := dm.calculateTextMatch( query.Query, product.Name+" "+product.Description+" "+product.SearchKeywords, ) match.TextMatchScore = textScore // 2. Category match (20% weight) categoryScore := dm.calculateCategoryMatch(query.Categories, string(product.Category)) match.CategoryMatchScore = categoryScore // 3. Distance score (20% weight) var distanceScore float64 = 1.0 var distanceKm float64 = 0.0 if query.Location != nil && product.Location.Valid { productPoint := geospatial.Point{ Latitude: product.Location.Latitude, Longitude: product.Location.Longitude, } distanceResult, err := dm.geoCalc.CalculateDistance(*query.Location, productPoint) if err == nil { distanceKm = distanceResult.DistanceKm // Distance score: closer = higher (max 50km) if distanceKm <= query.RadiusKm { distanceScore = 1.0 - (distanceKm / math.Max(query.RadiusKm, 50.0)) distanceScore = math.Max(0, math.Min(1, distanceScore)) } else { distanceScore = 0.0 // Outside radius } } } match.DistanceScore = distanceScore match.DistanceKm = distanceKm // 4. Price match (15% weight) priceScore := dm.calculatePriceMatch( query.MinPrice, query.MaxPrice, product.UnitPrice, ) match.PriceMatchScore = priceScore // 5. Availability score (15% weight) availabilityScore := dm.calculateAvailabilityScore( query.AvailabilityStatus, product.AvailabilityStatus, ) match.AvailabilityScore = availabilityScore // Calculate overall relevance score match.RelevanceScore = 0.3*textScore + 0.2*categoryScore + 0.2*distanceScore + 0.15*priceScore + 0.15*availabilityScore match.Organization = org match.Site = site return match, nil } // ScoreServiceMatch calculates relevance score for a service match func (dm *DiscoveryMatcher) ScoreServiceMatch( service *domain.Service, query DiscoveryQuery, org *domain.Organization, site *domain.Site, ) (*DiscoveryMatch, error) { match := &DiscoveryMatch{ Service: service, MatchType: "service", } // 1. Text match (30% weight) textScore := dm.calculateTextMatch( query.Query, service.Domain+" "+service.Description+" "+service.SearchKeywords, ) match.TextMatchScore = textScore // 2. Category match (20% weight) - using service type and domain categories := []string{string(service.Type), service.Domain} categoryScore := dm.calculateCategoryMatch(query.Categories, categories...) match.CategoryMatchScore = categoryScore // 3. Distance score (20% weight) var distanceScore float64 = 1.0 var distanceKm float64 = 0.0 if query.Location != nil && service.ServiceLocation.Valid { servicePoint := geospatial.Point{ Latitude: service.ServiceLocation.Latitude, Longitude: service.ServiceLocation.Longitude, } distanceResult, err := dm.geoCalc.CalculateDistance(*query.Location, servicePoint) if err == nil { distanceKm = distanceResult.DistanceKm // Check if within service area if distanceKm <= service.ServiceAreaKm && distanceKm <= query.RadiusKm { distanceScore = 1.0 - (distanceKm / math.Max(query.RadiusKm, service.ServiceAreaKm)) distanceScore = math.Max(0, math.Min(1, distanceScore)) } else { distanceScore = 0.0 // Outside service area or search radius } } } match.DistanceScore = distanceScore match.DistanceKm = distanceKm // 4. Price match (15% weight) - using hourly rate var priceScore float64 = 1.0 if query.MaxPrice != nil && service.HourlyRate > 0 { if service.HourlyRate <= *query.MaxPrice { if query.MinPrice != nil { if service.HourlyRate >= *query.MinPrice { priceScore = 1.0 } else { priceScore = 0.0 } } else { priceScore = 1.0 } } else { priceScore = 0.0 } } match.PriceMatchScore = priceScore // 5. Availability score (15% weight) availabilityScore := dm.calculateAvailabilityScore( query.AvailabilityStatus, service.AvailabilityStatus, ) match.AvailabilityScore = availabilityScore // Calculate overall relevance score match.RelevanceScore = 0.3*textScore + 0.2*categoryScore + 0.2*distanceScore + 0.15*priceScore + 0.15*availabilityScore match.Organization = org match.Site = site return match, nil } // ScoreCommunityListingMatch calculates relevance score for a community listing match func (dm *DiscoveryMatcher) ScoreCommunityListingMatch( listing *domain.CommunityListing, query DiscoveryQuery, ) (*DiscoveryMatch, error) { match := &DiscoveryMatch{ CommunityListing: listing, MatchType: "community", } // Similar scoring logic as products textScore := dm.calculateTextMatch( query.Query, listing.Title+" "+listing.Description+" "+listing.SearchKeywords, ) match.TextMatchScore = textScore categoryScore := dm.calculateCategoryMatch(query.Categories, listing.Category) match.CategoryMatchScore = categoryScore var distanceScore float64 = 1.0 var distanceKm float64 = 0.0 if query.Location != nil && listing.Location.Valid { listingPoint := geospatial.Point{ Latitude: listing.Location.Latitude, Longitude: listing.Location.Longitude, } distanceResult, err := dm.geoCalc.CalculateDistance(*query.Location, listingPoint) if err == nil { distanceKm = distanceResult.DistanceKm if distanceKm <= query.RadiusKm { distanceScore = 1.0 - (distanceKm / math.Max(query.RadiusKm, 50.0)) distanceScore = math.Max(0, math.Min(1, distanceScore)) } else { distanceScore = 0.0 } } } match.DistanceScore = distanceScore match.DistanceKm = distanceKm var priceScore float64 = 1.0 if listing.Price != nil { priceScore = dm.calculatePriceMatch(query.MinPrice, query.MaxPrice, *listing.Price) } match.PriceMatchScore = priceScore availabilityScore := dm.calculateAvailabilityScore( query.AvailabilityStatus, listing.AvailabilityStatus, ) match.AvailabilityScore = availabilityScore match.RelevanceScore = 0.3*textScore + 0.2*categoryScore + 0.2*distanceScore + 0.15*priceScore + 0.15*availabilityScore return match, nil } // Helper methods func (dm *DiscoveryMatcher) calculateTextMatch(query, text string) float64 { if query == "" { return 1.0 // No query = match everything } queryLower := strings.ToLower(query) textLower := strings.ToLower(text) // Simple word-based matching queryWords := strings.Fields(queryLower) textWords := strings.Fields(textLower) if len(queryWords) == 0 { return 1.0 } matches := 0 for _, qw := range queryWords { for _, tw := range textWords { if strings.Contains(tw, qw) || strings.Contains(qw, tw) { matches++ break } } } return float64(matches) / float64(len(queryWords)) } func (dm *DiscoveryMatcher) calculateCategoryMatch(queryCategories []string, itemCategories ...string) float64 { if len(queryCategories) == 0 { return 1.0 // No category filter = match everything } for _, qc := range queryCategories { qcLower := strings.ToLower(qc) for _, ic := range itemCategories { icLower := strings.ToLower(ic) if qcLower == icLower || strings.Contains(icLower, qcLower) { return 1.0 // Exact or partial match } } } return 0.0 // No match } func (dm *DiscoveryMatcher) calculatePriceMatch(minPrice, maxPrice *float64, itemPrice float64) float64 { if minPrice == nil && maxPrice == nil { return 1.0 // No price filter } if maxPrice != nil && itemPrice > *maxPrice { return 0.0 // Above max } if minPrice != nil && itemPrice < *minPrice { return 0.0 // Below min } return 1.0 // Within range } func (dm *DiscoveryMatcher) calculateAvailabilityScore(queryStatus, itemStatus string) float64 { if queryStatus == "" { return 1.0 // No filter } if queryStatus == itemStatus { return 1.0 // Exact match } // Partial matches if queryStatus == "available" && (itemStatus == "available" || itemStatus == "limited") { return 0.8 } return 0.0 // No match }