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, FamilyProfileResponse, FamilyProfileUpdate, FamilyProfileCreate,
FamilyMemberResponse, FamilyMemberCreate 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 app.security import require_session
from uuid import UUID from uuid import UUID
from typing import List from typing import List
@@ -78,4 +80,69 @@ def delete_member(member_id: UUID, db: Session = Depends(get_db)):
db.delete(member) db.delete(member)
db.commit() 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) budget_per_meal = Column(Numeric(10, 2), default=50.00)
calorie_target = Column(Integer) calorie_target = Column(Integer)
pending_approval_policy = Column(String(10), nullable=False, server_default="approve") 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()) created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=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 __future__ import annotations
from typing import Optional, List, Any
from uuid import UUID from decimal import Decimal
from datetime import date, datetime
from enum import Enum 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): class FamilyMemberRole(str, Enum):
@@ -97,16 +115,66 @@ class FamilyProfileBase(BaseModel):
budget_per_meal: float = 50.00 budget_per_meal: float = 50.00
class FamilyProfileResponse(FamilyProfileBase): class FamilyProfileResponse(BaseModel):
id: UUID 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 created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None updated_at: Optional[datetime] = None
members: List[FamilyMemberResponse] = [] members: List[FamilyMemberResponse] = []
planner_config: Optional[dict] = None
class Config: class Config:
from_attributes = True 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): class FamilyProfileCreate(FamilyProfileBase):
pass pass
@@ -118,56 +186,7 @@ class FamilyProfileUpdate(BaseModel):
child_count: Optional[int] = None child_count: Optional[int] = None
dietary_notes: Optional[str] = None dietary_notes: Optional[str] = None
budget_per_meal: Optional[float] = None budget_per_meal: Optional[float] = None
planner_config: Optional[dict] = 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
class RecipeCreate(RecipeBase): 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 __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -37,6 +38,53 @@ class PlannerConfig:
if abs(total - 1.0) > 1e-6: if abs(total - 1.0) > 1e-6:
raise ValueError(f"weights must sum to 1.0, got {total}") 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 = PlannerConfig()
DEFAULT.validate() DEFAULT.validate()
+12 -3
View File
@@ -107,6 +107,15 @@ def generate_meal_plan(
if family is None: if family is None:
raise ValueError(f"family_profile {family_id} not found") 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() recipes = db.query(Recipe).all()
exclude_set = exclude_recipe_ids or set() exclude_set = exclude_recipe_ids or set()
recipe_dicts = [ recipe_dicts = [
@@ -160,7 +169,7 @@ def generate_meal_plan(
blocked_recipe_ids=blocked_recipes, blocked_recipe_ids=blocked_recipes,
last_cooked_at=last_cooked, last_cooked_at=last_cooked,
family_calorie_target=family.calorie_target, family_calorie_target=family.calorie_target,
config=config, config=effective_config,
today=today, today=today,
) )
@@ -169,10 +178,10 @@ def generate_meal_plan(
recipes=feasible_recipes, recipes=feasible_recipes,
recipe_costs=recipe_costs, recipe_costs=recipe_costs,
last_cooked_at=last_cooked, last_cooked_at=last_cooked,
config=config, config=effective_config,
today=today, today=today,
) )
chosen, set_score = select_set(scored, config) chosen, set_score = select_set(scored, effective_config)
plan = MealPlan( plan = MealPlan(
family_profile_id=family_id, family_profile_id=family_id,