mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-07-04 15:03:13 +02:00
Add the diet_guard package: a screen-locking meal-logging gate that fires on 4-hour slots (08/12/16/20) and records calories/macros, persisting an autocompleting food bank. - Trigger fix: the systemd timer fires at session start (Persistent=true) before lightdm has written ~/.Xauthority, so the gate crashed with a TclError instead of locking the screen. Add wait_for_display() / _display_is_ready() in _gatelock.py and wire it into _cli._cmd_gate so the gate retries on the next tick instead of crashing; add Environment=XAUTHORITY=%h/.Xauthority to the service as belt-and-suspenders. - Food-bank hardening: a transiently corrupt food_bank.json was warned about on every keystroke and then silently overwritten (data loss). _read_bank now quarantines it via _quarantine_corrupt_bank() (warn-once + timestamped backup) before starting fresh. - Multi-item meals: new _meal.py (MealItem, meal_total, MEAL_SOURCE), remember_meal() + _upsert() in _foodbank.py, and a "+ Add item" control in the gate that logs both the individual items and the composite meal. - Bundle resolve_nutrition's manual macros into a ManualMacros dataclass to stay within the argument-count limit. diet_guard at 100% branch coverage; full pre-commit suite passes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
162 lines
6.2 KiB
Python
162 lines
6.2 KiB
Python
"""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 python_pkg.diet_guard._estimator import (
|
|
Nutrition,
|
|
estimate_off,
|
|
manual,
|
|
off_candidates,
|
|
scale_nutrition,
|
|
)
|
|
from python_pkg.diet_guard._foodbank import lookup_food, search_foods
|
|
from python_pkg.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]
|