#!/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))