mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-11 17:13:58 +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
215 lines
7.2 KiB
Python
215 lines
7.2 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
|
|
|
|
import logging
|
|
|
|
import requests
|
|
|
|
from diet_guard._constants import (
|
|
DEFAULT_PORTION_GRAMS,
|
|
OFF_PAGE_SIZE,
|
|
OFF_SEARCH_URL,
|
|
OFF_TIMEOUT_SECONDS,
|
|
OFF_USER_AGENT,
|
|
)
|
|
from diet_guard._estimator import Nutrition, _as_float, manual
|
|
|
|
_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
|
|
|
|
|
|
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)
|