fix(planner): correct set_size to 3 dinners and switch cost filter to per-serving

- set_size 21→3, top_k 20→10: generate 3 weekly dinners not full 3×7 matrix
- All 3 items assigned MealType.DINNER on Mon/Wed/Fri
- RecipeCost gains servings field + cost_per_serving property
- compute_recipe_cost accepts servings param (default 4)
- filter.py gates on cost_per_serving instead of total_cost
- max_meal_cost 500→50 (now a meaningful $/serving threshold)
- Email displays ~$X/serving instead of inflated raw total
- select_set: candidate_pool uses max(top_k, set_size) to prevent
  combinations(n<set_size) returning empty iterator

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-22 07:20:17 -07:00
co-authored by Claude Sonnet 4.6
parent 16069bfa92
commit 35f736a052
7 changed files with 27 additions and 13 deletions
+8 -5
View File
@@ -238,8 +238,8 @@ def step_email(run: "WeeklyRun", db: "Session") -> None:
if instr_rows else "" if instr_rows else ""
) )
# Estimated cost: sum top-confidence grocery match prices # Estimated cost: sum top-confidence grocery match prices ÷ servings
est_cost = 0.0 est_cost_total = 0.0
for ing in ingredients: for ing in ingredients:
ing_name = ingredient_names.get(str(ing.get("ingredient_id", "")), ing.get("name", "")).lower() ing_name = ingredient_names.get(str(ing.get("ingredient_id", "")), ing.get("name", "")).lower()
match = ( match = (
@@ -250,11 +250,14 @@ def step_email(run: "WeeklyRun", db: "Session") -> None:
.first() .first()
) )
if match and match.grocery_item and match.grocery_item.current_price: if match and match.grocery_item and match.grocery_item.current_price:
est_cost += float(match.grocery_item.current_price) est_cost_total += float(match.grocery_item.current_price)
recipe_servings = (item.recipe.servings or 4) if item.recipe else 4
est_cost_per_serving = est_cost_total / recipe_servings
cost_block = ( cost_block = (
f"<p style='font-size:13px;color:#888'>Est. cost: ~${est_cost:.2f}</p>" f"<p style='font-size:13px;color:#888'>Est. ~${est_cost_per_serving:.2f}/serving</p>"
if est_cost > 0 else "" if est_cost_total > 0 else ""
) )
item_html_parts.append( item_html_parts.append(
+3 -3
View File
@@ -10,7 +10,7 @@ class PlannerConfig:
recency_weeks: int = 4 # constraint #3: no repeat within N weeks recency_weeks: int = 4 # constraint #3: no repeat within N weeks
calorie_tolerance_pct: int = 20 # constraint #4: ±X% of family.calorie_target calorie_tolerance_pct: int = 20 # constraint #4: ±X% of family.calorie_target
max_total_minutes: int = 45 # constraint #5: prep + cook max_total_minutes: int = 45 # constraint #5: prep + cook
max_meal_cost: float = 30.00 # constraint #6: dollars per meal max_meal_cost: float = 50.00 # constraint #6: dollars per serving (dimensionless proxy — unit conversion not yet implemented)
# Scoring weights (must sum to 1.0) # Scoring weights (must sum to 1.0)
w_savings: float = 0.30 w_savings: float = 0.30
@@ -27,8 +27,8 @@ class PlannerConfig:
recency_full_weeks: int = 12 # full bonus when last cooked >= this many weeks ago recency_full_weeks: int = 12 # full bonus when last cooked >= this many weeks ago
# Set selection # Set selection
top_k: int = 20 # how many feasible recipes to enumerate over top_k: int = 10 # how many feasible recipes to enumerate over
set_size: int = 21 # 21 meals/week (3 per day × 7 days) set_size: int = 3 # 3 dinners/week
p_protein: float = 0.15 # diversity penalty per shared-protein pair p_protein: float = 0.15 # diversity penalty per shared-protein pair
p_cuisine: float = 0.10 # diversity penalty per shared-cuisine pair p_cuisine: float = 0.10 # diversity penalty per shared-cuisine pair
+2
View File
@@ -43,6 +43,7 @@ def compute_recipe_cost(
ingredients: Iterable[dict], ingredients: Iterable[dict],
match_index: Dict[UUID, List[dict]], match_index: Dict[UUID, List[dict]],
pantry_ingredient_ids: Set[UUID], pantry_ingredient_ids: Set[UUID],
servings: int = 4,
) -> RecipeCost: ) -> RecipeCost:
line_items: List[IngredientCost] = [] line_items: List[IngredientCost] = []
total_cost = Decimal("0.00") total_cost = Decimal("0.00")
@@ -119,5 +120,6 @@ def compute_recipe_cost(
matched_ingredient_count=matched_count, matched_ingredient_count=matched_count,
total_ingredient_count=total, total_ingredient_count=total,
pantry_hit_count=pantry_hits, pantry_hit_count=pantry_hits,
servings=max(servings, 1),
line_items=line_items, line_items=line_items,
) )
+1 -1
View File
@@ -79,7 +79,7 @@ def filter_recipes(
if cost is None: if cost is None:
rejected[rid] = "no_cost" rejected[rid] = "no_cost"
continue continue
if float(cost.total_cost) > config.max_meal_cost: if float(cost.cost_per_serving) > config.max_meal_cost:
rejected[rid] = "cost" rejected[rid] = "cost"
continue continue
+5 -3
View File
@@ -113,6 +113,7 @@ def generate_meal_plan(
"protein_type": r.protein_type, "protein_type": r.protein_type,
"cuisine_tags": list(r.cuisine_tags or []), "cuisine_tags": list(r.cuisine_tags or []),
"ingredients": list(r.ingredients or []), "ingredients": list(r.ingredients or []),
"servings": r.servings or 4,
} }
for r in recipes for r in recipes
] ]
@@ -139,6 +140,7 @@ def generate_meal_plan(
ingredients=r["ingredients"], ingredients=r["ingredients"],
match_index=match_index, match_index=match_index,
pantry_ingredient_ids=pantry_ids, pantry_ingredient_ids=pantry_ids,
servings=r["servings"],
) )
for r in recipe_dicts for r in recipe_dicts
} }
@@ -176,10 +178,10 @@ def generate_meal_plan(
db.add(plan) db.add(plan)
db.flush() db.flush()
_dinner_days = [1, 3, 5] # Mon, Wed, Fri — spread across the week
for index, scored_recipe in enumerate(chosen): for index, scored_recipe in enumerate(chosen):
day = (index % 7) + 1 # 1..7 (Mon..Sun) day = _dinner_days[index] if index < len(_dinner_days) else index + 1
meal_type_index = index // 7 # 0=breakfast, 1=lunch, 2=dinner meal_type = MealType.DINNER
meal_type = [MealType.BREAKFAST, MealType.LUNCH, MealType.DINNER][meal_type_index]
item = MealPlanItem( item = MealPlanItem(
meal_plan_id=plan.id, meal_plan_id=plan.id,
recipe_id=scored_recipe.recipe_id, recipe_id=scored_recipe.recipe_id,
+2 -1
View File
@@ -41,7 +41,8 @@ def select_set(
return pool, _set_score(pool, config) return pool, _set_score(pool, config)
pool.sort(key=lambda s: s.score, reverse=True) pool.sort(key=lambda s: s.score, reverse=True)
candidate_pool = pool[: config.top_k] # top_k must cover at least set_size items or combinations() yields nothing
candidate_pool = pool[: max(config.top_k, config.set_size)]
best: List[ScoredRecipe] = [] best: List[ScoredRecipe] = []
best_score = float("-inf") best_score = float("-inf")
+6
View File
@@ -31,8 +31,14 @@ class RecipeCost:
matched_ingredient_count: int matched_ingredient_count: int
total_ingredient_count: int total_ingredient_count: int
pantry_hit_count: int pantry_hit_count: int
servings: int = 4
line_items: List[IngredientCost] = field(default_factory=list) line_items: List[IngredientCost] = field(default_factory=list)
@property
def cost_per_serving(self) -> Decimal:
s = max(self.servings, 1)
return (self.total_cost / Decimal(s)).quantize(Decimal("0.01"))
@property @property
def sale_coverage_pct(self) -> float: def sale_coverage_pct(self) -> float:
if self.total_ingredient_count == 0: if self.total_ingredient_count == 0: