feat(backend): tunable planner weights via family profile config

- 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
This commit is contained in:
2026-05-25 16:03:46 -07:00
parent 86164e6dd3
commit 98d611d7b3
7 changed files with 288 additions and 59 deletions
+48
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
@dataclass(frozen=True)
@@ -37,6 +38,53 @@ class PlannerConfig:
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()