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)
|
||||
Reference in New Issue
Block a user