Public Access
feat: planner hard-constraint filter for the 6 spec constraints
This commit is contained in:
@@ -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)
|
||||||
@@ -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"]]
|
||||||
Reference in New Issue
Block a user