From 95396137c632820c66b67b8b9ffd832d3749c0cd Mon Sep 17 00:00:00 2001 From: Peter Woolery Date: Wed, 6 May 2026 06:42:49 -0700 Subject: [PATCH] feat: planner hard-constraint filter for the 6 spec constraints --- backend/app/services/planner/filter.py | 88 ++++++++++++ backend/tests/test_planner_filter.py | 187 +++++++++++++++++++++++++ 2 files changed, 275 insertions(+) create mode 100644 backend/app/services/planner/filter.py create mode 100644 backend/tests/test_planner_filter.py diff --git a/backend/app/services/planner/filter.py b/backend/app/services/planner/filter.py new file mode 100644 index 0000000..fb50cb3 --- /dev/null +++ b/backend/app/services/planner/filter.py @@ -0,0 +1,88 @@ +"""Hard-constraint filter (constraints #1-#6 from the spec).""" +from __future__ import annotations + +from datetime import date, timedelta +from typing import Dict, Iterable, List, Optional, Set +from uuid import UUID + +from app.services.planner.config import PlannerConfig +from app.services.planner.types import FilterResult, RecipeCost + + +def _calorie_band(target: int, pct: int) -> tuple[int, int]: + delta = target * pct / 100.0 + return (int(target - delta), int(target + delta)) + + +def filter_recipes( + *, + recipes: Iterable[dict], + recipe_ingredient_ids: Dict[UUID, Set[UUID]], + recipe_costs: Dict[UUID, RecipeCost], + blocked_ingredient_ids: Set[UUID], + blocked_recipe_ids: Set[UUID], + last_cooked_at: Dict[UUID, date], + family_calorie_target: Optional[int], + config: PlannerConfig, + today: date, +) -> FilterResult: + feasible: List[UUID] = [] + rejected: Dict[UUID, str] = {} + + cal_band: Optional[tuple[int, int]] = None + if family_calorie_target is not None: + cal_band = _calorie_band(family_calorie_target, config.calorie_tolerance_pct) + + recency_cutoff = today - timedelta(weeks=config.recency_weeks) + + for r in recipes: + rid = r["id"] + if isinstance(rid, str): + rid = UUID(rid) + + # #2: per-recipe blocklist + if rid in blocked_recipe_ids: + rejected[rid] = "blocked_recipe" + continue + + # #1: ingredient blocklist intersection + if blocked_ingredient_ids & recipe_ingredient_ids.get(rid, set()): + rejected[rid] = "blocked_ingredient" + continue + + # #3: recency + last = last_cooked_at.get(rid) + if last and last >= recency_cutoff: + rejected[rid] = "recency" + continue + + # #4: calories + if cal_band is not None: + cps = r.get("calories_per_serving") + if cps is None: + rejected[rid] = "calories_missing" + continue + lo, hi = cal_band + if cps < lo or cps > hi: + rejected[rid] = "calories" + continue + + # #5: time + prep = int(r.get("prep_time_minutes") or 0) + cook = int(r.get("cook_time_minutes") or 0) + if prep + cook > config.max_total_minutes: + rejected[rid] = "time" + continue + + # #6: cost + cost = recipe_costs.get(rid) + if cost is None: + rejected[rid] = "no_cost" + continue + if float(cost.total_cost) > config.max_meal_cost: + rejected[rid] = "cost" + continue + + feasible.append(rid) + + return FilterResult(feasible_recipe_ids=feasible, rejected=rejected) diff --git a/backend/tests/test_planner_filter.py b/backend/tests/test_planner_filter.py new file mode 100644 index 0000000..aa56d01 --- /dev/null +++ b/backend/tests/test_planner_filter.py @@ -0,0 +1,187 @@ +from datetime import date, timedelta +from decimal import Decimal +from uuid import uuid4 + +import pytest + +from app.services.planner.config import PlannerConfig +from app.services.planner.filter import filter_recipes +from app.services.planner.types import RecipeCost + + +_CFG = PlannerConfig() + + +def _recipe( + recipe_id=None, + name="r", + prep=10, + cook=20, + calories=2000, + protein="chicken", + cuisine_tags=("american",), +): + return { + "id": recipe_id or uuid4(), + "name": name, + "prep_time_minutes": prep, + "cook_time_minutes": cook, + "calories_per_serving": calories, + "protein_type": protein, + "cuisine_tags": list(cuisine_tags), + } + + +def _cost(recipe_id, total_cost): + return RecipeCost( + recipe_id=recipe_id, + total_cost=Decimal(str(total_cost)), + total_savings=Decimal("0"), + sale_ingredient_count=0, + matched_ingredient_count=1, + total_ingredient_count=1, + pantry_hit_count=0, + line_items=[], + ) + + +def test_filter_blocks_by_never_suggest_ingredient(): + r1 = _recipe(name="has-mushrooms") + r2 = _recipe(name="clean") + blocked_ingredient = uuid4() + recipe_ingredient_ids = {r1["id"]: {blocked_ingredient}, r2["id"]: {uuid4()}} + + result = filter_recipes( + recipes=[r1, r2], + recipe_ingredient_ids=recipe_ingredient_ids, + recipe_costs={r1["id"]: _cost(r1["id"], 10), r2["id"]: _cost(r2["id"], 10)}, + blocked_ingredient_ids={blocked_ingredient}, + blocked_recipe_ids=set(), + last_cooked_at={}, + family_calorie_target=2000, + config=_CFG, + today=date(2026, 5, 5), + ) + assert r2["id"] in result.feasible_recipe_ids + assert r1["id"] not in result.feasible_recipe_ids + assert "blocked_ingredient" in result.rejected[r1["id"]] + + +def test_filter_blocks_by_never_suggest_recipe(): + r1 = _recipe() + result = filter_recipes( + recipes=[r1], + recipe_ingredient_ids={r1["id"]: set()}, + recipe_costs={r1["id"]: _cost(r1["id"], 10)}, + blocked_ingredient_ids=set(), + blocked_recipe_ids={r1["id"]}, + last_cooked_at={}, + family_calorie_target=2000, + config=_CFG, + today=date(2026, 5, 5), + ) + assert r1["id"] not in result.feasible_recipe_ids + assert "blocked_recipe" in result.rejected[r1["id"]] + + +def test_filter_blocks_by_recency(): + r1 = _recipe() + last_week = date(2026, 5, 5) - timedelta(days=7) + result = filter_recipes( + recipes=[r1], + recipe_ingredient_ids={r1["id"]: set()}, + recipe_costs={r1["id"]: _cost(r1["id"], 10)}, + blocked_ingredient_ids=set(), + blocked_recipe_ids=set(), + last_cooked_at={r1["id"]: last_week}, + family_calorie_target=2000, + config=_CFG, + today=date(2026, 5, 5), + ) + assert "recency" in result.rejected[r1["id"]] + + +def test_filter_blocks_by_calories(): + over = _recipe(calories=3500) # 75% over a 2000 target + under = _recipe(calories=2200) + result = filter_recipes( + recipes=[over, under], + recipe_ingredient_ids={over["id"]: set(), under["id"]: set()}, + recipe_costs={over["id"]: _cost(over["id"], 10), under["id"]: _cost(under["id"], 10)}, + blocked_ingredient_ids=set(), + blocked_recipe_ids=set(), + last_cooked_at={}, + family_calorie_target=2000, + config=_CFG, + today=date(2026, 5, 5), + ) + assert under["id"] in result.feasible_recipe_ids + assert "calories" in result.rejected[over["id"]] + + +def test_filter_blocks_by_time(): + slow = _recipe(prep=30, cook=20) # 50 min total + fast = _recipe(prep=10, cook=20) + result = filter_recipes( + recipes=[slow, fast], + recipe_ingredient_ids={slow["id"]: set(), fast["id"]: set()}, + recipe_costs={slow["id"]: _cost(slow["id"], 10), fast["id"]: _cost(fast["id"], 10)}, + blocked_ingredient_ids=set(), + blocked_recipe_ids=set(), + last_cooked_at={}, + family_calorie_target=2000, + config=_CFG, + today=date(2026, 5, 5), + ) + assert "time" in result.rejected[slow["id"]] + assert fast["id"] in result.feasible_recipe_ids + + +def test_filter_blocks_by_cost(): + pricey = _recipe() + cheap = _recipe() + result = filter_recipes( + recipes=[pricey, cheap], + recipe_ingredient_ids={pricey["id"]: set(), cheap["id"]: set()}, + recipe_costs={pricey["id"]: _cost(pricey["id"], 50), cheap["id"]: _cost(cheap["id"], 25)}, + blocked_ingredient_ids=set(), + blocked_recipe_ids=set(), + last_cooked_at={}, + family_calorie_target=2000, + config=_CFG, + today=date(2026, 5, 5), + ) + assert "cost" in result.rejected[pricey["id"]] + assert cheap["id"] in result.feasible_recipe_ids + + +def test_filter_passes_when_calorie_target_is_none(): + r = _recipe(calories=9999) # absurd, but no target → can't enforce + result = filter_recipes( + recipes=[r], + recipe_ingredient_ids={r["id"]: set()}, + recipe_costs={r["id"]: _cost(r["id"], 10)}, + blocked_ingredient_ids=set(), + blocked_recipe_ids=set(), + last_cooked_at={}, + family_calorie_target=None, + config=_CFG, + today=date(2026, 5, 5), + ) + assert r["id"] in result.feasible_recipe_ids + + +def test_filter_blocks_recipe_with_null_calories_when_target_set(): + r = _recipe(calories=None) + result = filter_recipes( + recipes=[r], + recipe_ingredient_ids={r["id"]: set()}, + recipe_costs={r["id"]: _cost(r["id"], 10)}, + blocked_ingredient_ids=set(), + blocked_recipe_ids=set(), + last_cooked_at={}, + family_calorie_target=2000, + config=_CFG, + today=date(2026, 5, 5), + ) + assert "calories" in result.rejected[r["id"]]