mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-10 18:07:20 +01:00
Ninth batch of the 250-line sweep. Every Python file in the repo is now under cap (over-cap count 25 -> 18; the 18 remaining are all Dart). _foodbank.py 365 -> 210 (+ _foodbank_search, _foodbank_rebuild) test_averages.py 351 -> 222 (+ test_averages_report.py) _estimator.py 325 -> 137 (+ _estimator_off.py) _budget.py 323 -> 227 (+ _budget_biometrics, _budget_derived) test_foodbank.py 311 -> 117 (+ test_foodbank_rebuild.py) _averages.py 298 -> 174 (+ _averages_periods, _averages_report) test_budget_history.py 288 -> 162 (+ test_budget_history_io.py) The split boundaries are constrained by which module owns each conftest-patched state path, not by line count. BUDGET_FILE stays in _budget, FOOD_BANK_FILE in _foodbank: the derived/search/rebuild halves reach the file only through their parent's private helpers. Two of the new modules had copied FOOD_BANK_FILE into their headers as an incidental import, which is exactly the leak the guard is for -- both cleaned. test_state_redirect.py now covers all five redirected paths (FOOD_LOG_FILE, BUDGET_FILE, BUDGET_HISTORY_FILE, FOOD_BANK_FILE, MANUAL_BANK_FILE) rather than just the food log, in both directions: only the owning module may name each constant, and each redirect demonstrably moves a real write off the live path. Two rewires fail-closed rather than silently: `_budget.budget_weight` and `_estimator.requests` were module-attribute references no import rewrite touches, and both raised AttributeError immediately. The latter appeared only after ruff removed the now-unused `requests` import from _estimator -- the import was load-bearing solely as a patch target. Verified: 707 Python tests / 100% branch coverage, 5 layout-fits tests under xvfb, check_patch_targets 0, pre-commit clean, ~/.local/share/diet_guard byte-identical. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RA9ynjqXPnexRQnR5oAuCC
138 lines
4.5 KiB
Python
138 lines
4.5 KiB
Python
"""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
|
|
|
|
_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),
|
|
)
|