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:
3
SKILL.md
3
SKILL.md
@ -71,6 +71,9 @@ Schema:
|
|||||||
>=85% of that meal's protein — e.g. a bowl of 10 sausages. A balanced plate where the lead
|
>=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
|
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.
|
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**
|
**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
|
— 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
|
things to buy (eggs, whey, chicken, tuna) to close the gap. Never suggest "10 sausages" or any
|
||||||
|
|||||||
@ -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;">
|
<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>
|
<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 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>
|
<p style="margin:0;font-size:14px;color:#3e4c59;line-height:1.5;"><strong>Method:</strong> 1) … 2) … 3) …</p>
|
||||||
</div>
|
</div>
|
||||||
<!-- MEAL 2 -->
|
<!-- MEAL 2 -->
|
||||||
<div style="border:1px solid #e4e7eb;border-radius:10px;padding:16px;margin-bottom:16px;">
|
<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>
|
<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 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>
|
<p style="margin:0;font-size:14px;color:#3e4c59;line-height:1.5;"><strong>Method:</strong> 1) … 2) …</p>
|
||||||
</div>
|
</div>
|
||||||
</td></tr>
|
</td></tr>
|
||||||
|
|||||||
@ -11,7 +11,9 @@ job must record them into `meal-history.json` (same skill dir) so the user can l
|
|||||||
"weekday": "Thursday",
|
"weekday": "Thursday",
|
||||||
"meal": "lunch" | "dinner",
|
"meal": "lunch" | "dinner",
|
||||||
"name": "Human-readable meal name",
|
"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
111
scripts/calc_meal.py
Normal 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()
|
||||||
@ -121,6 +121,19 @@ def main():
|
|||||||
f"{it['name']} {it['qty']} is >=70% of its cap and supplies "
|
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"{pg:.0f}/{total:.0f} g ({pg/total*100:.0f}%) of meal protein "
|
||||||
f"— not a balanced plate")
|
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:
|
if violations:
|
||||||
print("FAIL: rational-portion violations:")
|
print("FAIL: rational-portion violations:")
|
||||||
for v in violations:
|
for v in violations:
|
||||||
|
|||||||
Reference in New Issue
Block a user