From d8aa87921ef23b9b407548ede50012d934ce7ab4 Mon Sep 17 00:00:00 2001 From: meal-agent Date: Thu, 27 Aug 2026 19:07:53 +0100 Subject: [PATCH] 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) --- .gitignore | 5 + README.md | 361 +++++++++++++++++++++++++++++++++++----- SKILL.md | 80 +++++++++ scripts/pick_cuisine.py | 143 ++++++++++++++++ scripts/verify_eggs.py | 68 ++++++++ 5 files changed, 614 insertions(+), 43 deletions(-) create mode 100644 scripts/pick_cuisine.py create mode 100644 scripts/verify_eggs.py diff --git a/.gitignore b/.gitignore index d60332a..0ec629b 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,8 @@ # Your current stock and 7-day meal log drift as you eat / the daily job runs. inventory.json meal-history.json +# Weekly cuisine pick (regenerated by scripts/pick_cuisine.py; not source). +cuisine-rotation.json +# Python caches +__pycache__/ +scripts/__pycache__/ diff --git a/README.md b/README.md index f9795ed..1d07f99 100644 --- a/README.md +++ b/README.md @@ -1,49 +1,324 @@ -# meal-suggestion skill +# Meal Suggestion — Kitchen Inventory + High-Protein Meals -Kitchen-inventory + high-protein meal suggestion skill for Hermes Agent. +An automated, research-grounded meal system for a single user (jp): a recovering +burns victim on a **high-protein diet (~190–200 g/day)** who is also losing fat, +and who wants to **avoid buying red meat**. It does two things on a schedule: -- Tracks a kitchen inventory (`inventory.json`). -- Emails **2 daily meal suggestions** (from stock) every day at 09:00. -- Emails a **weekly £30 shopping list** (Mondays 08:00) with a per-store - price comparison (Lidl preferred) and a plain-text fallback. -- Targets ~190–200 g protein/day for a recovering burns victim (2 g/kg at - ~95–100 kg, age 46), avoids buying red meat, allows Amazon Prime + low-cost - condiments/flavourings/micronutrients when budget allows. +1. **Daily meal suggestions** — two meals (lunch + dinner) built only from what + is actually in the kitchen inventory, emailed every day at 09:00. +2. **Weekly shopping list** — a £30 Lidl basket, themed around a randomly chosen + world cuisine that week, emailed Mondays at 08:00. -## Files -- `SKILL.md` — the skill (rules, shop logic, 7-day history removal flow). -- `references/` — email template, protein sources, Eastbourne shop ratios, - meal-history logger spec, protein-target notes. -- `inventory.json` — **LIVE DATA** (your current stock). Git-ignored; a - `inventory.example.json` is committed as a template. -- `meal-history.json` — **LIVE DATA** (rolling 7-day meal log). Git-ignored. +Sender: `hpm6@txt3.com` (the agent's Gmail, via `smtp.gmail.com:587`). +Recipient: `jp@txt3.com`. -## Setup -1. Copy the skill into your Hermes skills dir: - ``` - cp -r meal-suggestion ~/.hermes/skills/ - ``` -2. Create `inventory.json` from the example: - ``` - cp ~/.hermes/skills/meal-suggestion/inventory.example.json \ - ~/.hermes/skills/meal-suggestion/inventory.json - ``` -3. Ensure the email sender exists at `~/.hermes/.env`: - ``` - EMAIL_ADDRESS=hpm6@txt3.com - EMAIL_PASSWORD="your-app-password" - EMAIL_SMTP_HOST=smtp.gmail.com - EMAIL_SMTP_PORT=587 - ``` -4. Place the send helper: - `scripts/meal/send_meal_email.py` (reads creds from `~/.hermes/.env`, - supports `--subject --html --text --to`). -5. Create the two cron jobs (Hermes `cronjob` tool): - - Daily 09:00: `0 9 * * *` — load skill `meal-suggestion`, read inventory, - build 2 meals, email to `jp@txt3.com`, record into `meal-history.json`. - - Monday 08:00: `0 8 * * 1` — load skill `meal-suggestion`, build £30 Lidl - list + comparison, email to `jp@txt3.com`. +> General guidance, **not medical advice** — defer to the user's clinical / +> dietitian team; keep hydrated on a high-protein intake. -See `SKILL.md` for the full behavior spec and the removal-from-history flow. +--- -> General guidance, not medical advice — defer to clinical/dietitian team. +## Table of contents +- [How it works](#how-it-works) +- [Repository layout](#repository-layout) +- [Core rules](#core-rules) +- [Egg limit (hard rule)](#egg-limit-hard-rule) +- [Weekly cuisine rotation](#weekly-cuisine-rotation) +- [Inventory & meal history](#inventory--meal-history) +- [Email format](#email-format) +- [Automation (cron)](#automation-cron) +- [Scripts](#scripts) +- [Where to shop](#where-to-shop-eastbourne) +- [On-demand usage](#on-demand-usage) +- [Testing & regression guards](#testing--regression-guards) +- [Git & sync](#git--sync) + +--- + +## How it works + +The system is driven by a **skill** (`SKILL.md`) plus two cron jobs that load it. +On each run the agent: + +- Reads the kitchen **`inventory.json`** (current stock). +- **Daily:** searches the web for easy, tasty, high-protein budget recipes for + inspiration, then builds two meals **strictly from in-stock items**, records + them to `meal-history.json`, and emails them. +- **Weekly:** picks a cuisine, builds a £30 Lidl basket themed around it, adds a + per-store price comparison and a health-coverage note, and emails it. + +All generated meals obey the **strict inventory rule**: no ingredient may appear +that is not already in `inventory.json`. Recipe ideas are only used as +inspiration; if a recipe needs something not in stock, it is substituted with an +in-stock item or dropped. + +--- + +## Repository layout + +``` +meal-suggestion/ +├── SKILL.md # The operative spec: rules, format, automation +├── README.md # This document +├── .gitignore # Excludes live data (inventory/meal-history/cuisine-rotation) +├── inventory.example.json # Schema example for inventory.json +├── meal-history.example.json # Schema example for meal-history.json +├── references/ # Knowledge base the agent reads when generating +│ ├── email-template.md # HTML email scaffold (inline CSS, mobile-friendly) +│ ├── food-health.md # Healing + 46yo health + easy/tasty meal research +│ ├── protein-sources.md # Allowed proteins + example pairings +│ ├── protein-targets.md # Protein math (2 g/kg) +│ ├── meal-history.md # 7-day logger spec + reconcile-from-inventory logic +│ └── eastbourne-shops.md # Shop price table (Lidl preferred) +├── scripts/ +│ ├── pick_cuisine.py # Picks this week's world cuisine (seeded, varied) +│ └── verify_eggs.py # Regression guard for the EGG LIMIT hard rule +└── (live, git-ignored) + ├── inventory.json # CURRENT stock — drifts as the user eats/buys + ├── meal-history.json # 7-day rolling log of suggested meals + └── cuisine-rotation.json # Current + recent weekly cuisine picks +``` + +The live skill directory is `/home/jp/.hermes/skills/meal-suggestion/`; this repo +is a synced mirror under `~/IdeaProjects/meal-suggestion/`. The two stay in step +(see [Git & sync](#git--sync)). + +--- + +## Core rules + +- **High-protein target:** ~190–200 g/day (≈2 g/kg at ~95–100 kg, for burns + recovery + lean-mass preservation during ~15 kg fat loss). Aim ~90–100 g per + meal and **state the per-meal and daily protein totals** in the email. +- **Weight-loss framing:** mild calorie deficit via bulking with veg and going + easy on chips/bread/oil — **not** by cutting protein. +- **Red meat:** already in stock is *usable* (use it up, deprioritised), but the + shopping list must **never suggest buying red meat** (beef, lamb, pork, + venison, bacon, gammon). +- **Strict inventory rule:** both daily meals are built ONLY from items in + `inventory.json`. Nothing invented, assumed, or added. +- **Tasty & varied:** the agent does a 1–2 query web search for easy, high-protein + budget meals and draws on real recipes so meals aren't the same plate repeated. + Prefers no-cook / one-pan / 10–15 min methods. +- **Health coverage:** meals/items favour wound-healing nutrients (Vit C, Zinc, + Vit A, Copper, Vit K, iron+VitC) and 46-year-old health (Vit D, B12, + Magnesium, Omega-3) plus mental + visual acuity (leafy greens, eggs/choline, + berries, walnuts, olive oil, lutein/zeaxanthin, omega-3 DHA). Noted as gentle + "health boost" lines, not medical advice. + +--- + +## Egg limit (hard rule) + +Eggs were previously overloaded (a day suggested 8 eggs across two egg-bearing +meals). This is now a **hard cap**, enforced three ways: + +1. **In SKILL.md:** *max 4 eggs per meal; only ONE of the two daily meals may + contain eggs — the other must be egg-free.* If a meal needs more protein than + 4 eggs supply, close the gap with lentils/beans/halloumi/cheese/tinned fish/ + whey — never a 5th+ egg. This overrides the "lead with eggs" preference. +2. **In inventory.json:** `diet.egg_rules` carries `max_per_meal: 4` and + `max_meals_per_day_with_eggs: 1`; the Eggs item note states the cap. Every run + reads the limit from the data itself. +3. **In scripts/verify_eggs.py:** a regression guard that checks the persisted + history against `diet.egg_rules` and exits non-zero on violation. Run it after + any change to inventory/meal-history or the daily job, and before sending a + regenerated day's email. + +--- + +## Weekly cuisine rotation + +To keep shops and meals varied over time, each week's list is themed around a +**randomly chosen world cuisine**, driven by `scripts/pick_cuisine.py`. + +- **Stable within a week:** the pick is seeded by the week's Monday ISO date, so + re-runs / retries of the Monday job never flip the cuisine mid-week. +- **Varied over time:** the last 4 chosen cuisines are excluded from the pool + (no back-to-back repeats). +- **Persisted:** writes the choice to `cuisine-rotation.json` + (`week_monday`, `cuisine`, `theme_items`) so the **daily job can read it** and + cook on-theme meals from the same week's buys. +- **Cuisine pool (all Lidl-achievable on ~£30, high-protein, red-meat-free by + design):** Mexican/Tex-Mex, Greek/Mediterranean, Indian/South Asian, Thai, East + Asian/Japanese, Middle Eastern/Levantine, Italian, Korean. +- **Themes without breaking rules:** the cuisine leads protein/veg/condiment buys + with its `theme_items`, but still respects £30 budget, protein-first, healing/ + 46yo coverage, Lidl-default, and never-buy-red-meat. Pure flavour buys + (spices/sauces/lime/herbs) are the optional low-price "interest" items. + +The weekly email subject/header shows `Cuisine: `. + +--- + +## Inventory & meal history + +**`inventory.json`** — current stock. Schema: + +```json +{ + "updated": "YYYY-MM-DD", + "diet": { + "goal": "high-protein", + "avoid_purchase": ["red meat"], + "egg_rules": { "max_per_meal": 4, "max_meals_per_day_with_eggs": 1, + "note": "..." }, + "age_years": 46, "bodyweight_kg": "95-100", + "protein_per_kg": 2, "protein_target_g_per_day": "190-200", + "weight_loss_goal_kg": 15 + }, + "items": [ + { "name": "Eggs", "qty": 10, "unit": "count", "category": "protein", + "notes": "Hard cap: max 4 eggs per meal; only ONE of the two daily meals may use eggs." } + ] +} +``` + +- `category` ∈ `protein, veg, fruit, dairy, grain, tinned, frozen, condiment, other`. +- **You own the inventory.** When the user says they *used/consumed* ingredients, + decrement/remove them (via the `patch` tool) and bump `updated`. When they + *bought* items, add/increase them. The daily email only *proposes* meals — it + does **not** auto-consume. + +**`meal-history.json`** — rolling 7-day log. The daily job **UPSERTS** today's two +entries (replaces same `date`+`meal`, so re-runs don't duplicate) and **prunes** +entries older than 7 days. Each entry: + +```json +{ "date": "YYYY-MM-DD", "weekday": "Thursday", + "meal": "lunch", "name": "Halloumi & Red Lentil Power Bowl (egg-free)", + "items": [{ "name": "Halloumi", "qty": 100, "unit": "g" }] } +``` + +Only substantive food items are logged — pure condiments and the optional +"third hit" snack are not. This log is what the on-demand inventory-removal +feature reconciles against. + +--- + +## Email format + +Sent via `python3 /home/jp/.hermes/scripts/meal/send_meal_email.py` (reads SMTP +creds from `~/.hermes/.env`). Both HTML (inline CSS, mobile-friendly, dark-on-light) +and a plain-text alternative are produced so the plan survives HTML-stripping +clients. + +- **Daily:** header (date + `high-protein · ~190–200g/day · no red meat bought`), + two meal cards (name, ingredients, 2–3 step method, protein total, short + "health boost" note), footer (`Reply to tell me what you used and I'll update + the inventory`). +- **Weekly:** shopping table (item, est. price, running total ≤ £30, Total row), + header with `Cuisine: `, a "health coverage" note (healing + brain + eyes + + 46yo + cuisine theme), a "Same basket — where else?" per-store comparison + table, and a "Same basket — where else?" plain-text mirror. + +--- + +## Automation (cron) + +Two cron jobs (created via the `cronjob` tool), both loading the +`meal-suggestion` skill: + +| Job | Schedule | Purpose | +|-----|----------|---------| +| Daily meal suggestions | `0 9 * * *` | 2 in-stock meal suggestions → email | +| Weekly shopping list | `0 8 * * 1` | £30 cuisine-themed Lidl basket → email | + +Delivery is `local` (the email is the deliverable); check `cronjob action=list` / +logs if a send seems missing. The weekly job runs `pick_cuisine.py` **first**, +then themes the basket; the daily job reads `cuisine-rotation.json` and prefers +on-theme cooking while still obeying the strict inventory rule and the egg cap. + +--- + +## Scripts + +### `scripts/pick_cuisine.py` +Picks this week's cuisine. + +- `python3 scripts/pick_cuisine.py` — pick (or reuse this week's) and persist. +- `python3 scripts/pick_cuisine.py --dry-run` — print only, do not write. +- Prints JSON: `{"week_monday", "cuisine", "theme_items", "reused"}`. +- Seeded by the week's Monday → stable within a week; excludes the last 4 + cuisines → no back-to-back repeats. + +### `scripts/verify_eggs.py` +Regression guard for the EGG LIMIT contract. Reads `inventory.json` +(`diet.egg_rules`) and `meal-history.json`, checks no meal exceeds +`max_per_meal` and no day exceeds `max_meals_per_day_with_eggs`. Exits non-zero +on violation. Run after any change to the egg logic or before sending a +regenerated day. + +--- + +## Where to shop (Eastbourne) + +User preference (confirmed): **Lidl is the preferred shop** — a weekly trip, so +the extra distance is fine; best value and leaves budget for essential +micronutrients and the occasional treat. Others (Tesco Express, Sainsbury's, +Co-op, Londis) are comparison/fallback only. + +Grounded in Which? 2026 (93-item basket; Lidl ≈ £160.70 baseline): +- Lidl ≈ 1.00x — cheapest, preferred +- Tesco superstore w/ Clubcard ≈ 1.17x; Tesco Express ≈ 1.22x +- Sainsbury's w/ Nectar ≈ 1.17x +- Co-op ≈ 1.30x (membership = annual dividend) +- Londis ≈ 1.25–1.35x (milk 2L £1.40 is a confirmed cheaper line) + +The weekly email shows a "Same basket — where else?" comparison (Lidl / Tesco +Express Clubcard / Sainsbury's Nectar / Co-op member / Londis) as **estimates** +from the Which? 2026 ratios + known local prices — no live web search. Default is +a single Lidl shop; split only when a specific item's local price is *definitely* +known lower. + +--- + +## On-demand usage + +- **"What should I eat today?"** — read `inventory.json`, suggest now, offer to email. +- **"Remove Wednesday's dinner from inventory"** — resolve the entry, decrement + each logged item from `inventory.json` (remove if ~0), skip items not present, + bump `updated`, confirm. Works per-item and partial-consumption overrides too. +- **Adding stock** — list items in chat; they're added to `inventory.json` and an + immediate 2-meal suggestion can follow. + +--- + +## Testing & regression guards + +- `python3 scripts/verify_eggs.py` — asserts the EGG LIMIT holds across + `meal-history.json` using the persisted `diet.egg_rules`. Should print + `PASS: egg limit OK (max 4/meal, max 1 egg-meal/day)`. +- The cuisine picker is deterministic per week (Monday-seeded) and was verified to + produce no consecutive repeats across 8 simulated weeks. + +--- + +## Git & sync + +This repo (`~/IdeaProjects/meal-suggestion/`) is the version-controlled mirror of +the live skill (`/home/jp/.hermes/skills/meal-suggestion/`). + +- **Live data is git-ignored:** `inventory.json`, `meal-history.json`, + `cuisine-rotation.json`, and `__pycache__/`. Only the spec, references, + examples, and scripts are committed. +- **Sync workflow:** copy the changed source files (currently `SKILL.md` and + `scripts/`) from the live skill dir into this repo, then commit. Keep + `.gitignore` excluding live data in both places. + +```bash +# from /home/jp/.hermes/skills/meal-suggestion +cp SKILL.md /home/jp/IdeaProjects/meal-suggestion/SKILL.md +cp scripts/pick_cuisine.py scripts/verify_eggs.py \ + /home/jp/IdeaProjects/meal-suggestion/scripts/ + +# commit the mirror +cd /home/jp/IdeaProjects/meal-suggestion +git add -A && git commit -m "Sync: egg-limit hard rule + weekly cuisine rotation" +git push +``` + +Version history of the mirror: +- `72ce56e` Initial meal-suggestion skill (logic + template; live data git-ignored) +- `c5f4fb1` Sync: food-health research, mackerel, easy/tasty + acuity, weekly health-coverage +- `9e44876` Sync: strict inventory-only rule for daily meals +- *(current)* Sync: EGG LIMIT hard rule + weekly cuisine rotation + scripts diff --git a/SKILL.md b/SKILL.md index b710270..c6460c2 100644 --- a/SKILL.md +++ b/SKILL.md @@ -45,6 +45,7 @@ Schema: ## Meal suggestion rules - Suggest **two meals** per day (e.g. lunch + dinner) using only items currently in the inventory. - **Maximize protein** per meal; lead with a protein source. +- **EGG LIMIT (hard rule):** Eggs are capped at **4 per meal** and **only ONE of the two daily meals may contain eggs** — the other meal MUST be egg-free. Never suggest more than 4 eggs in a single meal, and never put eggs in both meals. If a meal needs more protein than 4 eggs supply, close the gap with other proteins (lentils, beans, halloumi, cheese, tinned fish, whey) or a whey shake — do NOT add a 5th+ egg. This overrides the "lead with eggs" preference below. - **Avoid red meat for PURCHASES** (beef, lamb, pork, venison, bacon, gammon) — the weekly shopping list must NEVER suggest red meat to buy. - **If red meat is already in the inventory, it stays usable** in daily meal @@ -53,6 +54,7 @@ Schema: - Preferred proteins (lead with these): eggs, chicken, turkey, fish, prawns, tofu, tempeh, lentils, beans, chickpeas, Greek yogurt, cottage cheese, quark, skim milk, whey, tinned tuna/salmon, edamame, halloumi. + (Eggs are subject to the EGG LIMIT hard rule above: max 4 per meal, only one meal/day may include them.) - **Protein target:** ~**190–200 g/day** (user ~95–100 kg × 2 g/kg — burns recovery + preserving lean mass while losing ~15 kg fat). Aim each meal at **~90–100 g protein** and **STATE the per-meal and daily protein totals** in the email. If 2 meals can't @@ -103,8 +105,35 @@ header, the two meal cards, and a footer note ("Reply to tell me what you used and I'll update the inventory"). Use the scaffold in `references/email-template.md`. +## Weekly cuisine rotation +To keep shops and meals varied over time, each week's list is themed around a +**randomly chosen world cuisine**. This is driven by +`scripts/pick_cuisine.py` (same skill dir). + +- Run `python3 scripts/pick_cuisine.py` at the **start of the weekly job**. It + picks a cuisine seeded by the week's Monday, so it is **stable within a week** + (re-runs/retries never flip the cuisine mid-week) but **varies week to week** + and avoids the last 4 cuisines (no back-to-back repeats). It prints JSON with + `cuisine` and `theme_items`, and persists the choice to `cuisine-rotation.json`. +- The chosen cuisine must **theme the entire weekly basket**: lead the protein, + veg, and 1–2 condiment/flavour buys with that cuisine's `theme_items`, so the + shop introduces varied ingredients. Cuisines are all Lidl-achievable on ~£30, + high-protein, and **red-meat-free by design**. +- Do NOT abandon the core rules for the theme: still hit the protein target, + still cover healing/brain/eyes/46yo micronutrients where possible, still + default to Lidl, still never buy red meat, still keep the £30 budget. Theme + items that are just flavour (spices, sauces, lime, herbs) are the optional + "interest" buys — keep them low-price and behind protein + veg + staples. +- Persist the active cuisine so the **daily meal job can read `cuisine-rotation.json` + and on-theme meals from the same week's buys** (e.g. build a Korean bowl, a + Mexican bowl, etc., from stock). The daily job should still respect the STRICT + inventory rule — only cook with what's actually in inventory.json. +- The rotation file keeps a short history; no manual tracking needed. + ## Weekly shopping list (Mondays) - Budget **£30** total. Use realistic UK supermarket prices (Tesco/Asda/Sainsbury's). +- **Pick this week's cuisine first** (see "Weekly cuisine rotation" above) and + theme the basket around it — varied ingredients keep meals interesting. - Prioritize protein, then veg/fruit, then staples. Show item, est. price, and a running total that lands in range. - **APPLY the research** (`references/food-health.md`): build the basket so it covers @@ -201,11 +230,54 @@ garlic, soy, etc.) and the optional "third hit" snack are NOT logged. Units matc 6) Confirm what was removed. The user can also say "remove all of " to drop both meals, or name a specific item to override. +**Item-level overrides & partial consumption (apply BEFORE step 3 above):** +- If the user corrects a quantity for one item (e.g. "only used 100g halloumi, not + 200g"), use their stated qty instead of the logged qty for that item. +- If the user says they only used PART of an item and the rest remains (e.g. "still + got half a lettuce", "there's half a head of broccoli left"), do NOT remove the whole + inventory entry — keep/restore the remaining portion (re-add at e.g. 0.5 count, or + leave prior-qty minus what was used). Never zero out an item the user says they still + have. Log the restored amount back into `inventory.json` and re-sort. + +**Reconcile other meal-history entries AFTER removal (prevents stale future plans):** +The daily job only writes TODAY's two meals at 09:00 — it never pre-plans future days. +But `meal-history.json` may contain future-dated entries (pre-seeded, or written by a +re-run). After any removal that deletes an inventory item (hits ~0), scan EVERY entry +in `meal-history.json` — including future-dated ones inside the 7-day window — for +`items` that reference the now-absent inventory name. For each such entry: + - Flag it to the user: " ('') still lists but it's no + longer in inventory." + - Offer to rewrite that entry using ONLY in-stock items (substitute the missing item + for an in-stock alternative, or drop it). Do NOT silently leave a plan that can't be + cooked. +This is the only guard against "Thursday's plan used chicken, but I ate the chicken +today" — the design is correct; the stale future row is the anomaly, so fix the row, +not the design. See `references/reconcile-history.md` for the scan snippet. + ## On-demand (chat) 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 the inventory and suggest now (and offer to email it). +## Pitfalls (learned the hard way) +- **"Regenerate / resend today's meals" means GENERATE FRESH, not replay.** When the + user asks to regenerate a day's meals or re-send the email, do NOT pull the two meals + back out of `meal-history.json` and re-format them. Re-read `inventory.json`, build + two new meals from current stock (honouring the egg limit, STRICT inventory rule, etc.), + then update today's `meal-history.json` entries to match what you actually send. + The history is a log; it is not the source of truth for a regeneration request. +- **Editing a cron job's prompt: edit jobs.json AND push via the `cronjob update` + action.** The scheduler may hold jobs in memory, so a bare file edit is not enough — + the running daemon won't see it. After writing the new prompt string, call + `cronjob update job_id= prompt="..."` so the in-memory job reloads. Verify with + `cronjob list` that the preview reflects the change. (Pushing to a non-running daemon + is harmless; not pushing is the silent-failure trap.) +- **The egg rule is a HARD cap, not a preference.** The daily job leaned on eggs as the + "preferred protein" and produced 8 eggs across two meals (6 + 4). Cured by (a) the + EGG LIMIT prose rule above, (b) `diet.egg_rules` in inventory.json, and (c) running + `scripts/verify_eggs.py` after any history/inventory change or before a resend. + Always run that probe before sending a regenerated day. + ## Automation Two cron jobs (created via the `cronjob` tool), both loading this skill: - Daily 09:00 — `0 9 * * *` — 2 meal suggestions. @@ -214,6 +286,14 @@ Two cron jobs (created via the `cronjob` tool), both loading this skill: Delivery is `local` because the email itself is the deliverable; check `cronjob action=list` / logs if a send ever seems missing. +## Scripts (in this skill dir) +- `scripts/pick_cuisine.py` — picks the week's random world cuisine (stable per week, + varies weekly, no recent repeats); persists to `cuisine-rotation.json`. Used by the + Monday weekly job (see "Weekly cuisine rotation"). +- `scripts/verify_eggs.py` — regression probe for the EGG LIMIT. Run after any change to + `meal-history.json`, `inventory.json`, or the daily job, and before sending a + regenerated day's email. Exits non-zero on violation. See "Pitfalls". + ## References - `references/protein-sources.md` — allowed proteins + example pairings. - `references/email-template.md` — HTML email scaffold. diff --git a/scripts/pick_cuisine.py b/scripts/pick_cuisine.py new file mode 100644 index 0000000..5d637d7 --- /dev/null +++ b/scripts/pick_cuisine.py @@ -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() diff --git a/scripts/verify_eggs.py b/scripts/verify_eggs.py new file mode 100644 index 0000000..83e877a --- /dev/null +++ b/scripts/verify_eggs.py @@ -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()