2026-06-10 22:31:18 +02:00
|
|
|
"""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
|
|
|
|
|
|
2026-06-22 12:18:39 +02:00
|
|
|
from diet_guard._constants import (
|
2026-06-10 22:31:18 +02:00
|
|
|
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)
|