2026-06-10 22:31:18 +02:00
|
|
|
"""Resolve a food description to nutrition, food-bank first, OFF last.
|
|
|
|
|
|
|
|
|
|
This is the shared precedence both the CLI and the gate window use so a food is
|
|
|
|
|
always resolved the same way:
|
|
|
|
|
|
|
|
|
|
1. **Manual calories** the user typed -- always honored, always offline. Full
|
|
|
|
|
macros are recorded too when supplied.
|
|
|
|
|
2. **The food bank** -- a food the user has logged before is served from local
|
|
|
|
|
history with its remembered macros (no network).
|
|
|
|
|
3. **Open Food Facts** -- only for a brand-new food with no manual value, to
|
|
|
|
|
fill in macros the first time it is seen.
|
|
|
|
|
|
|
|
|
|
Keeping Open Food Facts strictly last is what makes the gate offline-safe: a
|
|
|
|
|
dead endpoint can never stop you logging a manual or already-known food, so the
|
|
|
|
|
lock can never trap you.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
from dataclasses import dataclass
|
|
|
|
|
|
2026-06-22 12:18:39 +02:00
|
|
|
from diet_guard._estimator import (
|
2026-06-10 22:31:18 +02:00
|
|
|
Nutrition,
|
|
|
|
|
estimate_off,
|
|
|
|
|
manual,
|
|
|
|
|
off_candidates,
|
|
|
|
|
scale_nutrition,
|
|
|
|
|
)
|
2026-06-22 12:18:39 +02:00
|
|
|
from diet_guard._foodbank import lookup_food, search_foods
|
|
|
|
|
from diet_guard._portions import staple_nutrition, suggest_staples
|
2026-06-10 22:31:18 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
class ManualMacros:
|
|
|
|
|
"""Calories and optional macros the user typed directly for a food.
|
|
|
|
|
|
|
|
|
|
Bundling these keeps :func:`resolve_nutrition` to a short argument list.
|
|
|
|
|
|
|
|
|
|
Attributes:
|
|
|
|
|
kcal: Calories entered directly; when supplied the lookups are skipped.
|
|
|
|
|
protein: Protein grams to record alongside ``kcal``.
|
|
|
|
|
carbs: Carbohydrate grams to record alongside ``kcal``.
|
|
|
|
|
fat: Fat grams to record alongside ``kcal``.
|
|
|
|
|
per_grams: Reference weight the macros are stated for (e.g. 100 for
|
|
|
|
|
"per 100 g" off a label). When given, the typed macros are scaled
|
|
|
|
|
from this basis to the eaten amount; when None they are taken as
|
|
|
|
|
totals for the portion (back-compatible behaviour).
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
kcal: float
|
|
|
|
|
protein: float = 0.0
|
|
|
|
|
carbs: float = 0.0
|
|
|
|
|
fat: float = 0.0
|
|
|
|
|
per_grams: float | None = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def resolve_nutrition(
|
|
|
|
|
description: str,
|
|
|
|
|
*,
|
|
|
|
|
grams: float | None = None,
|
|
|
|
|
manual_macros: ManualMacros | None = None,
|
|
|
|
|
) -> Nutrition | None:
|
|
|
|
|
"""Resolve ``description`` to a :class:`Nutrition`, or None if unresolvable.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
description: Free-text food name.
|
|
|
|
|
grams: Amount actually eaten, in grams (used to rescale every source).
|
|
|
|
|
manual_macros: Calories and macros the user typed directly; when given,
|
|
|
|
|
they are recorded and the lookups are skipped entirely.
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
The resolved Nutrition, or None only when no manual value was supplied,
|
|
|
|
|
the food is neither banked nor a known staple, and Open Food Facts
|
|
|
|
|
produced no usable match.
|
|
|
|
|
"""
|
|
|
|
|
if manual_macros is not None:
|
|
|
|
|
# The typed macros describe ``per_grams`` of food (the label basis);
|
|
|
|
|
# build that reference, then rescale it to the amount actually eaten so
|
|
|
|
|
# "200 kcal per 100 g, ate 330 g" logs 660 -- no manual arithmetic.
|
|
|
|
|
reference_grams = (
|
|
|
|
|
manual_macros.per_grams if manual_macros.per_grams is not None else grams
|
|
|
|
|
)
|
|
|
|
|
reference = manual(
|
|
|
|
|
manual_macros.kcal,
|
|
|
|
|
reference_grams,
|
|
|
|
|
protein_g=manual_macros.protein,
|
|
|
|
|
carbs_g=manual_macros.carbs,
|
|
|
|
|
fat_g=manual_macros.fat,
|
|
|
|
|
)
|
|
|
|
|
eaten = grams if grams is not None else reference_grams
|
|
|
|
|
return scale_nutrition(reference, eaten) if eaten is not None else reference
|
|
|
|
|
banked = lookup_food(description)
|
|
|
|
|
if banked is not None:
|
|
|
|
|
# Reuse the remembered macros, rescaled if a different amount was eaten.
|
|
|
|
|
return scale_nutrition(banked, grams) if grams is not None else banked
|
|
|
|
|
staple = staple_nutrition(description)
|
|
|
|
|
if staple is not None:
|
|
|
|
|
# A known whole food (apple, egg, ...) resolves locally and correctly,
|
|
|
|
|
# before Open Food Facts whose top "apple" hit is a packaged pastry.
|
|
|
|
|
return scale_nutrition(staple, grams) if grams is not None else staple
|
|
|
|
|
return estimate_off(description, grams)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def lookup_candidates(
|
|
|
|
|
description: str,
|
|
|
|
|
grams: float | None = None,
|
|
|
|
|
) -> list[tuple[str, Nutrition]]:
|
|
|
|
|
"""Return reviewable candidates for a food whose macros must be looked up.
|
|
|
|
|
|
|
|
|
|
Used by the gate when the user leaves the calorie field blank: it returns
|
|
|
|
|
the banked food if known (a single, instant, offline match), otherwise the
|
|
|
|
|
Open Food Facts alternatives so the user can pick the right product and see
|
|
|
|
|
where each value comes from. Empty means nothing resolved -- the caller
|
|
|
|
|
must then ask for a manual calorie value (the offline-safe escape).
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
description: Free-text food name the user typed.
|
|
|
|
|
grams: Portion size in grams, if the user supplied one.
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
``(label, nutrition)`` pairs to show for review; at most one for a
|
|
|
|
|
banked food, otherwise the OFF candidates in relevance order.
|
|
|
|
|
"""
|
|
|
|
|
banked = lookup_food(description)
|
|
|
|
|
if banked is not None:
|
|
|
|
|
scaled = scale_nutrition(banked, grams) if grams is not None else banked
|
|
|
|
|
return [(description, scaled)]
|
|
|
|
|
staple = staple_nutrition(description)
|
|
|
|
|
if staple is not None:
|
|
|
|
|
scaled = scale_nutrition(staple, grams) if grams is not None else staple
|
|
|
|
|
return [(staple.source, scaled)]
|
|
|
|
|
return [
|
|
|
|
|
(nutrition.source, nutrition)
|
|
|
|
|
for nutrition in off_candidates(description, grams)
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def suggest_foods(
|
|
|
|
|
query: str,
|
|
|
|
|
limit: int = 6,
|
|
|
|
|
) -> list[tuple[str, Nutrition]]:
|
|
|
|
|
"""Return live autocomplete suggestions: banked foods, then matching staples.
|
|
|
|
|
|
|
|
|
|
The user's own logged foods rank first (they are the most likely repeats);
|
|
|
|
|
built-in staples fill any remaining slots so common whole foods surface even
|
|
|
|
|
before they have ever been logged. A staple already covered by a banked
|
|
|
|
|
name is not duplicated.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
query: Free-text the user has typed so far.
|
|
|
|
|
limit: Maximum number of suggestions to return.
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
``(display_name, Nutrition)`` pairs, ranked, at most ``limit`` long.
|
|
|
|
|
"""
|
|
|
|
|
results = list(search_foods(query, limit))
|
|
|
|
|
seen = {name.casefold() for name, _ in results}
|
|
|
|
|
for name, nutrition in suggest_staples(query, limit):
|
|
|
|
|
if name.casefold() not in seen:
|
|
|
|
|
results.append((name, nutrition))
|
|
|
|
|
return results[:limit]
|