Public Access
- Add planner_config JSONB to family_profile model + migration - Add PlannerConfig.merge(overrides) + to_dict() for family-level override merging - generate_meal_plan merges family.planner_config into DEFAULT before filtering/scoring/selection - New endpoints on /api/profile: - GET /planner-config — returns merged effective config - PUT /planner-config — partial override validation + merge - DELETE /planner-config — reset to system defaults - Schemas: PlannerConfigOverride, PlannerConfigResponse, PlannerConfigUpdateRequest with weight-sum validation (0.999–1.001) - Export RecipeBase/Create/Read/Update from schemas/__init__ to resolve forward refs
91 lines
3.5 KiB
Python
91 lines
3.5 KiB
Python
"""Planner constants. Tune here without touching algorithm code."""
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from typing import Any
|
|
|
|
|
|
@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 = 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
|
|
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 = 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
|
|
|
|
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}")
|
|
|
|
def merge(self, overrides: "dict[str, Any]") -> "PlannerConfig":
|
|
"""Return a new PlannerConfig with overrides applied."""
|
|
current = {
|
|
"recency_weeks": self.recency_weeks,
|
|
"calorie_tolerance_pct": self.calorie_tolerance_pct,
|
|
"max_total_minutes": self.max_total_minutes,
|
|
"max_meal_cost": self.max_meal_cost,
|
|
"w_savings": self.w_savings,
|
|
"w_coverage": self.w_coverage,
|
|
"w_pantry": self.w_pantry,
|
|
"w_time": self.w_time,
|
|
"w_recency": self.w_recency,
|
|
"time_ideal_minutes": self.time_ideal_minutes,
|
|
"time_full_minutes": self.time_full_minutes,
|
|
"recency_full_weeks": self.recency_full_weeks,
|
|
"top_k": self.top_k,
|
|
"set_size": self.set_size,
|
|
"p_protein": self.p_protein,
|
|
"p_cuisine": self.p_cuisine,
|
|
}
|
|
for key, value in overrides.items():
|
|
if key in current:
|
|
current[key] = value
|
|
inst = PlannerConfig(**current)
|
|
inst.validate()
|
|
return inst
|
|
|
|
def to_dict(self) -> dict[str, Any]:
|
|
return {
|
|
"recency_weeks": self.recency_weeks,
|
|
"calorie_tolerance_pct": self.calorie_tolerance_pct,
|
|
"max_total_minutes": self.max_total_minutes,
|
|
"max_meal_cost": self.max_meal_cost,
|
|
"w_savings": self.w_savings,
|
|
"w_coverage": self.w_coverage,
|
|
"w_pantry": self.w_pantry,
|
|
"w_time": self.w_time,
|
|
"w_recency": self.w_recency,
|
|
"time_ideal_minutes": self.time_ideal_minutes,
|
|
"time_full_minutes": self.time_full_minutes,
|
|
"recency_full_weeks": self.recency_full_weeks,
|
|
"top_k": self.top_k,
|
|
"set_size": self.set_size,
|
|
"p_protein": self.p_protein,
|
|
"p_cuisine": self.p_cuisine,
|
|
}
|
|
|
|
|
|
DEFAULT = PlannerConfig()
|
|
DEFAULT.validate()
|