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

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))