feat: planner per-recipe scoring with 5 weighted signals

This commit is contained in:
2026-05-06 06:44:58 -07:00
parent 95396137c6
commit 77813cc7d3
2 changed files with 217 additions and 0 deletions
+110
View File
@@ -0,0 +1,110 @@
"""Per-recipe scoring with the 5 weighted signals from the spec."""
from __future__ import annotations
from datetime import date
from decimal import Decimal
from typing import Dict, Iterable, List, Optional
from uuid import UUID
from app.services.planner.config import PlannerConfig
from app.services.planner.types import RecipeCost, ScoredRecipe
def time_bonus(total_minutes: int, config: PlannerConfig) -> float:
if total_minutes <= config.time_ideal_minutes:
return 1.0
if total_minutes >= config.time_full_minutes:
return 0.0
span = config.time_full_minutes - config.time_ideal_minutes
over = total_minutes - config.time_ideal_minutes
return max(0.0, min(1.0, 1.0 - over / span))
def recency_bonus(
last_cooked: Optional[date],
today: date,
config: PlannerConfig,
) -> float:
if last_cooked is None:
return 1.0
weeks_ago = (today - last_cooked).days / 7
if weeks_ago >= config.recency_full_weeks:
return 1.0
# Below recency_weeks the recipe wouldn't be in the feasible set, so we treat
# exactly recency_weeks as score 0 and recency_full_weeks as score 1.
if weeks_ago <= config.recency_weeks:
return 0.0
span = config.recency_full_weeks - config.recency_weeks
over = weeks_ago - config.recency_weeks
return max(0.0, min(1.0, over / span))
def normalize_savings(values: List[Decimal]) -> List[float]:
if not values:
return []
floats = [float(v) for v in values]
lo, hi = min(floats), max(floats)
if hi == lo:
return [0.0] * len(floats)
return [(v - lo) / (hi - lo) for v in floats]
def score_recipes(
*,
recipes: Iterable[dict],
recipe_costs: Dict[UUID, RecipeCost],
last_cooked_at: Dict[UUID, date],
config: PlannerConfig,
today: date,
) -> List[ScoredRecipe]:
"""Returns recipes scored DESC. Caller passes only the feasible set."""
materialized = list(recipes)
if not materialized:
return []
savings = [recipe_costs[r["id"]].total_savings for r in materialized]
norm_savings = normalize_savings(savings)
out: List[ScoredRecipe] = []
for r, ns in zip(materialized, norm_savings):
rid = r["id"]
if isinstance(rid, str):
rid = UUID(rid)
cost = recipe_costs[rid]
total_min = int(r.get("prep_time_minutes") or 0) + int(r.get("cook_time_minutes") or 0)
tb = time_bonus(total_min, config)
rb = recency_bonus(last_cooked_at.get(rid), today, config)
components = {
"savings_normalized": ns,
"sale_coverage_pct": cost.sale_coverage_pct,
"pantry_hit_pct": cost.pantry_hit_pct,
"time_bonus": tb,
"recency_bonus": rb,
"savings_dollars": float(cost.total_savings),
}
score = (
config.w_savings * ns
+ config.w_coverage * cost.sale_coverage_pct
+ config.w_pantry * cost.pantry_hit_pct
+ config.w_time * tb
+ config.w_recency * rb
)
cuisine_tags = r.get("cuisine_tags") or []
primary_cuisine = cuisine_tags[0] if cuisine_tags else None
out.append(
ScoredRecipe(
recipe_id=rid,
score=score,
components=components,
cost=cost,
protein=r.get("protein_type"),
cuisine=primary_cuisine,
)
)
out.sort(key=lambda s: s.score, reverse=True)
return out
+107
View File
@@ -0,0 +1,107 @@
from datetime import date, timedelta
from decimal import Decimal
from uuid import uuid4
from app.services.planner.config import PlannerConfig
from app.services.planner.score import (
score_recipes,
time_bonus,
recency_bonus,
normalize_savings,
)
from app.services.planner.types import RecipeCost
_CFG = PlannerConfig()
def test_time_bonus_capped_at_ideal():
assert time_bonus(20, _CFG) == 1.0
assert time_bonus(25, _CFG) == 1.0
def test_time_bonus_decays_to_zero_at_full():
assert time_bonus(45, _CFG) == 0.0
def test_time_bonus_linear_midpoint():
# 35 min is halfway between 25 and 45
assert abs(time_bonus(35, _CFG) - 0.5) < 1e-6
def test_recency_bonus_full_when_long_ago():
today = date(2026, 5, 5)
long_ago = today - timedelta(weeks=20)
assert recency_bonus(long_ago, today, _CFG) == 1.0
def test_recency_bonus_zero_when_just_eligible():
# right at recency_weeks boundary → 0
today = date(2026, 5, 5)
cutoff = today - timedelta(weeks=_CFG.recency_weeks)
assert recency_bonus(cutoff, today, _CFG) == 0.0
def test_recency_bonus_full_when_never_cooked():
assert recency_bonus(None, date(2026, 5, 5), _CFG) == 1.0
def test_normalize_savings_minmax():
out = normalize_savings([Decimal("0"), Decimal("5"), Decimal("10")])
assert out == [0.0, 0.5, 1.0]
def test_normalize_savings_uniform_returns_zeros():
out = normalize_savings([Decimal("3"), Decimal("3"), Decimal("3")])
assert out == [0.0, 0.0, 0.0]
def test_score_recipes_orders_by_combined_score():
recipes = [
{
"id": uuid4(),
"name": "low_savings",
"prep_time_minutes": 10,
"cook_time_minutes": 30,
"protein_type": "chicken",
"cuisine_tags": ["american"],
},
{
"id": uuid4(),
"name": "high_savings",
"prep_time_minutes": 10,
"cook_time_minutes": 15, # also lower time
"protein_type": "beef",
"cuisine_tags": ["mexican"],
},
]
costs = {
recipes[0]["id"]: RecipeCost(
recipe_id=recipes[0]["id"],
total_cost=Decimal("10"),
total_savings=Decimal("1"),
sale_ingredient_count=1,
matched_ingredient_count=4,
total_ingredient_count=4,
pantry_hit_count=0,
line_items=[],
),
recipes[1]["id"]: RecipeCost(
recipe_id=recipes[1]["id"],
total_cost=Decimal("12"),
total_savings=Decimal("8"),
sale_ingredient_count=3,
matched_ingredient_count=4,
total_ingredient_count=4,
pantry_hit_count=2,
line_items=[],
),
}
scored = score_recipes(
recipes=recipes,
recipe_costs=costs,
last_cooked_at={},
config=_CFG,
today=date(2026, 5, 5),
)
assert scored[0].recipe_id == recipes[1]["id"] # high_savings first