diff --git a/SKILL.md b/SKILL.md index 55afed8..85670f7 100644 --- a/SKILL.md +++ b/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 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 2โ€“3 things to buy (eggs, whey, chicken, tuna) to close the gap. Never suggest "10 sausages" or any diff --git a/references/email-template.md b/references/email-template.md index 39c4dd9..0430b89 100644 --- a/references/email-template.md +++ b/references/email-template.md @@ -20,12 +20,14 @@ Copy this scaffold, fill the meal sections, and save to `/tmp/meal_today.html`

๐Ÿฝ Lunch โ€” Meal Name

Ingredients: 3 eggs, 100g spinach, 1 tbsp olive oil

+

Protein: ~24 g ยท Est. kcal: ~310

Method: 1) โ€ฆ 2) โ€ฆ 3) โ€ฆ

๐Ÿฝ Dinner โ€” Meal Name

Ingredients: โ€ฆ

+

Protein: ~40 g ยท Est. kcal: ~520

Method: 1) โ€ฆ 2) โ€ฆ

diff --git a/references/meal-history.md b/references/meal-history.md index 1440b12..ddf56f6 100644 --- a/references/meal-history.md +++ b/references/meal-history.md @@ -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 } ``` diff --git a/scripts/calc_meal.py b/scripts/calc_meal.py new file mode 100644 index 0000000..df5ea00 --- /dev/null +++ b/scripts/calc_meal.py @@ -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() diff --git a/scripts/verify_portions.py b/scripts/verify_portions.py index 4a522e8..115b4dc 100644 --- a/scripts/verify_portions.py +++ b/scripts/verify_portions.py @@ -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: