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
+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