Public Access
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:
@@ -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")
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user