| """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)) |
|
|