Add on-demand meal-log health/calorie analyzer + git-sync README trap pitfall

- scripts/analyze_history.py: read-only per-meal/per-day kcal+protein+micronutrient review
  of meal-history.json, unit-aware via references/nutrition-db.json (g_per_unit conversion so
  veg logged in g vs count both resolve correctly)
- references/nutrition-db.json: estimate per-item nutrition DB (ESTIMATE-grade; condiments/oil
  not logged => ~+80-120 kcal/meal real)
- SKILL.md: 'Health / calorie review (on-demand)' section; 'Git-sync README trap' pitfall
  (live README may be a stub while repo README is the real doc -- safe-sync procedure);
  scripts + references pointers
This commit is contained in:
hermes
2026-08-29 08:11:16 +01:00
parent 33b7e17ef9
commit d4c5a8f658
4 changed files with 256 additions and 0 deletions

View File

@ -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 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). 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) ### Import a weekly shop into inventory (exclusions supported)
The weekly job does **NOT** auto-add the shop to inventory — `last-shop.json` is 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 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 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 audit of history, not a send gate; historical over-cap rows (old halloumi quantities) are
already-sent and only fixed on explicit request. 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 ## Automation
Two cron jobs (created via the `cronjob` tool), both loading this skill: 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 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; 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. 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
- `references/protein-sources.md` — allowed proteins + example pairings. - `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 - `references/food-health.md` — research-grounded food & health knowledge base
(healing nutrition, wound-healing micronutrients, men's health at 46, easy & tasty (healing nutrition, wound-healing micronutrients, men's health at 46, easy & tasty
budget meals, flavour-without-cost). Read this when generating meals. 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.

60
references/calories.md Normal file
View File

@ -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 ~80150 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.

View File

@ -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
}
}

120
scripts/analyze_history.py Normal file
View File

@ -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()