diff --git a/backend/app/services/orchestrator/steps.py b/backend/app/services/orchestrator/steps.py index 5488445..c07457a 100644 --- a/backend/app/services/orchestrator/steps.py +++ b/backend/app/services/orchestrator/steps.py @@ -238,8 +238,8 @@ def step_email(run: "WeeklyRun", db: "Session") -> None: if instr_rows else "" ) - # Estimated cost: sum top-confidence grocery match prices - est_cost = 0.0 + # Estimated cost: sum top-confidence grocery match prices ÷ servings + est_cost_total = 0.0 for ing in ingredients: ing_name = ingredient_names.get(str(ing.get("ingredient_id", "")), ing.get("name", "")).lower() match = ( @@ -250,11 +250,14 @@ def step_email(run: "WeeklyRun", db: "Session") -> None: .first() ) 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 = ( - f"

Est. cost: ~${est_cost:.2f}

" - if est_cost > 0 else "" + f"

Est. ~${est_cost_per_serving:.2f}/serving

" + if est_cost_total > 0 else "" ) item_html_parts.append( diff --git a/backend/app/services/planner/config.py b/backend/app/services/planner/config.py index 185dcc3..0d3dc4a 100644 --- a/backend/app/services/planner/config.py +++ b/backend/app/services/planner/config.py @@ -10,7 +10,7 @@ class PlannerConfig: recency_weeks: int = 4 # constraint #3: no repeat within N weeks calorie_tolerance_pct: int = 20 # constraint #4: ±X% of family.calorie_target 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) 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 # Set selection - top_k: int = 20 # how many feasible recipes to enumerate over - set_size: int = 21 # 21 meals/week (3 per day × 7 days) + top_k: int = 10 # how many feasible recipes to enumerate over + set_size: int = 3 # 3 dinners/week p_protein: float = 0.15 # diversity penalty per shared-protein pair p_cuisine: float = 0.10 # diversity penalty per shared-cuisine pair diff --git a/backend/app/services/planner/cost.py b/backend/app/services/planner/cost.py index 38840bf..4b3da4e 100644 --- a/backend/app/services/planner/cost.py +++ b/backend/app/services/planner/cost.py @@ -43,6 +43,7 @@ def compute_recipe_cost( ingredients: Iterable[dict], match_index: Dict[UUID, List[dict]], pantry_ingredient_ids: Set[UUID], + servings: int = 4, ) -> RecipeCost: line_items: List[IngredientCost] = [] total_cost = Decimal("0.00") @@ -119,5 +120,6 @@ def compute_recipe_cost( matched_ingredient_count=matched_count, total_ingredient_count=total, pantry_hit_count=pantry_hits, + servings=max(servings, 1), line_items=line_items, ) diff --git a/backend/app/services/planner/filter.py b/backend/app/services/planner/filter.py index fb50cb3..20e1575 100644 --- a/backend/app/services/planner/filter.py +++ b/backend/app/services/planner/filter.py @@ -79,7 +79,7 @@ def filter_recipes( if cost is None: rejected[rid] = "no_cost" continue - if float(cost.total_cost) > config.max_meal_cost: + if float(cost.cost_per_serving) > config.max_meal_cost: rejected[rid] = "cost" continue diff --git a/backend/app/services/planner/generate.py b/backend/app/services/planner/generate.py index b1eaf10..dcb779e 100644 --- a/backend/app/services/planner/generate.py +++ b/backend/app/services/planner/generate.py @@ -113,6 +113,7 @@ def generate_meal_plan( "protein_type": r.protein_type, "cuisine_tags": list(r.cuisine_tags or []), "ingredients": list(r.ingredients or []), + "servings": r.servings or 4, } for r in recipes ] @@ -139,6 +140,7 @@ def generate_meal_plan( ingredients=r["ingredients"], match_index=match_index, pantry_ingredient_ids=pantry_ids, + servings=r["servings"], ) for r in recipe_dicts } @@ -176,10 +178,10 @@ def generate_meal_plan( db.add(plan) db.flush() + _dinner_days = [1, 3, 5] # Mon, Wed, Fri — spread across the week for index, scored_recipe in enumerate(chosen): - day = (index % 7) + 1 # 1..7 (Mon..Sun) - meal_type_index = index // 7 # 0=breakfast, 1=lunch, 2=dinner - meal_type = [MealType.BREAKFAST, MealType.LUNCH, MealType.DINNER][meal_type_index] + day = _dinner_days[index] if index < len(_dinner_days) else index + 1 + meal_type = MealType.DINNER item = MealPlanItem( meal_plan_id=plan.id, recipe_id=scored_recipe.recipe_id, diff --git a/backend/app/services/planner/select.py b/backend/app/services/planner/select.py index 228dc63..234b132 100644 --- a/backend/app/services/planner/select.py +++ b/backend/app/services/planner/select.py @@ -41,7 +41,8 @@ def select_set( return pool, _set_score(pool, config) 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_score = float("-inf") diff --git a/backend/app/services/planner/types.py b/backend/app/services/planner/types.py index 95fb445..6d27144 100644 --- a/backend/app/services/planner/types.py +++ b/backend/app/services/planner/types.py @@ -31,8 +31,14 @@ class RecipeCost: matched_ingredient_count: int total_ingredient_count: int pantry_hit_count: int + servings: int = 4 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 def sale_coverage_pct(self) -> float: if self.total_ingredient_count == 0: