Skip to content

Commit 99dcc40

Browse files
committed
Update recommendation service
1 parent 7d0dbab commit 99dcc40

File tree

3 files changed

+116
-28
lines changed

3 files changed

+116
-28
lines changed

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,12 @@
1+
# v5.8.0
2+
## New
3+
- Added `bars/{id}/sync-datapack` endpoint
4+
- This endpoint will sync existing bar data with the default datapack
5+
- Existing recipes and ingredients will not be overwritten, only new data will be added
6+
7+
## Changes
8+
- Recommendations now take into account bar shelf ingredients, recipe recency and negative tags
9+
110
# v5.7.0
211
## New
312
- Import parent cocktails via datapack

app/Services/CocktailRecommendationService.php

Lines changed: 98 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -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
}

app/Services/CocktailService.php

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -377,16 +377,20 @@ public function getTopRatedCocktails(int $barId, int $limit = 10): Collection
377377
/**
378378
* @return Collection<array-key, mixed>
379379
*/
380-
public function getMemberFavoriteCocktailTags(int $barMembershipId, int $limit = 15): Collection
380+
public function getMemberFavoriteCocktailTags(int $barMembershipId, ?int $limit = 15): Collection
381381
{
382-
return $this->db->table('tags')
382+
$query = $this->db->table('tags')
383383
->selectRaw('tags.id, tags.name, COUNT(cocktail_favorites.cocktail_id) AS cocktails_count')
384384
->join('cocktail_tag', 'cocktail_tag.tag_id', '=', 'tags.id')
385385
->join('cocktail_favorites', 'cocktail_favorites.cocktail_id', '=', 'cocktail_tag.cocktail_id')
386386
->where('cocktail_favorites.bar_membership_id', $barMembershipId)
387387
->groupBy('tags.id')
388-
->orderBy('cocktails_count', 'DESC')
389-
->limit($limit)
390-
->get();
388+
->orderBy('cocktails_count', 'DESC');
389+
390+
if ($limit) {
391+
$query->limit($limit);
392+
}
393+
394+
return $query->get();
391395
}
392396
}

0 commit comments

Comments
 (0)