From 33b7e17ef9fae776a3e5cf516f4b25eb8cdf3c13 Mon Sep 17 00:00:00 2001 From: hermes Date: Sat, 29 Aug 2026 07:50:16 +0100 Subject: [PATCH] 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 --- .gitignore | 4 + README.md | 8 +- SKILL.md | 86 ++++++++++++++++++++-- references/food-health.md | 2 +- references/protein-targets.md | 11 +-- scripts/verify_portions.py | 134 ++++++++++++++++++++++++++++++++++ 6 files changed, 229 insertions(+), 16 deletions(-) create mode 100644 scripts/verify_portions.py diff --git a/.gitignore b/.gitignore index d418ed5..7fa21c2 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,7 @@ last-shop.json # Python caches __pycache__/ scripts/__pycache__/ + +# IDE artifacts +.idea/ +.agentbridge/ diff --git a/README.md b/README.md index 1d07f99..6efcc3d 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Meal Suggestion — Kitchen Inventory + High-Protein Meals 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: 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) │ ├── food-health.md # Healing + 46yo health + easy/tasty meal research │ ├── 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 │ └── eastbourne-shops.md # Shop price table (Lidl preferred) ├── scripts/ @@ -86,7 +86,7 @@ is a synced mirror under `~/IdeaProjects/meal-suggestion/`. The two stay in step ## 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 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 @@ -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 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 "health boost" note), footer (`Reply to tell me what you used and I'll update the inventory`). diff --git a/SKILL.md b/SKILL.md index e30d782..5aba883 100644 --- a/SKILL.md +++ b/SKILL.md @@ -46,6 +46,35 @@ Schema: - 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. - **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 weekly shopping list must NEVER suggest red meat to buy. - **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, 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.) -- **Protein target:** ~**190–200 g/day** (user ~95–100 kg × 2 g/kg — burns recovery + - preserving lean mass while losing ~15 kg fat). Aim each meal at **~90–100 g protein** +- **Protein target:** ~**165–175 g/day** (user ~95–100 kg × 1.7 g/kg — evidence ceiling for + 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 - 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. - **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 @@ -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 `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 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** @@ -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 "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` - and on-theme meals from the same week's buys** (e.g. build a Korean bowl, a - Mexican bowl, etc., from stock). The daily job should still respect the STRICT - inventory rule — only cook with what's actually in inventory.json. + and cook on-theme meals from the same week's buys (TUESDAY–SUNDAY only; Monday is + unthemed)** (e.g. build a Korean bowl, a Mexican bowl, etc., from stock). The daily + 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. ## 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). - **Pick this week's cuisine first** (see "Weekly cuisine rotation" above) and 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 `scripts/verify_eggs.py` after any history/inventory change or before a resend. 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 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 `meal-history.json`, `inventory.json`, or the daily job, and before sending a 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/protein-sources.md` — allowed proteins + example pairings. diff --git a/references/food-health.md b/references/food-health.md index 7db8ce2..f033f4c 100644 --- a/references/food-health.md +++ b/references/food-health.md @@ -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. ## 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 & shellfish, dairy (milk/yogurt/cottage cheese/quark), tofu, tempeh, edamame, lentils, beans, peas, nuts, peanut butter, whey. diff --git a/references/protein-targets.md b/references/protein-targets.md index 25b1e97..a2b0fea 100644 --- a/references/protein-targets.md +++ b/references/protein-targets.md @@ -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**. ## Target math -- Burns recovery + lean-mass preservation during a deficit: **2 g protein per kg - bodyweight per day**. -- At 95–100 kg → **~190–200 g/day**. -- Split across 2 main meals: aim **~90–100 g per meal**; any remainder comes from a +- Burns recovery + lean-mass preservation during a deficit: **1.7 g protein per kg + bodyweight per day** (evidence ceiling for trained/resistance-adapted adults; + relaxed from 2 g/kg per user preference). +- 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. -- **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 `diet.protein_target_g_per_day` in inventory.json in sync with that. diff --git a/scripts/verify_portions.py b/scripts/verify_portions.py new file mode 100644 index 0000000..4a522e8 --- /dev/null +++ b/scripts/verify_portions.py @@ -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()