Files
honeyDueAPI/internal/services/suggestion_service.go
Trey T 00fd674b56 Remove dead climate region code from suggestion engine
Suggestion engine now purely uses home profile features (heating,
cooling, pool, etc.) for template matching. Climate region field
and matching block removed — ZIP code is no longer collected.
2026-03-30 11:19:04 -05:00

373 lines
10 KiB
Go

package services
import (
"encoding/json"
"sort"
"gorm.io/gorm"
"github.com/treytartt/honeydue-api/internal/apperrors"
"github.com/treytartt/honeydue-api/internal/dto/responses"
"github.com/treytartt/honeydue-api/internal/models"
"github.com/treytartt/honeydue-api/internal/repositories"
)
// SuggestionService handles residence-aware task template suggestions
type SuggestionService struct {
db *gorm.DB
residenceRepo *repositories.ResidenceRepository
}
// NewSuggestionService creates a new suggestion service
func NewSuggestionService(db *gorm.DB, residenceRepo *repositories.ResidenceRepository) *SuggestionService {
return &SuggestionService{
db: db,
residenceRepo: residenceRepo,
}
}
// templateConditions represents the parsed conditions JSON from a task template
type templateConditions struct {
HeatingType *string `json:"heating_type,omitempty"`
CoolingType *string `json:"cooling_type,omitempty"`
WaterHeaterType *string `json:"water_heater_type,omitempty"`
RoofType *string `json:"roof_type,omitempty"`
ExteriorType *string `json:"exterior_type,omitempty"`
FlooringPrimary *string `json:"flooring_primary,omitempty"`
LandscapingType *string `json:"landscaping_type,omitempty"`
HasPool *bool `json:"has_pool,omitempty"`
HasSprinkler *bool `json:"has_sprinkler_system,omitempty"`
HasSeptic *bool `json:"has_septic,omitempty"`
HasFireplace *bool `json:"has_fireplace,omitempty"`
HasGarage *bool `json:"has_garage,omitempty"`
HasBasement *bool `json:"has_basement,omitempty"`
HasAttic *bool `json:"has_attic,omitempty"`
PropertyType *string `json:"property_type,omitempty"`
}
// isEmpty returns true if no conditions are set
func (c *templateConditions) isEmpty() bool {
return c.HeatingType == nil && c.CoolingType == nil && c.WaterHeaterType == nil &&
c.RoofType == nil && c.ExteriorType == nil && c.FlooringPrimary == nil &&
c.LandscapingType == nil && c.HasPool == nil && c.HasSprinkler == nil &&
c.HasSeptic == nil && c.HasFireplace == nil && c.HasGarage == nil &&
c.HasBasement == nil && c.HasAttic == nil &&
c.PropertyType == nil
}
const (
maxSuggestions = 30
baseUniversalScore = 0.3
stringMatchBonus = 0.25
boolMatchBonus = 0.3
// climateRegionBonus removed — suggestions now based on home features only
propertyTypeBonus = 0.15
totalProfileFields = 14
)
// GetSuggestions returns task template suggestions scored against a residence's profile
func (s *SuggestionService) GetSuggestions(residenceID uint, userID uint) (*responses.TaskSuggestionsResponse, error) {
// Check access
hasAccess, err := s.residenceRepo.HasAccess(residenceID, userID)
if err != nil {
return nil, apperrors.Internal(err)
}
if !hasAccess {
return nil, apperrors.Forbidden("error.residence_access_denied")
}
// Load residence
residence, err := s.residenceRepo.FindByID(residenceID)
if err != nil {
return nil, apperrors.Internal(err)
}
// Load all active templates
var templates []models.TaskTemplate
if err := s.db.
Preload("Category").
Preload("Frequency").
Preload("Regions").
Where("is_active = ?", true).
Find(&templates).Error; err != nil {
return nil, apperrors.Internal(err)
}
// Score each template
suggestions := make([]responses.TaskSuggestionResponse, 0, len(templates))
for i := range templates {
score, reasons, include := s.scoreTemplate(&templates[i], residence)
if !include {
continue
}
suggestions = append(suggestions, responses.TaskSuggestionResponse{
Template: responses.NewTaskTemplateResponse(&templates[i]),
RelevanceScore: score,
MatchReasons: reasons,
})
}
// Sort by score descending
sort.Slice(suggestions, func(i, j int) bool {
return suggestions[i].RelevanceScore > suggestions[j].RelevanceScore
})
// Cap at maxSuggestions
if len(suggestions) > maxSuggestions {
suggestions = suggestions[:maxSuggestions]
}
completeness := CalculateProfileCompleteness(residence)
return &responses.TaskSuggestionsResponse{
Suggestions: suggestions,
TotalCount: len(suggestions),
ProfileCompleteness: completeness,
}, nil
}
// scoreTemplate scores a template against a residence profile.
// Returns (score, matchReasons, shouldInclude).
func (s *SuggestionService) scoreTemplate(tmpl *models.TaskTemplate, residence *models.Residence) (float64, []string, bool) {
cond := &templateConditions{}
// Parse conditions JSON
if len(tmpl.Conditions) > 0 && string(tmpl.Conditions) != "{}" && string(tmpl.Conditions) != "null" {
if err := json.Unmarshal(tmpl.Conditions, cond); err != nil {
// Malformed conditions - treat as universal
cond = &templateConditions{}
}
}
// Universal template (no conditions): base score
if cond.isEmpty() {
return baseUniversalScore, []string{"universal"}, true
}
score := 0.0
reasons := make([]string, 0)
conditionCount := 0
// String field matches
if cond.HeatingType != nil {
conditionCount++
if residence.HeatingType == nil {
// Field not set - ignore
} else if *residence.HeatingType == *cond.HeatingType {
score += stringMatchBonus
reasons = append(reasons, "heating_type:"+*cond.HeatingType)
} else {
// Mismatch - don't exclude, just don't reward
}
}
if cond.CoolingType != nil {
conditionCount++
if residence.CoolingType == nil {
// ignore
} else if *residence.CoolingType == *cond.CoolingType {
score += stringMatchBonus
reasons = append(reasons, "cooling_type:"+*cond.CoolingType)
}
}
if cond.WaterHeaterType != nil {
conditionCount++
if residence.WaterHeaterType == nil {
// ignore
} else if *residence.WaterHeaterType == *cond.WaterHeaterType {
score += stringMatchBonus
reasons = append(reasons, "water_heater_type:"+*cond.WaterHeaterType)
}
}
if cond.RoofType != nil {
conditionCount++
if residence.RoofType == nil {
// ignore
} else if *residence.RoofType == *cond.RoofType {
score += stringMatchBonus
reasons = append(reasons, "roof_type:"+*cond.RoofType)
}
}
if cond.ExteriorType != nil {
conditionCount++
if residence.ExteriorType == nil {
// ignore
} else if *residence.ExteriorType == *cond.ExteriorType {
score += stringMatchBonus
reasons = append(reasons, "exterior_type:"+*cond.ExteriorType)
}
}
if cond.FlooringPrimary != nil {
conditionCount++
if residence.FlooringPrimary == nil {
// ignore
} else if *residence.FlooringPrimary == *cond.FlooringPrimary {
score += stringMatchBonus
reasons = append(reasons, "flooring_primary:"+*cond.FlooringPrimary)
}
}
if cond.LandscapingType != nil {
conditionCount++
if residence.LandscapingType == nil {
// ignore
} else if *residence.LandscapingType == *cond.LandscapingType {
score += stringMatchBonus
reasons = append(reasons, "landscaping_type:"+*cond.LandscapingType)
}
}
// Bool field matches - exclude if requires true but residence has false
if cond.HasPool != nil {
conditionCount++
if *cond.HasPool && !residence.HasPool {
return 0, nil, false // EXCLUDE
}
if *cond.HasPool && residence.HasPool {
score += boolMatchBonus
reasons = append(reasons, "has_pool")
}
}
if cond.HasSprinkler != nil {
conditionCount++
if *cond.HasSprinkler && !residence.HasSprinklerSystem {
return 0, nil, false
}
if *cond.HasSprinkler && residence.HasSprinklerSystem {
score += boolMatchBonus
reasons = append(reasons, "has_sprinkler_system")
}
}
if cond.HasSeptic != nil {
conditionCount++
if *cond.HasSeptic && !residence.HasSeptic {
return 0, nil, false
}
if *cond.HasSeptic && residence.HasSeptic {
score += boolMatchBonus
reasons = append(reasons, "has_septic")
}
}
if cond.HasFireplace != nil {
conditionCount++
if *cond.HasFireplace && !residence.HasFireplace {
return 0, nil, false
}
if *cond.HasFireplace && residence.HasFireplace {
score += boolMatchBonus
reasons = append(reasons, "has_fireplace")
}
}
if cond.HasGarage != nil {
conditionCount++
if *cond.HasGarage && !residence.HasGarage {
return 0, nil, false
}
if *cond.HasGarage && residence.HasGarage {
score += boolMatchBonus
reasons = append(reasons, "has_garage")
}
}
if cond.HasBasement != nil {
conditionCount++
if *cond.HasBasement && !residence.HasBasement {
return 0, nil, false
}
if *cond.HasBasement && residence.HasBasement {
score += boolMatchBonus
reasons = append(reasons, "has_basement")
}
}
if cond.HasAttic != nil {
conditionCount++
if *cond.HasAttic && !residence.HasAttic {
return 0, nil, false
}
if *cond.HasAttic && residence.HasAttic {
score += boolMatchBonus
reasons = append(reasons, "has_attic")
}
}
// Property type match
if cond.PropertyType != nil {
conditionCount++
if residence.PropertyType != nil && residence.PropertyType.Name == *cond.PropertyType {
score += propertyTypeBonus
reasons = append(reasons, "property_type:"+*cond.PropertyType)
}
}
// Cap at 1.0
if score > 1.0 {
score = 1.0
}
// If template has conditions but no matches and no reasons, still include with low score
if conditionCount > 0 && len(reasons) == 0 {
return baseUniversalScore * 0.5, []string{"partial_profile"}, true
}
return score, reasons, true
}
// CalculateProfileCompleteness returns how many of the 14 home profile fields are filled
func CalculateProfileCompleteness(residence *models.Residence) float64 {
filled := 0
if residence.HeatingType != nil {
filled++
}
if residence.CoolingType != nil {
filled++
}
if residence.WaterHeaterType != nil {
filled++
}
if residence.RoofType != nil {
filled++
}
if residence.HasPool {
filled++
}
if residence.HasSprinklerSystem {
filled++
}
if residence.HasSeptic {
filled++
}
if residence.HasFireplace {
filled++
}
if residence.HasGarage {
filled++
}
if residence.HasBasement {
filled++
}
if residence.HasAttic {
filled++
}
if residence.ExteriorType != nil {
filled++
}
if residence.FlooringPrimary != nil {
filled++
}
if residence.LandscapingType != nil {
filled++
}
return float64(filled) / float64(totalProfileFields)
}