File size: 3,823 Bytes
14a7484
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""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]