diet-guard/diet_guard/_kuchnia_parse.py
Krzysztof kuhy Rudnicki 26f1770090 feat: import Kuchnia Wikinga catering deliveries into the food bank
Pulls the day's delivered dishes and their real per-portion macros out of
panel.kuchniavikinga.pl, so a catering meal is one keypress instead of five
retyped numbers.

A delivered meal is not an eaten meal, so nothing is ever logged unattended:
dishes land in the *curated* bank, and entries are written only after an
explicit confirmation. Auto-logging would let the gate satisfy its own
checkpoint from a delivery note, and would record what the courier dropped off
rather than what was eaten.

Three surfaces:
  - `diet-guard kuchnia [--log] [--yes]`
  - the lock screen's "Today's delivery" button, which prefills the meal form
    dish by dish (the user still clicks Log & Continue)
  - a warm-up after each logged meal, on the existing background thread

All event-driven; no new systemd timer. The two automatic paths go through
`refresh_delivery_once`, guarded to one fetch per day.

The panel is an undocumented white-labelled Dietly API; the endpoint map was
recovered from its JS bundles and confirmed against live responses. Notes,
including the traps, are in docs/kuchnia-wikinga.md.

Load-bearing details:

* Macros are per portion and `weight` is that portion in grams -- proven, not
  assumed: 4P+4C+9F reproduces the stated calories within ~1% on every meal of
  the captured day. `_energy_is_consistent` re-runs that check per dish and
  drops any that fails; a per-100g mix-up is invisible in a payload and would
  silently skew every logged meal by a factor of several.
* `bank_dishes` compares nutritional fields before calling `add_manual_entry`,
  which restamps `t` unconditionally. Without the compare, every refresh
  republishes the whole curated bank to every peer. Tests assert on call count.
* Duplicate log suppression reads today's synced log, not a local marker:
  `log_meal` mints a fresh uuid, so a re-import after marker loss would merge
  as duplicates on every peer.
* The gate hook is in `on_focus_ready`, not `_cli_gate._should_lock`:
  `gate_is_due()` never reads the food bank, so a refresh there could not
  change the lock decision while adding a third-party round trip to the ~105ms
  `gate --check` fast path.
* `requests` is lazy-imported so the gate's not-due tick does not pay for it.
* Credentials and the cached session cookie live in ~/.config/diet_guard at
  mode 600, outside the synced tree. The credentials file is written by hand,
  never by the package -- the same contract as sync_token.

`_GateDashboard` is merged into `_GateMealFlow`, and both "pull it in from
elsewhere" flows share one `_PullFlows` mixin: pylint's max-parents counts
every MRO entry, so the gate's controller chain had no room for a new class.

Also adds a side-by-side row for the two pull buttons -- stacking them cost
42px and pushed the tab past the viewport at 1366x768.

989 tests, 100% branch coverage, pre-commit clean.
2026-08-22 23:04:22 +02:00

156 lines
5.2 KiB
Python

"""Turn the panel's menu payload into ``Dish`` records.
Written against a real capture (``scripts/probe_kuchnia.py``), not against the
field names guessed from the site's JavaScript. The shape that matters:
{"deliveryMenuMeal": [
{"mealName": "Śniadanie", # the slot label
"menuMealName": "Marchewkowe pancakes, ...", # the actual dish
"mealPriority": 1, # eating order, 1..N
"nutrition": {"weight": 270.0, "calories": 435.0, "protein": 25.86,
"carbohydrate": 54.64, "fat": 12.01, ...}},
...]}
**Macros are per portion and ``weight`` is that portion in grams.** Verified
arithmetically rather than assumed: 4·protein + 4·carbs + 9·fat reproduces the
stated ``calories`` within ~1% on every meal of the captured day, and the day
sums to 2055 kcal against the plan's declared 2000. Had they been per-100 g,
every imported meal would have been silently wrong by a factor of several --
which is why :func:`parse_menu` drops a dish whose numbers do not hold together
instead of importing a plausible-looking lie.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from diet_guard._coerce import as_float
#: Atwater factors. Used only as a sanity check on the *units*, never to
#: recompute a value the panel already states.
_KCAL_PER_G_PROTEIN = 4.0
_KCAL_PER_G_CARB = 4.0
_KCAL_PER_G_FAT = 9.0
#: How far the macro-derived energy may drift from the stated calories before
#: the dish is treated as unusable. Generous: the panel rounds, and fibre and
#: polyols carry energy these three factors ignore. A per-100 g mix-up would
#: miss by a factor of 2-5, nowhere near this band.
_ENERGY_TOLERANCE = 0.35
#: A portion outside this range is not a meal. Guards against a unit change
#: (kg for g) rather than against unusual food.
_MIN_GRAMS = 1.0
_MAX_GRAMS = 5000.0
@dataclass(frozen=True)
class Dish:
"""One delivered dish, with its portion macros.
Mirrors :class:`diet_guard._estimator.Nutrition`'s convention: every macro
is the total *for this portion*, not per 100 g.
"""
name: str
kcal: float
protein_g: float
carbs_g: float
fat_g: float
grams: float
priority: int
slot_label: str
def _energy_is_consistent(
kcal: float, protein: float, carbs: float, fat: float
) -> bool:
"""Return True when the macros roughly account for the stated calories.
This is a *unit* check, not a nutrition check: it catches a payload whose
macros and calories are quoted on different bases (per portion vs per
100 g), which is the one failure mode that would corrupt every import
without looking wrong.
"""
if kcal <= 0:
return False
derived = (
_KCAL_PER_G_PROTEIN * protein + _KCAL_PER_G_CARB * carbs + _KCAL_PER_G_FAT * fat
)
return abs(derived - kcal) <= _ENERGY_TOLERANCE * kcal
def _parse_meal(meal: object, fallback_priority: int) -> Dish | None:
"""Build one :class:`Dish`, or None when the entry is unusable.
Args:
meal: One ``deliveryMenuMeal`` element.
fallback_priority: Position to use when ``mealPriority`` is missing.
Returns:
The dish, or None when it lacks a name or fails the unit check.
"""
if not isinstance(meal, dict):
return None
nutrition = meal.get("nutrition")
if not isinstance(nutrition, dict):
return None
# ``menuMealName`` is the dish ("Kaszotto grzybowe..."); ``mealName`` is the
# slot label ("Kolacja"). The dish is what belongs in the food bank -- the
# label would collapse every Monday dinner onto one bank entry.
name = str(meal.get("menuMealName") or "").strip()
if not name:
return None
kcal = as_float(nutrition.get("calories"))
protein = as_float(nutrition.get("protein"))
carbs = as_float(nutrition.get("carbohydrate"))
fat = as_float(nutrition.get("fat"))
grams = as_float(nutrition.get("weight"))
if not _MIN_GRAMS <= grams <= _MAX_GRAMS:
return None
if not _energy_is_consistent(kcal, protein, carbs, fat):
return None
raw_priority = meal.get("mealPriority")
priority = (
raw_priority
if isinstance(raw_priority, int) and not isinstance(raw_priority, bool)
else fallback_priority
)
return Dish(
name=name,
kcal=kcal,
protein_g=protein,
carbs_g=carbs,
fat_g=fat,
grams=grams,
priority=priority,
slot_label=str(meal.get("mealName") or "").strip(),
)
def parse_menu(payload: object) -> list[Dish]:
"""Extract the day's dishes from a ``menus/delivery/{id}/new`` response.
Unusable entries are skipped rather than raising: a single malformed dish
must not cost the user the other four.
Args:
payload: The decoded response body.
Returns:
The dishes, in payload order (:mod:`diet_guard._kuchnia_spread` sorts
them by priority).
"""
if not isinstance(payload, dict):
return []
meals: Any = payload.get("deliveryMenuMeal")
if not isinstance(meals, list):
return []
parsed = (_parse_meal(meal, index + 1) for index, meal in enumerate(meals))
return [dish for dish in parsed if dish is not None]