feat(backend): implement unit conversion for cost calculation

- Add UnitConverter (normalization, within-family, density tables)
- Update cost.py to convert recipe qty to grocery price unit
- Update generate.py _load_match_index to fetch ingredient name + unit
- Fix orchestrator email/shopping-list cost loops to use conversion
- Fix missing Ingredient import in generate.py
- Add 19 unit tests
This commit is contained in:
2026-05-24 13:46:50 -07:00
parent 3885d7d0dc
commit fd8ba3c4d2
6 changed files with 429 additions and 20 deletions
+25 -5
View File
@@ -23,6 +23,7 @@ from app.services.email import get_email_backend
from app.services.orchestrator.alerts import send_admin_alert from app.services.orchestrator.alerts import send_admin_alert
from app.services.planner.generate import generate_meal_plan from app.services.planner.generate import generate_meal_plan
from app.services.scraper_service import ScraperService from app.services.scraper_service import ScraperService
from app.utils.units import convert_qty
from app.services.feedback_analyzer import FeedbackAnalyzer from app.services.feedback_analyzer import FeedbackAnalyzer
from app.services.recipe_discovery import RecipeDiscoveryService from app.services.recipe_discovery import RecipeDiscoveryService
@@ -254,7 +255,16 @@ def step_email(run: "WeeklyRun", db: "Session") -> None:
.first() .first()
) )
if match and match.grocery_item and match.grocery_item.current_price: if match and match.grocery_item and match.grocery_item.current_price:
est_cost_total += float(match.grocery_item.current_price) qty = ing.get("qty", 1.0)
unit = ing.get("unit", "")
gunit = match.grocery_item.unit or ""
converted = convert_qty(
qty,
unit,
gunit,
ingredient_name_lower=ing_name,
)
est_cost_total += float(match.grocery_item.current_price) * float(converted)
recipe_servings = (item.recipe.servings or 4) if item.recipe else 4 recipe_servings = (item.recipe.servings or 4) if item.recipe else 4
est_cost_per_serving = est_cost_total / recipe_servings est_cost_per_serving = est_cost_total / recipe_servings
@@ -408,10 +418,20 @@ def step_finalize(run: "WeeklyRun", db: "Session") -> None:
.first() .first()
) )
if match and match.grocery_item: if match and match.grocery_item:
lucky_name = html.escape(match.grocery_item.name or "") g_item = match.grocery_item
price = float(match.grocery_item.current_price or 0) lucky_name = html.escape(g_item.name or "")
total_cost += price price = float(g_item.current_price or 0)
price_str = f"${price:.2f}" qty = ing.get("qty", 1.0)
unit = ing.get("unit", "")
gunit = g_item.unit or ""
converted = convert_qty(
qty,
unit,
gunit,
ingredient_name_lower=ing_name,
)
total_cost += price * float(converted)
price_str = f"${price * float(converted):.2f}"
else: else:
lucky_name = "" lucky_name = ""
price_str = "" price_str = ""
+18 -13
View File
@@ -4,27 +4,24 @@ Inputs:
ingredients: list[dict] from recipe.ingredients JSONB ingredients: list[dict] from recipe.ingredients JSONB
match_index: dict[ingredient_id, list[match_dict]] — pre-fetched, sorted by confidence DESC match_index: dict[ingredient_id, list[match_dict]] — pre-fetched, sorted by confidence DESC
pantry_ingredient_ids: set of ingredient_ids in home_pantry pantry_ingredient_ids: set of ingredient_ids in home_pantry
ingredient_name_index: optional dict[ingredient_id, name_lower] for unit conversion
Strategy: Strategy:
For each ingredient in the recipe, take the top-confidence match For each ingredient in the recipe, take the top-confidence match
(or skip if none). Cost = current_price * qty (best-effort scaling (or skip if none). Cost = current_price × converted_qty.
that ignores unit conversion — see Limitations below). Unit conversion is attempted via app.utils.units.convert_qty; if
Savings = max(regular - current, 0) * qty. the units are incommensurable, falls back to dimensionless qty to
preserve the monotonic ranking signal.
Limitations: Savings = max(regular - current, 0) × converted_qty.
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 __future__ import annotations
from decimal import Decimal from decimal import Decimal
from typing import Dict, Iterable, List, Set from typing import Dict, Iterable, List, Optional, Set
from uuid import UUID from uuid import UUID
from app.services.planner.types import IngredientCost, RecipeCost from app.services.planner.types import IngredientCost, RecipeCost
from app.utils.units import convert_qty
def _decimal(v) -> Decimal: def _decimal(v) -> Decimal:
@@ -87,8 +84,16 @@ def compute_recipe_cost(
current = _decimal(best.get("current_price")) current = _decimal(best.get("current_price"))
regular = _decimal(best.get("regular_price")) regular = _decimal(best.get("regular_price"))
is_on_sale = bool(best.get("is_on_sale")) is_on_sale = bool(best.get("is_on_sale"))
line_cost = _scale(current, qty) converted_qty = float(
line_savings = _scale(max(regular - current, Decimal("0")), qty) convert_qty(
qty,
unit,
best.get("grocery_unit"),
ingredient_name_lower=best.get("ingredient_name"),
)
)
line_cost = _scale(current, converted_qty)
line_savings = _scale(max(regular - current, Decimal("0")), converted_qty)
matched_count += 1 matched_count += 1
if is_on_sale: if is_on_sale:
+6 -2
View File
@@ -12,6 +12,7 @@ from app.models import (
FamilyProfile, FamilyProfile,
GroceryItem, GroceryItem,
HomePantry, HomePantry,
Ingredient,
IngredientGroceryMatch, IngredientGroceryMatch,
MealPlan, MealPlan,
MealPlanItem, MealPlanItem,
@@ -32,13 +33,14 @@ from app.services.planner.types import GenerationResult
def _load_match_index(db: Session) -> Dict[UUID, List[dict]]: def _load_match_index(db: Session) -> Dict[UUID, List[dict]]:
"""ingredient_id → list of match dicts ordered by confidence DESC.""" """ingredient_id → list of match dicts ordered by confidence DESC."""
rows = ( rows = (
db.query(IngredientGroceryMatch, GroceryItem) db.query(IngredientGroceryMatch, GroceryItem, Ingredient)
.join(GroceryItem, GroceryItem.id == IngredientGroceryMatch.grocery_item_id) .join(GroceryItem, GroceryItem.id == IngredientGroceryMatch.grocery_item_id)
.join(Ingredient, Ingredient.id == IngredientGroceryMatch.ingredient_id)
.order_by(IngredientGroceryMatch.confidence.desc()) .order_by(IngredientGroceryMatch.confidence.desc())
.all() .all()
) )
index: Dict[UUID, List[dict]] = {} index: Dict[UUID, List[dict]] = {}
for match, grocery in rows: for match, grocery, ingredient in rows:
index.setdefault(match.ingredient_id, []).append( index.setdefault(match.ingredient_id, []).append(
{ {
"grocery_item_id": grocery.id, "grocery_item_id": grocery.id,
@@ -47,6 +49,8 @@ def _load_match_index(db: Session) -> Dict[UUID, List[dict]]:
"regular_price": grocery.regular_price, "regular_price": grocery.regular_price,
"is_on_sale": bool(grocery.is_on_sale), "is_on_sale": bool(grocery.is_on_sale),
"confidence": match.confidence, "confidence": match.confidence,
"ingredient_name": ingredient.name.lower(),
"grocery_unit": grocery.unit,
} }
) )
return index return index
+273
View File
@@ -0,0 +1,273 @@
"""Unit conversion between recipe quantities and grocery store prices.
Handles:
1. Unit normalization (synonyms → canonical)
2. Within-family linear conversion (lb ↔ oz, cup ↔ tbsp, etc.)
3. Cross-family conversion via ingredient density lookups
4. Fallback to dimensionless qty if conversion is impossible
"""
from __future__ import annotations
from decimal import Decimal
from typing import Dict, Optional
# ---------------------------------------------------------------------------
# Normalization
# ---------------------------------------------------------------------------
_NORMALIZE: Dict[str, str] = {
# Weight
"pound": "lb",
"pounds": "lb",
"lbs": "lb",
"ounce": "oz",
"ounces": "oz",
"gram": "g",
"grams": "g",
"kg": "g",
"kilogram": "g",
"kilograms": "g",
# Volume
"cups": "cup",
"tablespoon": "tbsp",
"tablespoons": "tbsp",
"tbs": "tbsp",
"teaspoon": "tsp",
"teaspoons": "tsp",
"gallon": "gal",
"gallons": "gal",
"pint": "pt",
"pints": "pt",
"quart": "qt",
"quarts": "qt",
"fluid ounce": "fl oz",
"fluid ounces": "fl oz",
"fl oz": "fl oz",
"milliliter": "ml",
"milliliters": "ml",
"liter": "l",
"liters": "l",
# Count / discrete
"each": "ea",
"piece": "ea",
"pieces": "ea",
"item": "ea",
"items": "ea",
"doz": "dozen",
"dozen": "dozen",
# Container / pack
"jar": "jar",
"pack": "pack",
"container": "container",
"carton": "carton",
"bag": "bag",
"loaf": "loaf",
"head": "head",
"bunch": "bunch",
"stalk": "stalk",
"clove": "clove",
"can": "can",
# Leave these alone
"lb": "lb",
"oz": "oz",
"g": "g",
"cup": "cup",
"tbsp": "tbsp",
"tsp": "tsp",
"ea": "ea",
"dozen": "dozen",
"gal": "gal",
"pt": "pt",
"qt": "qt",
"ml": "ml",
"l": "l",
"fl oz": "fl oz",
}
# ---------------------------------------------------------------------------
# Linear conversion tables (canonical unit → factor to base unit in family)
# ---------------------------------------------------------------------------
_WEIGHT = {"lb": Decimal("1"), "oz": Decimal("0.0625"), "g": Decimal("0.00220462")}
_VOLUME = {
"cup": Decimal("1"),
"tbsp": Decimal("0.0625"),
"tsp": Decimal("0.0208333"),
"gal": Decimal("16"),
"qt": Decimal("4"),
"pt": Decimal("2"),
"fl oz": Decimal("0.125"),
"ml": Decimal("0.00422675"),
"l": Decimal("4.22675"),
}
_COUNT = {"ea": Decimal("1"), "dozen": Decimal("12")}
# ---------------------------------------------------------------------------
# Ingredient density table (grams per canonical unit used in recipes)
# ---------------------------------------------------------------------------
_DENSITY_G_PER_UNIT: Dict[str, Dict[str, Decimal]] = {
# ingredient_name_lower → {canonical_unit: grams_per_unit}
"olive oil": {"tbsp": Decimal("13.5"), "cup": Decimal("216")},
"butter, unsalted": {"tbsp": Decimal("14.2"), "cup": Decimal("227")},
"greek yogurt, plain": {"cup": Decimal("227")},
"milk, whole": {"cup": Decimal("244")},
"sour cream": {"cup": Decimal("230")},
"chicken broth": {"cup": Decimal("240")},
"rice, long-grain white": {"cup": Decimal("185")},
"pasta, penne": {"lb": Decimal("454"), "cup": Decimal("100")}, # dry
"pasta, spaghetti": {"lb": Decimal("454"), "cup": Decimal("100")}, # dry
"salt, kosher": {"tsp": Decimal("6"), "tbsp": Decimal("18")},
"black pepper": {"tsp": Decimal("2.3")},
"cumin, ground": {"tsp": Decimal("2.1")},
"italian seasoning": {"tsp": Decimal("1.5")},
"curry powder": {"tsp": Decimal("2.4")},
"paprika, smoked": {"tsp": Decimal("2.3")},
"soy sauce": {"tbsp": Decimal("16"), "cup": Decimal("256")},
"salsa, jarred": {"tbsp": Decimal("16")},
"cheddar cheese, sharp": {"oz": Decimal("28.35")},
"mozzarella, shredded": {"oz": Decimal("28.35")},
"parmesan, grated": {"tbsp": Decimal("5")},
"coconut milk, canned": {"can": Decimal("400"), "cup": Decimal("240")},
"black beans, canned": {"can": Decimal("439"), "cup": Decimal("240")},
"chickpeas, canned": {"can": Decimal("439"), "cup": Decimal("240")},
"diced tomatoes, canned": {"can": Decimal("411"), "cup": Decimal("240")},
}
# Fallbacks: when an ingredient isn't in density table, but we know typical
# pack sizes from seed data (makes approximate cross-unit possible)
_SEED_SIZE_G: Dict[str, Decimal] = {
"eggs, large": Decimal("50"),
"avocado": Decimal("170"),
"lemon": Decimal("58"),
"lime": Decimal("44"),
"tomato, roma": Decimal("62"),
"yellow onion": Decimal("110"),
"red bell pepper": Decimal("119"),
"green bell pepper": Decimal("109"),
"sweet potato": Decimal("114"),
"zucchini": Decimal("196"),
"carrot": Decimal("61"),
"broccoli": Decimal("454"),
"spinach, fresh": Decimal("28.35"), # per oz
"cilantro": Decimal("15"),
"parsley, italian": Decimal("15"),
}
class UnitConverter:
"""Convert recipe qty+unit to a store price unit (dimensionless fallback)."""
def __init__(self) -> None:
# Build fast lookup: alias → density row
self._density: Dict[str, Dict[str, Decimal]] = {}
for name, dmap in _DENSITY_G_PER_UNIT.items():
entry: Dict[str, Decimal] = dict(dmap)
# Standard weight units are always grams-per-weight-unit
entry.setdefault("lb", Decimal("453.592"))
entry.setdefault("oz", Decimal("28.3495"))
entry.setdefault("g", Decimal("1"))
self._density[name] = entry
for alias in name.split(", "):
self._density.setdefault(alias, entry)
for name, grams in _SEED_SIZE_G.items():
entry = self._density.setdefault(name, {})
entry.setdefault("ea", grams)
entry.setdefault("lb", Decimal("453.592"))
entry.setdefault("oz", Decimal("28.3495"))
entry.setdefault("g", Decimal("1"))
for alias in name.split(", "):
self._density.setdefault(alias, entry)
# -- public API ---------------------------------------------------------
def normalize(self, unit: Optional[str]) -> str:
"""Return canonical unit string or 'unknown'."""
if not unit:
return "unknown"
s = unit.strip().lower()
# Exact match first
canon = _NORMALIZE.get(s)
if canon:
return canon
# De-pluralize fallback
return _NORMALIZE.get(s.rstrip("s"), s)
def convert(
self,
qty: float,
from_unit: Optional[str],
to_unit: Optional[str],
ingredient_name_lower: Optional[str] = None,
) -> Decimal:
"""Return a dimensionless scalar to multiply store price by.
If conversion is impossible, returns the original qty unchanged
(preserves the old monotonic ranking signal).
"""
if not from_unit or not to_unit:
return Decimal(str(qty))
fu = self.normalize(from_unit)
tu = self.normalize(to_unit)
if fu == tu:
return Decimal(str(qty))
# 1. Same family → linear ratio
ratio = self._same_family_ratio(fu, tu)
if ratio is not None:
return (Decimal(str(qty)) * ratio).quantize(Decimal("0.0001"))
# 2. Cross-family with density
if ingredient_name_lower:
ratio = self._density_ratio(fu, tu, ingredient_name_lower)
if ratio is not None:
return (Decimal(str(qty)) * ratio).quantize(Decimal("0.0001"))
# 3. Fallback: treat as dimensionless
return Decimal(str(qty))
# -- helpers -----------------------------------------------------------
def _same_family_ratio(self, fu: str, tu: str) -> Optional[Decimal]:
for family in (_WEIGHT, _VOLUME, _COUNT):
if fu in family and tu in family:
base_fu = family[fu]
base_tu = family[tu]
# fu →* base, tu →* base → qty_fu × (base_fu / base_tu) = qty_tu
return base_fu / base_tu
return None
def _density_ratio(
self,
fu: str,
tu: str,
ing_name: str,
) -> Optional[Decimal]:
dmap = self._density.get(ing_name.lower())
if not dmap:
return None
g_fu = dmap.get(fu)
g_tu = dmap.get(tu)
if g_fu is None or g_tu is None:
return None
# qty_fu × g_fu grams = qty_tu × g_tu grams
# → qty_tu = qty_fu × g_fu / g_tu
return g_fu / g_tu
# Module-level singleton for convenience
_DEFAULT_CONVERTER = UnitConverter()
# Convenience free functions so callers don't need an instance
def normalize_unit(unit: Optional[str]) -> str:
return _DEFAULT_CONVERTER.normalize(unit)
def convert_qty(
qty: float,
from_unit: Optional[str],
to_unit: Optional[str],
ingredient_name_lower: Optional[str] = None,
) -> Decimal:
return _DEFAULT_CONVERTER.convert(qty, from_unit, to_unit, ingredient_name_lower)
+2
View File
@@ -15,10 +15,12 @@ def _match(grocery_id, name, current, regular, is_on_sale, confidence=0.9):
return { return {
"grocery_item_id": grocery_id, "grocery_item_id": grocery_id,
"grocery_item_name": name, "grocery_item_name": name,
"ingredient_name": name.lower(),
"current_price": Decimal(str(current)), "current_price": Decimal(str(current)),
"regular_price": Decimal(str(regular)), "regular_price": Decimal(str(regular)),
"is_on_sale": is_on_sale, "is_on_sale": is_on_sale,
"confidence": Decimal(str(confidence)), "confidence": Decimal(str(confidence)),
"grocery_unit": None,
} }
+105
View File
@@ -0,0 +1,105 @@
"""Tests for app.utils.units."""
from decimal import Decimal
import pytest
from app.utils.units import UnitConverter, convert_qty, normalize_unit
class TestNormalizeUnit:
def test_synonyms(self):
assert normalize_unit("pounds") == "lb"
assert normalize_unit("OUNCE") == "oz"
assert normalize_unit("Cups") == "cup"
assert normalize_unit("TBS") == "tbsp"
assert normalize_unit("each") == "ea"
assert normalize_unit("doz") == "dozen"
def test_already_canonical(self):
assert normalize_unit("lb") == "lb"
assert normalize_unit("cup") == "cup"
assert normalize_unit("ea") == "ea"
def test_unknown_passthrough(self):
assert normalize_unit("bunch") == "bunch"
assert normalize_unit("can") == "can"
def test_none(self):
assert normalize_unit(None) == "unknown"
assert normalize_unit("") == "unknown"
class TestWithinFamilyConversion:
def test_weight_lb_to_oz(self):
assert convert_qty(2.0, "lb", "oz") == Decimal("32")
def test_weight_oz_to_lb(self):
result = convert_qty(16.0, "oz", "lb")
assert result == Decimal("1.0000")
def test_volume_cup_to_tbsp(self):
result = convert_qty(1.0, "cup", "tbsp")
assert result == Decimal("16")
def test_volume_tbsp_to_tsp(self):
result = convert_qty(1.0, "tbsp", "tsp")
assert result == Decimal("3")
def test_count_dozen_to_ea(self):
result = convert_qty(1.0, "dozen", "ea")
assert result == Decimal("12")
def test_same_unit(self):
assert convert_qty(5.0, "lb", "lb") == Decimal("5")
class TestDensityConversion:
def test_olive_oil_tbsp_to_cup(self):
# 1 cup = 16 tbsp
result = convert_qty(
1.0, "cup", "tbsp", ingredient_name_lower="olive oil"
)
assert result == Decimal("16")
def test_rice_cup_to_lb(self):
# 1 cup rice = 185 g; 1 lb = 453.592 g → 1 cup = 185/453.592 ≈ 0.4079 lb
result = convert_qty(
1.0, "cup", "lb", ingredient_name_lower="rice, long-grain white"
)
assert result == Decimal("0.4079")
def test_butter_tbsp_to_cup(self):
result = convert_qty(
1.0, "cup", "tbsp", ingredient_name_lower="butter, unsalted"
)
assert result == Decimal("16")
def test_seed_size_ea_to_lb(self):
# 1 avocado ≈ 170 g = 0.3748 lb
result = convert_qty(
1.0, "ea", "lb", ingredient_name_lower="avocado"
)
assert result == Decimal("0.3748")
def test_unknown_density_falls_back(self):
# No density for "dragon fruit" → fallback to dimensionless qty
result = convert_qty(2.0, "cup", "lb", ingredient_name_lower="dragon fruit")
assert result == Decimal("2")
class TestFallback:
def test_incommensurable_units_without_name(self):
result = convert_qty(2.0, "cup", "lb")
assert result == Decimal("2")
def test_incommensurable_units_with_unmapped_name(self):
result = convert_qty(2.0, "cup", "lb", ingredient_name_lower="xyz")
assert result == Decimal("2")
def test_missing_from_unit(self):
result = convert_qty(2.0, None, "lb")
assert result == Decimal("2")
def test_missing_to_unit(self):
result = convert_qty(2.0, "lb", None)
assert result == Decimal("2")