he99codes's picture
Clean deployment with LFS setup correctly
f75c5b2
"""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}