diff --git a/SKILL.md b/SKILL.md index 5aba883..55afed8 100644 --- a/SKILL.md +++ b/SKILL.md @@ -309,6 +309,20 @@ If the user lists ingredients in chat, add them to the inventory and optionally give an immediate 2-meal suggestion. If they ask "what should I eat today?", read the inventory and suggest now (and offer to email it). +### Health / calorie review of the meal log (on-demand, one-off) +If the user asks to "analyse the last meals file" / "show calories for each meal" / +"how healthy is the plan", run `scripts/analyze_history.py` (optionally +`--days N` to limit the window). It reads `meal-history.json`, looks each item up in +`references/nutrition-db.json` (unit-aware: converts logged qty/unit to grams, then +scales), and prints per-meal kcal + protein, per-day totals, and a window average with a +rough micronutrient read (fibre, sat fat, vit C/A, calcium) vs RDA-ish targets. The DB is +ESTIMATE-grade — condiments/oils/cooking fat are NOT logged so real meals run ~80-120 kcal +higher per cooked meal. Present the output plainly; lead with the headline (protein on +target? sat fat high? veg variety good?) and note it is NOT medical advice. Do NOT modify +any file — this is a read-only review. If you instead hand-roll nutrition math inline, you +will likely mis-handle units (a veg logged in 'g' vs 'count'): use the script + DB, not ad +hoc arithmetic. + ### Import a weekly shop into inventory (exclusions supported) The weekly job does **NOT** auto-add the shop to inventory — `last-shop.json` is only a record of what the Monday email proposed. To bring it in, the user says @@ -364,6 +378,18 @@ Note: this is entirely on-demand — the weekly job itself never writes inventor 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. +- **Git-sync trap: the LIVE skill README may be a stub, the REPO README may be the real doc — + do NOT let `cp` clobber the better one.** When syncing the live skill dir into the repo + (`/home/jp/IdeaProjects/meal-suggestion`) before a commit, the two README.md files can + diverge: the live skill's README was found to have been reduced to a 49-line stub while the + committed repo README was a fuller 324-line version. Blind `cp live/README.md repo/README.md` + would silently destroy the richer doc. SAFE SYNC PROCEDURE: (1) `git show HEAD:README.md | wc -l` + and compare to the live README's line count; (2) if the committed version is longer, RESTORE it + with `git checkout HEAD -- README.md` and patch only the few figures that changed (e.g. protein + target), rather than overwriting with the stub; (3) copy SKILL.md / references/* / scripts/* from + live (those are the authoritative, richer copies); (4) never stage `.idea/` or `.agentbridge/` + — add them to `.gitignore`. Same caution applies to any tracked file that the live dir might + carry a degraded copy of. ## Automation Two cron jobs (created via the `cronjob` tool), both loading this skill: @@ -390,6 +416,10 @@ Delivery is `local` because the email itself is the deliverable; check 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. +- `scripts/analyze_history.py` — read-only health/calorie review of `meal-history.json` + (per-meal kcal+protein, per-day totals, window averages vs RDA-ish targets). Looks items up + in `references/nutrition-db.json` (unit-aware). Use for the "analyse the meals / show calories" + on-demand request. Does NOT modify files. ## References - `references/protein-sources.md` — allowed proteins + example pairings. @@ -400,3 +430,6 @@ Delivery is `local` because the email itself is the deliverable; check - `references/food-health.md` — research-grounded food & health knowledge base (healing nutrition, wound-healing micronutrients, men's health at 46, easy & tasty budget meals, flavour-without-cost). Read this when generating meals. +- `references/nutrition-db.json` — estimate per-item nutrition (kcal/protein/fibre/satfat/ + vitC/vitA/calcium) with canonical unit + g_per_unit, consumed by `scripts/analyze_history.py`. + ESTIMATE-grade; update values if better figures become available. diff --git a/references/calories.md b/references/calories.md new file mode 100644 index 0000000..8b994ee --- /dev/null +++ b/references/calories.md @@ -0,0 +1,60 @@ +# Calorie & macro estimates (per logged portion) + +Rough, food-database-style estimates used by `scripts/calc_meal.py` to show per-meal +calories and to enforce the RATIONAL PORTIONS rule (incl. the 1000 kcal/meal cap). +These are SUGGESTED-meal estimates — they exclude cooking oil/butter/condiments that +are NOT logged in meal-history.json, so real cooked calories run ~80–150 kcal higher +per cooked meal. Label any shown figure as "est." Not medical/dietitian-grade. + +`kcal_per` is the value for the portion unit used in meal-history.json: +- count / can / portion / bulb / tub / jar / pack / loaf / serving → whole item +- g / ml → per gram (× qty) + +## Animal (non-red-meat) +- Eggs — 70 kcal each (count) +- Chicken breast — 165 kcal (count ~1 breast / ~120g) +- Tinned tuna in veg oil — 190 kcal (can, drained+oil) +- Tinned tuna in spring water — 130 kcal (can) +- Tinned green lentils — 230 kcal (can) +- Greek yogurt (0%) — 50 kcal / 100g +- Cottage cheese — 95 kcal / 100g +- Cheese — 400 kcal / 100g +- Halloumi — 320 kcal / 100g +- Whey protein powder — 4 kcal / g (i.e. 200 kcal per 50g) +- Milk (semi) — 0.5 kcal / ml + +## Plant / grain / legume +- Dried red lentils — 3.5 kcal / g (dry) +- Baked beans — 200 kcal / can +- Chickpeas — 180 kcal / can +- Tofu (firm) — 1.4 kcal / g +- Edamame (frozen) — 1.2 kcal / g +- Wholemeal pasta — 350 kcal / serving +- Microwave rice — 220 kcal / portion +- Scottish oats — 3.7 kcal / g + +## Veg (mostly low-cal, high volume) +- Broccoli — 35 kcal (count ~1 head half) +- Carrots — 0.41 kcal / g +- Courgette — 20 kcal (count) +- Sweet potatoes — 130 kcal (count) +- Aubergine — 40 kcal (count) +- Red cabbage — 40 kcal (count) +- Cauliflower — 30 kcal (count) +- Leeks — 55 kcal (count) +- Mushrooms — 0.23 kcal / g +- Lettuce — 15 kcal (count) +- Salad tomatoes — 15 kcal (count) +- Orange bell pepper — 30 kcal (count) +- Small white onions — 15 kcal (count) +- Frozen mixed veg — 0.73 kcal / g +- Peeled plum tomatoes — 90 kcal (can) + +## Red meat (in-stock only, never bought) +- Cumberland sausages — 55 kcal (count) +- Lamb mince — 2.5 kcal / g + +## Calorie cap (RATIONAL PORTIONS) +Each meal must stay at or under **1000 kcal** (est., excluding unlogged cooking fat). +If a capped+balanced meal would exceed 1000 kcal, reduce starch/cheese/halloumi/lentil +portions (not protein) until it fits — or it's a sign the plate is too starch-heavy. diff --git a/references/nutrition-db.json b/references/nutrition-db.json new file mode 100644 index 0000000..3ed25d4 --- /dev/null +++ b/references/nutrition-db.json @@ -0,0 +1,43 @@ +{ + "_meta": "Estimate nutrition DB for scripts/analyze_history.py. Values are ESTIMATE-grade (high-level review, not lab-accurate). Each item has: unit (canonical unit the VALUES are given per) and g_per_unit (grams in that canonical unit). The script converts the LOGGED qty+unit to grams, then scales. Condiments/oils/cooking fat are NOT logged, so real meals run ~80-120 kcal higher per cooked meal.", + "items": { + "Tinned tuna in veg oil": {"unit": "can", "g_per_unit": 120, "kcal": 190, "protein": 25, "fibre": 0, "satfat": 3, "vitC": 0, "vitA": 5, "calcium": 10}, + "Baked beans": {"unit": "can", "g_per_unit": 400, "kcal": 200, "protein": 18, "fibre": 14, "satfat": 0, "vitC": 6, "vitA": 20, "calcium": 80}, + "Dried red lentils": {"unit": "g", "g_per_unit": 100, "kcal": 350, "protein": 25, "fibre": 11, "satfat": 0, "vitC": 0, "vitA": 0, "calcium": 0}, + "Cheese": {"unit": "g", "g_per_unit": 100, "kcal": 400, "protein": 25, "fibre": 0, "satfat": 21, "vitC": 0, "vitA": 0, "calcium": 70}, + "Microwave rice": {"unit": "portion", "g_per_unit": 250, "kcal": 220, "protein": 3, "fibre": 1, "satfat": 0, "vitC": 0, "vitA": 0, "calcium": 0}, + "Frozen mixed veg": {"unit": "g", "g_per_unit": 100, "kcal": 73, "protein": 2, "fibre": 3, "satfat": 0, "vitC": 8, "vitA": 27, "calcium": 13}, + "Salad tomatoes": {"unit": "count", "g_per_unit": 50, "kcal": 15, "protein": 1, "fibre": 1, "satfat": 0, "vitC": 5, "vitA": 7, "calcium": 2}, + "Small white onions": {"unit": "count", "g_per_unit": 60, "kcal": 15, "protein": 1, "fibre": 1, "satfat": 0, "vitC": 1.5, "vitA": 0, "calcium": 2}, + "Chicken breast": {"unit": "count", "g_per_unit": 150, "kcal": 165, "protein": 33, "fibre": 0, "satfat": 1, "vitC": 0, "vitA": 5, "calcium": 15}, + "Eggs": {"unit": "count", "g_per_unit": 50, "kcal": 70, "protein": 6, "fibre": 0, "satfat": 1.5,"vitC": 0, "vitA": 40, "calcium": 12}, + "Halloumi": {"unit": "g", "g_per_unit": 100, "kcal": 320, "protein": 19, "fibre": 0, "satfat": 26, "vitC": 0, "vitA": 0, "calcium": 60}, + "Courgette": {"unit": "count", "g_per_unit": 150, "kcal": 20, "protein": 1, "fibre": 1, "satfat": 0, "vitC": 6, "vitA": 7, "calcium": 4}, + "Carrots": {"unit": "g", "g_per_unit": 100, "kcal": 41, "protein": 1, "fibre": 3, "satfat": 0, "vitC": 2, "vitA": 300, "calcium": 10}, + "Broccoli": {"unit": "count", "g_per_unit": 150, "kcal": 35, "protein": 3, "fibre": 3, "satfat": 0, "vitC": 65, "vitA": 55, "calcium": 40}, + "Sweet potatoes": {"unit": "count", "g_per_unit": 150, "kcal": 130, "protein": 2, "fibre": 4, "satfat": 0, "vitC": 1.5, "vitA": 350, "calcium": 10}, + "Aubergine": {"unit": "count", "g_per_unit": 300, "kcal": 40, "protein": 1, "fibre": 3, "satfat": 0, "vitC": 1.5, "vitA": 2.5, "calcium": 2.5}, + "Red cabbage": {"unit": "g", "g_per_unit": 100, "kcal": 8, "protein": 0.2,"fibre": 0.8,"satfat": 0, "vitC": 4, "vitA": 3, "calcium": 3}, + "Peeled plum tomatoes": {"unit": "can", "g_per_unit": 400, "kcal": 90, "protein": 2, "fibre": 2, "satfat": 0, "vitC": 6, "vitA": 20, "calcium": 7.5}, + "Tinned green lentils": {"unit": "can", "g_per_unit": 400, "kcal": 230, "protein": 18, "fibre": 14, "satfat": 0, "vitC": 0, "vitA": 0, "calcium": 20}, + "Orange bell pepper": {"unit": "count", "g_per_unit": 120, "kcal": 30, "protein": 1, "fibre": 2, "satfat": 0, "vitC": 130, "vitA": 20, "calcium": 3}, + "Lettuce": {"unit": "count", "g_per_unit": 120, "kcal": 15, "protein": 1, "fibre": 1, "satfat": 0, "vitC": 4, "vitA": 15, "calcium": 7.5}, + "Whey protein powder": {"unit": "g", "g_per_unit": 100, "kcal": 400, "protein": 80, "fibre": 0, "satfat": 0, "vitC": 0, "vitA": 0, "calcium": 300}, + "Cumberland sausages": {"unit": "count", "g_per_unit": 50, "kcal": 55, "protein": 11, "fibre": 0, "satfat": 3.6,"vitC": 0, "vitA": 0, "calcium": 8}, + "Cauliflower": {"unit": "count", "g_per_unit": 500, "kcal": 30, "protein": 3, "fibre": 3, "satfat": 0, "vitC": 25, "vitA": 0, "calcium": 10}, + "Leeks": {"unit": "count", "g_per_unit": 100, "kcal": 55, "protein": 1, "fibre": 3, "satfat": 0, "vitC": 4, "vitA": 0, "calcium": 15}, + "Mushrooms": {"unit": "g", "g_per_unit": 100, "kcal": 23, "protein": 2, "fibre": 2, "satfat": 0, "vitC": 1, "vitA": 0, "calcium": 3}, + "Wholemeal pasta": {"unit": "serving", "g_per_unit": 75, "kcal": 350, "protein": 7, "fibre": 5, "satfat": 0, "vitC": 0, "vitA": 0, "calcium": 5}, + "Lamb mince": {"unit": "g", "g_per_unit": 100, "kcal": 250, "protein": 18, "fibre": 0, "satfat": 9, "vitC": 0, "vitA": 0, "calcium": 8} + }, + "veg_names": ["Broccoli","Carrots","Courgette","Sweet potatoes","Aubergine","Red cabbage","Peeled plum tomatoes","Frozen mixed veg","Salad tomatoes","Orange bell pepper","Lettuce","Cauliflower","Leeks","Mushrooms","Small white onions"], + "targets": { + "protein_per_day_g": 165, + "protein_min_ok_g": 165, + "fibre_per_day_g": 30, + "satfat_per_day_g": 30, + "vitC_mg": 40, + "vitA_mcg": 700, + "calcium_mg": 700 + } +} diff --git a/scripts/analyze_history.py b/scripts/analyze_history.py new file mode 100644 index 0000000..0b300ec --- /dev/null +++ b/scripts/analyze_history.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python3 +""" +analyze_history.py — one-off health/calorie review of the meal-history.json log. + +Reads the rolling 7-day meal log and prints, per meal and per day: + - estimated kcal and protein (g) + - daily totals + averages across the window + - a rough micronutrient read (fibre, sat fat, vit C, vit A, calcium) + - whether each day meets the protein / fibre / sat-fat targets + +Nutrition is ESTIMATE-grade (see references/nutrition-db.json). Each DB item carries its +canonical `unit` and `g_per_unit`; the script converts the LOGGED qty+unit to grams, then +scales — so a veg logged in 'g' and one logged in 'count' both resolve correctly. Condiments, +oils and cooking fat are NOT in the log, so real meals run ~80-120 kcal higher per cooked meal. +This is a review tool, not a send gate. It does NOT modify any file. + +Usage: + python3 scripts/analyze_history.py # whole logged window + python3 scripts/analyze_history.py --days 4 # last N days only +""" +import json, os, argparse +from datetime import datetime + +SKILL_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +FIELDS = ["kcal", "protein", "fibre", "satfat", "vitC", "vitA", "calcium"] + + +def load(): + with open(os.path.join(SKILL_DIR, "meal-history.json")) as f: + hist = json.load(f) + with open(os.path.join(SKILL_DIR, "references", "nutrition-db.json")) as f: + db = json.load(f) + entries = hist["entries"] if isinstance(hist, dict) else hist + return entries, db + + +def to_grams(name, qty, unit, db): + spec = next((db["items"][k] for k in db["items"] if k.lower() == name.lower()), None) + if not spec: + return 0.0 + if unit in ("g", "ml", "kg"): + return qty * (1000 if unit == "kg" else 1) + # count/can/serving/portion: grams = qty * g_per_unit of the canonical unit + return qty * spec.get("g_per_unit", 100) + + +def item_nutrition(name, qty, unit, db): + spec = next((db["items"][k] for k in db["items"] if k.lower() == name.lower()), None) + if not spec: + return {f: 0 for f in FIELDS} + grams = to_grams(name, qty, unit, db) + factor = grams / spec["g_per_unit"] # g_per_unit == grams per one canonical unit + return {f: spec.get(f, 0) * factor for f in FIELDS} + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--days", type=int, default=0, help="limit to last N days (0 = all)") + args = ap.parse_args() + entries, db = load() + entries = sorted(entries, key=lambda e: e.get("date", "")) + if args.days: + dates = sorted({e["date"] for e in entries})[-args.days:] + entries = [e for e in entries if e["date"] in dates] + + by_day = {} + for e in entries: + by_day.setdefault(e["date"], []).append(e) + + grand = {f: 0 for f in FIELDS} + day_proteins = [] + print("=" * 78) + print("MEAL-HISTORY HEALTH REVIEW (estimated; condiments/oil not logged => +~80-120 kcal/meal)") + print("=" * 78) + for d in sorted(by_day): + day_tot = {f: 0 for f in FIELDS} + veg = set() + print(f"\n--- {d} ({by_day[d][0].get('weekday','?')}) ---") + for e in by_day[d]: + mtot = {f: 0 for f in FIELDS} + for it in e.get("items", []): + n = item_nutrition(it["name"], it["qty"], it.get("unit", "count"), db) + for f in FIELDS: + mtot[f] += n[f] + if it["name"] in db.get("veg_names", []): + veg.add(it["name"]) + for f in FIELDS: + day_tot[f] += mtot[f] + print(f" {e['meal']:6} {e['name']}") + print(f" ~{mtot['kcal']:.0f} kcal | {mtot['protein']:.0f} g protein | " + f"fibre {mtot['fibre']:.0f} | satfat {mtot['satfat']:.0f} | " + f"vitC {mtot['vitC']:.0f} | vitA {mtot['vitA']:.0f} | Ca {mtot['calcium']:.0f}") + for f in FIELDS: + grand[f] += day_tot[f] + day_proteins.append(day_tot["protein"]) + t = db["targets"] + p_ok = "OK" if day_tot["protein"] >= t["protein_per_day_g"] else "LOW" + sf = "HIGH" if day_tot["satfat"] > t["satfat_per_day_g"] else "ok" + print(f" DAY TOTAL ~{day_tot['kcal']:.0f} kcal | protein {day_tot['protein']:.0f} g [{p_ok}] | " + f"fibre {day_tot['fibre']:.0f} | satfat {day_tot['satfat']:.0f} [{sf}] | " + f"veg types {len(veg)}") + + n = len(by_day) or 1 + t = db["targets"] + print("\n" + "=" * 78) + print(f"WINDOW AVERAGE/DAY (over {len(by_day)} day(s)):") + print(f" kcal ~{grand['kcal']/n:.0f}") + print(f" protein {grand['protein']/n:.0f} g (target >= {t['protein_per_day_g']}) " + f"-> days meeting: {sum(1 for p in day_proteins if p >= t['protein_per_day_g'])}/{len(day_proteins)}") + print(f" fibre {grand['fibre']/n:.0f} g (target ~{t['fibre_per_day_g']})") + print(f" satfat {grand['satfat']/n:.0f} g (target <= {t['satfat_per_day_g']})") + print(f" vitC {grand['vitC']/n:.0f} mg (RDA ~{t['vitC_mg']})") + print(f" vitA {grand['vitA']/n:.0f} mcg (RDA ~{t['vitA_mcg']})") + print(f" calcium {grand['calcium']/n:.0f} mg (RDA ~{t['calcium_mg']})") + print("=" * 78) + print("Not medical advice — defer to the user's clinical/dietitian team.") + + +if __name__ == "__main__": + main()