Add per-meal calorie cap (1000 kcal) + kcal/protein display

- SKILL.md RATIONAL PORTIONS: add ~1000 kcal/meal cap; trim starch/cheese/halloumi/lentil
  (not protein) if over; use scripts/calc_meal.py to check
- scripts/calc_meal.py: estimate protein/kcal/fibre/satfat for a meal's items, flag
  over_kcal (>1000) + per-item max_per_meal breaches (unit-aware); gates daily sends
- scripts/verify_portions.py: now also flags KCAL CAP violations via calc_meal
- references/calories.md: per-portion kcal/macro estimate table (source of truth)
- references/email-template.md + meal-history.md: show Est. kcal + Protein per card/entry
- daily cron prompt: compute + record kcal/protein_g on each entry, show kcal in email,
  enforce 1000 kcal cap before send (pushed via cronjob update)
This commit is contained in:
hermes
2026-08-29 08:17:43 +01:00
parent d4c5a8f658
commit f7c9b78dc1
5 changed files with 132 additions and 1 deletions

View File

@ -71,6 +71,9 @@ Schema:
>=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.
**CALORIE CAP: each meal must stay at or under ~1000 kcal** (est., excluding unlogged cooking
fat) — run `scripts/calc_meal.py` on the meal's items to check. If a capped+balanced meal
would exceed 1000 kcal, trim starch/cheese/halloumi/lentil portions (not protein) until it fits.
**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

View File

@ -20,12 +20,14 @@ Copy this scaffold, fill the meal sections, and save to `/tmp/meal_today.html`
<div style="border:1px solid #e4e7eb;border-radius:10px;padding:16px;margin-bottom:16px;">
<h2 style="margin:0 0 6px;font-size:17px;color:#0f766e;">🍽 Lunch — Meal Name</h2>
<p style="margin:0 0 8px;font-size:14px;color:#52606d;"><strong>Ingredients:</strong> 3 eggs, 100g spinach, 1 tbsp olive oil</p>
<p style="margin:0 0 4px;font-size:14px;color:#3e4c59;"><strong>Protein:</strong> ~24 g · <strong>Est. kcal:</strong> ~310</p>
<p style="margin:0;font-size:14px;color:#3e4c59;line-height:1.5;"><strong>Method:</strong> 1) … 2) … 3) …</p>
</div>
<!-- MEAL 2 -->
<div style="border:1px solid #e4e7eb;border-radius:10px;padding:16px;margin-bottom:16px;">
<h2 style="margin:0 0 6px;font-size:17px;color:#0f766e;">🍽 Dinner — Meal Name</h2>
<p style="margin:0 0 8px;font-size:14px;color:#52606d;"><strong>Ingredients:</strong></p>
<p style="margin:0 0 4px;font-size:14px;color:#3e4c59;"><strong>Protein:</strong> ~40 g · <strong>Est. kcal:</strong> ~520</p>
<p style="margin:0;font-size:14px;color:#3e4c59;line-height:1.5;"><strong>Method:</strong> 1) … 2) …</p>
</div>
</td></tr>

View File

@ -11,7 +11,9 @@ job must record them into `meal-history.json` (same skill dir) so the user can l
"weekday": "Thursday",
"meal": "lunch" | "dinner",
"name": "Human-readable meal name",
"items": [ { "name": "Name as in inventory.json", "qty": 1, "unit": "count" }, ... ]
"items": [ { "name": "Name as in inventory.json", "qty": 1, "unit": "count" }, ... ],
"kcal": 720,
"protein_g": 64
}
```

111
scripts/calc_meal.py Normal file
View File

@ -0,0 +1,111 @@
#!/usr/bin/env python3
"""
Estimate protein (g), calories (kcal), fibre (g) and saturated fat (g) for a meal
from its items, and check the RATIONAL PORTIONS caps (kcal cap + per-item protein
max_per_meal). Reads references/calories.md-style numbers inline (kept in sync with
that file). Usage:
python3 calc_meal.py '[{"name":"Eggs","qty":4,"unit":"count"}, ...]'
Prints JSON: {protein_g, kcal, fibre_g, satfat_g, over_kcal, over_item}.
Exit non-zero if over_kcal (so it can gate sends in the daily job).
"""
import json, sys, os
SKILL_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# kcal per logged portion. For g/ml-unit items the value is PER GRAM (× qty).
# For count/can/portion items the value is the whole item. Keep in sync with
# references/calories.md (which shows dairy per 100g for humans — divide by 100 here).
KCAL = {
"Eggs": 70, "Chicken breast": 165, "Tinned tuna in veg oil": 190,
"Tinned tuna in spring water": 130, "Tinned green lentils": 230,
"Greek yogurt (0%)": 0.5, "Cottage cheese": 0.95, "Cheese": 4.0, "Halloumi": 3.2,
"Whey protein powder": 4, "Milk (semi)": 0.5,
"Dried red lentils": 3.5, "Baked beans": 200, "Chickpeas": 180, "Tofu (firm)": 1.4,
"Edamame (frozen)": 1.2, "Wholemeal pasta": 350, "Microwave rice": 220,
"Scottish oats": 3.7,
"Broccoli": 35, "Carrots": 0.41, "Courgette": 20, "Sweet potatoes": 130,
"Aubergine": 40, "Red cabbage": 0.04, "Cauliflower": 30, "Leeks": 55, "Mushrooms": 0.23,
"Lettuce": 15, "Salad tomatoes": 15, "Orange bell pepper": 30, "Small white onions": 15,
"Frozen mixed veg": 0.73, "Peeled plum tomatoes": 90,
"Cumberland sausages": 55, "Lamb mince": 2.5,
}
PROTEIN_G = {
"Eggs": 6, "Chicken breast": 33, "Tinned tuna in veg oil": 25,
"Tinned tuna in spring water": 25, "Tinned green lentils": 18,
"Greek yogurt (0%)": 0.10, "Cottage cheese": 0.12, "Cheese": 0.25, "Halloumi": 0.19,
"Whey protein powder": 0.8, "Milk (semi)": 3.4,
"Dried red lentils": 0.25, "Baked beans": 18, "Chickpeas": 8, "Tofu (firm)": 13,
"Edamame (frozen)": 11, "Wholemeal pasta": 7, "Microwave rice": 3, "Scottish oats": 11,
"Broccoli": 3, "Carrots": 0.01, "Courgette": 1, "Sweet potatoes": 2, "Aubergine": 1,
"Red cabbage": 0.01, "Cauliflower": 3, "Leeks": 1, "Mushrooms": 0.03, "Lettuce": 1,
"Salad tomatoes": 1, "Orange bell pepper": 1, "Small white onions": 1,
"Frozen mixed veg": 0.03, "Peeled plum tomatoes": 2,
"Cumberland sausages": 11, "Lamb mince": 18,
}
FIBRE_G = {
"Dried red lentils": 0.11, "Baked beans": 14, "Chickpeas": 14, "Tinned green lentils": 14,
"Tofu (firm)": 0.004, "Edamame (frozen)": 0.1, "Wholemeal pasta": 5, "Scottish oats": 0.1,
"Broccoli": 3, "Carrots": 0.03, "Courgette": 1, "Sweet potatoes": 4, "Aubergine": 3,
"Red cabbage": 0.04, "Cauliflower": 3, "Leeks": 3, "Mushrooms": 0.02, "Lettuce": 1,
"Salad tomatoes": 1, "Orange bell pepper": 2, "Small white onions": 1,
"Frozen mixed veg": 0.04, "Peeled plum tomatoes": 2, "Cumberland sausages": 0, "Lamb mince": 0,
}
SATFAT_G = {
"Eggs": 6, "Cheese": 0.21, "Halloumi": 0.26, "Cumberland sausages": 18, "Lamb mince": 9,
"Tinned tuna in veg oil": 3, "Chicken breast": 1, "Whey protein powder": 0,
}
KCAL_CAP = 1000
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,
}
def estimate(items, inv):
tot = {"protein_g": 0.0, "kcal": 0.0, "fibre_g": 0.0, "satfat_g": 0.0}
over_kcal = False
over_item = []
for it in items:
name, qty, unit = it["name"], it["qty"], it.get("unit", "count")
# kcal
kp = KCAL.get(name, 0)
k = kp * (qty if unit not in ("g", "ml") else qty)
# protein
pp = PROTEIN_G.get(name, 0)
p = pp * (qty if unit not in ("g", "ml") else qty)
# fibre
fp = FIBRE_G.get(name, 0)
f = fp * (qty if unit not in ("g", "ml") else qty)
# satfat
sp = SATFAT_G.get(name, 0)
s = sp * (qty if unit not in ("g", "ml") else qty)
tot["kcal"] += k; tot["protein_g"] += p; tot["fibre_g"] += f; tot["satfat_g"] += s
# per-item max_per_meal (protein) check — convert both sides to grams
inv_item = next((i for i in inv["items"] if i["name"].lower() == name.lower()), None)
cap = inv_item.get("max_per_meal") if inv_item else None
if cap is not None:
cap_unit = inv_item.get("unit", unit)
used_g = qty * GRAMS_PER_UNIT.get(unit, 1)
cap_g = cap * GRAMS_PER_UNIT.get(cap_unit, 1)
if used_g > cap_g + 1e-6:
over_item.append(f"{name} {qty}{unit} > cap {cap}{cap_unit}")
if tot["kcal"] > KCAL_CAP + 1e-6:
over_kcal = True
for key in tot:
tot[key] = round(tot[key], 1)
return {**tot, "over_kcal": over_kcal, "over_item": over_item}
def main():
items = json.loads(sys.argv[1]) if len(sys.argv) > 1 else []
inv = json.load(open(os.path.join(SKILL_DIR, "inventory.json")))
res = estimate(items, inv)
print(json.dumps(res))
if res["over_kcal"] or res["over_item"]:
sys.exit(1)
if __name__ == "__main__":
main()

View File

@ -121,6 +121,19 @@ def main():
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")
# 3) calorie cap check (uses calc_meal helper if available)
try:
import importlib.util
spec = importlib.util.spec_from_file_location(
"calc_meal", os.path.join(SKILL_DIR, "scripts", "calc_meal.py"))
cm = importlib.util.module_from_spec(spec); spec.loader.exec_module(cm)
res = cm.estimate(meal_items, inv)
if res["over_kcal"]:
violations.append(
f"KCAL CAP {e['date']} {e['meal']} '{e['name']}': "
f"~{res['kcal']:.0f} kcal exceeds 1000 kcal/meal")
except Exception:
pass
if violations:
print("FAIL: rational-portion violations:")
for v in violations: