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