Files
Meal-Planner/backend/app/services/planner/generate.py
T
admin 7b5e65087e feat: 21 meals per week (3/day) + approve/deny + generate single meal
Backend:
- Planner config: set_size=21 (was 3), top_k=30 (was 20)
- generate.py: distribute 21 recipes across 7 days × 3 meal types
- meals.py: add POST /items/{id}/approve, /items/{id}/deny
- meals.py: add POST /{plan_id}/generate-item for empty slots

Frontend:
- Dashboard: Approve/Deny buttons on pending meal cards
- Dashboard: Generate button in empty meal slots
- API client: approveItem, denyItem, generateItem methods

Build: TypeScript compiles clean, Python syntax verified.
2026-05-15 11:44:04 -07:00

207 lines
6.6 KiB
Python

"""End-to-end planner orchestration: load → filter → score → select → persist."""
from __future__ import annotations
from datetime import date
from decimal import Decimal
from typing import Dict, List, Optional, Set
from uuid import UUID
from sqlalchemy.orm import Session
from app.models import (
FamilyProfile,
GroceryItem,
HomePantry,
IngredientGroceryMatch,
MealPlan,
MealPlanItem,
MealPlanItemStatus,
MealPlanStatus,
MealType,
NeverSuggest,
Recipe,
)
from app.services.planner.config import DEFAULT, PlannerConfig
from app.services.planner.cost import compute_recipe_cost
from app.services.planner.filter import filter_recipes
from app.services.planner.score import score_recipes
from app.services.planner.select import select_set
from app.services.planner.types import GenerationResult
def _load_match_index(db: Session) -> Dict[UUID, List[dict]]:
"""ingredient_id → list of match dicts ordered by confidence DESC."""
rows = (
db.query(IngredientGroceryMatch, GroceryItem)
.join(GroceryItem, GroceryItem.id == IngredientGroceryMatch.grocery_item_id)
.order_by(IngredientGroceryMatch.confidence.desc())
.all()
)
index: Dict[UUID, List[dict]] = {}
for match, grocery in rows:
index.setdefault(match.ingredient_id, []).append(
{
"grocery_item_id": grocery.id,
"grocery_item_name": grocery.name,
"current_price": grocery.current_price,
"regular_price": grocery.regular_price,
"is_on_sale": bool(grocery.is_on_sale),
"confidence": match.confidence,
}
)
return index
def _load_blocklists(
db: Session, family_id: UUID
) -> tuple[Set[UUID], Set[UUID]]:
blocked_ingredients: Set[UUID] = set()
blocked_recipes: Set[UUID] = set()
for row in db.query(NeverSuggest).filter(NeverSuggest.family_profile_id == family_id).all():
if row.ingredient_id is not None:
blocked_ingredients.add(row.ingredient_id)
if row.recipe_id is not None:
blocked_recipes.add(row.recipe_id)
return blocked_ingredients, blocked_recipes
def _load_pantry(db: Session, family_id: UUID) -> Set[UUID]:
return {
row.ingredient_id
for row in db.query(HomePantry).filter(HomePantry.family_profile_id == family_id).all()
if row.ingredient_id is not None
}
def _load_last_cooked(db: Session, family_id: UUID) -> Dict[UUID, date]:
rows = (
db.query(MealPlanItem, MealPlan)
.join(MealPlan, MealPlan.id == MealPlanItem.meal_plan_id)
.filter(MealPlan.family_profile_id == family_id)
.all()
)
last: Dict[UUID, date] = {}
for item, plan in rows:
if item.recipe_id is None:
continue
if item.recipe_id not in last or plan.week_start_date > last[item.recipe_id]:
last[item.recipe_id] = plan.week_start_date
return last
def generate_meal_plan(
db: Session,
*,
family_id: UUID,
week_start_date: date,
config: PlannerConfig = DEFAULT,
today: Optional[date] = None,
) -> GenerationResult:
today = today or date.today()
family = db.query(FamilyProfile).filter(FamilyProfile.id == family_id).first()
if family is None:
raise ValueError(f"family_profile {family_id} not found")
recipes = db.query(Recipe).all()
recipe_dicts = [
{
"id": r.id,
"name": r.name,
"prep_time_minutes": r.prep_time_minutes,
"cook_time_minutes": r.cook_time_minutes,
"calories_per_serving": r.calories_per_serving,
"protein_type": r.protein_type,
"cuisine_tags": list(r.cuisine_tags or []),
"ingredients": list(r.ingredients or []),
}
for r in recipes
]
recipe_ingredient_ids: Dict[UUID, Set[UUID]] = {}
for r in recipe_dicts:
ids: Set[UUID] = set()
for line in r["ingredients"]:
ing_id = line.get("ingredient_id")
if isinstance(ing_id, str):
ing_id = UUID(ing_id)
if ing_id is not None:
ids.add(ing_id)
recipe_ingredient_ids[r["id"]] = ids
match_index = _load_match_index(db)
pantry_ids = _load_pantry(db, family_id)
blocked_ings, blocked_recipes = _load_blocklists(db, family_id)
last_cooked = _load_last_cooked(db, family_id)
recipe_costs = {
r["id"]: compute_recipe_cost(
recipe_id=r["id"],
ingredients=r["ingredients"],
match_index=match_index,
pantry_ingredient_ids=pantry_ids,
)
for r in recipe_dicts
}
filtered = filter_recipes(
recipes=recipe_dicts,
recipe_ingredient_ids=recipe_ingredient_ids,
recipe_costs=recipe_costs,
blocked_ingredient_ids=blocked_ings,
blocked_recipe_ids=blocked_recipes,
last_cooked_at=last_cooked,
family_calorie_target=family.calorie_target,
config=config,
today=today,
)
feasible_recipes = [r for r in recipe_dicts if r["id"] in filtered.feasible_recipe_ids]
scored = score_recipes(
recipes=feasible_recipes,
recipe_costs=recipe_costs,
last_cooked_at=last_cooked,
config=config,
today=today,
)
chosen, set_score = select_set(scored, config)
plan = MealPlan(
family_profile_id=family_id,
week_start_date=week_start_date,
status=MealPlanStatus.DRAFT,
total_estimated_cost=sum(
(s.cost.total_cost for s in chosen), Decimal("0.00")
),
)
db.add(plan)
db.flush()
for index, scored_recipe in enumerate(chosen):
day = (index % 7) + 1 # 1..7 (Mon..Sun)
meal_type_index = index // 7 # 0=breakfast, 1=lunch, 2=dinner
meal_type = [MealType.BREAKFAST, MealType.LUNCH, MealType.DINNER][meal_type_index]
item = MealPlanItem(
meal_plan_id=plan.id,
recipe_id=scored_recipe.recipe_id,
day_of_week=day,
meal_type=meal_type,
approval_status=MealPlanItemStatus.pending,
estimated_cost=scored_recipe.cost.total_cost,
)
db.add(item)
db.commit()
db.refresh(plan)
rejected_summary: Dict[str, int] = {}
for reason in filtered.rejected.values():
rejected_summary[reason] = rejected_summary.get(reason, 0) + 1
return GenerationResult(
meal_plan_id=plan.id,
selected=chosen,
feasible_count=len(filtered.feasible_recipe_ids),
rejected_summary=rejected_summary,
set_score=set_score,
)