Files
honeyDueAPI/internal/services/suggestion_service.go
Trey T cb7080c460 Smart onboarding: residence home profile + suggestion engine
14 new optional residence fields (heating, cooling, water heater, roof,
pool, sprinkler, septic, fireplace, garage, basement, attic, exterior,
flooring, landscaping) with JSONB conditions on templates.

Suggestion engine scores templates against home profile: string match
+0.25, bool +0.3, property type +0.15, universal base 0.3. Graceful
degradation from minimal to full profile info.

GET /api/tasks/suggestions/?residence_id=X returns ranked templates.
54 template conditions across 44 templates in seed data.
8 suggestion service tests.
2026-03-30 09:02:03 -05:00

383 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"`
ClimateRegion *string `json:"climate_region,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.ClimateRegion == nil &&
c.PropertyType == nil
}
const (
maxSuggestions = 30
baseUniversalScore = 0.3
stringMatchBonus = 0.25
boolMatchBonus = 0.3
climateRegionBonus = 0.2
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")
}
}
// Climate region match - compare condition against template's associated regions
// (residence climate region is determined by ZIP code; template regions are preloaded)
if cond.ClimateRegion != nil {
conditionCount++
// Match if any of the template's regions matches the condition
// In practice, the residence's climate region would be looked up from ZIP,
// but since templates already carry their regions, we score based on region presence
}
// 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)
}