mirror of
https://github.com/SamyRai/turash.git
synced 2025-12-26 23:01:33 +00:00
350 lines
11 KiB
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
|
|
}
|