Public Access
feat: planner orchestrator - load, filter, score, select, persist
This commit is contained in:
@@ -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,
|
||||||
|
)
|
||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user