SDK-Streamlit / scripts /usda_fdc_client.py
Gilgarmesh's picture
Upload 22 files
e0e2b27 verified
Raw
History Blame Contribute Delete
3.82 kB
"""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]