diff --git a/backend/app/services/planner/generate.py b/backend/app/services/planner/generate.py new file mode 100644 index 0000000..78fdb41 --- /dev/null +++ b/backend/app/services/planner/generate.py @@ -0,0 +1,203 @@ +"""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, + ) diff --git a/backend/tests/test_planner_generate.py b/backend/tests/test_planner_generate.py new file mode 100644 index 0000000..6536df4 --- /dev/null +++ b/backend/tests/test_planner_generate.py @@ -0,0 +1,89 @@ +from datetime import date +from decimal import Decimal +from uuid import UUID, uuid4 + +import pytest + +pytestmark = pytest.mark.requires_postgres + + +def test_generate_meal_plan_against_seeded_data(): + """Smoke test: with the seeded 30 recipes and a seeded grocery scrape, + generate produces a MealPlan with up to 3 items. + """ + from datetime import datetime, timezone + + from app.database import SessionLocal + from app.models import ( + FamilyProfile, + GroceryItem, + Ingredient, + IngredientGroceryMatch, + IngredientMatchSource, + MealPlan, + MealPlanItem, + ) + from app.services.matcher import run_match_job + from app.services.planner.generate import generate_meal_plan + + setup = SessionLocal() + try: + family = FamilyProfile( + id=uuid4(), + name="Generate Smoke Family", + household_size=4, + adult_count=2, + child_count=2, + calorie_target=500, + ) + setup.add(family) + setup.commit() + family_id = family.id + + # Seed at least one grocery_item per ingredient that the recipes use + # so cost matching can find prices. Pick a handful to keep runtime low. + for ing in setup.query(Ingredient).limit(20).all(): + setup.add( + GroceryItem( + id=uuid4(), + name=ing.name, + source="lucky_california", + external_id=f"ext-test-{ing.id}", + current_price=Decimal("3.99"), + regular_price=Decimal("4.99"), + is_on_sale=True, + scraped_at=datetime.now(timezone.utc), + ) + ) + setup.commit() + finally: + setup.close() + + work = SessionLocal() + try: + run_match_job(work) + result = generate_meal_plan(work, family_id=family_id, week_start_date=date(2026, 5, 11)) + assert result.meal_plan_id is not None + assert 1 <= len(result.selected) <= 3 # at least 1, at most 3 + plan = work.query(MealPlan).filter(MealPlan.id == result.meal_plan_id).first() + assert plan is not None + items = work.query(MealPlanItem).filter(MealPlanItem.meal_plan_id == plan.id).all() + assert len(items) == len(result.selected) + finally: + cleanup = SessionLocal() + try: + cleanup.query(MealPlanItem).filter( + MealPlanItem.meal_plan_id.in_( + [r[0] for r in cleanup.query(MealPlan.id).filter(MealPlan.family_profile_id == family_id).all()] + ) + ).delete(synchronize_session=False) + cleanup.query(MealPlan).filter(MealPlan.family_profile_id == family_id).delete(synchronize_session=False) + cleanup.query(FamilyProfile).filter(FamilyProfile.id == family_id).delete(synchronize_session=False) + cleanup.query(GroceryItem).filter(GroceryItem.external_id.like("ext-test-%")).delete(synchronize_session=False) + cleanup.query(IngredientGroceryMatch).filter( + IngredientGroceryMatch.source == IngredientMatchSource.AUTO + ).delete(synchronize_session=False) + cleanup.commit() + finally: + cleanup.close() + work.close()