Files
Meal-Planner/backend/app/services/planner/generate.py
T
admin 7f5757094e
CI / backend (pytest + alembic) (push) Has been cancelled
CI / frontend (build) (push) Has been cancelled
feat(meals): suggest complementary sides
2026-06-29 14:59:30 -07:00

282 lines
10 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 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
from app.services.meal_pairings import components_with_suggested_sides
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
recipe_by_id = {recipe.id: recipe for recipe in recipes}
for index, scored_recipe in enumerate(chosen):
day = _dinner_days[index] if index < len(_dinner_days) else index + 1
meal_type = MealType.DINNER
recipe = recipe_by_id.get(scored_recipe.recipe_id)
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=components_with_suggested_sides(
recipe,
meal_type,
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,
)