he99codes's picture
Clean deployment with LFS setup correctly
f75c5b2
"""nutrition_engine/mapper.py — unit-to-gram conversion, per-ingredient scaling, aggregation."""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from typing import Dict, List
from recipe_nlp.extractor import Ingredient
from nutrition_engine.usda_client import USDAClient
from utils.config import config, NutritionConfig
from utils.logger import logger
UNIT_TO_GRAMS: Dict[str, float] = {
"cup":240,"cups":240,"tablespoon":15,"tablespoons":15,"tbsp":15,
"teaspoon":5,"teaspoons":5,"tsp":5,"liter":1000,"liters":1000,
"milliliter":1,"milliliters":1,"ml":1,"fluid ounce":30,"fl oz":30,
"gram":1,"grams":1,"g":1,"kilogram":1000,"kg":1000,
"ounce":28.35,"ounces":28.35,"oz":28.35,"pound":453.6,"pounds":453.6,"lb":453.6,"lbs":453.6,
"piece":100,"pieces":100,"slice":30,"slices":30,"clove":5,"cloves":5,
"head":150,"bunch":100,"handful":50,"can":400,"cans":400,
"pinch":0.5,"dash":1,"":100,
}
DENSITY = {
"butter":0.96,"oil":0.92,"olive oil":0.92,"flour":0.53,
"sugar":0.85,"salt":1.2,"oats":0.4,"cheese":0.85,
}
@dataclass
class IngredientNutrition:
ingredient_name: str
quantity_g: float
nutrition_per_100g: Dict[str, float] = field(default_factory=dict)
nutrition_total: Dict[str, float] = field(default_factory=dict)
def compute_total(self):
scale = self.quantity_g / 100.0
self.nutrition_total = {k: round(v * scale, 2) for k, v in self.nutrition_per_100g.items()}
@dataclass
class RecipeNutrition:
total: Dict[str, float] = field(default_factory=dict)
per_serving: Dict[str, float] = field(default_factory=dict)
servings: int = 4
ingredient_breakdown: List[IngredientNutrition] = field(default_factory=list)
pct_calories_from_fat: float = 0.0
pct_calories_from_protein: float = 0.0
pct_calories_from_carbs: float = 0.0
cooking_method_score: float = 0.0
def to_feature_vector(self) -> Dict[str, float]:
feats = dict(self.per_serving)
feats["pct_calories_from_fat"] = self.pct_calories_from_fat
feats["pct_calories_from_protein"] = self.pct_calories_from_protein
feats["pct_calories_from_carbs"] = self.pct_calories_from_carbs
feats["cooking_method_score"] = self.cooking_method_score
return feats
class NutritionMapper:
def __init__(self, cfg: NutritionConfig = None):
self.cfg = cfg or config.nutrition
self.client = USDAClient(cfg)
def map_ingredients(self, ingredients: List[Ingredient]) -> List[IngredientNutrition]:
return [self._map_single(i) for i in ingredients]
def _map_single(self, ing: Ingredient) -> IngredientNutrition:
g = self._qty_to_grams(ing.quantity, ing.unit, ing.name)
per100 = self.client.get_nutrition(ing.name)
n = IngredientNutrition(ing.name, g, per100)
n.compute_total()
return n
def _qty_to_grams(self, qty_str: str, unit_str: str, food: str) -> float:
num = self._parse_num(qty_str or "")
if num == 0:
num = 1.0
unit = (unit_str or "").lower().strip()
gpunit = UNIT_TO_GRAMS.get(unit, 100.0)
total = num * gpunit
for k, c in DENSITY.items():
if k in food.lower():
total *= c
break
return float(max(0.5, min(3000.0, total)))
def _parse_num(self, s: str) -> float:
s = s.strip()
if not s:
return 0.0
m = re.match(r"^(\d+)\s+(\d+)/(\d+)$", s)
if m:
return float(m.group(1)) + float(m.group(2)) / float(m.group(3))
m = re.match(r"^(\d+)/(\d+)$", s)
if m:
return float(m.group(1)) / float(m.group(2))
try:
return float(s)
except ValueError:
return 0.0
class NutritionAggregator:
def __init__(self, cfg: NutritionConfig = None):
self.cfg = cfg or config.nutrition
def aggregate(self, ing_nutritions: List[IngredientNutrition],
servings: int, cooking_methods: List[str]) -> RecipeNutrition:
keys = self.cfg.nutrient_keys
total = {k: 0.0 for k in keys}
for n in ing_nutritions:
for k in keys:
total[k] += n.nutrition_total.get(k, 0.0)
srv = max(servings, 1)
per_srv = {k: round(v / srv, 1) for k, v in total.items()}
cals = per_srv.get("calories", 1) or 1
pct_fat = round(per_srv.get("total_fat", 0) * 9 / cals * 100, 1)
pct_prot = round(per_srv.get("protein", 0) * 4 / cals * 100, 1)
pct_carb = round(per_srv.get("carbohydrates", 0) * 4 / cals * 100, 1)
method_score = self._method_score(cooking_methods)
return RecipeNutrition(
total={k: round(v, 1) for k, v in total.items()},
per_serving=per_srv, servings=srv,
ingredient_breakdown=ing_nutritions,
pct_calories_from_fat=pct_fat,
pct_calories_from_protein=pct_prot,
pct_calories_from_carbs=pct_carb,
cooking_method_score=method_score,
)
def _method_score(self, methods: List[str]) -> float:
if not methods:
return 0.3
scores = [config.nlp.cooking_method_scores.get(m.lower(), 0.3) for m in methods]
return float(max(scores))