diff --git a/backend/app/services/orchestrator/steps.py b/backend/app/services/orchestrator/steps.py index f8bd1c9..8cad5fb 100644 --- a/backend/app/services/orchestrator/steps.py +++ b/backend/app/services/orchestrator/steps.py @@ -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 = "—" diff --git a/backend/app/services/planner/cost.py b/backend/app/services/planner/cost.py index 4b3da4e..9f62c05 100644 --- a/backend/app/services/planner/cost.py +++ b/backend/app/services/planner/cost.py @@ -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: diff --git a/backend/app/services/planner/generate.py b/backend/app/services/planner/generate.py index dcb779e..75050b6 100644 --- a/backend/app/services/planner/generate.py +++ b/backend/app/services/planner/generate.py @@ -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 diff --git a/backend/app/utils/units.py b/backend/app/utils/units.py new file mode 100644 index 0000000..98a92c4 --- /dev/null +++ b/backend/app/utils/units.py @@ -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) diff --git a/backend/tests/test_planner_cost.py b/backend/tests/test_planner_cost.py index c5163a7..81e1879 100644 --- a/backend/tests/test_planner_cost.py +++ b/backend/tests/test_planner_cost.py @@ -15,10 +15,12 @@ def _match(grocery_id, name, current, regular, is_on_sale, confidence=0.9): return { "grocery_item_id": grocery_id, "grocery_item_name": name, + "ingredient_name": name.lower(), "current_price": Decimal(str(current)), "regular_price": Decimal(str(regular)), "is_on_sale": is_on_sale, "confidence": Decimal(str(confidence)), + "grocery_unit": None, } diff --git a/backend/tests/test_units.py b/backend/tests/test_units.py new file mode 100644 index 0000000..d91ce44 --- /dev/null +++ b/backend/tests/test_units.py @@ -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")