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