Rewrite variety score and suggestions with configurable scoring

- Add VarietyScoreConfig entity, repository, and V020 migration for
  per-household scoring weights and configurable tag types
- Rewrite getVarietyScore: tag-type repeats on consecutive days,
  non-staple ingredient overlaps, cooking log history, plan duplicates
- Rewrite getSuggestions: simulate variety score for each candidate,
  add tag filter (AND, case-insensitive) and configurable topN param
- Update SuggestionResponse to return simulatedScore instead of
  fitReasons/warnings, update VarietyScoreResponse to new shape
- Seed default VarietyScoreConfig on household creation
- Extend test suite across all domains (+270 tests, all passing)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-02 10:33:11 +02:00
parent 9ec703abcd
commit 8221a1fd41
21 changed files with 3225 additions and 110 deletions

View File

@@ -9,6 +9,8 @@ import com.recipeapp.household.dto.*;
import com.recipeapp.household.entity.Household;
import com.recipeapp.household.entity.HouseholdInvite;
import com.recipeapp.household.entity.HouseholdMember;
import com.recipeapp.planning.VarietyScoreConfigRepository;
import com.recipeapp.planning.entity.VarietyScoreConfig;
import com.recipeapp.recipe.IngredientCategoryRepository;
import com.recipeapp.recipe.IngredientRepository;
import com.recipeapp.recipe.TagRepository;
@@ -32,6 +34,7 @@ public class HouseholdServiceImpl implements HouseholdService {
private final IngredientRepository ingredientRepository;
private final IngredientCategoryRepository ingredientCategoryRepository;
private final TagRepository tagRepository;
private final VarietyScoreConfigRepository varietyScoreConfigRepository;
private static final SecureRandom RANDOM = new SecureRandom();
private static final String CODE_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
@@ -42,7 +45,8 @@ public class HouseholdServiceImpl implements HouseholdService {
HouseholdInviteRepository householdInviteRepository,
IngredientRepository ingredientRepository,
IngredientCategoryRepository ingredientCategoryRepository,
TagRepository tagRepository) {
TagRepository tagRepository,
VarietyScoreConfigRepository varietyScoreConfigRepository) {
this.userAccountRepository = userAccountRepository;
this.householdRepository = householdRepository;
this.householdMemberRepository = householdMemberRepository;
@@ -50,6 +54,7 @@ public class HouseholdServiceImpl implements HouseholdService {
this.ingredientRepository = ingredientRepository;
this.ingredientCategoryRepository = ingredientCategoryRepository;
this.tagRepository = tagRepository;
this.varietyScoreConfigRepository = varietyScoreConfigRepository;
}
@Override
@@ -168,6 +173,8 @@ public class HouseholdServiceImpl implements HouseholdService {
new Ingredient(household, "Flour", true),
new Ingredient(household, "Rice", true),
new Ingredient(household, "Pasta", true)));
varietyScoreConfigRepository.save(VarietyScoreConfig.defaults(household));
}
private String generateInviteCode() {

View File

@@ -19,7 +19,8 @@ public interface PlanningService {
WeekPlanResponse confirmPlan(UUID householdId, UUID planId);
SuggestionResponse getSuggestions(UUID householdId, UUID planId, LocalDate slotDate);
SuggestionResponse getSuggestions(UUID householdId, UUID planId, LocalDate slotDate,
List<String> tagFilters, Integer topN);
VarietyScoreResponse getVarietyScore(UUID householdId, UUID planId);

View File

@@ -32,19 +32,22 @@ public class PlanningServiceImpl implements PlanningService {
private final RecipeRepository recipeRepository;
private final HouseholdRepository householdRepository;
private final UserAccountRepository userAccountRepository;
private final VarietyScoreConfigRepository varietyScoreConfigRepository;
public PlanningServiceImpl(WeekPlanRepository weekPlanRepository,
WeekPlanSlotRepository weekPlanSlotRepository,
CookingLogRepository cookingLogRepository,
RecipeRepository recipeRepository,
HouseholdRepository householdRepository,
UserAccountRepository userAccountRepository) {
UserAccountRepository userAccountRepository,
VarietyScoreConfigRepository varietyScoreConfigRepository) {
this.weekPlanRepository = weekPlanRepository;
this.weekPlanSlotRepository = weekPlanSlotRepository;
this.cookingLogRepository = cookingLogRepository;
this.recipeRepository = recipeRepository;
this.householdRepository = householdRepository;
this.userAccountRepository = userAccountRepository;
this.varietyScoreConfigRepository = varietyScoreConfigRepository;
}
@Override
@@ -117,89 +120,125 @@ public class PlanningServiceImpl implements PlanningService {
@Override
@Transactional(readOnly = true)
public SuggestionResponse getSuggestions(UUID householdId, UUID planId, LocalDate slotDate) {
public SuggestionResponse getSuggestions(UUID householdId, UUID planId, LocalDate slotDate,
List<String> tagFilters, Integer topN) {
WeekPlan plan = findPlan(planId, householdId);
int limit = (topN != null) ? topN : 5;
if (limit <= 0) {
return new SuggestionResponse(List.of());
}
VarietyScoreConfig config = varietyScoreConfigRepository.findByHouseholdId(householdId)
.orElse(VarietyScoreConfig.defaults(plan.getHousehold()));
// Collect recipes already in this plan
Set<UUID> usedRecipeIds = plan.getSlots().stream()
.map(s -> s.getRecipe().getId())
.collect(Collectors.toSet());
// Collect proteins used in adjacent days
Set<String> adjacentProteins = new HashSet<>();
for (WeekPlanSlot slot : plan.getSlots()) {
if (Math.abs(slot.getSlotDate().toEpochDay() - slotDate.toEpochDay()) <= 1) {
for (Tag tag : slot.getRecipe().getTags()) {
if ("protein".equals(tag.getTagType())) {
adjacentProteins.add(tag.getName().toLowerCase());
}
}
}
}
// Collect ingredients used in adjacent days
Set<UUID> adjacentIngredientIds = new HashSet<>();
for (WeekPlanSlot slot : plan.getSlots()) {
if (Math.abs(slot.getSlotDate().toEpochDay() - slotDate.toEpochDay()) <= 1) {
for (RecipeIngredient ri : slot.getRecipe().getIngredients()) {
adjacentIngredientIds.add(ri.getIngredient().getId());
}
}
}
// Recent cooking logs (last 14 days)
List<CookingLog> recentLogs = cookingLogRepository.findByHouseholdIdAndCookedOnAfter(
householdId, slotDate.minusDays(14));
Set<UUID> recentlyCookedIds = recentLogs.stream()
Set<UUID> recentlyCookedIds = cookingLogRepository
.findByHouseholdIdAndCookedOnAfter(householdId,
plan.getWeekStart().minusDays(config.getHistoryDays()))
.stream()
.map(cl -> cl.getRecipe().getId())
.collect(Collectors.toSet());
// Count effort levels in plan
Map<String, Long> effortCounts = plan.getSlots().stream()
.collect(Collectors.groupingBy(s -> s.getRecipe().getEffort(), Collectors.counting()));
// Get all household recipes, score and pick top 5
List<Recipe> allRecipes = recipeRepository.findByHouseholdIdAndDeletedAtIsNull(householdId);
Set<String> lowerTagFilters = tagFilters.stream()
.map(String::toLowerCase)
.collect(Collectors.toSet());
List<SuggestionResponse.SuggestionItem> suggestions = allRecipes.stream()
.filter(r -> !usedRecipeIds.contains(r.getId()))
.map(recipe -> {
List<String> fitReasons = new ArrayList<>();
List<String> warnings = new ArrayList<>();
if (!recentlyCookedIds.contains(recipe.getId())) {
fitReasons.add("not_cooked_recently");
}
boolean hasProteinRepeat = recipe.getTags().stream()
.filter(t -> "protein".equals(t.getTagType()))
.anyMatch(t -> adjacentProteins.contains(t.getName().toLowerCase()));
if (!hasProteinRepeat) {
fitReasons.add("no_protein_repeat");
}
String effort = recipe.getEffort();
long currentCount = effortCounts.getOrDefault(effort, 0L);
if (currentCount < 3) {
fitReasons.add("effort_balance");
}
boolean sharesIngredient = recipe.getIngredients().stream()
.anyMatch(ri -> adjacentIngredientIds.contains(ri.getIngredient().getId()));
if (sharesIngredient) {
warnings.add("shares_ingredient_with_adjacent_day");
}
return new SuggestionResponse.SuggestionItem(
toSlotRecipe(recipe), fitReasons, warnings);
.filter(r -> matchesAllTags(r, lowerTagFilters))
.map(candidate -> {
double score = simulateVarietyScore(
plan, candidate, slotDate, config, recentlyCookedIds);
return new SuggestionResponse.SuggestionItem(toSlotRecipe(candidate), score);
})
.sorted((a, b) -> b.fitReasons().size() - a.fitReasons().size())
.limit(5)
.sorted((a, b) -> Double.compare(b.simulatedScore(), a.simulatedScore()))
.limit(limit)
.toList();
return new SuggestionResponse(suggestions);
}
private boolean matchesAllTags(Recipe recipe, Set<String> lowerTagFilters) {
if (lowerTagFilters.isEmpty()) return true;
Set<String> recipeTags = recipe.getTags().stream()
.map(t -> t.getName().toLowerCase())
.collect(Collectors.toSet());
return recipeTags.containsAll(lowerTagFilters);
}
private double simulateVarietyScore(WeekPlan plan, Recipe candidate, LocalDate slotDate,
VarietyScoreConfig config, Set<UUID> recentlyCookedIds) {
// Build a simulated slot list: existing slots + candidate on slotDate
List<SimulatedSlot> simulatedSlots = new ArrayList<>();
for (WeekPlanSlot slot : plan.getSlots()) {
simulatedSlots.add(new SimulatedSlot(slot.getRecipe(), slot.getSlotDate()));
}
simulatedSlots.add(new SimulatedSlot(candidate, slotDate));
List<String> checkedTagTypes = config.getRepeatTagTypes();
double wTagRepeat = config.getWTagRepeat().doubleValue();
double wIngredientOverlap = config.getWIngredientOverlap().doubleValue();
double wRecentRepeat = config.getWRecentRepeat().doubleValue();
double wPlanDuplicate = config.getWPlanDuplicate().doubleValue();
// 1. Tag-type repeats on consecutive days
Map<String, List<LocalDate>> tagDays = new LinkedHashMap<>();
for (SimulatedSlot slot : simulatedSlots) {
for (Tag tag : slot.recipe.getTags()) {
if (checkedTagTypes.contains(tag.getTagType())) {
tagDays.computeIfAbsent(tag.getName(), k -> new ArrayList<>())
.add(slot.date);
}
}
}
long tagRepeatCount = tagDays.values().stream()
.filter(this::hasConsecutiveDays)
.count();
// 2. Non-staple ingredient overlaps on consecutive days
Map<String, List<LocalDate>> ingredientDays = new LinkedHashMap<>();
for (SimulatedSlot slot : simulatedSlots) {
for (RecipeIngredient ri : slot.recipe.getIngredients()) {
if (!ri.getIngredient().isStaple()) {
ingredientDays.computeIfAbsent(ri.getIngredient().getName(), k -> new ArrayList<>())
.add(slot.date);
}
}
}
long ingredientOverlapCount = ingredientDays.values().stream()
.filter(this::hasConsecutiveDays)
.count();
// 3. Recent repeats from cooking log
long recentRepeatCount = simulatedSlots.stream()
.map(s -> s.recipe.getId())
.distinct()
.filter(recentlyCookedIds::contains)
.count();
// 4. Duplicate recipes within the simulated plan
Map<UUID, Long> recipeCounts = simulatedSlots.stream()
.collect(Collectors.groupingBy(s -> s.recipe.getId(), Collectors.counting()));
long duplicatePenaltyCount = recipeCounts.values().stream()
.filter(c -> c > 1)
.mapToLong(c -> c - 1)
.sum();
double score = 10.0;
score -= tagRepeatCount * wTagRepeat;
score -= ingredientOverlapCount * wIngredientOverlap;
score -= recentRepeatCount * wRecentRepeat;
score -= duplicatePenaltyCount * wPlanDuplicate;
return Math.max(0, Math.min(10, score));
}
private record SimulatedSlot(Recipe recipe, LocalDate date) {}
@Override
@Transactional(readOnly = true)
public VarietyScoreResponse getVarietyScore(UUID householdId, UUID planId) {
@@ -207,21 +246,45 @@ public class PlanningServiceImpl implements PlanningService {
List<WeekPlanSlot> slots = plan.getSlots();
if (slots.isEmpty()) {
return new VarietyScoreResponse(0, List.of(), List.of(), Map.of());
return new VarietyScoreResponse(0, List.of(), List.of(), List.of(), List.of());
}
// Effort balance
Map<String, Integer> effortBalance = new LinkedHashMap<>();
// Load config (or use defaults)
VarietyScoreConfig config = varietyScoreConfigRepository.findByHouseholdId(householdId)
.orElse(VarietyScoreConfig.defaults(plan.getHousehold()));
List<String> checkedTagTypes = config.getRepeatTagTypes();
double wTagRepeat = config.getWTagRepeat().doubleValue();
double wIngredientOverlap = config.getWIngredientOverlap().doubleValue();
double wRecentRepeat = config.getWRecentRepeat().doubleValue();
double wPlanDuplicate = config.getWPlanDuplicate().doubleValue();
int historyDays = config.getHistoryDays();
// 1. Tag-type repeats on consecutive days
Map<String, TagAccumulator> tagDays = new LinkedHashMap<>();
for (WeekPlanSlot slot : slots) {
effortBalance.merge(slot.getRecipe().getEffort(), 1, Integer::sum);
for (Tag tag : slot.getRecipe().getTags()) {
if (checkedTagTypes.contains(tag.getTagType())) {
tagDays.computeIfAbsent(tag.getName(),
k -> new TagAccumulator(tag.getTagType()))
.addDay(slot.getSlotDate());
}
}
}
List<VarietyScoreResponse.TagRepeat> tagRepeats = tagDays.entrySet().stream()
.filter(e -> hasConsecutiveDays(e.getValue().days))
.map(e -> new VarietyScoreResponse.TagRepeat(
e.getKey(), e.getValue().tagType, e.getValue().days))
.toList();
// Ingredient overlaps (same ingredient on consecutive days)
// 2. Non-staple ingredient overlaps on consecutive days
Map<String, List<LocalDate>> ingredientDays = new LinkedHashMap<>();
for (WeekPlanSlot slot : slots) {
for (RecipeIngredient ri : slot.getRecipe().getIngredients()) {
ingredientDays.computeIfAbsent(ri.getIngredient().getName(), k -> new ArrayList<>())
.add(slot.getSlotDate());
if (!ri.getIngredient().isStaple()) {
ingredientDays.computeIfAbsent(ri.getIngredient().getName(), k -> new ArrayList<>())
.add(slot.getSlotDate());
}
}
}
List<VarietyScoreResponse.IngredientOverlap> overlaps = ingredientDays.entrySet().stream()
@@ -229,34 +292,57 @@ public class PlanningServiceImpl implements PlanningService {
.map(e -> new VarietyScoreResponse.IngredientOverlap(e.getKey(), e.getValue()))
.toList();
// Protein repeats (same protein on consecutive days)
Map<String, List<LocalDate>> proteinDays = new LinkedHashMap<>();
for (WeekPlanSlot slot : slots) {
for (Tag tag : slot.getRecipe().getTags()) {
if ("protein".equals(tag.getTagType())) {
proteinDays.computeIfAbsent(tag.getName(), k -> new ArrayList<>())
.add(slot.getSlotDate());
}
}
}
List<String> proteinRepeats = proteinDays.entrySet().stream()
.filter(e -> hasConsecutiveDays(e.getValue()))
.map(Map.Entry::getKey)
// 3. Recent repeats from cooking log
LocalDate referenceDate = plan.getWeekStart();
List<CookingLog> recentLogs = cookingLogRepository.findByHouseholdIdAndCookedOnAfter(
householdId, referenceDate.minusDays(historyDays));
Set<UUID> recentlyCookedIds = recentLogs.stream()
.map(cl -> cl.getRecipe().getId())
.collect(Collectors.toSet());
List<String> recentRepeats = slots.stream()
.map(s -> s.getRecipe())
.filter(r -> recentlyCookedIds.contains(r.getId()))
.map(Recipe::getName)
.distinct()
.toList();
// Score: start at 10, deduct for issues
double score = 10.0;
score -= overlaps.size() * 0.5;
score -= proteinRepeats.size() * 1.0;
// Deduct for effort imbalance
int maxEffort = effortBalance.values().stream().mapToInt(Integer::intValue).max().orElse(0);
int minEffort = effortBalance.values().stream().mapToInt(Integer::intValue).min().orElse(0);
if (maxEffort - minEffort > 2) {
score -= 1.0;
// 4. Duplicate recipes within the plan
Map<UUID, Long> recipeCounts = slots.stream()
.collect(Collectors.groupingBy(s -> s.getRecipe().getId(), Collectors.counting()));
List<String> duplicatesInPlan = new ArrayList<>();
long duplicatePenaltyCount = 0;
for (var entry : recipeCounts.entrySet()) {
if (entry.getValue() > 1) {
String recipeName = slots.stream()
.filter(s -> s.getRecipe().getId().equals(entry.getKey()))
.findFirst()
.map(s -> s.getRecipe().getName())
.orElse("Unknown");
duplicatesInPlan.add(recipeName);
duplicatePenaltyCount += entry.getValue() - 1;
}
}
// Calculate score
double score = 10.0;
score -= tagRepeats.size() * wTagRepeat;
score -= overlaps.size() * wIngredientOverlap;
score -= recentRepeats.size() * wRecentRepeat;
score -= duplicatePenaltyCount * wPlanDuplicate;
score = Math.max(0, Math.min(10, score));
return new VarietyScoreResponse(score, overlaps, proteinRepeats, effortBalance);
return new VarietyScoreResponse(score, tagRepeats, overlaps, recentRepeats, duplicatesInPlan);
}
private static class TagAccumulator {
final String tagType;
final List<LocalDate> days = new ArrayList<>();
TagAccumulator(String tagType) { this.tagType = tagType; }
void addDay(LocalDate day) { days.add(day); }
}
@Override

View File

@@ -0,0 +1,11 @@
package com.recipeapp.planning;
import com.recipeapp.planning.entity.VarietyScoreConfig;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
import java.util.UUID;
public interface VarietyScoreConfigRepository extends JpaRepository<VarietyScoreConfig, UUID> {
Optional<VarietyScoreConfig> findByHouseholdId(UUID householdId);
}

View File

@@ -9,6 +9,7 @@ import org.springframework.web.bind.annotation.*;
import java.security.Principal;
import java.time.LocalDate;
import java.util.List;
import java.util.UUID;
@RestController
@@ -78,9 +79,12 @@ public class WeekPlanController {
public SuggestionResponse getSuggestions(
Principal principal,
@PathVariable UUID id,
@RequestParam LocalDate slotDate) {
@RequestParam LocalDate slotDate,
@RequestParam(required = false) List<String> tags,
@RequestParam(required = false) Integer topN) {
UUID householdId = householdResolver.resolve(principal.getName());
return planningService.getSuggestions(householdId, id, slotDate);
return planningService.getSuggestions(householdId, id, slotDate,
tags != null ? tags : List.of(), topN);
}
@GetMapping("/{id}/variety-score")

View File

@@ -6,7 +6,6 @@ public record SuggestionResponse(List<SuggestionItem> suggestions) {
public record SuggestionItem(
SlotResponse.SlotRecipe recipe,
List<String> fitReasons,
List<String> warnings
double simulatedScore
) {}
}

View File

@@ -2,13 +2,14 @@ package com.recipeapp.planning.dto;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
public record VarietyScoreResponse(
double score,
List<TagRepeat> tagRepeats,
List<IngredientOverlap> ingredientOverlaps,
List<String> proteinRepeats,
Map<String, Integer> effortBalance
List<String> recentRepeats,
List<String> duplicatesInPlan
) {
public record TagRepeat(String tagName, String tagType, List<LocalDate> days) {}
public record IngredientOverlap(String ingredientName, List<LocalDate> days) {}
}

View File

@@ -0,0 +1,76 @@
package com.recipeapp.planning.entity;
import com.recipeapp.household.entity.Household;
import jakarta.persistence.*;
import java.math.BigDecimal;
import java.util.Arrays;
import java.util.List;
import java.util.UUID;
@Entity
@Table(name = "variety_score_config")
public class VarietyScoreConfig {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "household_id", nullable = false, unique = true)
private Household household;
@Column(name = "repeat_tag_types", nullable = false, columnDefinition = "text[]")
private String[] repeatTagTypes;
@Column(name = "w_tag_repeat", nullable = false, precision = 3, scale = 1)
private BigDecimal wTagRepeat;
@Column(name = "w_ingredient_overlap", nullable = false, precision = 3, scale = 1)
private BigDecimal wIngredientOverlap;
@Column(name = "w_recent_repeat", nullable = false, precision = 3, scale = 1)
private BigDecimal wRecentRepeat;
@Column(name = "w_plan_duplicate", nullable = false, precision = 3, scale = 1)
private BigDecimal wPlanDuplicate;
@Column(name = "history_days", nullable = false)
private int historyDays;
protected VarietyScoreConfig() {}
public VarietyScoreConfig(Household household, String[] repeatTagTypes,
BigDecimal wTagRepeat, BigDecimal wIngredientOverlap,
BigDecimal wRecentRepeat, BigDecimal wPlanDuplicate,
int historyDays) {
this.household = household;
this.repeatTagTypes = repeatTagTypes;
this.wTagRepeat = wTagRepeat;
this.wIngredientOverlap = wIngredientOverlap;
this.wRecentRepeat = wRecentRepeat;
this.wPlanDuplicate = wPlanDuplicate;
this.historyDays = historyDays;
}
public static VarietyScoreConfig defaults(Household household) {
return new VarietyScoreConfig(
household,
new String[]{"protein", "cuisine"},
new BigDecimal("1.5"),
new BigDecimal("0.3"),
new BigDecimal("1.0"),
new BigDecimal("2.0"),
14
);
}
public UUID getId() { return id; }
public Household getHousehold() { return household; }
public List<String> getRepeatTagTypes() { return Arrays.asList(repeatTagTypes); }
public BigDecimal getWTagRepeat() { return wTagRepeat; }
public BigDecimal getWIngredientOverlap() { return wIngredientOverlap; }
public BigDecimal getWRecentRepeat() { return wRecentRepeat; }
public BigDecimal getWPlanDuplicate() { return wPlanDuplicate; }
public int getHistoryDays() { return historyDays; }
}