- 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)
144 lines
5.0 KiB
Python
144 lines
5.0 KiB
Python
#!/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()
|