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
+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)