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:
2
.gitignore
vendored
2
.gitignore
vendored
@ -4,6 +4,8 @@ inventory.json
|
|||||||
meal-history.json
|
meal-history.json
|
||||||
# Weekly cuisine pick (regenerated by scripts/pick_cuisine.py; not source).
|
# Weekly cuisine pick (regenerated by scripts/pick_cuisine.py; not source).
|
||||||
cuisine-rotation.json
|
cuisine-rotation.json
|
||||||
|
# Last generated weekly shop (regenerated by the weekly job; not the inventory).
|
||||||
|
last-shop.json
|
||||||
# Python caches
|
# Python caches
|
||||||
__pycache__/
|
__pycache__/
|
||||||
scripts/__pycache__/
|
scripts/__pycache__/
|
||||||
|
|||||||
23
SKILL.md
23
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
|
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).
|
||||||
|
|
||||||
|
### 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)
|
## Pitfalls (learned the hard way)
|
||||||
- **"Regenerate / resend today's meals" means GENERATE FRESH, not replay.** When the
|
- **"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
|
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
181
scripts/shop_basket.py
Normal 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))
|
||||||
Reference in New Issue
Block a user