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
@@ -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")
+68 -1
View File
@@ -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"}
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
+1
View File
@@ -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())
+74 -55
View File
@@ -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):
+57
View File
@@ -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
+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()
+12 -3
View File
@@ -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,