"""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 from diet_guard._estimator import ( Nutrition, estimate_off, manual, off_candidates, scale_nutrition, ) from diet_guard._foodbank import lookup_food, search_foods from diet_guard._portions import staple_nutrition, suggest_staples @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]