- 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)
148 lines
6.1 KiB
Python
148 lines
6.1 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Regression guard for RATIONAL PORTIONS.
|
|
|
|
Checks TODAY's meals in meal-history.json (override with --all for the 7-day window)
|
|
for:
|
|
1. CAP EXCEEDED — a protein item used in a single meal above its inventory
|
|
`max_per_meal`, or above the fallback cap when no explicit cap exists.
|
|
2. OVERLOAD — a single protein at >=70% of its cap that ALSO supplies >=85% of the
|
|
meal's protein (a "pile of one ingredient", e.g. 10 sausages). A balanced plate
|
|
where the lead protein is capped but other proteins/veg contribute is NOT flagged.
|
|
|
|
Units are normalised to grams so items logged in different units (cheese in g vs
|
|
inventory's "small block") compare correctly.
|
|
|
|
Exits non-zero (prints violations) if any rule is broken.
|
|
"""
|
|
import json, sys, os, argparse
|
|
from datetime import datetime, timedelta
|
|
|
|
SKILL_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
HISTORY = os.path.join(SKILL_DIR, "meal-history.json")
|
|
INV = os.path.join(SKILL_DIR, "inventory.json")
|
|
|
|
# protein (g) per unit, for the dominance estimate
|
|
PROTEIN_G = {
|
|
"egg": 6, "sausage": 11, "mince": 0.18, "lentil": 0.24, "tofu": 0.12,
|
|
"tempeh": 0.19, "bean": 9, "whey": 0.8, "cheese": 0.25, "halloumi": 0.21,
|
|
"tuna": 25, "salmon": 25, "mackerel": 20, "chicken": 0.31, "turkey": 0.29,
|
|
"prawn": 0.24, "yogurt": 0.10, "yoghurt": 0.10, "fish": 25,
|
|
}
|
|
|
|
# grams per inventory unit, for converting a stored cap to grams
|
|
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,
|
|
}
|
|
|
|
PROTEIN_CATS = {"protein", "dairy"}
|
|
|
|
|
|
def cap_grams(name, unit, inv):
|
|
it = next((i for i in inv["items"] if i["name"].lower() == name.lower()), None)
|
|
if it and "max_per_meal" in it:
|
|
gpu = GRAMS_PER_UNIT.get(it.get("unit", unit), 1)
|
|
return it["max_per_meal"] * gpu
|
|
rp = inv.get("diet", {}).get("rational_portions", {})
|
|
defaults = rp.get("default_caps", {})
|
|
for kw, cap in defaults.items():
|
|
if kw in name.lower():
|
|
gpu = GRAMS_PER_UNIT.get(rp.get("unit_fallback_unit", unit), 1)
|
|
return cap * gpu
|
|
fb = rp.get("unit_fallback", {}).get(unit, 6)
|
|
return fb * GRAMS_PER_UNIT.get(unit, 1)
|
|
|
|
|
|
def est_protein_g(name, qty, unit):
|
|
per = PROTEIN_G.get(name.lower())
|
|
if per is None:
|
|
for kw, v in PROTEIN_G.items():
|
|
if kw in name.lower():
|
|
per = v
|
|
break
|
|
if per is None:
|
|
return 0.0
|
|
gpu = GRAMS_PER_UNIT.get(unit, 1)
|
|
base = qty * gpu # grams of food
|
|
# per is g protein per g of food for g/ml units; per-unit for count/can
|
|
if unit in ("g", "ml", "kg"):
|
|
return per * base
|
|
return per * qty
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("--all", action="store_true", help="check full 7-day window")
|
|
args = ap.parse_args()
|
|
inv = json.load(open(INV))
|
|
hist = json.load(open(HISTORY))
|
|
entries = hist if isinstance(hist, list) else hist.get("entries", [])
|
|
today = datetime.now().strftime("%Y-%m-%d")
|
|
cutoff = datetime.now() - timedelta(days=7)
|
|
violations = []
|
|
for e in entries:
|
|
try:
|
|
d = datetime.strptime(e["date"], "%Y-%m-%d")
|
|
except Exception:
|
|
continue
|
|
if args.all:
|
|
if d < cutoff:
|
|
continue
|
|
else:
|
|
if e["date"] != today:
|
|
continue
|
|
meal_items = e.get("items", [])
|
|
# 1) cap check
|
|
for it in meal_items:
|
|
cap = cap_grams(it["name"], it.get("unit", "count"), inv)
|
|
used = est_protein_g(it["name"], it["qty"], it.get("unit", "count"))
|
|
# compare the RAW quantity against the cap's raw quantity (both in same unit)
|
|
cap_raw = cap / GRAMS_PER_UNIT.get(it.get("unit", "count"), 1) if it.get("unit") in ("g", "ml", "kg") else cap
|
|
# simpler: compare grams of food used vs grams of cap
|
|
food_g = it["qty"] * GRAMS_PER_UNIT.get(it.get("unit", "count"), 1)
|
|
cap_food_g = cap
|
|
if food_g > cap_food_g + 1e-6:
|
|
violations.append(
|
|
f"CAP EXCEEDED {e['date']} {e['meal']} '{e['name']}': "
|
|
f"{it['name']} {it['qty']} {it.get('unit','')} exceeds cap "
|
|
f"~{cap_food_g:.0f} g ({it.get('unit','')})")
|
|
# 2) overload check (single protein >=70% cap AND >=85% of meal protein)
|
|
prot = [(it, cap_grams(it["name"], it.get("unit", "count"), inv),
|
|
est_protein_g(it["name"], it["qty"], it.get("unit", "count")))
|
|
for it in meal_items]
|
|
total = sum(p for _, _, p in prot) or 1
|
|
for it, cap, pg in prot:
|
|
food_g = it["qty"] * GRAMS_PER_UNIT.get(it.get("unit", "count"), 1)
|
|
if cap and food_g > 0.7 * cap and pg > 0.85 * total:
|
|
violations.append(
|
|
f"OVERLOAD {e['date']} {e['meal']} '{e['name']}': "
|
|
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:
|
|
print(" -", v)
|
|
sys.exit(1)
|
|
scope = "all logged meals (7d)" if args.all else f"today ({today})"
|
|
print(f"PASS: portions rational — no overload ({scope}).")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|