feat: planner hard-constraint filter for the 6 spec constraints

This commit is contained in:
2026-05-06 06:42:49 -07:00
parent bf0a327561
commit 95396137c6
2 changed files with 275 additions and 0 deletions
+88
View File
@@ -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)