Add weekly-shop-to-inventory import (on-demand, exclusions) + structured basket save

- scripts/shop_basket.py: normalize + save structured weekly basket to last-shop.json (git-ignored live data)
- SKILL.md: weekly job now persists last-shop.json; new on-demand rule 'add everything from Monday's shop except X'
- .gitignore: ignore last-shop.json in both live skill and repo
- Weekly cron prompt updated to save basket (still never auto-modifies inventory)
This commit is contained in:
meal-agent
2026-08-27 19:39:31 +01:00
parent d8aa87921e
commit fb80f6a3dd
3 changed files with 206 additions and 0 deletions

2
.gitignore vendored
View File

@ -4,6 +4,8 @@ inventory.json
meal-history.json
# Weekly cuisine pick (regenerated by scripts/pick_cuisine.py; not source).
cuisine-rotation.json
# Last generated weekly shop (regenerated by the weekly job; not the inventory).
last-shop.json
# Python caches
__pycache__/
scripts/__pycache__/

View File

@ -259,6 +259,29 @@ 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).
### Import a weekly shop into inventory (exclusions supported)
The weekly job does **NOT** auto-add the shop to inventory — `last-shop.json` is
only a record of what the Monday email proposed. To bring it in, the user says
e.g. *"add everything from Monday's shop to the inventory, except milk and
vitamin D3"*. Procedure:
1) Read `/home/jp/.hermes/skills/meal-suggestion/last-shop.json` (set by the most
recent weekly run; if missing, tell the user the shop hasn't been generated yet
and ask them to wait for Monday or to list items directly).
2) Collect the `items` list. Remove any whose normalized `name` matches a named
exclusion (case-insensitive; match against the item's `name`, not just `raw`).
3) For each remaining item, **merge into `inventory.json`**: find an existing entry
by case-insensitive name match; if found, ADD its `qty` to the existing `qty`
(keep the existing `unit` if they agree, otherwise keep the existing entry's unit
and note the discrepancy); if not found, create a new entry with the item's
`name`, `qty`, `unit`, and `category`, sorted into the list. Items whose `qty`
is `null` (flagged in `warnings`) are skipped with a note asking the user for a
quantity — never invent a number.
4) Bump `updated` to today. Do NOT modify `last-shop.json`.
5) Confirm: list what was added (name + qty + unit), what was excluded, and any
items skipped pending a quantity. If the shop would push a staple already in
stock (e.g. whey, eggs), just add to the existing quantity.
Note: this is entirely on-demand — the weekly job itself never writes inventory.
## 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

181
scripts/shop_basket.py Normal file
View File

@ -0,0 +1,181 @@
#!/usr/bin/env python3
"""Structured weekly-shop basket: save + read + normalize.
The weekly job calls `save_basket(items, cuisine, week_monday)` after it has
picked the basket, writing last-shop.json. This file is GIT-IGNORED (live data,
like inventory.json / meal-history.json) — it is NOT the inventory; it only
records what the Monday email proposed, so the user can later say
"add everything from Monday's shop to the inventory, except X, Y" and the agent
has a machine-readable source instead of scraping the email.
Item schema (one dict per line):
{"raw": "<as shown in email>", "name": "<normalized>", "qty": <number>,
"unit": "<count|g|ml|can|pack|jar|tub|...>", "category": "<...>",
"price": <float>}
normalize_item(raw_line) turns an email-style line like "Eggs (10)" or
"Firm tofu (225g)" into a normalized {name, qty, unit} that matches how
inventory.json names things. New items not in the known map get a best-effort
name and unit, so nothing is silently dropped — the agent reviews before merging.
Usage (from the weekly job / agent):
from shop_basket import save_basket, read_basket, normalize_item
save_basket(items=[...], cuisine="Korean", week_monday="2026-08-24")
data = read_basket()
"""
import json
import os
import re
HERE = os.path.dirname(os.path.abspath(__file__))
SKILL_DIR = os.path.dirname(HERE)
LAST_SHOP = os.path.join(SKILL_DIR, "last-shop.json")
# Normalization map: lowercased raw "head" token -> (inventory_name, category, default_unit)
# Tokens are matched greedily against the start of the raw label (ignoring trailing
# pack sizes in parentheses).
NORMALIZE = [
("eggs", ("Eggs", "protein", "count")),
("chicken breast", ("Chicken breast", "protein", "count")),
("firm tofu", ("Tofu", "protein", "g")),
("tofu", ("Tofu", "protein", "g")),
("tinned tuna", ("Tinned tuna in water", "protein", "can")),
("tuna", ("Tinned tuna in water", "protein", "can")),
("tinned mackerel", ("Tinned mackerel", "protein", "can")),
("mackerel", ("Tinned mackerel", "protein", "can")),
("greek yogurt", ("Greek yogurt", "dairy", "g")),
("yogurt", ("Greek yogurt", "dairy", "g")),
("dried red lentils", ("Dried red lentils", "grain", "g")),
("red lentils", ("Dried red lentils", "grain", "g")),
("lentils", ("Dried red lentils", "grain", "g")),
("jasmine rice", ("Jasmine rice", "grain", "g")),
("rice", ("Rice", "grain", "g")),
("frozen mixed veg", ("Frozen mixed veg", "frozen", "g")),
("mixed veg", ("Frozen mixed veg", "frozen", "g")),
("sweet potatoes", ("Sweet potatoes", "veg", "count")),
("sweet potato", ("Sweet potatoes", "veg", "count")),
("broccoli", ("Broccoli", "veg", "count")),
("spinach", ("Spinach", "veg", "g")),
("spring onions", ("Spring onions", "veg", "count")),
("onions", ("Small white onions", "veg", "count")),
("bananas", ("Bananas", "fruit", "count")),
("berries", ("Frozen berries", "fruit", "g")),
("soy sauce", ("Soy sauce", "condiment", "ml")),
("sesame oil", ("Sesame oil", "condiment", "ml")),
("sesame seeds", ("Sesame seeds", "condiment", "g")),
("gochujang", ("Gochujang", "condiment", "g")),
("ginger", ("Grated ginger", "condiment", "g")),
("kimchi", ("Kimchi", "veg", "g")),
("milk", ("Milk", "dairy", "ml")),
("vitamin d3", ("Vitamin D3", "other", "count")),
("vitamin d", ("Vitamin D3", "other", "count")),
]
def _parse_qty_unit(raw):
"""Extract qty + unit from a trailing parenthetical like '(225g)', '(10)',
'(x2)', '(300g)', '(4-pack)'. Returns (qty, unit) or (None, None)."""
m = re.search(r"\(([^)]*)\)", raw)
if not m:
return None, None
inner = m.group(1).strip().lower()
mm = re.match(r"(\d+(?:\.\d+)?)\s*([a-z]*)", inner)
if mm:
qty = float(mm.group(1))
unit = mm.group(2) or None
if unit in ("g", "ml", "kg", "l", "l"):
return qty, unit
if unit in ("pack", "x"):
return qty, "pack"
if unit == "":
return qty, None
return qty, unit
# "(x2)" style
mx = re.match(r"x\s*(\d+)", inner)
if mx:
return float(mx.group(1)), "pack"
return None, None
def normalize_item(raw_line):
"""Turn an email-style label into {name, qty, unit, category}.
Falls back to a best-effort name (title-cased, parentheses stripped) and
unit 'count' when no known token matches, so the agent can still review it.
"""
line = raw_line.strip()
# head = everything before the first parenthesis (the descriptor)
head = re.sub(r"\(.*?\)", "", line).strip().lower()
qty, unit = _parse_qty_unit(line)
name, category, default_unit = None, "other", "count"
for token, (n, c, u) in NORMALIZE:
if token in head:
name, category, default_unit = n, c, u
break
if name is None:
# best effort: strip trailing pack qualifiers
name = re.sub(r"\s*\(.*?\)", "", line).strip().title()
category = "other"
default_unit = "count"
if unit is None:
unit = default_unit
# canonicalise unit aliases
unit = {"kg": "g", "l": "ml", "litre": "ml", "litres": "ml"}.get(unit, unit)
if unit == "g" and qty and qty >= 1000 and "kg" not in line.lower():
# keep as-is; caller can convert, but avoid wrong scaling
pass
return {"name": name, "qty": qty, "unit": unit, "category": category}
def save_basket(items, cuisine, week_monday, date=None):
"""items: list of {raw, name?, qty?, unit?, category?, price}. Callers (the
weekly job) supply explicit qty/unit per item so the saved basket is fully
structured. Any entry missing name/qty/unit is best-effort normalized; an
entry with qty still None after normalization is flagged in data['warnings'].
Writes last-shop.json (git-ignored)."""
normed = []
warnings = []
for it in items:
rec = dict(it)
if not rec.get("name") or rec.get("qty") is None or not rec.get("unit"):
n = normalize_item(rec.get("raw", ""))
rec.setdefault("name", n["name"])
if rec.get("qty") is None:
rec["qty"] = n["qty"]
if not rec.get("unit"):
rec["unit"] = n["unit"]
rec.setdefault("category", n["category"])
if rec.get("qty") is None:
warnings.append(f'{rec.get("raw", rec.get("name"))}: qty unknown — set manually on import')
normed.append(rec)
data = {
"week_monday": week_monday,
"date": date,
"cuisine": cuisine,
"items": normed,
"warnings": warnings,
}
os.makedirs(SKILL_DIR, exist_ok=True)
with open(LAST_SHOP, "w") as f:
json.dump(data, f, indent=2)
return data
def read_basket():
try:
with open(LAST_SHOP) as f:
return json.load(f)
except FileNotFoundError:
return None
if __name__ == "__main__":
# quick self-test
tests = ["Eggs (10)", "Firm tofu (225g)", "Tinned tuna in water (4-pack)",
"Kimchi (jar)", "Milk (1L)", "Vitamin D3 1000 IU (general health)"]
for t in tests:
print(t, "->", normalize_item(t))