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