Skip to content

Commit

Permalink
suggest recipes based on cook history
Browse files Browse the repository at this point in the history
  • Loading branch information
barneyb committed Sep 7, 2024
1 parent 2373e8a commit f613af4
Show file tree
Hide file tree
Showing 8 changed files with 109 additions and 8 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@
import com.brennaswitzer.cookbook.domain.Recipe;
import com.brennaswitzer.cookbook.graphql.model.OffsetConnection;
import com.brennaswitzer.cookbook.graphql.model.OffsetConnectionCursor;
import com.brennaswitzer.cookbook.graphql.model.SuggestionRequest;
import com.brennaswitzer.cookbook.payload.RecognizedItem;
import com.brennaswitzer.cookbook.repositories.SearchResponse;
import com.brennaswitzer.cookbook.repositories.impl.LibrarySearchScope;
import com.brennaswitzer.cookbook.services.ItemService;
import com.brennaswitzer.cookbook.services.PantryItemService;
import com.brennaswitzer.cookbook.services.RecipeService;
import graphql.relay.Connection;
import jakarta.persistence.NoResultException;
Expand All @@ -22,9 +22,6 @@ public class LibraryQuery extends PagingQuery {
@Autowired
private RecipeService recipeService;

@Autowired
private PantryItemService pantryItemService;

@Autowired
private ItemService itemService;

Expand Down Expand Up @@ -52,4 +49,11 @@ public RecognizedItem recognizeItem(String raw, Integer cursor) {
false);
}

public Connection<Recipe> suggestRecipesToCook(SuggestionRequest req) {
if (req == null) req = new SuggestionRequest();
SearchResponse<Recipe> rs = recipeService.suggestRecipes(getOffset(req.getAfter()),
req.getFirst());
return new OffsetConnection<>(rs);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -30,18 +30,33 @@ static String encode(int offset) {

String value;

// for Jackson conversion
@SuppressWarnings("unused")
private OffsetConnectionCursor(int offset, String value) {
this.offset = offset;
this.value = value;
if (decode(value) != offset) {
throw new IllegalArgumentException(String.format(
"Inconsistent %d and '%s' cursor values",
offset,
value));
}
}

public OffsetConnectionCursor(String value) {
this(decode(value));
if (!Objects.equals(value, this.value)) {
throw new IllegalArgumentException(String.format("Invalid '%s' cursor value",
value));
throw new IllegalArgumentException(String.format(
"Invalid '%s' cursor value",
value));
}
}

public OffsetConnectionCursor(int offset) {
if (offset < 0) {
throw new IllegalArgumentException(String.format("Offsets cannot be negative, but '%d' is",
offset));
throw new IllegalArgumentException(String.format(
"Offsets cannot be negative, but '%d' is",
offset));
}
this.offset = offset;
this.value = encode(offset);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.brennaswitzer.cookbook.graphql.model;

import lombok.Data;

@Data
public class SuggestionRequest {

int first;

OffsetConnectionCursor after;

}
Original file line number Diff line number Diff line change
@@ -1,6 +1,35 @@
package com.brennaswitzer.cookbook.repositories;

import com.brennaswitzer.cookbook.domain.PlannedRecipeHistory;
import org.springframework.data.jpa.repository.Query;

import java.util.List;

public interface PlannedRecipeHistoryRepository extends BaseEntityRepository<PlannedRecipeHistory> {

/**
* I return a list of recipe ids based on how likely the user is to want to
* cook them, most likely first. The specific algorithm (and its parameters)
* are subject to change.
*
* <p>Currently, recipes are ranked based on the user's (not other users')
* ratings, decayed over time. If a recipe was cooked but not rated, a
* 3-star rating is assumed. If a recipe was cooked two months ago, it will
* contribute half the weight it would if it were cooked today. Four months
* ago will contribute a quarter as much as today.
*/
@Query(value = """
SELECT recipe_id
FROM planned_recipe_history
WHERE owner_id = :userId
AND status_id = 100 -- completed
GROUP BY recipe_id
ORDER BY SUM(EXP(LN(0.5) / 60 * (CURRENT_DATE - CAST(done_at AS DATE))) * COALESCE(rating, 3)) DESC
, 1
LIMIT :limit
OFFSET :offset
""",
nativeQuery = true)
List<Long> getRecipeSuggestions(Long userId, int offset, int limit);

}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import com.brennaswitzer.cookbook.domain.User;
import org.springframework.stereotype.Repository;

import java.util.Collection;
import java.util.List;
import java.util.Optional;

Expand All @@ -17,6 +18,8 @@ public interface RecipeRepository extends BaseEntityRepository<Recipe>, RecipeSe

List<Recipe> findAllByOwnerAndNameIgnoreCaseContainingOrderById(User owner, String name);

List<Recipe> findByIdIn(Collection<Long> ids);

@Override
Optional<Recipe> findById(Long aLong);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import com.brennaswitzer.cookbook.domain.Recipe;
import com.brennaswitzer.cookbook.domain.S3File;
import com.brennaswitzer.cookbook.domain.Upload;
import com.brennaswitzer.cookbook.repositories.PlannedRecipeHistoryRepository;
import com.brennaswitzer.cookbook.repositories.RecipeRepository;
import com.brennaswitzer.cookbook.repositories.SearchResponse;
import com.brennaswitzer.cookbook.repositories.impl.LibrarySearchRequest;
Expand All @@ -15,8 +16,11 @@
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;

@Service
@Transactional
Expand All @@ -37,6 +41,9 @@ public class RecipeService {
@Autowired
private StorageService storageService;

@Autowired
private PlannedRecipeHistoryRepository historyRepository;

public Recipe createNewRecipe(Recipe recipe) {
return createNewRecipe(recipe, null);
}
Expand Down Expand Up @@ -162,4 +169,24 @@ public SearchResponse<Recipe> searchRecipes(LibrarySearchScope scope,
.build());
}

public SearchResponse<Recipe> suggestRecipes(int offset, int limit) {
LibrarySearchRequest req = LibrarySearchRequest.builder()
.user(principalAccess.getUser())
.offset(offset)
.limit(limit)
.build();
List<Long> ids = historyRepository.getRecipeSuggestions(
req.getUser().getId(),
req.getOffset(),
req.getLimit() + 1); // one extra (for SearchResponse)
var byId = recipeRepository.findByIdIn(ids).stream()
.collect(Collectors.toMap(
Recipe::getId,
Function.identity()));
List<Recipe> recipes = ids.stream()
.map(byId::get)
.toList();
return SearchResponse.of(req, recipes);
}

}
4 changes: 4 additions & 0 deletions src/main/resources/db/changelog/gobrennas-2024.sql
Original file line number Diff line number Diff line change
Expand Up @@ -457,3 +457,7 @@ where exists (select *
from ingredient
where id = s.pantry_item_id
and name = s.synonym);

--changeset barneyb:index-cook-history-owner-status
CREATE INDEX idx_planned_recipe_history_owner_status
ON planned_recipe_history (owner_id, status_id);
7 changes: 7 additions & 0 deletions src/main/resources/graphqls/library.graphqls
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ type LibraryQuery {
after: Cursor = null
): RecipeConnection!

suggestRecipesToCook(req: SuggestionRequest): RecipeConnection!

getRecipeById(id: ID!): Recipe

"""Recognize quantity, unit, and/or ingredient in a raw ingredient ref (aka
Expand All @@ -48,6 +50,11 @@ type LibraryQuery {
): RecognizedItem
}

input SuggestionRequest {
first: NonNegativeInt! = 5
after: Cursor = null
}

"""The result of recognizing a raw ingredient ref item.
"""
type RecognizedItem {
Expand Down

0 comments on commit f613af4

Please sign in to comment.