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