Public Access
feat: planner cost+savings estimator against ingredient_grocery_match
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
"""Compute per-recipe cost and savings against ingredient_grocery_match.
|
||||
|
||||
Inputs:
|
||||
ingredients: list[dict] from recipe.ingredients JSONB
|
||||
match_index: dict[ingredient_id, list[match_dict]] — pre-fetched, sorted by confidence DESC
|
||||
pantry_ingredient_ids: set of ingredient_ids in home_pantry
|
||||
|
||||
Strategy:
|
||||
For each ingredient in the recipe, take the top-confidence match
|
||||
(or skip if none). Cost = current_price * qty (best-effort scaling
|
||||
that ignores unit conversion — see Limitations below).
|
||||
Savings = max(regular - current, 0) * qty.
|
||||
|
||||
Limitations:
|
||||
Unit conversion (lb vs oz, cup vs ml) is NOT implemented in this
|
||||
pass. The qty multiplier is treated as dimensionless. This produces
|
||||
a biased-but-monotonic ranking signal: recipes that use more of an
|
||||
expensive ingredient still rank as more expensive, which is what
|
||||
the planner needs. Real dollar accuracy can come later.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
from typing import Dict, Iterable, List, Set
|
||||
from uuid import UUID
|
||||
|
||||
from app.services.planner.types import IngredientCost, RecipeCost
|
||||
|
||||
|
||||
def _decimal(v) -> Decimal:
|
||||
if v is None:
|
||||
return Decimal("0")
|
||||
return v if isinstance(v, Decimal) else Decimal(str(v))
|
||||
|
||||
|
||||
def _scale(price: Decimal, qty: float) -> Decimal:
|
||||
return (price * Decimal(str(qty))).quantize(Decimal("0.01"))
|
||||
|
||||
|
||||
def compute_recipe_cost(
|
||||
*,
|
||||
recipe_id: UUID,
|
||||
ingredients: Iterable[dict],
|
||||
match_index: Dict[UUID, List[dict]],
|
||||
pantry_ingredient_ids: Set[UUID],
|
||||
) -> RecipeCost:
|
||||
line_items: List[IngredientCost] = []
|
||||
total_cost = Decimal("0.00")
|
||||
total_savings = Decimal("0.00")
|
||||
sale_count = 0
|
||||
matched_count = 0
|
||||
pantry_hits = 0
|
||||
total = 0
|
||||
|
||||
for raw in ingredients:
|
||||
total += 1
|
||||
ing_id = raw["ingredient_id"]
|
||||
if isinstance(ing_id, str):
|
||||
ing_id = UUID(ing_id)
|
||||
qty = float(raw.get("qty") or 1.0)
|
||||
unit = raw.get("unit")
|
||||
|
||||
if ing_id in pantry_ingredient_ids:
|
||||
pantry_hits += 1
|
||||
|
||||
candidates = match_index.get(ing_id) or []
|
||||
if not candidates:
|
||||
line_items.append(
|
||||
IngredientCost(
|
||||
ingredient_id=ing_id,
|
||||
qty=qty,
|
||||
unit=unit,
|
||||
grocery_item_id=None,
|
||||
grocery_item_name=None,
|
||||
current_price=None,
|
||||
regular_price=None,
|
||||
is_on_sale=False,
|
||||
estimated_cost=Decimal("0.00"),
|
||||
estimated_savings=Decimal("0.00"),
|
||||
matched=False,
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
best = candidates[0]
|
||||
current = _decimal(best.get("current_price"))
|
||||
regular = _decimal(best.get("regular_price"))
|
||||
is_on_sale = bool(best.get("is_on_sale"))
|
||||
line_cost = _scale(current, qty)
|
||||
line_savings = _scale(max(regular - current, Decimal("0")), qty)
|
||||
|
||||
matched_count += 1
|
||||
if is_on_sale:
|
||||
sale_count += 1
|
||||
total_cost += line_cost
|
||||
total_savings += line_savings
|
||||
|
||||
line_items.append(
|
||||
IngredientCost(
|
||||
ingredient_id=ing_id,
|
||||
qty=qty,
|
||||
unit=unit,
|
||||
grocery_item_id=best.get("grocery_item_id"),
|
||||
grocery_item_name=best.get("grocery_item_name"),
|
||||
current_price=current,
|
||||
regular_price=regular,
|
||||
is_on_sale=is_on_sale,
|
||||
estimated_cost=line_cost,
|
||||
estimated_savings=line_savings,
|
||||
matched=True,
|
||||
)
|
||||
)
|
||||
|
||||
return RecipeCost(
|
||||
recipe_id=recipe_id,
|
||||
total_cost=total_cost.quantize(Decimal("0.01")),
|
||||
total_savings=total_savings.quantize(Decimal("0.01")),
|
||||
sale_ingredient_count=sale_count,
|
||||
matched_ingredient_count=matched_count,
|
||||
total_ingredient_count=total,
|
||||
pantry_hit_count=pantry_hits,
|
||||
line_items=line_items,
|
||||
)
|
||||
@@ -0,0 +1,80 @@
|
||||
from decimal import Decimal
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services.planner.cost import compute_recipe_cost
|
||||
from app.services.planner.types import RecipeCost
|
||||
|
||||
|
||||
def _ingredient(ing_id, qty=1.0, unit=None):
|
||||
return {"ingredient_id": ing_id, "qty": qty, "unit": unit}
|
||||
|
||||
|
||||
def _match(grocery_id, name, current, regular, is_on_sale, confidence=0.9):
|
||||
return {
|
||||
"grocery_item_id": grocery_id,
|
||||
"grocery_item_name": name,
|
||||
"current_price": Decimal(str(current)),
|
||||
"regular_price": Decimal(str(regular)),
|
||||
"is_on_sale": is_on_sale,
|
||||
"confidence": Decimal(str(confidence)),
|
||||
}
|
||||
|
||||
|
||||
def test_compute_recipe_cost_sums_ingredient_costs():
|
||||
recipe_id = uuid4()
|
||||
i1, i2 = uuid4(), uuid4()
|
||||
g1, g2 = uuid4(), uuid4()
|
||||
matches = {
|
||||
i1: [_match(g1, "Chicken", current=4.00, regular=5.00, is_on_sale=True)],
|
||||
i2: [_match(g2, "Olive Oil", current=8.00, regular=8.00, is_on_sale=False)],
|
||||
}
|
||||
pantry_ingredient_ids = set()
|
||||
|
||||
result = compute_recipe_cost(
|
||||
recipe_id=recipe_id,
|
||||
ingredients=[_ingredient(i1, qty=2.0), _ingredient(i2, qty=1.0)],
|
||||
match_index=matches,
|
||||
pantry_ingredient_ids=pantry_ingredient_ids,
|
||||
)
|
||||
|
||||
assert isinstance(result, RecipeCost)
|
||||
assert result.recipe_id == recipe_id
|
||||
assert result.total_ingredient_count == 2
|
||||
assert result.matched_ingredient_count == 2
|
||||
assert result.sale_ingredient_count == 1
|
||||
assert result.total_cost == Decimal("16.00") # 2*4 + 1*8
|
||||
assert result.total_savings == Decimal("2.00") # 2*(5-4)
|
||||
|
||||
|
||||
def test_unmatched_ingredient_zero_cost_and_savings():
|
||||
recipe_id = uuid4()
|
||||
i1 = uuid4()
|
||||
result = compute_recipe_cost(
|
||||
recipe_id=recipe_id,
|
||||
ingredients=[_ingredient(i1, qty=1.0)],
|
||||
match_index={},
|
||||
pantry_ingredient_ids=set(),
|
||||
)
|
||||
assert result.matched_ingredient_count == 0
|
||||
assert result.total_cost == Decimal("0.00")
|
||||
assert result.total_savings == Decimal("0.00")
|
||||
|
||||
|
||||
def test_pantry_hits_counted():
|
||||
recipe_id = uuid4()
|
||||
i1, i2 = uuid4(), uuid4()
|
||||
g1, g2 = uuid4(), uuid4()
|
||||
matches = {
|
||||
i1: [_match(g1, "X", current=2.00, regular=2.00, is_on_sale=False)],
|
||||
i2: [_match(g2, "Y", current=3.00, regular=3.00, is_on_sale=False)],
|
||||
}
|
||||
result = compute_recipe_cost(
|
||||
recipe_id=recipe_id,
|
||||
ingredients=[_ingredient(i1), _ingredient(i2)],
|
||||
match_index=matches,
|
||||
pantry_ingredient_ids={i1},
|
||||
)
|
||||
assert result.pantry_hit_count == 1
|
||||
assert result.pantry_hit_pct == 0.5
|
||||
Reference in New Issue
Block a user