| """nutrition_engine/usda_client.py — USDA FDC API client with local cache + fallback DB.""" |
| from __future__ import annotations |
| import json, time |
| from pathlib import Path |
| from typing import Dict, Optional, Any |
| import requests |
| from utils.config import config, NutritionConfig |
| from utils.logger import logger |
|
|
| USDA_NUTRIENT_ID_MAP = { |
| 1008:"calories", 1004:"total_fat", 1258:"saturated_fat", |
| 1003:"protein", 1005:"carbohydrates", 2000:"sugar", 1079:"fiber", 1093:"sodium", |
| } |
| NUTRIENT_NAME_MAP = { |
| "energy":"calories","total lipid":"total_fat","fatty acids, total saturated":"saturated_fat", |
| "protein":"protein","carbohydrate":"carbohydrates","sugars, total":"sugar", |
| "fiber, total dietary":"fiber","sodium":"sodium", |
| } |
|
|
| FALLBACK_NUTRITION_DB: Dict[str, Dict[str, float]] = { |
| "butter": {"calories":717,"total_fat":81.1,"saturated_fat":51.4,"protein":0.85,"carbohydrates":0.06,"sugar":0.06,"fiber":0.0,"sodium":714}, |
| "chicken": {"calories":239,"total_fat":13.6,"saturated_fat":3.8, "protein":27.3,"carbohydrates":0.0, "sugar":0.0, "fiber":0.0,"sodium":82}, |
| "olive oil": {"calories":884,"total_fat":100.0,"saturated_fat":13.8,"protein":0.0,"carbohydrates":0.0, "sugar":0.0, "fiber":0.0,"sodium":2}, |
| "flour": {"calories":364,"total_fat":1.0, "saturated_fat":0.16,"protein":10.3,"carbohydrates":76.3,"sugar":0.27,"fiber":2.7,"sodium":2}, |
| "sugar": {"calories":387,"total_fat":0.0, "saturated_fat":0.0, "protein":0.0, "carbohydrates":99.98,"sugar":99.8,"fiber":0.0,"sodium":1}, |
| "heavy cream": {"calories":345,"total_fat":37.0, "saturated_fat":23.0,"protein":2.1, "carbohydrates":2.8, "sugar":2.8, "fiber":0.0,"sodium":38}, |
| "egg": {"calories":143,"total_fat":9.5, "saturated_fat":3.1, "protein":12.6,"carbohydrates":0.72,"sugar":0.37,"fiber":0.0,"sodium":142}, |
| "milk": {"calories":61, "total_fat":3.3, "saturated_fat":1.9, "protein":3.2, "carbohydrates":4.8, "sugar":5.0, "fiber":0.0,"sodium":44}, |
| "cheese": {"calories":402,"total_fat":33.1, "saturated_fat":20.8,"protein":25.0,"carbohydrates":1.3, "sugar":0.5, "fiber":0.0,"sodium":621}, |
| "salt": {"calories":0, "total_fat":0.0, "saturated_fat":0.0, "protein":0.0, "carbohydrates":0.0, "sugar":0.0, "fiber":0.0,"sodium":38758}, |
| "garlic": {"calories":149,"total_fat":0.5, "saturated_fat":0.09,"protein":6.4, "carbohydrates":33.1,"sugar":1.0, "fiber":2.1,"sodium":17}, |
| "onion": {"calories":40, "total_fat":0.1, "saturated_fat":0.04,"protein":1.1, "carbohydrates":9.3, "sugar":4.2, "fiber":1.7,"sodium":4}, |
| "tomato": {"calories":18, "total_fat":0.2, "saturated_fat":0.03,"protein":0.88,"carbohydrates":3.9, "sugar":2.6, "fiber":1.2,"sodium":5}, |
| "spinach": {"calories":23, "total_fat":0.4, "saturated_fat":0.06,"protein":2.9, "carbohydrates":3.6, "sugar":0.42,"fiber":2.2,"sodium":79}, |
| "broccoli": {"calories":34, "total_fat":0.4, "saturated_fat":0.04,"protein":2.8, "carbohydrates":6.6, "sugar":1.7, "fiber":2.6,"sodium":33}, |
| "salmon": {"calories":208,"total_fat":13.4, "saturated_fat":3.1, "protein":20.4,"carbohydrates":0.0, "sugar":0.0, "fiber":0.0,"sodium":59}, |
| "rice": {"calories":130,"total_fat":0.3, "saturated_fat":0.08,"protein":2.7, "carbohydrates":28.2,"sugar":0.05,"fiber":0.4,"sodium":1}, |
| "oats": {"calories":389,"total_fat":6.9, "saturated_fat":1.2, "protein":16.9,"carbohydrates":66.3,"sugar":0.99,"fiber":10.6,"sodium":2}, |
| "bacon": {"calories":541,"total_fat":45.0, "saturated_fat":15.1,"protein":37.0,"carbohydrates":1.4, "sugar":0.0, "fiber":0.0,"sodium":1717}, |
| "avocado": {"calories":160,"total_fat":14.7, "saturated_fat":2.1, "protein":2.0, "carbohydrates":8.5, "sugar":0.66,"fiber":6.7,"sodium":7}, |
| "lentil": {"calories":116,"total_fat":0.4, "saturated_fat":0.05,"protein":9.0, "carbohydrates":20.1,"sugar":1.8, "fiber":7.9,"sodium":2}, |
| "oil": {"calories":884,"total_fat":100.0,"saturated_fat":14.0,"protein":0.0, "carbohydrates":0.0, "sugar":0.0, "fiber":0.0,"sodium":0}, |
| "cream": {"calories":345,"total_fat":37.0, "saturated_fat":23.0,"protein":2.1, "carbohydrates":2.8, "sugar":2.8, "fiber":0.0,"sodium":38}, |
| "pasta": {"calories":371,"total_fat":1.5, "saturated_fat":0.28,"protein":13.0,"carbohydrates":75.0,"sugar":0.56,"fiber":3.2,"sodium":6}, |
| "spaghetti": {"calories":371,"total_fat":1.5, "saturated_fat":0.28,"protein":13.0,"carbohydrates":75.0,"sugar":0.56,"fiber":3.2,"sodium":6}, |
| "carrot": {"calories":41, "total_fat":0.24, "saturated_fat":0.04,"protein":0.93,"carbohydrates":9.6, "sugar":4.7, "fiber":2.8,"sodium":69}, |
| "celery": {"calories":16, "total_fat":0.17, "saturated_fat":0.04,"protein":0.69,"carbohydrates":3.0, "sugar":1.8, "fiber":1.6,"sodium":80}, |
| "potato": {"calories":77, "total_fat":0.09, "saturated_fat":0.02,"protein":2.0, "carbohydrates":17.0,"sugar":0.78,"fiber":2.2,"sodium":6}, |
| "parmesan": {"calories":431,"total_fat":29.0, "saturated_fat":18.6,"protein":38.0,"carbohydrates":3.2, "sugar":0.0, "fiber":0.0,"sodium":1529}, |
| "brown rice": {"calories":216,"total_fat":1.8, "saturated_fat":0.36,"protein":5.0, "carbohydrates":45.0,"sugar":0.7, "fiber":3.5,"sodium":10}, |
| } |
|
|
|
|
| class NutritionCache: |
| def __init__(self, cache_file: Path): |
| self.cache_file = cache_file |
| self._data: Dict[str, Any] = {} |
| self._load() |
|
|
| def _load(self): |
| if self.cache_file.exists(): |
| try: |
| with open(self.cache_file) as f: |
| self._data = json.load(f) |
| except Exception: |
| self._data = {} |
|
|
| def _save(self): |
| self.cache_file.parent.mkdir(parents=True, exist_ok=True) |
| with open(self.cache_file, "w") as f: |
| json.dump(self._data, f) |
|
|
| def get(self, key: str) -> Optional[Dict]: |
| return self._data.get(key.lower().strip()) |
|
|
| def set(self, key: str, value: Dict): |
| self._data[key.lower().strip()] = value |
| self._save() |
|
|
| def __contains__(self, key: str) -> bool: |
| return key.lower().strip() in self._data |
|
|
|
|
| class USDAClient: |
| def __init__(self, cfg: NutritionConfig = None): |
| self.cfg = cfg or config.nutrition |
| self._cache = NutritionCache(self.cfg.cache_file) if self.cfg.use_cache else None |
| self._last_req = 0.0 |
|
|
| def get_nutrition(self, food_name: str) -> Dict[str, float]: |
| food_name = food_name.strip().lower() |
| if self._cache and food_name in self._cache: |
| return self._cache.get(food_name) |
| try: |
| result = self._fetch(food_name) |
| except Exception as e: |
| logger.warning(f"USDA fallback for '{food_name}': {e}") |
| result = self._fallback(food_name) |
| if self._cache: |
| self._cache.set(food_name, result) |
| return result |
|
|
| def _rate_limit(self): |
| elapsed = time.time() - self._last_req |
| if elapsed < 0.35: |
| time.sleep(0.35 - elapsed) |
| self._last_req = time.time() |
|
|
| def _fetch(self, food_name: str) -> Dict[str, float]: |
| self._rate_limit() |
| resp = requests.get( |
| f"{self.cfg.usda_base_url}/foods/search", |
| params={"query": food_name, "api_key": self.cfg.usda_api_key, |
| "pageSize": 5, "dataType": "Foundation,SR Legacy"}, |
| timeout=8, |
| ) |
| resp.raise_for_status() |
| foods = resp.json().get("foods", []) |
| if not foods: |
| return self._fallback(food_name) |
| return self._parse(foods[0]) |
|
|
| def _parse(self, food_data: Dict) -> Dict[str, float]: |
| result = {k: 0.0 for k in self.cfg.nutrient_keys} |
| for n in food_data.get("foodNutrients", []): |
| nid = n.get("nutrientId", 0) |
| if nid in USDA_NUTRIENT_ID_MAP: |
| result[USDA_NUTRIENT_ID_MAP[nid]] = float(n.get("value", 0)) |
| continue |
| name = n.get("nutrientName", "").lower() |
| for sub, key in NUTRIENT_NAME_MAP.items(): |
| if sub in name: |
| result[key] = float(n.get("value", 0)) |
| break |
| return result |
|
|
| def _fallback(self, food_name: str) -> Dict[str, float]: |
| for key in FALLBACK_NUTRITION_DB: |
| if key in food_name or food_name in key: |
| return FALLBACK_NUTRITION_DB[key] |
| return {"calories":150,"total_fat":5,"saturated_fat":1.5,"protein":5, |
| "carbohydrates":20,"sugar":3,"fiber":2,"sodium":100} |
|
|