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