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:
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -6,7 +6,6 @@ public record SuggestionResponse(List<SuggestionItem> suggestions) {
|
||||
|
||||
public record SuggestionItem(
|
||||
SlotResponse.SlotRecipe recipe,
|
||||
List<String> fitReasons,
|
||||
List<String> warnings
|
||||
double simulatedScore
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -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) {}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
Reference in New Issue
Block a user