"""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 import func from sqlalchemy.orm import Session from app.models import ( FamilyProfile, GroceryItem, HomePantry, Ingredient, 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, Ingredient) .join(GroceryItem, GroceryItem.id == IngredientGroceryMatch.grocery_item_id) .join(Ingredient, Ingredient.id == IngredientGroceryMatch.ingredient_id) .order_by(IngredientGroceryMatch.confidence.desc()) .all() ) index: Dict[UUID, List[dict]] = {} for match, grocery, ingredient 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, "ingredient_name": ingredient.name.lower(), "grocery_unit": grocery.unit, } ) return index def _load_blocklists( db: Session, family_id: UUID ) -> tuple[Set[UUID], Set[UUID], Set[UUID]]: """Sprint 8: returns 3 sets of UUIDs. - blocked_ingredients: ingredient-level NeverSuggest entries - blocked_recipes: recipe-level NeverSuggest entries (permanent, no decay) - soft_denied_recipes: meal_plan_item rows with approval_status='denied' and denial_expires_at > now() (decaying in DENIAL_DECAY_DAYS; auto- promoted to blocked_recipes on the 2nd denial in the window by the /deny API path). Both recipe sets are hard filters (user decision: "Hard filter. If it is denied this week twice, it should be considered denied for good."). """ 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) soft_denied_recipes: Set[UUID] = set() rows = ( db.query(MealPlanItem.recipe_id) .join(MealPlan, MealPlanItem.meal_plan_id == MealPlan.id) .filter( MealPlan.family_profile_id == family_id, MealPlanItem.approval_status == MealPlanItemStatus.denied, MealPlanItem.denial_expires_at.isnot(None), MealPlanItem.denial_expires_at > func.now(), MealPlanItem.recipe_id.isnot(None), ) .distinct() .all() ) for (rid,) in rows: soft_denied_recipes.add(rid) return blocked_ingredients, blocked_recipes, soft_denied_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, exclude_recipe_ids: Optional[Set[UUID]] = 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") # 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() exclude_set = exclude_recipe_ids or set() 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 []), "servings": r.servings or 4, } for r in recipes if r.id not in exclude_set ] 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, soft_denied_recipes = _load_blocklists(db, family_id) last_cooked = _load_last_cooked(db, family_id) # Sprint 8: union the soft-denied set with the permanent blocklist # so the filter treats them identically. The `rejected[rid]` reason # is "blocked_recipe" for both — operators reading the planner's # `rejected_summary` see a single bucket. The soft set is also # passed in separately so the diagnostic label could be split # later if needed. all_blocked_recipes = blocked_recipes | soft_denied_recipes recipe_costs = { r["id"]: compute_recipe_cost( recipe_id=r["id"], ingredients=r["ingredients"], match_index=match_index, pantry_ingredient_ids=pantry_ids, servings=r["servings"], ) 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=all_blocked_recipes, last_cooked_at=last_cooked, family_calorie_target=family.calorie_target, config=effective_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=effective_config, today=today, ) chosen, set_score = select_set(scored, effective_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() _dinner_days = [1, 3, 5] # Mon, Wed, Fri — spread across the week for index, scored_recipe in enumerate(chosen): day = _dinner_days[index] if index < len(_dinner_days) else index + 1 meal_type = MealType.DINNER 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, score=scored_recipe.score, components=scored_recipe.components, ) 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 # Sprint 8: surface how many recipes are blocked specifically because # of soft denials (vs. permanent NeverSuggest entries). Both are # bucketed under "blocked_recipe" in the filter; this adds a # "soft_denied_recipe" sub-bucket for diagnostics. if soft_denied_recipes: # Only count those that were actually candidates (in recipe_dicts). soft_in_pool = sum(1 for r in recipe_dicts if r["id"] in soft_denied_recipes) if soft_in_pool > 0: rejected_summary["soft_denied_recipe"] = soft_in_pool return GenerationResult( meal_plan_id=plan.id, selected=chosen, feasible_count=len(filtered.feasible_recipe_ids), rejected_summary=rejected_summary, set_score=set_score, )