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.planner.generate import generate_meal_plan
from app.services.scraper_service import ScraperService
from app.utils.units import convert_qty
from app.services.feedback_analyzer import FeedbackAnalyzer
from app.services.recipe_discovery import RecipeDiscoveryService
@@ -254,7 +255,16 @@ def step_email(run: "WeeklyRun", db: "Session") -> None:
.first()
)
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
est_cost_per_serving = est_cost_total / recipe_servings
@@ -408,10 +418,20 @@ def step_finalize(run: "WeeklyRun", db: "Session") -> None:
.first()
)
if match and match.grocery_item:
lucky_name = html.escape(match.grocery_item.name or "")
price = float(match.grocery_item.current_price or 0)
total_cost += price
price_str = f"${price:.2f}"
g_item = match.grocery_item
lucky_name = html.escape(g_item.name or "")
price = float(g_item.current_price or 0)
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:
lucky_name = ""
price_str = ""
+18 -13
View File
@@ -4,27 +4,24 @@ 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
ingredient_name_index: optional dict[ingredient_id, name_lower] for unit conversion
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.
(or skip if none). Cost = current_price × converted_qty.
Unit conversion is attempted via app.utils.units.convert_qty; if
the units are incommensurable, falls back to dimensionless qty to
preserve the monotonic ranking signal.
Savings = max(regular - current, 0) × converted_qty.
"""
from __future__ import annotations
from decimal import Decimal
from typing import Dict, Iterable, List, Set
from typing import Dict, Iterable, List, Optional, Set
from uuid import UUID
from app.services.planner.types import IngredientCost, RecipeCost
from app.utils.units import convert_qty
def _decimal(v) -> Decimal:
@@ -87,8 +84,16 @@ def compute_recipe_cost(
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)
converted_qty = float(
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
if is_on_sale:
+6 -2
View File
@@ -12,6 +12,7 @@ from app.models import (
FamilyProfile,
GroceryItem,
HomePantry,
Ingredient,
IngredientGroceryMatch,
MealPlan,
MealPlanItem,
@@ -32,13 +33,14 @@ from app.services.planner.types import GenerationResult
def _load_match_index(db: Session) -> Dict[UUID, List[dict]]:
"""ingredient_id → list of match dicts ordered by confidence DESC."""
rows = (
db.query(IngredientGroceryMatch, GroceryItem)
db.query(IngredientGroceryMatch, GroceryItem, Ingredient)
.join(GroceryItem, GroceryItem.id == IngredientGroceryMatch.grocery_item_id)
.join(Ingredient, Ingredient.id == IngredientGroceryMatch.ingredient_id)
.order_by(IngredientGroceryMatch.confidence.desc())
.all()
)
index: Dict[UUID, List[dict]] = {}
for match, grocery in rows:
for match, grocery, ingredient in rows:
index.setdefault(match.ingredient_id, []).append(
{
"grocery_item_id": grocery.id,
@@ -47,6 +49,8 @@ def _load_match_index(db: Session) -> Dict[UUID, List[dict]]:
"regular_price": grocery.regular_price,
"is_on_sale": bool(grocery.is_on_sale),
"confidence": match.confidence,
"ingredient_name": ingredient.name.lower(),
"grocery_unit": grocery.unit,
}
)
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)