Spaces:
Running
Running
| """Small USDA FoodData Central API client used by the nutrition pipeline.""" | |
| from __future__ import annotations | |
| import json | |
| import re | |
| from pathlib import Path | |
| from typing import Any | |
| from urllib.parse import urlencode | |
| from urllib.request import urlopen | |
| FDC_SEARCH_URL = "https://api.nal.usda.gov/fdc/v1/foods/search" | |
| NUTRIENT_NUMBERS = { | |
| "kcal": "208", | |
| "protein": "203", | |
| "fat": "204", | |
| "carbs": "205", | |
| } | |
| class FdcClient: | |
| def __init__( | |
| self, | |
| api_key: str, | |
| cache_path: Path, | |
| data_type: str = "Foundation,SR Legacy,FNDDS", | |
| page_size: int = 10, | |
| timeout: int = 30, | |
| ) -> None: | |
| self.api_key = api_key | |
| self.cache_path = cache_path | |
| self.data_type = data_type | |
| self.page_size = page_size | |
| self.timeout = timeout | |
| self.cache = self._load_cache() | |
| def search_first(self, query: str) -> dict[str, Any] | None: | |
| cache_key = f"ranked-v2|{self.data_type}|{self.page_size}|{query}".lower() | |
| if cache_key in self.cache: | |
| return self.cache[cache_key] | |
| params = { | |
| "api_key": self.api_key, | |
| "query": query, | |
| "pageSize": self.page_size, | |
| "dataType": self.data_type, | |
| } | |
| url = f"{FDC_SEARCH_URL}?{urlencode(params)}" | |
| with urlopen(url, timeout=self.timeout) as response: | |
| payload = json.loads(response.read().decode("utf-8")) | |
| foods = payload.get("foods", []) | |
| result = choose_best_food(query, foods) | |
| self.cache[cache_key] = result | |
| return result | |
| def save_cache(self) -> None: | |
| self.cache_path.parent.mkdir(parents=True, exist_ok=True) | |
| self.cache_path.write_text( | |
| json.dumps(self.cache, indent=2, sort_keys=True), | |
| encoding="utf-8", | |
| ) | |
| def _load_cache(self) -> dict[str, Any]: | |
| if not self.cache_path.exists(): | |
| return {} | |
| return json.loads(self.cache_path.read_text(encoding="utf-8")) | |
| def extract_macros(food: dict[str, Any] | None) -> dict[str, float | None]: | |
| values: dict[str, float | None] = {name: None for name in NUTRIENT_NUMBERS} | |
| if food is None: | |
| return values | |
| nutrients = food.get("foodNutrients", []) | |
| by_number = { | |
| str(nutrient.get("nutrientNumber")): nutrient.get("value") | |
| for nutrient in nutrients | |
| if nutrient.get("nutrientNumber") is not None | |
| } | |
| for macro_name, nutrient_number in NUTRIENT_NUMBERS.items(): | |
| value = by_number.get(nutrient_number) | |
| values[macro_name] = round(float(value), 2) if value is not None else None | |
| return values | |
| def choose_best_food(query: str, foods: list[dict[str, Any]]) -> dict[str, Any] | None: | |
| if not foods: | |
| return None | |
| ranked = sorted( | |
| enumerate(foods), | |
| key=lambda item: food_match_score(query, item[1], item[0]), | |
| reverse=True, | |
| ) | |
| return ranked[0][1] | |
| def food_match_score(query: str, food: dict[str, Any], index: int) -> float: | |
| description = str(food.get("description", "")).lower() | |
| category = str(food.get("foodCategory", "")).lower() | |
| haystack = f"{description} {category}" | |
| tokens = tokenize(query) | |
| score = 0.0 | |
| cooking_words = {"cooked", "grilled", "boiled", "raw", "roasted", "fried", "steamed"} | |
| for token in tokens: | |
| if token in haystack: | |
| score += 0.25 if token in cooking_words else 2.0 | |
| if "skin" not in tokens: | |
| if description.startswith("chicken, skin") or " skin (" in description: | |
| score -= 4.0 | |
| # Keep API ordering as a small tie-breaker after semantic token matches. | |
| score -= index * 0.01 | |
| return score | |
| def tokenize(text: str) -> list[str]: | |
| return [token for token in re.findall(r"[a-z0-9]+", text.lower()) if len(token) > 2] | |