Public Access
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:
@@ -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 = "—"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user