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:
143
scripts/pick_cuisine.py
Normal file
143
scripts/pick_cuisine.py
Normal 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
68
scripts/verify_eggs.py
Normal 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()
|
||||
Reference in New Issue
Block a user