@@ -13,7 +13,15 @@ class CocktailRecommendationService
1313{
1414 private const TAG_MATCH_WEIGHT = 0.8 ;
1515 private const INGREDIENT_MATCH_WEIGHT = 0.5 ;
16- // private const FAVORITES_MATCH_WEIGHT = 0.2; // TODO
16+ private const BAR_SHELF_INGREDIENT_MATCH_WEIGHT = 0.7 ;
17+ private const BAR_SHELF_COMPLETE_MATCH_WEIGHT = 1 ;
18+ private const RECENCY_BOOST_WEIGHT = 0.3 ;
19+
20+ private const NEGATIVE_RATING_PENALTY = -0.5 ;
21+
22+ public function __construct (private readonly CocktailService $ cocktailService )
23+ {
24+ }
1725
1826 /**
1927 * @return \Illuminate\Support\Collection<array-key, Cocktail>
@@ -22,60 +30,127 @@ public function recommend(BarMembership $barMembership, int $limit = 10): Collec
2230 {
2331 $ barMembership ->loadMissing ('cocktailFavorites ' );
2432
25- // We dont want to list cocktails that user has already favorited or rated
26- $ memberFavoriteCocktailIds = $ barMembership ->cocktailFavorites ->pluck ('cocktail_id ' )->toArray ();
27- $ memberRatedCocktailIds = DB ::table ('ratings ' )
28- ->select ('rateable_id ' )
29- ->where ('rateable_type ' , Cocktail::class)
30- ->where ('user_id ' , $ barMembership ->user_id )
31- ->pluck ('rateable_id ' );
33+ $ excludedCocktailIds = $ this ->getExcludedCocktails ($ barMembership );
3234
3335 // Collect all favorite tags
34- $ memberFavoriteTags = DB ::table ('tags ' )
35- ->selectRaw ('tags.id, COUNT(cocktail_favorites.cocktail_id) * ? AS weight ' , [self ::TAG_MATCH_WEIGHT ])
36- ->join ('cocktail_tag ' , 'cocktail_tag.tag_id ' , '= ' , 'tags.id ' )
37- ->join ('cocktail_favorites ' , 'cocktail_favorites.cocktail_id ' , '= ' , 'cocktail_tag.cocktail_id ' )
38- ->where ('cocktail_favorites.bar_membership_id ' , $ barMembership ->id )
39- ->groupBy ('tags.id ' )
40- ->get ();
36+ $ memberFavoriteTags = $ this ->cocktailService ->getMemberFavoriteCocktailTags ($ barMembership ->id , null );
37+ $ memberFavoriteTags ->map (function ($ tag ) {
38+ $ tag ->weight = $ tag ->cocktails_count * self ::TAG_MATCH_WEIGHT ;
39+
40+ return $ tag ;
41+ });
42+
43+ // Collect negative tags from low-rated cocktails
44+ $ negativeTags = $ this ->getNegativeTags ($ barMembership );
4145
4246 // Collect all favorite ingredients
4347 $ memberFavoriteIngredients = DB ::table ('cocktail_ingredients ' )
4448 ->selectRaw ('ingredients.id, COUNT(cocktail_id) * ? AS weight ' , [self ::INGREDIENT_MATCH_WEIGHT ])
45- ->whereIn ('cocktail_id ' , $ memberFavoriteCocktailIds )
49+ ->whereIn ('cocktail_id ' , $ excludedCocktailIds )
4650 ->where ('ingredients.bar_id ' , $ barMembership ->bar_id )
4751 ->join ('ingredients ' , 'ingredients.id ' , '= ' , 'cocktail_ingredients.ingredient_id ' )
4852 ->groupBy ('ingredient_id ' )
4953 ->get ();
5054
51- // TODO: Prefer shelf cocktails
52- // TODO: Find cocktail tags with poor ratings
53- // TODO: Take in account number of ingredients
55+ // Bar shelf ingredients
56+ $ barShelfIngredients = DB ::table ('bar_ingredients ' )
57+ ->select ('ingredient_id ' )
58+ ->where ('bar_id ' , $ barMembership ->bar_id )
59+ ->pluck ('ingredient_id ' );
5460
5561 $ potentialCocktails = Cocktail::query ()
5662 ->select ('cocktails.* ' , DB ::raw ('0 AS recommendation_score ' ))
57- ->whereNotIn ('cocktails.id ' , $ memberFavoriteCocktailIds )
58- ->whereNotIn ('cocktails.id ' , $ memberRatedCocktailIds )
63+ ->whereNotIn ('cocktails.id ' , $ excludedCocktailIds )
5964 ->where ('cocktails.bar_id ' , $ barMembership ->bar_id )
60- ->limit (150 )
6165 ->with ('tags ' , 'ingredients.ingredient ' , 'images ' )
62- ->inRandomOrder ()
66+ // ->inRandomOrder()
6367 ->get ();
6468
6569 foreach ($ potentialCocktails as $ cocktail ) {
6670 $ score = 0 ;
6771
6872 foreach ($ cocktail ->tags ->pluck ('id ' ) as $ tagId ) {
6973 $ score += $ memberFavoriteTags ->firstWhere ('id ' , $ tagId )->weight ?? 0 ;
74+
75+ if (array_key_exists ($ tagId , $ negativeTags )) {
76+ $ score += self ::NEGATIVE_RATING_PENALTY * $ negativeTags [$ tagId ];
77+ }
7078 }
7179
80+ $ totalIngredients = $ cocktail ->ingredients ->count ();
81+ $ shelfMatches = 0 ;
7282 foreach ($ cocktail ->ingredients ->pluck ('ingredient_id ' ) as $ ingredientId ) {
7383 $ score += $ memberFavoriteIngredients ->firstWhere ('id ' , $ ingredientId )->weight ?? 0 ;
84+
85+ if ($ barShelfIngredients ->contains ($ ingredientId )) {
86+ $ score += self ::BAR_SHELF_INGREDIENT_MATCH_WEIGHT ;
87+ $ shelfMatches ++;
88+ }
89+ }
90+
91+ // Shelf completeness bonus (percentage of ingredients user has)
92+ if ($ totalIngredients > 0 ) {
93+ $ shelfCompleteness = $ shelfMatches / $ totalIngredients ;
94+ $ score += $ shelfCompleteness * self ::BAR_SHELF_COMPLETE_MATCH_WEIGHT ;
95+ }
96+
97+ // Recency boost for newer cocktails
98+ if ($ cocktail ->created_at && $ cocktail ->created_at ->gt (now ()->subMonths (2 ))) {
99+ $ score += self ::RECENCY_BOOST_WEIGHT ;
74100 }
75101
76102 $ cocktail ['recommendation_score ' ] = $ score ;
77103 }
78104
79105 return $ potentialCocktails ->sortByDesc ('recommendation_score ' )->take ($ limit );
80106 }
107+
108+ /**
109+ * Get cocktail IDs that should be excluded from recommendations.
110+ * This includes favorites and rated cocktails.
111+ *
112+ * @return array<int>
113+ */
114+ private function getExcludedCocktails (BarMembership $ barMembership ): array
115+ {
116+ $ favorites = DB ::table ('cocktail_favorites ' )
117+ ->where ('bar_membership_id ' , $ barMembership ->id )
118+ ->pluck ('cocktail_id ' )
119+ ->toArray ();
120+
121+ $ rated = DB ::table ('ratings ' )
122+ ->where ('user_id ' , $ barMembership ->user_id )
123+ ->where ('rateable_type ' , Cocktail::class)
124+ ->pluck ('rateable_id ' )
125+ ->toArray ();
126+
127+ return array_unique (array_merge ($ favorites , $ rated ));
128+ }
129+
130+ /**
131+ * Get tags from low-rated cocktails.
132+ *
133+ * @return array<int, int>
134+ */
135+ private function getNegativeTags (BarMembership $ barMembership ): array
136+ {
137+ $ lowRatedCocktails = DB ::table ('ratings ' )
138+ ->select ('rateable_id ' )
139+ ->where ('user_id ' , $ barMembership ->user_id )
140+ ->where ('rateable_type ' , Cocktail::class)
141+ ->where ('rating ' , '<= ' , 2 )
142+ ->pluck ('rateable_id ' );
143+
144+ if ($ lowRatedCocktails ->isEmpty ()) {
145+ return [];
146+ }
147+
148+ return DB ::table ('cocktail_tag ' )
149+ ->select ('tag_id ' , DB ::raw ('COUNT(*) as frequency ' ))
150+ ->whereIn ('cocktail_id ' , $ lowRatedCocktails )
151+ ->groupBy ('tag_id ' )
152+ ->having ('frequency ' , '>= ' , 2 )
153+ ->pluck ('frequency ' , 'tag_id ' )
154+ ->toArray ();
155+ }
81156}
0 commit comments