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