diff --git a/.gitignore b/.gitignore index 0ec629b..d418ed5 100644 --- a/.gitignore +++ b/.gitignore @@ -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__/ diff --git a/SKILL.md b/SKILL.md index c6460c2..e30d782 100644 --- a/SKILL.md +++ b/SKILL.md @@ -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 diff --git a/scripts/shop_basket.py b/scripts/shop_basket.py new file mode 100644 index 0000000..ee2cf52 --- /dev/null +++ b/scripts/shop_basket.py @@ -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": "", "name": "", "qty": , + "unit": "", "category": "<...>", + "price": } + +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))