From 98d611d7b3784baa290cebfd5b076eedefe00039 Mon Sep 17 00:00:00 2001 From: Peter Woolery Date: Mon, 25 May 2026 16:03:46 -0700 Subject: [PATCH] feat(backend): tunable planner weights via family profile config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../0013_family_profile_planner_config.py | 28 ++++ backend/app/api/profile.py | 69 +++++++++- backend/app/models/__init__.py | 1 + backend/app/schemas/__init__.py | 129 ++++++++++-------- backend/app/schemas/planner_config.py | 57 ++++++++ backend/app/services/planner/config.py | 48 +++++++ backend/app/services/planner/generate.py | 15 +- 7 files changed, 288 insertions(+), 59 deletions(-) create mode 100644 backend/alembic/versions/0013_family_profile_planner_config.py create mode 100644 backend/app/schemas/planner_config.py diff --git a/backend/alembic/versions/0013_family_profile_planner_config.py b/backend/alembic/versions/0013_family_profile_planner_config.py new file mode 100644 index 0000000..b87bab1 --- /dev/null +++ b/backend/alembic/versions/0013_family_profile_planner_config.py @@ -0,0 +1,28 @@ +"""Add planner_config JSONB to family_profile. + +Revision ID: 0013 +Revises: 0012 +Create Date: 2026-05-24 17:00:00 +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = "0013" +down_revision: Union[str, None] = "0012" +branch_labels: Union[Sequence[str], None] = None +depends_on: Union[Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "family_profile", + sa.Column("planner_config", postgresql.JSONB, nullable=True), + ) + + +def downgrade() -> None: + op.drop_column("family_profile", "planner_config") diff --git a/backend/app/api/profile.py b/backend/app/api/profile.py index 15f0145..ef12dc1 100644 --- a/backend/app/api/profile.py +++ b/backend/app/api/profile.py @@ -6,6 +6,8 @@ from app.schemas import ( FamilyProfileResponse, FamilyProfileUpdate, FamilyProfileCreate, FamilyMemberResponse, FamilyMemberCreate ) +from app.schemas.planner_config import PlannerConfigOverride, PlannerConfigResponse, PlannerConfigUpdateRequest +from app.services.planner.config import DEFAULT from app.security import require_session from uuid import UUID from typing import List @@ -78,4 +80,69 @@ def delete_member(member_id: UUID, db: Session = Depends(get_db)): db.delete(member) db.commit() - return {"message": "Member deleted"} \ No newline at end of file + return {"message": "Member deleted"} + + +@router.get("/planner-config", response_model=PlannerConfigResponse) +def get_planner_config(db: Session = Depends(get_db)): + """Return the effective planner configuration for the family (merged defaults + overrides).""" + profile = db.query(FamilyProfile).first() + if not profile: + raise HTTPException(status_code=404, detail="Family profile not found") + + effective = DEFAULT + if profile.planner_config: + try: + effective = DEFAULT.merge(profile.planner_config) + except ValueError: + pass # fall back to default + + data = effective.to_dict() + data["source"] = "family_override" if profile.planner_config else "default" + return data + + +@router.put("/planner-config", response_model=PlannerConfigResponse, dependencies=[Depends(require_session)]) +def update_planner_config(payload: PlannerConfigUpdateRequest, db: Session = Depends(get_db)): + """Update the family planner_config. Partial overrides are merged into defaults.""" + profile = db.query(FamilyProfile).first() + if not profile: + raise HTTPException(status_code=404, detail="Family profile not found") + + # Build a clean dict of overrides + overrides = payload.planner_config.model_dump(exclude_unset=True, exclude_none=True) + if not overrides: + raise HTTPException(status_code=400, detail="No overrides provided") + + # Merge with existing family config if present + existing = dict(profile.planner_config) if profile.planner_config else {} + merged = {**existing, **overrides} + + # Validate via PlannerConfig + try: + DEFAULT.merge(merged) + except Exception as exc: + raise HTTPException(status_code=400, detail=f"Invalid planner config: {exc}") + + profile.planner_config = merged + db.commit() + db.refresh(profile) + + effective = DEFAULT.merge(merged) + data = effective.to_dict() + data["source"] = "family_override" + return data + + +@router.delete("/planner-config", dependencies=[Depends(require_session)]) +def reset_planner_config(db: Session = Depends(get_db)): + """Clear family planner_config and restore system defaults.""" + profile = db.query(FamilyProfile).first() + if not profile: + raise HTTPException(status_code=404, detail="Family profile not found") + + profile.planner_config = None + db.commit() + data = DEFAULT.to_dict() + data["source"] = "default" + return data diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 133bc91..d8a5e01 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -97,6 +97,7 @@ class FamilyProfile(Base): budget_per_meal = Column(Numeric(10, 2), default=50.00) calorie_target = Column(Integer) pending_approval_policy = Column(String(10), nullable=False, server_default="approve") + planner_config = Column(JSONB, nullable=True) created_at = Column(DateTime(timezone=True), server_default=func.now()) updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index 42f0512..a784613 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -1,8 +1,26 @@ -from pydantic import BaseModel, Field, model_validator -from typing import Optional, List, Any -from uuid import UUID -from datetime import date, datetime +from __future__ import annotations + +from decimal import Decimal from enum import Enum +from typing import Any, Dict, List, Optional +from uuid import UUID + +from pydantic import BaseModel, Field, model_validator + +# Re-export from submodules so forward references resolve +from .recipe import ( + RecipeBase, + RecipeCreate, + RecipeIngredientRef, + RecipeRead, + RecipeUpdate, + ResolveIngredientCandidate, + ResolveIngredientRequest, + ResolveIngredientResponse, +) + +# These may be referenced by other models; re-export as aliases if needed. +RecipeResponse = RecipeRead class FamilyMemberRole(str, Enum): @@ -97,16 +115,66 @@ class FamilyProfileBase(BaseModel): budget_per_meal: float = 50.00 -class FamilyProfileResponse(FamilyProfileBase): +class FamilyProfileResponse(BaseModel): id: UUID + name: str + household_size: int + adult_count: int + child_count: int + dietary_notes: Optional[str] = None + budget_per_meal: float = 50.00 created_at: Optional[datetime] = None updated_at: Optional[datetime] = None members: List[FamilyMemberResponse] = [] + planner_config: Optional[dict] = None class Config: from_attributes = True +class PlannerConfigOverride(BaseModel): + recency_weeks: Optional[int] = Field(default=None, ge=0) + calorie_tolerance_pct: Optional[int] = Field(default=None, ge=0, le=100) + max_total_minutes: Optional[int] = Field(default=None, ge=0) + max_meal_cost: Optional[float] = Field(default=None, ge=0) + w_savings: Optional[float] = Field(default=None, ge=0, le=1) + w_coverage: Optional[float] = Field(default=None, ge=0, le=1) + w_pantry: Optional[float] = Field(default=None, ge=0, le=1) + w_time: Optional[float] = Field(default=None, ge=0, le=1) + w_recency: Optional[float] = Field(default=None, ge=0, le=1) + time_ideal_minutes: Optional[int] = Field(default=None, ge=0) + time_full_minutes: Optional[int] = Field(default=None, ge=0) + recency_full_weeks: Optional[int] = Field(default=None, ge=0) + top_k: Optional[int] = Field(default=None, ge=1) + set_size: Optional[int] = Field(default=None, ge=1) + p_protein: Optional[float] = Field(default=None, ge=0) + p_cuisine: Optional[float] = Field(default=None, ge=0) + + +class PlannerConfigResponse(BaseModel): + recency_weeks: int + calorie_tolerance_pct: int + max_total_minutes: int + max_meal_cost: float + w_savings: float + w_coverage: float + w_pantry: float + w_time: float + w_recency: float + time_ideal_minutes: int + time_full_minutes: int + recency_full_weeks: int + top_k: int + set_size: int + p_protein: float + p_cuisine: float + source: str = "default" + + +class PlannerConfigUpdateRequest(BaseModel): + planner_config: PlannerConfigOverride + + class FamilyProfileCreate(FamilyProfileBase): pass @@ -118,56 +186,7 @@ class FamilyProfileUpdate(BaseModel): child_count: Optional[int] = None dietary_notes: Optional[str] = None budget_per_meal: Optional[float] = None - - -class RecipeIngredient(BaseModel): - ingredient_id: Optional[UUID] = None - name: Optional[str] = None - quantity: Optional[float] = None - unit: Optional[str] = None - is_optional: bool = False - notes: Optional[str] = None - - @model_validator(mode="before") - @classmethod - def _normalize(cls, data): - if not isinstance(data, dict): - return data - # JSONB stores qty; schema uses quantity - if "qty" in data and "quantity" not in data: - data["quantity"] = data.pop("qty") - return data - - -class RecipeBase(BaseModel): - name: str - description: Optional[str] = None - image_url: Optional[str] = None - image_source: Optional[str] = None - prep_time_minutes: Optional[int] = None - cook_time_minutes: Optional[int] = None - servings: int - servings_scaled: Optional[int] = None - cuisine_tags: Optional[List[str]] = [] - dietary_tags: Optional[List[str]] = [] - protein_type: Optional[str] = None - spice_level: Optional[int] = None - ingredients: List[RecipeIngredient] = [] - instructions: List[str] = [] - source_url: Optional[str] = None - is_manually_added: bool = False - - -class RecipeResponse(RecipeBase): - id: UUID - family_profile_id: Optional[UUID] = None - scraped_at: Optional[datetime] = None - created_at: Optional[datetime] = None - updated_at: Optional[datetime] = None - total_time_minutes: Optional[int] = None - - class Config: - from_attributes = True + planner_config: Optional[dict] = None class RecipeCreate(RecipeBase): diff --git a/backend/app/schemas/planner_config.py b/backend/app/schemas/planner_config.py new file mode 100644 index 0000000..aa1cf6f --- /dev/null +++ b/backend/app/schemas/planner_config.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +from typing import Optional + +from pydantic import BaseModel, Field, model_validator + + +class PlannerConfigOverride(BaseModel): + recency_weeks: Optional[int] = Field(default=None, ge=0) + calorie_tolerance_pct: Optional[int] = Field(default=None, ge=0, le=100) + max_total_minutes: Optional[int] = Field(default=None, ge=0) + max_meal_cost: Optional[float] = Field(default=None, ge=0) + w_savings: Optional[float] = Field(default=None, ge=0, le=1) + w_coverage: Optional[float] = Field(default=None, ge=0, le=1) + w_pantry: Optional[float] = Field(default=None, ge=0, le=1) + w_time: Optional[float] = Field(default=None, ge=0, le=1) + w_recency: Optional[float] = Field(default=None, ge=0, le=1) + time_ideal_minutes: Optional[int] = Field(default=None, ge=0) + time_full_minutes: Optional[int] = Field(default=None, ge=0) + recency_full_weeks: Optional[int] = Field(default=None, ge=0) + top_k: Optional[int] = Field(default=None, ge=1) + set_size: Optional[int] = Field(default=None, ge=1) + p_protein: Optional[float] = Field(default=None, ge=0) + p_cuisine: Optional[float] = Field(default=None, ge=0) + + @model_validator(mode="after") + def _check_weights_sum(self): + weights = [self.w_savings, self.w_coverage, self.w_pantry, self.w_time, self.w_recency] + if all(w is not None for w in weights): + total = sum(weights) + if not 0.999 <= total <= 1.001: + raise ValueError(f"weights must sum to 1.0, got {total:.4f}") + return self + + +class PlannerConfigResponse(BaseModel): + recency_weeks: int + calorie_tolerance_pct: int + max_total_minutes: int + max_meal_cost: float + w_savings: float + w_coverage: float + w_pantry: float + w_time: float + w_recency: float + time_ideal_minutes: int + time_full_minutes: int + recency_full_weeks: int + top_k: int + set_size: int + p_protein: float + p_cuisine: float + source: str = "default" + + +class PlannerConfigUpdateRequest(BaseModel): + planner_config: PlannerConfigOverride diff --git a/backend/app/services/planner/config.py b/backend/app/services/planner/config.py index 0d3dc4a..cfb5de8 100644 --- a/backend/app/services/planner/config.py +++ b/backend/app/services/planner/config.py @@ -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() diff --git a/backend/app/services/planner/generate.py b/backend/app/services/planner/generate.py index 7512d20..45fd71b 100644 --- a/backend/app/services/planner/generate.py +++ b/backend/app/services/planner/generate.py @@ -107,6 +107,15 @@ def generate_meal_plan( if family is None: raise ValueError(f"family_profile {family_id} not found") + # Merge family-level planner overrides if present + effective_config = config + if family.planner_config: + from app.services.planner.config import PlannerConfig + try: + effective_config = config.merge(family.planner_config) + except (ValueError, TypeError) as exc: + logger.warning("Invalid planner_config for family %s: %s", family_id, exc) + recipes = db.query(Recipe).all() exclude_set = exclude_recipe_ids or set() recipe_dicts = [ @@ -160,7 +169,7 @@ def generate_meal_plan( blocked_recipe_ids=blocked_recipes, last_cooked_at=last_cooked, family_calorie_target=family.calorie_target, - config=config, + config=effective_config, today=today, ) @@ -169,10 +178,10 @@ def generate_meal_plan( recipes=feasible_recipes, recipe_costs=recipe_costs, last_cooked_at=last_cooked, - config=config, + config=effective_config, today=today, ) - chosen, set_score = select_set(scored, config) + chosen, set_score = select_set(scored, effective_config) plan = MealPlan( family_profile_id=family_id,