Sync: EGG LIMIT hard rule + weekly cuisine rotation + scripts

- SKILL.md: add egg-limit hard rule (max 4/meal, one egg-meal/day), cuisine-rotation section
- scripts/pick_cuisine.py: seeded weekly world-cuisine picker (stable per week, no recent repeats)
- scripts/verify_eggs.py: regression guard for the egg cap
- README.md: full project documentation
- .gitignore: exclude cuisine-rotation.json + python caches (live data stays local)
This commit is contained in:
meal-agent
2026-08-27 19:07:53 +01:00
parent 9e448760cd
commit d8aa87921e
5 changed files with 614 additions and 43 deletions

143
scripts/pick_cuisine.py Normal file
View File

@ -0,0 +1,143 @@
#!/usr/bin/env python3
"""Pick this week's world cuisine for the weekly shop.
Design goals:
- RANDOM per week, but STABLE within a week (seeded by the week's Monday date)
so that re-runs / retries of the Monday job never flip the cuisine mid-week.
- VARIED over time: the last few chosen cuisines are excluded from the pool.
- PERSISTED to cuisine-rotation.json so the daily meal job can read the active
cuisine and cook on-theme meals from the same week's buys.
Usage:
python3 pick_cuisine.py # pick (or reuse) and persist
python3 pick_cuisine.py --dry-run # print only, do not write
Prints a JSON line:
{"week_monday": "...", "cuisine": "...", "theme_items": [...], "reused": bool}
"""
import argparse
import json
import os
from datetime import date, timedelta
HERE = os.path.dirname(os.path.abspath(__file__))
ROTATION_FILE = os.path.join(HERE, "..", "cuisine-rotation.json")
# Curated cuisines: all are achievable at Lidl on a ~£30 budget, high-protein,
# and contain NO red meat by design. theme_items are cheap, cuisine-appropriate
# buys (proteins/veg/staples/condiments) that add variety; some may already be
# in inventory — the weekly job still cross-checks budget + Lidl availability.
CUISINES = {
"Mexican / Tex-Mex": [
"wholemeal tortilla wraps", "tinned black beans", "tinned sweetcorn",
"tinned chopped tomatoes", "cheddar cheese", "lime", "cumin",
"smoked paprika", "avocado", "chicken breast", "Greek yogurt",
],
"Greek / Mediterranean": [
"feta", "Kalamata olives", "cucumber", "cherry tomatoes",
"chickpeas", "halloumi", "dried oregano", "lemon", "red onion",
"tinned tuna", "Greek yogurt",
],
"Indian / South Asian": [
"red lentils", "chickpeas", "tinned tomatoes", "fresh spinach",
"curry powder", "grated ginger", "onions", "Greek yogurt",
"firm tofu", "microwave rice", "chicken breast",
], "Thai": [
"jasmine rice", "coconut milk (tin)", "soy sauce", "lime",
"grated ginger", "red curry paste", "frozen prawns", "edamame",
"fish-free: tofu", "fresh coriander", "eggs",
],
"East Asian / Japanese": [
"egg noodles", "soy sauce", "edamame (frozen)", "eggs",
"firm tofu", "sesame seeds", "tinned tuna", "miso paste",
"frozen mixed veg", "grated ginger", "spring onions",
],
"Middle Eastern / Levantine": [
"chickpeas", "red lentils", "feta", "cucumber", "tomatoes",
"lemon", "cumin", "pitta breads", "halloumi", "Greek yogurt",
"fresh parsley", "chicken breast",
],
"Italian": [
"passata", "wholemeal pasta", "mozzarella", "tinned tuna",
"cherry tomatoes", "fresh basil", "black olives", "spinach",
"eggs", "chicken breast", "parmesan",
],
"Korean": [
"jasmine rice", "soy sauce", "sesame oil", "sesame seeds",
"gochujang (tube)", "eggs", "firm tofu", "kimchi (jar)",
"frozen mixed veg", "grated ginger", "spring onions",
],
}
# How many recent cuisines to exclude so weeks don't repeat back-to-back.
RECENT_WINDOW = 4
def week_monday(d=None):
d = d or date.today()
return d - timedelta(days=d.weekday()) # Monday=0
def load_rotation():
try:
with open(ROTATION_FILE) as f:
return json.load(f)
except FileNotFoundError:
return {"current": None, "history": []}
def save_rotation(data):
os.makedirs(os.path.dirname(ROTATION_FILE), exist_ok=True)
with open(ROTATION_FILE, "w") as f:
json.dump(data, f, indent=2)
def pick(dry_run=False):
import random
monday = week_monday()
monday_str = monday.isoformat()
rot = load_rotation()
# Same week -> reuse the locked-in cuisine (stable within the week).
if rot.get("current") and rot["current"].get("week_monday") == monday_str:
rot["current"]["reused"] = True
print(json.dumps(rot["current"]))
return
pool = list(CUISINES.keys())
recent = [h["cuisine"] for h in rot.get("history", [])[-RECENT_WINDOW:]]
candidates = [c for c in pool if c not in recent] or pool
# Seed by the week's Monday so the pick is deterministic for that week
# (re-runs in the same week always return the same cuisine).
rng = random.Random(monday_str)
cuisine = rng.choice(candidates)
chosen = {
"week_monday": monday_str,
"cuisine": cuisine,
"theme_items": CUISINES[cuisine],
"reused": False,
}
if dry_run:
print(json.dumps(chosen))
return
history = rot.get("history", [])
history.append({"week_monday": monday_str, "cuisine": cuisine})
# Keep history bounded.
history = history[-12:]
save_rotation({"current": chosen, "history": history})
print(json.dumps(chosen))
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--dry-run", action="store_true",
help="print the pick without persisting")
args = ap.parse_args()
pick(dry_run=args.dry_run)
if __name__ == "__main__":
main()

68
scripts/verify_eggs.py Normal file
View File

@ -0,0 +1,68 @@
#!/usr/bin/env python3
"""Regression guard for the EGG LIMIT hard rule.
Run after any change to meal-history.json, inventory.json, or the daily job,
and before sending a regenerated day's email:
python3 scripts/verify_eggs.py
Checks the EGG LIMIT contract against the persisted rules in inventory.json:
- No single meal may exceed diet.egg_rules.max_per_meal (default 4).
- No day may have more than diet.egg_rules.max_meals_per_day_with_eggs
(default 1) meals containing eggs — i.e. the other meal must be egg-free.
Exits non-zero on violation so it can gate a send or a CI-style check.
"""
import json
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
SKILL_DIR = os.path.dirname(HERE)
def load(name):
with open(os.path.join(SKILL_DIR, name)) as f:
return json.load(f)
def main():
inv = load("inventory.json")
hist = load("meal-history.json")
rules = inv.get("diet", {}).get("egg_rules", {})
max_per_meal = rules.get("max_per_meal", 4)
max_egg_meals = rules.get("max_meals_per_day_with_eggs", 1)
violations = []
by_date = {}
for e in hist.get("entries", []):
eggs = next((i["qty"] for i in e["items"] if i["name"].lower() == "eggs"), 0)
by_date.setdefault(e["date"], []).append((e["meal"], e.get("name", ""), eggs))
for date, meals in by_date.items():
for meal, name, eggs in meals:
if eggs > max_per_meal:
violations.append(
f"{date} {meal} ('{name}'): {eggs} eggs > max {max_per_meal}/meal")
egg_meal_count = sum(1 for _, _, eggs in meals if eggs > 0)
if egg_meal_count > max_egg_meals:
violations.append(
f"{date}: {egg_meal_count} egg-containing meals > max {max_egg_meals}/day "
f"(other meal must be egg-free)")
# Also confirm the inventory metadata carries the cap.
if not rules:
violations.append("inventory.json missing diet.egg_rules — egg cap not enforced at the data level")
if violations:
print("FAIL: egg-limit violations found:")
for v in violations:
print(f" - {v}")
sys.exit(1)
print(f"PASS: egg limit OK (max {max_per_meal}/meal, max {max_egg_meals} egg-meal/day) "
f"across {len(by_date)} day(s).")
if __name__ == "__main__":
main()