"""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): item = MealPlanItem( meal_plan_id=plan.id, recipe_id=scored_recipe.recipe_id, day_of_week=index + 1, # Mon=1, Tue=2, Wed=3 by default meal_type=MealType.DINNER, 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, )