Public Access
feat: planner per-recipe scoring with 5 weighted signals
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user