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:
5
.gitignore
vendored
5
.gitignore
vendored
@ -2,3 +2,8 @@
|
|||||||
# Your current stock and 7-day meal log drift as you eat / the daily job runs.
|
# Your current stock and 7-day meal log drift as you eat / the daily job runs.
|
||||||
inventory.json
|
inventory.json
|
||||||
meal-history.json
|
meal-history.json
|
||||||
|
# Weekly cuisine pick (regenerated by scripts/pick_cuisine.py; not source).
|
||||||
|
cuisine-rotation.json
|
||||||
|
# Python caches
|
||||||
|
__pycache__/
|
||||||
|
scripts/__pycache__/
|
||||||
|
|||||||
361
README.md
361
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`).
|
1. **Daily meal suggestions** — two meals (lunch + dinner) built only from what
|
||||||
- Emails **2 daily meal suggestions** (from stock) every day at 09:00.
|
is actually in the kitchen inventory, emailed every day at 09:00.
|
||||||
- Emails a **weekly £30 shopping list** (Mondays 08:00) with a per-store
|
2. **Weekly shopping list** — a £30 Lidl basket, themed around a randomly chosen
|
||||||
price comparison (Lidl preferred) and a plain-text fallback.
|
world cuisine that week, emailed Mondays at 08:00.
|
||||||
- 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.
|
|
||||||
|
|
||||||
## Files
|
Sender: `hpm6@txt3.com` (the agent's Gmail, via `smtp.gmail.com:587`).
|
||||||
- `SKILL.md` — the skill (rules, shop logic, 7-day history removal flow).
|
Recipient: `jp@txt3.com`.
|
||||||
- `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.
|
|
||||||
|
|
||||||
## Setup
|
> General guidance, **not medical advice** — defer to the user's clinical /
|
||||||
1. Copy the skill into your Hermes skills dir:
|
> dietitian team; keep hydrated on a high-protein intake.
|
||||||
```
|
|
||||||
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`.
|
|
||||||
|
|
||||||
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: <name>`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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: <name>`, 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
|
||||||
|
|||||||
80
SKILL.md
80
SKILL.md
@ -45,6 +45,7 @@ Schema:
|
|||||||
## Meal suggestion rules
|
## Meal suggestion rules
|
||||||
- Suggest **two meals** per day (e.g. lunch + dinner) using only items currently in the inventory.
|
- 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.
|
- **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
|
- **Avoid red meat for PURCHASES** (beef, lamb, pork, venison, bacon, gammon) — the
|
||||||
weekly shopping list must NEVER suggest red meat to buy.
|
weekly shopping list must NEVER suggest red meat to buy.
|
||||||
- **If red meat is already in the inventory, it stays usable** in daily meal
|
- **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,
|
- Preferred proteins (lead with these): eggs, chicken, turkey, fish, prawns, tofu,
|
||||||
tempeh, lentils, beans, chickpeas, Greek yogurt, cottage cheese, quark, skim milk,
|
tempeh, lentils, beans, chickpeas, Greek yogurt, cottage cheese, quark, skim milk,
|
||||||
whey, tinned tuna/salmon, edamame, halloumi.
|
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 +
|
- **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**
|
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
|
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").
|
("Reply to tell me what you used and I'll update the inventory").
|
||||||
Use the scaffold in `references/email-template.md`.
|
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)
|
## Weekly shopping list (Mondays)
|
||||||
- Budget **£30** total. Use realistic UK supermarket prices (Tesco/Asda/Sainsbury's).
|
- 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
|
- Prioritize protein, then veg/fruit, then staples. Show item, est. price, and a
|
||||||
running total that lands in range.
|
running total that lands in range.
|
||||||
- **APPLY the research** (`references/food-health.md`): build the basket so it covers
|
- **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 <day>" to drop both
|
6) Confirm what was removed. The user can also say "remove all of <day>" to drop both
|
||||||
meals, or name a specific item to override.
|
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: "<Day> <meal> ('<name>') still lists <gone item> 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)
|
## On-demand (chat)
|
||||||
If the user lists ingredients in chat, add them to the inventory and optionally
|
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
|
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).
|
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=<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
|
## Automation
|
||||||
Two cron jobs (created via the `cronjob` tool), both loading this skill:
|
Two cron jobs (created via the `cronjob` tool), both loading this skill:
|
||||||
- Daily 09:00 — `0 9 * * *` — 2 meal suggestions.
|
- 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
|
Delivery is `local` because the email itself is the deliverable; check
|
||||||
`cronjob action=list` / logs if a send ever seems missing.
|
`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
|
||||||
- `references/protein-sources.md` — allowed proteins + example pairings.
|
- `references/protein-sources.md` — allowed proteins + example pairings.
|
||||||
- `references/email-template.md` — HTML email scaffold.
|
- `references/email-template.md` — HTML email scaffold.
|
||||||
|
|||||||
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