File size: 8,576 Bytes
f75c5b2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
"""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}