diet-guard/diet_guard/_estimator.py

326 lines
11 KiB
Python
Raw Normal View History

"""Calorie/macro estimation backends for diet_guard.
The default backend queries the public Open Food Facts (OFF) database over
HTTP -- no API key required. It is strongest for branded/packaged foods
(fast food included, which is the binge target) and weaker for generic
home-cooked descriptions; in the latter case the caller should fall back to a
manual ``--kcal`` value.
The backend is intentionally small and pluggable: replace :func:`estimate`
with a local-LLM (ollama) or remote-LLM implementation later without touching
the log/state or CLI layers.
"""
from __future__ import annotations
from dataclasses import dataclass, replace
import logging
import requests
from python_pkg.diet_guard._constants import (
DEFAULT_PORTION_GRAMS,
OFF_PAGE_SIZE,
OFF_SEARCH_URL,
OFF_TIMEOUT_SECONDS,
OFF_USER_AGENT,
)
_logger = logging.getLogger(__name__)
# Open Food Facts nutriment field names (values are "per 100 g").
_OFF_KCAL_FIELD = "energy-kcal_100g"
_OFF_PROTEIN_FIELD = "proteins_100g"
_OFF_CARBS_FIELD = "carbohydrates_100g"
_OFF_FAT_FIELD = "fat_100g"
_GRAMS_PER_REFERENCE = 100.0
@dataclass(frozen=True)
class Nutrition:
"""Estimated nutrition for one logged portion of food.
Attributes:
kcal: Total energy for the portion, in kilocalories.
protein_g: Protein for the portion, in grams.
carbs_g: Carbohydrate for the portion, in grams.
fat_g: Fat for the portion, in grams.
grams: Portion size used for the estimate, in grams (0 if unknown).
source: Human-readable provenance, e.g. ``"openfoodfacts: Big Mac"``
or ``"manual"``.
"""
kcal: float
protein_g: float
carbs_g: float
fat_g: float
grams: float
source: str
def _as_float(value: object) -> float | None:
"""Coerce an Open Food Facts numeric field to ``float``.
OFF returns numbers as ints, floats, or numeric strings depending on the
product, so accept all three. ``bool`` is rejected even though it is an
``int`` subtype, since a boolean nutriment value is meaningless.
Args:
value: The raw field value.
Returns:
The value as a float, or None if it is not numeric.
"""
if isinstance(value, bool):
return None
if isinstance(value, (int, float)):
return float(value)
if isinstance(value, str):
try:
return float(value)
except ValueError:
return None
return None
def manual(
kcal: float,
grams: float | None = None,
*,
protein_g: float = 0.0,
carbs_g: float = 0.0,
fat_g: float = 0.0,
) -> Nutrition:
"""Build a :class:`Nutrition` from user-supplied values.
Calories are required; the three macros are optional so the offline path
stays low-friction (a bare ``--kcal`` always works) while a user who knows
the full breakdown can record it and seed the food bank with it.
Args:
kcal: Calories the user entered directly.
grams: Optional portion size, kept only for display.
protein_g: Protein in grams (0 if unknown).
carbs_g: Carbohydrate in grams (0 if unknown).
fat_g: Fat in grams (0 if unknown).
Returns:
A Nutrition with the supplied macros and ``source="manual"``.
"""
return Nutrition(
kcal=round(float(kcal), 1),
protein_g=round(float(protein_g), 1),
carbs_g=round(float(carbs_g), 1),
fat_g=round(float(fat_g), 1),
grams=round(float(grams), 1) if grams is not None else 0.0,
source="manual",
)
def scale_nutrition(nutrition: Nutrition, grams: float) -> Nutrition:
"""Rescale a portion's macros to a new weight in grams (pure).
A banked or looked-up food stores the macros for *some* portion; eating a
different amount must scale every macro proportionally, so 200 g of a food
banked at 100 g logs double the calories. When the basis portion is unknown
(``grams == 0``) there is nothing to scale from, so the macros are kept and
only the recorded weight is updated -- best effort rather than a wrong
number.
Args:
nutrition: The basis nutrition (its ``grams`` is the basis weight).
grams: The new portion weight in grams.
Returns:
A new Nutrition scaled to ``grams`` (source preserved).
"""
if nutrition.grams <= 0 or grams <= 0:
return replace(nutrition, grams=grams if grams > 0 else nutrition.grams)
factor = grams / nutrition.grams
return replace(
nutrition,
kcal=round(nutrition.kcal * factor, 1),
protein_g=round(nutrition.protein_g * factor, 1),
carbs_g=round(nutrition.carbs_g * factor, 1),
fat_g=round(nutrition.fat_g * factor, 1),
grams=round(grams, 1),
)
def _off_search(term: str) -> list[dict[str, object]]:
"""Query Open Food Facts for products matching ``term``.
Args:
term: Free-text food description.
Returns:
A list of product dicts (possibly empty), most relevant first.
Raises:
requests.RequestException: On any network or HTTP failure.
"""
params = {
"q": term,
"fields": "product_name,nutriments,serving_quantity",
"page_size": str(OFF_PAGE_SIZE),
}
response = requests.get(
OFF_SEARCH_URL,
params=params,
headers={"User-Agent": OFF_USER_AGENT},
timeout=OFF_TIMEOUT_SECONDS,
)
response.raise_for_status()
payload = response.json()
if not isinstance(payload, dict):
return []
# Search-a-licious returns matches under "hits" (ranked by relevance).
hits = payload.get("hits", [])
if not isinstance(hits, list):
return []
return [hit for hit in hits if isinstance(hit, dict)]
def _products_with_energy(
products: list[dict[str, object]],
) -> list[tuple[dict[str, object], dict[str, object]]]:
"""Return the products that carry a usable kcal/100 g value, in order.
Args:
products: Product dicts from :func:`_off_search`.
Returns:
``(product, nutriments)`` tuples for every product with a kcal value,
preserving Open Food Facts' relevance order.
"""
matches: list[tuple[dict[str, object], dict[str, object]]] = []
for product in products:
nutriments = product.get("nutriments")
if not isinstance(nutriments, dict):
continue
if _as_float(nutriments.get(_OFF_KCAL_FIELD)) is not None:
matches.append((product, nutriments))
return matches
def _resolve_portion(grams: float | None, product: dict[str, object]) -> float:
"""Decide the portion size, in grams, to use for an estimate.
Priority: an explicit ``grams`` argument, then the product's Open Food
Facts serving size, then the configured default. Keeping ``--grams``
optional is deliberate: per-entry friction is the whole reason food diaries
get abandoned, so ``ate "big mac"`` must just work.
Args:
grams: Caller-supplied portion, or None.
product: OFF product dict (may carry ``serving_quantity``).
Returns:
A portion size in grams, always greater than zero.
"""
if grams is not None and grams > 0:
return float(grams)
serving = _as_float(product.get("serving_quantity"))
if serving is not None and serving > 0:
return serving
return DEFAULT_PORTION_GRAMS
def _off_nutrition(
product: dict[str, object],
nutriments: dict[str, object],
grams: float | None,
description: str,
) -> Nutrition:
"""Build a Nutrition for one OFF product, scaled to the chosen portion."""
portion = _resolve_portion(grams, product)
factor = portion / _GRAMS_PER_REFERENCE
name = product.get("product_name")
label = name if isinstance(name, str) and name.strip() else description
return Nutrition(
kcal=round(_scaled(nutriments, _OFF_KCAL_FIELD, factor), 1),
protein_g=round(_scaled(nutriments, _OFF_PROTEIN_FIELD, factor), 1),
carbs_g=round(_scaled(nutriments, _OFF_CARBS_FIELD, factor), 1),
fat_g=round(_scaled(nutriments, _OFF_FAT_FIELD, factor), 1),
grams=round(portion, 1),
source=f"openfoodfacts: {label}",
)
def off_candidates(
description: str,
grams: float | None = None,
limit: int = OFF_PAGE_SIZE,
) -> list[Nutrition]:
"""Return up to ``limit`` Open Food Facts matches for ``description``.
Returning several candidates (rather than only the top hit) lets the gate
show alternatives so the user can pick the product that actually matches
what they ate, instead of silently accepting the first guess.
Args:
description: Free-text food description (e.g. ``"big mac"``).
grams: Portion size in grams; serving size or the default is used when
None.
limit: Maximum number of candidates to return.
Returns:
Nutrition estimates in OFF relevance order (empty if OFF is unreachable
or has no usable match).
"""
try:
products = _off_search(description)
except requests.RequestException as exc:
_logger.warning("Open Food Facts request failed: %s", exc)
return []
return [
_off_nutrition(product, nutriments, grams, description)
for product, nutriments in _products_with_energy(products)[:limit]
]
def estimate_off(description: str, grams: float | None) -> Nutrition | None:
"""Estimate nutrition for ``description`` via Open Food Facts (top match).
Args:
description: Free-text food description (e.g. ``"big mac"``).
grams: Portion size in grams. When None, the product's serving size
is used if known, otherwise the configured default portion.
Returns:
The best Nutrition estimate, or None if OFF is unreachable or has no
usable match (the caller should then fall back to a manual value).
"""
candidates = off_candidates(description, grams, limit=1)
return candidates[0] if candidates else None
def _scaled(nutriments: dict[str, object], field: str, factor: float) -> float:
"""Return a per-100 g nutriment scaled to the portion (0 if missing)."""
per_reference = _as_float(nutriments.get(field))
if per_reference is None:
return 0.0
return per_reference * factor
def estimate(
description: str,
*,
grams: float | None = None,
manual_kcal: float | None = None,
) -> Nutrition | None:
"""Estimate nutrition for a meal; a manual value takes precedence.
Args:
description: Free-text food description.
grams: Optional portion size in grams.
manual_kcal: If given, used directly and Open Food Facts is skipped.
Returns:
A Nutrition estimate, or None when no manual value was supplied and OFF
could not produce a usable match.
"""
if manual_kcal is not None:
return manual(manual_kcal, grams)
return estimate_off(description, grams)