Relax protein to 1.7 g/kg; add rational-portion guard + weekly protein floor
- diet.protein_per_kg 2 -> 1.7 g/kg (evidence ceiling for trained/resistance-adapted adults) daily target 190-200 -> 165-175 g/day, ~80-90 g/meal - weekly non-whey protein floor 1330 -> 1190 g (170 g/day x 7), still excl whey - SKILL.md: RATIONAL PORTIONS hard rule (per-item max_per_meal caps, no single-ingredient overload, honest shortfall reporting); WEEKLY PROTEIN FLOOR (>=3 sources, none >50%) - inventory.json: per-item max_per_meal caps + rational_portions defaults table - scripts/verify_portions.py: regression probe for overload/cap violations - README + references synced to 1.7 g/kg; .idea/.agentbridge gitignored
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@ -9,3 +9,7 @@ last-shop.json
|
|||||||
# Python caches
|
# Python caches
|
||||||
__pycache__/
|
__pycache__/
|
||||||
scripts/__pycache__/
|
scripts/__pycache__/
|
||||||
|
|
||||||
|
# IDE artifacts
|
||||||
|
.idea/
|
||||||
|
.agentbridge/
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
# Meal Suggestion — Kitchen Inventory + High-Protein Meals
|
# Meal Suggestion — Kitchen Inventory + High-Protein Meals
|
||||||
|
|
||||||
An automated, research-grounded meal system for a single user (jp): a recovering
|
An automated, research-grounded meal system for a single user (jp): a recovering
|
||||||
burns victim on a **high-protein diet (~190–200 g/day)** who is also losing fat,
|
burns victim on a **high-protein diet (~165–175 g/day)** who is also losing fat,
|
||||||
and who wants to **avoid buying red meat**. It does two things on a schedule:
|
and who wants to **avoid buying red meat**. It does two things on a schedule:
|
||||||
|
|
||||||
1. **Daily meal suggestions** — two meals (lunch + dinner) built only from what
|
1. **Daily meal suggestions** — two meals (lunch + dinner) built only from what
|
||||||
@ -66,7 +66,7 @@ meal-suggestion/
|
|||||||
│ ├── email-template.md # HTML email scaffold (inline CSS, mobile-friendly)
|
│ ├── email-template.md # HTML email scaffold (inline CSS, mobile-friendly)
|
||||||
│ ├── food-health.md # Healing + 46yo health + easy/tasty meal research
|
│ ├── food-health.md # Healing + 46yo health + easy/tasty meal research
|
||||||
│ ├── protein-sources.md # Allowed proteins + example pairings
|
│ ├── protein-sources.md # Allowed proteins + example pairings
|
||||||
│ ├── protein-targets.md # Protein math (2 g/kg)
|
│ ├── protein-targets.md # Protein math (1.7 g/kg)
|
||||||
│ ├── meal-history.md # 7-day logger spec + reconcile-from-inventory logic
|
│ ├── meal-history.md # 7-day logger spec + reconcile-from-inventory logic
|
||||||
│ └── eastbourne-shops.md # Shop price table (Lidl preferred)
|
│ └── eastbourne-shops.md # Shop price table (Lidl preferred)
|
||||||
├── scripts/
|
├── scripts/
|
||||||
@ -86,7 +86,7 @@ is a synced mirror under `~/IdeaProjects/meal-suggestion/`. The two stay in step
|
|||||||
|
|
||||||
## Core rules
|
## Core rules
|
||||||
|
|
||||||
- **High-protein target:** ~190–200 g/day (≈2 g/kg at ~95–100 kg, for burns
|
- **High-protein target:** ~165–175 g/day (≈1.7 g/kg at ~95–100 kg, for burns
|
||||||
recovery + lean-mass preservation during ~15 kg fat loss). Aim ~90–100 g per
|
recovery + lean-mass preservation during ~15 kg fat loss). Aim ~90–100 g per
|
||||||
meal and **state the per-meal and daily protein totals** in the email.
|
meal and **state the per-meal and daily protein totals** in the email.
|
||||||
- **Weight-loss framing:** mild calorie deficit via bulking with veg and going
|
- **Weight-loss framing:** mild calorie deficit via bulking with veg and going
|
||||||
@ -202,7 +202,7 @@ creds from `~/.hermes/.env`). Both HTML (inline CSS, mobile-friendly, dark-on-li
|
|||||||
and a plain-text alternative are produced so the plan survives HTML-stripping
|
and a plain-text alternative are produced so the plan survives HTML-stripping
|
||||||
clients.
|
clients.
|
||||||
|
|
||||||
- **Daily:** header (date + `high-protein · ~190–200g/day · no red meat bought`),
|
- **Daily:** header (date + `high-protein · ~165–175g/day · no red meat bought`),
|
||||||
two meal cards (name, ingredients, 2–3 step method, protein total, short
|
two meal cards (name, ingredients, 2–3 step method, protein total, short
|
||||||
"health boost" note), footer (`Reply to tell me what you used and I'll update
|
"health boost" note), footer (`Reply to tell me what you used and I'll update
|
||||||
the inventory`).
|
the inventory`).
|
||||||
|
|||||||
86
SKILL.md
86
SKILL.md
@ -46,6 +46,35 @@ Schema:
|
|||||||
- Suggest **two meals** per day (e.g. lunch + dinner) using only items currently in the inventory.
|
- Suggest **two meals** per day (e.g. lunch + dinner) using only items currently in the inventory.
|
||||||
- **Maximize protein** per meal; lead with a protein source.
|
- **Maximize protein** per meal; lead with a protein source.
|
||||||
- **EGG LIMIT (hard rule):** Eggs are capped at **4 per meal** and **only ONE of the two daily meals may contain eggs** — the other meal MUST be egg-free. Never suggest more than 4 eggs in a single meal, and never put eggs in both meals. If a meal needs more protein than 4 eggs supply, close the gap with other proteins (lentils, beans, halloumi, cheese, tinned fish, whey) or a whey shake — do NOT add a 5th+ egg. This overrides the "lead with eggs" preference below.
|
- **EGG LIMIT (hard rule):** Eggs are capped at **4 per meal** and **only ONE of the two daily meals may contain eggs** — the other meal MUST be egg-free. Never suggest more than 4 eggs in a single meal, and never put eggs in both meals. If a meal needs more protein than 4 eggs supply, close the gap with other proteins (lentils, beans, halloumi, cheese, tinned fish, whey) or a whey shake — do NOT add a 5th+ egg. This overrides the "lead with eggs" preference below.
|
||||||
|
- **MEAL VARIETY (rule):** the two daily meals (lunch + dinner) should use **disjoint
|
||||||
|
ingredient sets** — avoid repeating the same items across both meals. Build each meal
|
||||||
|
from a different subset of the inventory so the day isn't the same plate twice. Only
|
||||||
|
reuse an ingredient if stock genuinely forces it, and keep any reuse to the minimum.
|
||||||
|
This often means leading one meal with a non-red-meat protein (lentils/beans/eggs/whey/
|
||||||
|
cheese) and the other with a different protein (e.g. in-stock red meat being used up,
|
||||||
|
or eggs if the first meal was egg-free). Honour this alongside the EGG LIMIT and the
|
||||||
|
STRICT inventory rule.
|
||||||
|
Do NOT inflate a single ingredient's quantity to an unrealistic amount just to avoid
|
||||||
|
overlap (e.g. 2 cans of baked beans in one meal). If a fully-disjoint meal can't reach
|
||||||
|
the protein target, close the gap with a whey shake / extra eggs (third hit) rather than
|
||||||
|
unrealistic quantities.
|
||||||
|
|
||||||
|
- **RATIONAL PORTIONS (hard rule):** every meal must be a *realistic, balanced, interesting*
|
||||||
|
plate — NOT overloaded with one ingredient. Each protein item has a `max_per_meal` cap in
|
||||||
|
`inventory.json` (expressed in that item's own unit, e.g. Eggs 4, Cumberland sausages 6,
|
||||||
|
Cheese 0.5 "small block" ≈ 60 g, Lamb mince 200 g, Lentils 150 g, Whey 50 g, Baked beans 1 can).
|
||||||
|
The daily job MUST NOT exceed a cap in any single meal. If an item has no `max_per_meal`,
|
||||||
|
fall back to `diet.rational_portions.default_caps` (keyword match) or `unit_fallback`
|
||||||
|
(count→6, g→200, can→1). `scripts/verify_portions.py` converts caps and logged amounts to
|
||||||
|
grams so a cap stored as "small block" is compared correctly against e.g. cheese logged in grams.
|
||||||
|
OVERLOAD test (what makes a meal "silly"): ONE protein at >=70% of its cap AND supplying
|
||||||
|
>=85% of that meal's protein — e.g. a bowl of 10 sausages. A balanced plate where the lead
|
||||||
|
protein is capped but other proteins + veg contribute is NOT overload (a normal 4-egg meal
|
||||||
|
passes). So spread across 3+ different proteins (plus veg, grain, dairy) so the plate is varied.
|
||||||
|
**If capped + balanced meals still can't reach ~165 g/day with current stock, DO NOT fake it**
|
||||||
|
— produce the best balanced meal you can, state the realistic total, and list the top 2–3
|
||||||
|
things to buy (eggs, whey, chicken, tuna) to close the gap. Never suggest "10 sausages" or any
|
||||||
|
single-ingredient overload. This rule overrides "maximize protein" — balance and realism beat a number.
|
||||||
- **Avoid red meat for PURCHASES** (beef, lamb, pork, venison, bacon, gammon) — the
|
- **Avoid red meat for PURCHASES** (beef, lamb, pork, venison, bacon, gammon) — the
|
||||||
weekly shopping list must NEVER suggest red meat to buy.
|
weekly shopping list must NEVER suggest red meat to buy.
|
||||||
- **If red meat is already in the inventory, it stays usable** in daily meal
|
- **If red meat is already in the inventory, it stays usable** in daily meal
|
||||||
@ -55,10 +84,11 @@ Schema:
|
|||||||
tempeh, lentils, beans, chickpeas, Greek yogurt, cottage cheese, quark, skim milk,
|
tempeh, lentils, beans, chickpeas, Greek yogurt, cottage cheese, quark, skim milk,
|
||||||
whey, tinned tuna/salmon, edamame, halloumi.
|
whey, tinned tuna/salmon, edamame, halloumi.
|
||||||
(Eggs are subject to the EGG LIMIT hard rule above: max 4 per meal, only one meal/day may include them.)
|
(Eggs are subject to the EGG LIMIT hard rule above: max 4 per meal, only one meal/day may include them.)
|
||||||
- **Protein target:** ~**190–200 g/day** (user ~95–100 kg × 2 g/kg — burns recovery +
|
- **Protein target:** ~**165–175 g/day** (user ~95–100 kg × 1.7 g/kg — evidence ceiling for
|
||||||
preserving lean mass while losing ~15 kg fat). Aim each meal at **~90–100 g protein**
|
trained/resistance-adapted adults; burns recovery + preserving lean mass while losing ~15 kg
|
||||||
|
fat). Aim each meal at **~80–90 g protein**
|
||||||
and **STATE the per-meal and daily protein totals** in the email. If 2 meals can't
|
and **STATE the per-meal and daily protein totals** in the email. If 2 meals can't
|
||||||
reach ~190 g with current stock, say so and suggest a third hit (whey shake, extra
|
reach ~165 g with current stock, say so and suggest a third hit (whey shake, extra
|
||||||
eggs, Greek yogurt) to close the gap.
|
eggs, Greek yogurt) to close the gap.
|
||||||
- **Weight-loss framing:** keep meals calorie-moderate — bulk with veg, go easy on oven
|
- **Weight-loss framing:** keep meals calorie-moderate — bulk with veg, go easy on oven
|
||||||
chips, white bread, and oil; lead with lean protein. Fat loss comes from a mild calorie
|
chips, white bread, and oil; lead with lean protein. Fat loss comes from a mild calorie
|
||||||
@ -110,6 +140,15 @@ To keep shops and meals varied over time, each week's list is themed around a
|
|||||||
**randomly chosen world cuisine**. This is driven by
|
**randomly chosen world cuisine**. This is driven by
|
||||||
`scripts/pick_cuisine.py` (same skill dir).
|
`scripts/pick_cuisine.py` (same skill dir).
|
||||||
|
|
||||||
|
**Theme start rule (IMPORTANT):** the cuisine is picked by the **Monday weekly job**
|
||||||
|
and it themes that week's **shop basket**, but it applies to **MEALS from TUESDAY
|
||||||
|
onward**. Monday's daily meal suggestion is cooked **UNTHEMED** from whatever is
|
||||||
|
currently in stock (the shop items aren't in inventory yet). From Tuesday the daily
|
||||||
|
meal job cooks **on-theme**. If `cuisine-rotation.json` has no `current` cuisine set
|
||||||
|
(e.g. cleared between weeks, or the rest of a week with no active theme), meals are
|
||||||
|
unthemed. This keeps a fresh shop's new ingredients aligned with themed meals without
|
||||||
|
forcing a theme onto the day the shop is only proposed.
|
||||||
|
|
||||||
- Run `python3 scripts/pick_cuisine.py` at the **start of the weekly job**. It
|
- Run `python3 scripts/pick_cuisine.py` at the **start of the weekly job**. It
|
||||||
picks a cuisine seeded by the week's Monday, so it is **stable within a week**
|
picks a cuisine seeded by the week's Monday, so it is **stable within a week**
|
||||||
(re-runs/retries never flip the cuisine mid-week) but **varies week to week**
|
(re-runs/retries never flip the cuisine mid-week) but **varies week to week**
|
||||||
@ -125,12 +164,23 @@ To keep shops and meals varied over time, each week's list is themed around a
|
|||||||
items that are just flavour (spices, sauces, lime, herbs) are the optional
|
items that are just flavour (spices, sauces, lime, herbs) are the optional
|
||||||
"interest" buys — keep them low-price and behind protein + veg + staples.
|
"interest" buys — keep them low-price and behind protein + veg + staples.
|
||||||
- Persist the active cuisine so the **daily meal job can read `cuisine-rotation.json`
|
- Persist the active cuisine so the **daily meal job can read `cuisine-rotation.json`
|
||||||
and on-theme meals from the same week's buys** (e.g. build a Korean bowl, a
|
and cook on-theme meals from the same week's buys (TUESDAY–SUNDAY only; Monday is
|
||||||
Mexican bowl, etc., from stock). The daily job should still respect the STRICT
|
unthemed)** (e.g. build a Korean bowl, a Mexican bowl, etc., from stock). The daily
|
||||||
inventory rule — only cook with what's actually in inventory.json.
|
job should still respect the STRICT inventory rule — only cook with what's actually
|
||||||
|
in inventory.json.
|
||||||
- The rotation file keeps a short history; no manual tracking needed.
|
- The rotation file keeps a short history; no manual tracking needed.
|
||||||
|
|
||||||
## Weekly shopping list (Mondays)
|
## Weekly shopping list (Mondays)
|
||||||
|
- **WEEKLY PROTEIN FLOOR (hard rule):** the non-whey protein bought in the Monday shop MUST be
|
||||||
|
enough to cover the week. Required = 170 g/day × 7 = **1190 g** of **non-whey** protein
|
||||||
|
(whey is excluded — it is a third-hit top-up, not a meal base). Estimate the non-whey protein
|
||||||
|
ALREADY in `inventory.json` (sum each protein/cheese/dairy item's qty × its per-g from
|
||||||
|
`references/protein-sources.md`). The shop's bought non-whey protein must total at least
|
||||||
|
`MAX(0, 1190 − in_stock)` g. **SPREAD it across ≥3 distinct protein sources** — no single
|
||||||
|
source may exceed ~50% of the bought total (never "20 eggs only", never one tub). If £30 can't
|
||||||
|
reach the floor, get as close as budget allows and STATE the shortfall + what to buy next.
|
||||||
|
Record `bought_protein_g_excl_whey` and `stock_protein_g_excl_whey` on `last-shop.json` and
|
||||||
|
show both in the email so weekly coverage is visible.
|
||||||
- Budget **£30** total. Use realistic UK supermarket prices (Tesco/Asda/Sainsbury's).
|
- Budget **£30** total. Use realistic UK supermarket prices (Tesco/Asda/Sainsbury's).
|
||||||
- **Pick this week's cuisine first** (see "Weekly cuisine rotation" above) and
|
- **Pick this week's cuisine first** (see "Weekly cuisine rotation" above) and
|
||||||
theme the basket around it — varied ingredients keep meals interesting.
|
theme the basket around it — varied ingredients keep meals interesting.
|
||||||
@ -300,6 +350,20 @@ Note: this is entirely on-demand — the weekly job itself never writes inventor
|
|||||||
EGG LIMIT prose rule above, (b) `diet.egg_rules` in inventory.json, and (c) running
|
EGG LIMIT prose rule above, (b) `diet.egg_rules` in inventory.json, and (c) running
|
||||||
`scripts/verify_eggs.py` after any history/inventory change or before a resend.
|
`scripts/verify_eggs.py` after any history/inventory change or before a resend.
|
||||||
Always run that probe before sending a regenerated day.
|
Always run that probe before sending a regenerated day.
|
||||||
|
- **Don't auto-resend after a stock update.** When the user reports actual consumption or you
|
||||||
|
correct `inventory.json` mid-day, update the inventory and STOP — do NOT volunteer a fresh
|
||||||
|
meal email. The next scheduled 09:00 cron picks up the new stock automatically. Only
|
||||||
|
regenerate + resend when the user explicitly asks (usually because they flagged a
|
||||||
|
meal-QUALITY issue, e.g. unrealistic portions — "2 cans of baked beans is silly, be
|
||||||
|
realistic"). They said plainly "I will wait for the cron job to run." Distinguish: a
|
||||||
|
consumption report = update stock, no email; a meal-quality complaint = regenerate fresh.
|
||||||
|
- **Portion probe scope + threshold (avoid false positives).** `verify_portions.py` defaults to
|
||||||
|
TODAY only — run it that way before a send. Its OVERLOAD flag requires BOTH >=70% of one
|
||||||
|
protein's cap AND >=85% of that meal's protein; a capped lead protein with real supporting
|
||||||
|
proteins (e.g. a normal 4-egg meal) must NOT trip it. If you widen the threshold later you'll
|
||||||
|
start flagging legitimate balanced plates — keep the >=85% dominance condition. `--all` is an
|
||||||
|
audit of history, not a send gate; historical over-cap rows (old halloumi quantities) are
|
||||||
|
already-sent and only fixed on explicit request.
|
||||||
|
|
||||||
## Automation
|
## Automation
|
||||||
Two cron jobs (created via the `cronjob` tool), both loading this skill:
|
Two cron jobs (created via the `cronjob` tool), both loading this skill:
|
||||||
@ -316,6 +380,16 @@ Delivery is `local` because the email itself is the deliverable; check
|
|||||||
- `scripts/verify_eggs.py` — regression probe for the EGG LIMIT. Run after any change to
|
- `scripts/verify_eggs.py` — regression probe for the EGG LIMIT. Run after any change to
|
||||||
`meal-history.json`, `inventory.json`, or the daily job, and before sending a
|
`meal-history.json`, `inventory.json`, or the daily job, and before sending a
|
||||||
regenerated day's email. Exits non-zero on violation. See "Pitfalls".
|
regenerated day's email. Exits non-zero on violation. See "Pitfalls".
|
||||||
|
- `scripts/verify_portions.py` — regression probe for RATIONAL PORTIONS. By DEFAULT checks
|
||||||
|
only TODAY's entries in `meal-history.json` (the meals the daily job just wrote, before
|
||||||
|
sending). It flags (a) any protein used above its `max_per_meal` (or fallback) cap, and
|
||||||
|
(b) single-ingredient OVERLOAD — one protein at >=70% of its cap that ALSO supplies >=85%
|
||||||
|
of the meal's protein (a "pile of one thing", e.g. 10 sausages). Caps are stored in each
|
||||||
|
item's own unit (e.g. Cheese 0.5 "small block"); the probe converts to grams so it can
|
||||||
|
compare against logged grams (e.g. 40 g cheese). Run it alongside `verify_eggs.py` BEFORE
|
||||||
|
sending a regenerated day. Pass `--all` to audit the full 7-day window (this WILL flag
|
||||||
|
ALREADY-SENT historical rows like old 100–200 g halloumi — audit only, NOT a send gate;
|
||||||
|
only fix history on explicit user request). Exits non-zero on violation.
|
||||||
|
|
||||||
## References
|
## References
|
||||||
- `references/protein-sources.md` — allowed proteins + example pairings.
|
- `references/protein-sources.md` — allowed proteins + example pairings.
|
||||||
|
|||||||
@ -6,7 +6,7 @@ AARP / BodyLogic (men 40+); BBC Good Food & Berry Street (budget meals).
|
|||||||
General guidance, NOT medical advice — defer to the user's clinical/dietitian team.
|
General guidance, NOT medical advice — defer to the user's clinical/dietitian team.
|
||||||
|
|
||||||
## Healing & high-protein (burns recovery)
|
## Healing & high-protein (burns recovery)
|
||||||
- Protein at EVERY meal + snack. Target ~2 g/kg (user 95–100 kg → ~190–200 g/day).
|
- Protein at EVERY meal + snack. Target ~1.7 g/kg (user 95–100 kg → ~165–175 g/day).
|
||||||
- Best proteins (no red meat): eggs, poultry (chicken/turkey, skin off), fish &
|
- Best proteins (no red meat): eggs, poultry (chicken/turkey, skin off), fish &
|
||||||
shellfish, dairy (milk/yogurt/cottage cheese/quark), tofu, tempeh, edamame,
|
shellfish, dairy (milk/yogurt/cottage cheese/quark), tofu, tempeh, edamame,
|
||||||
lentils, beans, peas, nuts, peanut butter, whey.
|
lentils, beans, peas, nuts, peanut butter, whey.
|
||||||
|
|||||||
@ -4,12 +4,13 @@ User profile this skill was tuned for: recovering burns victim, healing well,
|
|||||||
~95–100 kg, wants to lose ~15 kg fat while **preserving lean mass**.
|
~95–100 kg, wants to lose ~15 kg fat while **preserving lean mass**.
|
||||||
|
|
||||||
## Target math
|
## Target math
|
||||||
- Burns recovery + lean-mass preservation during a deficit: **2 g protein per kg
|
- Burns recovery + lean-mass preservation during a deficit: **1.7 g protein per kg
|
||||||
bodyweight per day**.
|
bodyweight per day** (evidence ceiling for trained/resistance-adapted adults;
|
||||||
- At 95–100 kg → **~190–200 g/day**.
|
relaxed from 2 g/kg per user preference).
|
||||||
- Split across 2 main meals: aim **~90–100 g per meal**; any remainder comes from a
|
- At 95–100 kg → **~165–175 g/day**.
|
||||||
|
- Split across 2 main meals: aim **~80–90 g per meal**; any remainder comes from a
|
||||||
third hit.
|
third hit.
|
||||||
- **Recompute whenever the user reports a new weight:** target = weight_kg × 2.
|
- **Recompute whenever the user reports a new weight:** target = weight_kg × 1.7.
|
||||||
Keep `diet.bodyweight_kg`, `diet.protein_per_kg`, and
|
Keep `diet.bodyweight_kg`, `diet.protein_per_kg`, and
|
||||||
`diet.protein_target_g_per_day` in inventory.json in sync with that.
|
`diet.protein_target_g_per_day` in inventory.json in sync with that.
|
||||||
|
|
||||||
|
|||||||
134
scripts/verify_portions.py
Normal file
134
scripts/verify_portions.py
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Regression guard for RATIONAL PORTIONS.
|
||||||
|
|
||||||
|
Checks TODAY's meals in meal-history.json (override with --all for the 7-day window)
|
||||||
|
for:
|
||||||
|
1. CAP EXCEEDED — a protein item used in a single meal above its inventory
|
||||||
|
`max_per_meal`, or above the fallback cap when no explicit cap exists.
|
||||||
|
2. OVERLOAD — a single protein at >=70% of its cap that ALSO supplies >=85% of the
|
||||||
|
meal's protein (a "pile of one ingredient", e.g. 10 sausages). A balanced plate
|
||||||
|
where the lead protein is capped but other proteins/veg contribute is NOT flagged.
|
||||||
|
|
||||||
|
Units are normalised to grams so items logged in different units (cheese in g vs
|
||||||
|
inventory's "small block") compare correctly.
|
||||||
|
|
||||||
|
Exits non-zero (prints violations) if any rule is broken.
|
||||||
|
"""
|
||||||
|
import json, sys, os, argparse
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
SKILL_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
HISTORY = os.path.join(SKILL_DIR, "meal-history.json")
|
||||||
|
INV = os.path.join(SKILL_DIR, "inventory.json")
|
||||||
|
|
||||||
|
# protein (g) per unit, for the dominance estimate
|
||||||
|
PROTEIN_G = {
|
||||||
|
"egg": 6, "sausage": 11, "mince": 0.18, "lentil": 0.24, "tofu": 0.12,
|
||||||
|
"tempeh": 0.19, "bean": 9, "whey": 0.8, "cheese": 0.25, "halloumi": 0.21,
|
||||||
|
"tuna": 25, "salmon": 25, "mackerel": 20, "chicken": 0.31, "turkey": 0.29,
|
||||||
|
"prawn": 0.24, "yogurt": 0.10, "yoghurt": 0.10, "fish": 25,
|
||||||
|
}
|
||||||
|
|
||||||
|
# grams per inventory unit, for converting a stored cap to grams
|
||||||
|
GRAMS_PER_UNIT = {
|
||||||
|
"g": 1, "ml": 1, "kg": 1000, "count": 1, "can": 1,
|
||||||
|
"small block": 120, "block": 120, "serving": 1, "portion": 1,
|
||||||
|
"pack": 1, "jar": 1, "tub": 1, "bulb": 1, "bottle": 1, "loaf": 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
PROTEIN_CATS = {"protein", "dairy"}
|
||||||
|
|
||||||
|
|
||||||
|
def cap_grams(name, unit, inv):
|
||||||
|
it = next((i for i in inv["items"] if i["name"].lower() == name.lower()), None)
|
||||||
|
if it and "max_per_meal" in it:
|
||||||
|
gpu = GRAMS_PER_UNIT.get(it.get("unit", unit), 1)
|
||||||
|
return it["max_per_meal"] * gpu
|
||||||
|
rp = inv.get("diet", {}).get("rational_portions", {})
|
||||||
|
defaults = rp.get("default_caps", {})
|
||||||
|
for kw, cap in defaults.items():
|
||||||
|
if kw in name.lower():
|
||||||
|
gpu = GRAMS_PER_UNIT.get(rp.get("unit_fallback_unit", unit), 1)
|
||||||
|
return cap * gpu
|
||||||
|
fb = rp.get("unit_fallback", {}).get(unit, 6)
|
||||||
|
return fb * GRAMS_PER_UNIT.get(unit, 1)
|
||||||
|
|
||||||
|
|
||||||
|
def est_protein_g(name, qty, unit):
|
||||||
|
per = PROTEIN_G.get(name.lower())
|
||||||
|
if per is None:
|
||||||
|
for kw, v in PROTEIN_G.items():
|
||||||
|
if kw in name.lower():
|
||||||
|
per = v
|
||||||
|
break
|
||||||
|
if per is None:
|
||||||
|
return 0.0
|
||||||
|
gpu = GRAMS_PER_UNIT.get(unit, 1)
|
||||||
|
base = qty * gpu # grams of food
|
||||||
|
# per is g protein per g of food for g/ml units; per-unit for count/can
|
||||||
|
if unit in ("g", "ml", "kg"):
|
||||||
|
return per * base
|
||||||
|
return per * qty
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--all", action="store_true", help="check full 7-day window")
|
||||||
|
args = ap.parse_args()
|
||||||
|
inv = json.load(open(INV))
|
||||||
|
hist = json.load(open(HISTORY))
|
||||||
|
entries = hist if isinstance(hist, list) else hist.get("entries", [])
|
||||||
|
today = datetime.now().strftime("%Y-%m-%d")
|
||||||
|
cutoff = datetime.now() - timedelta(days=7)
|
||||||
|
violations = []
|
||||||
|
for e in entries:
|
||||||
|
try:
|
||||||
|
d = datetime.strptime(e["date"], "%Y-%m-%d")
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if args.all:
|
||||||
|
if d < cutoff:
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
if e["date"] != today:
|
||||||
|
continue
|
||||||
|
meal_items = e.get("items", [])
|
||||||
|
# 1) cap check
|
||||||
|
for it in meal_items:
|
||||||
|
cap = cap_grams(it["name"], it.get("unit", "count"), inv)
|
||||||
|
used = est_protein_g(it["name"], it["qty"], it.get("unit", "count"))
|
||||||
|
# compare the RAW quantity against the cap's raw quantity (both in same unit)
|
||||||
|
cap_raw = cap / GRAMS_PER_UNIT.get(it.get("unit", "count"), 1) if it.get("unit") in ("g", "ml", "kg") else cap
|
||||||
|
# simpler: compare grams of food used vs grams of cap
|
||||||
|
food_g = it["qty"] * GRAMS_PER_UNIT.get(it.get("unit", "count"), 1)
|
||||||
|
cap_food_g = cap
|
||||||
|
if food_g > cap_food_g + 1e-6:
|
||||||
|
violations.append(
|
||||||
|
f"CAP EXCEEDED {e['date']} {e['meal']} '{e['name']}': "
|
||||||
|
f"{it['name']} {it['qty']} {it.get('unit','')} exceeds cap "
|
||||||
|
f"~{cap_food_g:.0f} g ({it.get('unit','')})")
|
||||||
|
# 2) overload check (single protein >=70% cap AND >=85% of meal protein)
|
||||||
|
prot = [(it, cap_grams(it["name"], it.get("unit", "count"), inv),
|
||||||
|
est_protein_g(it["name"], it["qty"], it.get("unit", "count")))
|
||||||
|
for it in meal_items]
|
||||||
|
total = sum(p for _, _, p in prot) or 1
|
||||||
|
for it, cap, pg in prot:
|
||||||
|
food_g = it["qty"] * GRAMS_PER_UNIT.get(it.get("unit", "count"), 1)
|
||||||
|
if cap and food_g > 0.7 * cap and pg > 0.85 * total:
|
||||||
|
violations.append(
|
||||||
|
f"OVERLOAD {e['date']} {e['meal']} '{e['name']}': "
|
||||||
|
f"{it['name']} {it['qty']} is >=70% of its cap and supplies "
|
||||||
|
f"{pg:.0f}/{total:.0f} g ({pg/total*100:.0f}%) of meal protein "
|
||||||
|
f"— not a balanced plate")
|
||||||
|
if violations:
|
||||||
|
print("FAIL: rational-portion violations:")
|
||||||
|
for v in violations:
|
||||||
|
print(" -", v)
|
||||||
|
sys.exit(1)
|
||||||
|
scope = "all logged meals (7d)" if args.all else f"today ({today})"
|
||||||
|
print(f"PASS: portions rational — no overload ({scope}).")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user