turash/bugulma/backend/internal/matching/discovery_matcher.go

350 lines
11 KiB
Go

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
}