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:
hermes
2026-08-29 07:50:16 +01:00
parent fb80f6a3dd
commit 33b7e17ef9
6 changed files with 229 additions and 16 deletions

4
.gitignore vendored
View File

@ -9,3 +9,7 @@ last-shop.json
# Python caches
__pycache__/
scripts/__pycache__/
# IDE artifacts
.idea/
.agentbridge/

View File

@ -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 (~190200 g/day)** who is also losing fat,
burns victim on a **high-protein diet (~165175 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:** ~190200 g/day (≈2 g/kg at ~95100 kg, for burns
- **High-protein target:** ~165175 g/day (≈1.7 g/kg at ~95100 kg, for burns
recovery + lean-mass preservation during ~15 kg fat loss). Aim ~90100 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 · ~190200g/day · no red meat bought`),
- **Daily:** header (date + `high-protein · ~165175g/day · no red meat bought`),
two meal cards (name, ingredients, 23 step method, protein total, short
"health boost" note), footer (`Reply to tell me what you used and I'll update
the inventory`).

View File

@ -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 23
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:** ~**190200 g/day** (user ~95100 kg × 2 g/kg — burns recovery +
preserving lean mass while losing ~15 kg fat). Aim each meal at **~90100 g protein**
- **Protein target:** ~**165175 g/day** (user ~95100 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 **~8090 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 (TUESDAYSUNDAY 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 100200 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.

View File

@ -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 95100 kg → ~190200 g/day).
- Protein at EVERY meal + snack. Target ~1.7 g/kg (user 95100 kg → ~165175 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.

View File

@ -4,12 +4,13 @@ User profile this skill was tuned for: recovering burns victim, healing well,
~95100 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 95100 kg → **~190200 g/day**.
- Split across 2 main meals: aim **~90100 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 95100 kg → **~165175 g/day**.
- Split across 2 main meals: aim **~8090 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.

134
scripts/verify_portions.py Normal file
View 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()