diet-guard/diet_guard/_estimator_off.py
Krzysztof kuhy Rudnicki 3e51e614b1 Split the last seven Python files; extend the redirect guard to all five paths
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
2026-08-15 20:37:05 +02:00

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)