From c86321908c4b5606e99f2fa4295df333e1489d07 Mon Sep 17 00:00:00 2001 From: Peter Woolery Date: Wed, 6 May 2026 06:38:26 -0700 Subject: [PATCH] feat: planner config (weights, thresholds, K) and shared types --- backend/app/services/planner/__init__.py | 1 + backend/app/services/planner/config.py | 42 ++++++++++++++ backend/app/services/planner/types.py | 71 ++++++++++++++++++++++++ 3 files changed, 114 insertions(+) create mode 100644 backend/app/services/planner/__init__.py create mode 100644 backend/app/services/planner/config.py create mode 100644 backend/app/services/planner/types.py diff --git a/backend/app/services/planner/__init__.py b/backend/app/services/planner/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/app/services/planner/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/app/services/planner/config.py b/backend/app/services/planner/config.py new file mode 100644 index 0000000..a3e8e5f --- /dev/null +++ b/backend/app/services/planner/config.py @@ -0,0 +1,42 @@ +"""Planner constants. Tune here without touching algorithm code.""" +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class PlannerConfig: + # Hard constraints + 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 + + # Scoring weights (must sum to 1.0) + w_savings: float = 0.30 + w_coverage: float = 0.25 + w_pantry: float = 0.10 + w_time: float = 0.15 + w_recency: float = 0.20 + + # Time bonus boundaries + time_ideal_minutes: int = 25 # full bonus at <= this + time_full_minutes: int = 45 # zero bonus at this; matches max_total_minutes + + # Recency bonus boundary + 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 = 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 + + def validate(self) -> None: + total = self.w_savings + self.w_coverage + self.w_pantry + self.w_time + self.w_recency + if abs(total - 1.0) > 1e-6: + raise ValueError(f"weights must sum to 1.0, got {total}") + + +DEFAULT = PlannerConfig() +DEFAULT.validate() diff --git a/backend/app/services/planner/types.py b/backend/app/services/planner/types.py new file mode 100644 index 0000000..95fb445 --- /dev/null +++ b/backend/app/services/planner/types.py @@ -0,0 +1,71 @@ +"""Shared planner data structures.""" +from __future__ import annotations + +from dataclasses import dataclass, field +from decimal import Decimal +from typing import List, Optional +from uuid import UUID + + +@dataclass +class IngredientCost: + ingredient_id: UUID + qty: float + unit: Optional[str] + grocery_item_id: Optional[UUID] # None when no match found + grocery_item_name: Optional[str] + current_price: Optional[Decimal] + regular_price: Optional[Decimal] + is_on_sale: bool + estimated_cost: Decimal # current_price scaled to recipe qty (best-effort) + estimated_savings: Decimal # max(regular - current, 0) scaled to qty + matched: bool # False if no ingredient_grocery_match row found + + +@dataclass +class RecipeCost: + recipe_id: UUID + total_cost: Decimal + total_savings: Decimal + sale_ingredient_count: int + matched_ingredient_count: int + total_ingredient_count: int + pantry_hit_count: int + line_items: List[IngredientCost] = field(default_factory=list) + + @property + def sale_coverage_pct(self) -> float: + if self.total_ingredient_count == 0: + return 0.0 + return self.sale_ingredient_count / self.total_ingredient_count + + @property + def pantry_hit_pct(self) -> float: + if self.total_ingredient_count == 0: + return 0.0 + return self.pantry_hit_count / self.total_ingredient_count + + +@dataclass +class FilterResult: + feasible_recipe_ids: List[UUID] + rejected: dict # recipe_id -> reason string + + +@dataclass +class ScoredRecipe: + recipe_id: UUID + score: float + components: dict # signal name -> raw value (for debug payload) + cost: RecipeCost + protein: Optional[str] + cuisine: Optional[str] + + +@dataclass +class GenerationResult: + meal_plan_id: UUID + selected: List[ScoredRecipe] + feasible_count: int + rejected_summary: dict # reason -> count + set_score: float