diff --git a/.gitignore b/.gitignore index 2b8682f..5e63ce8 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,6 @@ dist/ # Data directories data/ + +# Test artifacts (MagicMock sqlite files from pytest) + dict: + """Re-run the expiration predictor over all available inventory items. + + Uses each item's stored purchase_date and current location. Safe to call + multiple times — idempotent per session. + """ + def _run(s: Store) -> tuple[int, int]: + return s.recalculate_expiry(tier=session.tier, has_byok=session.has_byok) + + updated, skipped = await asyncio.to_thread(_run, store) + return {"updated": updated, "skipped": skipped} + + @router.get("/stats", response_model=InventoryStats) async def get_inventory_stats(store: Store = Depends(get_store)): def _stats(): diff --git a/app/services/expiration_predictor.py b/app/services/expiration_predictor.py index d51919d..22eca01 100644 --- a/app/services/expiration_predictor.py +++ b/app/services/expiration_predictor.py @@ -21,6 +21,29 @@ logger = logging.getLogger(__name__) class ExpirationPredictor: """Predict expiration dates based on product category and storage location.""" + # Canonical location names and their aliases. + # All location strings are normalised through this before table lookup. + LOCATION_ALIASES: dict[str, str] = { + 'garage_freezer': 'freezer', + 'chest_freezer': 'freezer', + 'deep_freezer': 'freezer', + 'upright_freezer': 'freezer', + 'refrigerator': 'fridge', + 'frig': 'fridge', + 'cupboard': 'cabinet', + 'shelf': 'pantry', + 'counter': 'pantry', + } + + # When a category has no entry for the requested location, try these + # alternatives in order — prioritising same-temperature storage first. + LOCATION_FALLBACK: dict[str, tuple[str, ...]] = { + 'freezer': ('freezer', 'fridge', 'pantry', 'cabinet'), + 'fridge': ('fridge', 'pantry', 'cabinet', 'freezer'), + 'pantry': ('pantry', 'cabinet', 'fridge', 'freezer'), + 'cabinet': ('cabinet', 'pantry', 'fridge', 'freezer'), + } + # Default shelf life in days by category and location # Sources: USDA FoodKeeper app, FDA guidelines SHELF_LIFE = { @@ -39,6 +62,8 @@ class ExpirationPredictor: 'poultry': {'fridge': 2, 'freezer': 270}, 'chicken': {'fridge': 2, 'freezer': 270}, 'turkey': {'fridge': 2, 'freezer': 270}, + 'tempeh': {'fridge': 10, 'freezer': 365}, + 'tofu': {'fridge': 5, 'freezer': 180}, 'ground_meat': {'fridge': 2, 'freezer': 120}, # Seafood 'fish': {'fridge': 2, 'freezer': 180}, @@ -59,9 +84,9 @@ class ExpirationPredictor: 'bread': {'pantry': 5, 'freezer': 90}, 'bakery': {'pantry': 3, 'fridge': 7, 'freezer': 90}, # Frozen - 'frozen_foods': {'freezer': 180}, - 'frozen_vegetables': {'freezer': 270}, - 'frozen_fruit': {'freezer': 365}, + 'frozen_foods': {'freezer': 180, 'fridge': 3}, + 'frozen_vegetables': {'freezer': 270, 'fridge': 4}, + 'frozen_fruit': {'freezer': 365, 'fridge': 4}, 'ice_cream': {'freezer': 60}, # Pantry Staples 'canned_goods': {'pantry': 730, 'cabinet': 730}, @@ -91,44 +116,127 @@ class ExpirationPredictor: 'prepared_foods': {'fridge': 4, 'freezer': 90}, } + # Keyword lists are checked in declaration order — most specific first. + # Rules: + # - canned/processed goods BEFORE raw-meat terms (canned chicken != raw chicken) + # - frozen prepared foods BEFORE generic protein terms + # - multi-word phrases before single words where ambiguity exists CATEGORY_KEYWORDS = { + # ── Frozen prepared foods ───────────────────────────────────────────── + # Before raw protein entries so plant-based frozen products don't + # inherit 2–3 day raw-meat shelf lives. + 'ice_cream': ['ice cream', 'gelato', 'frozen yogurt', 'sorbet', 'sherbet'], + 'frozen_fruit': [ + 'frozen berries', 'frozen mango', 'frozen strawberries', + 'frozen blueberries', 'frozen raspberries', 'frozen peaches', + 'frozen fruit', 'frozen cherries', + ], + 'frozen_vegetables': [ + 'frozen veg', 'frozen corn', 'frozen peas', 'frozen broccoli', + 'frozen spinach', 'frozen edamame', 'frozen green beans', + 'frozen mixed vegetables', 'frozen carrots', + 'peas & carrots', 'peas and carrots', 'mixed vegetables', + 'spring rolls', 'vegetable spring rolls', + ], + 'frozen_foods': [ + 'plant-based', 'plant based', 'meatless', 'impossible', + "chik'n", 'chikn', 'veggie burger', 'veggie patty', + 'nugget', 'tater tot', 'waffle fries', 'hash brown', + 'onion ring', 'fish stick', 'fish fillet', 'potsticker', + 'dumpling', 'egg roll', 'empanada', 'tamale', 'falafel', + 'mac & cheese bite', 'cauliflower wing', 'ranchero potato', + ], + # ── Canned / shelf-stable processed goods ───────────────────────────── + # Before raw protein keywords so "canned chicken", "cream of chicken", + # and "lentil soup" resolve here rather than to raw chicken/cream. + 'canned_goods': [ + 'canned', 'can of', 'tin of', 'tinned', + 'cream of ', 'condensed soup', 'condensed cream', + 'baked beans', 'refried beans', + 'canned beans', 'canned tomatoes', 'canned corn', 'canned peas', + 'canned soup', 'canned tuna', 'canned salmon', 'canned chicken', + 'canned fruit', 'canned peaches', 'canned pears', + 'enchilada sauce', 'tomato sauce', 'tomato paste', + 'lentil soup', 'bean soup', 'chicken noodle soup', + ], + # ── Condiments & brined items ───────────────────────────────────────── + # Before produce/protein terms so brined olives, jarred peppers, etc. + # don't inherit raw vegetable shelf lives. + 'ketchup': ['ketchup', 'catsup'], + 'mustard': ['mustard', 'dijon', 'dijion', 'stoneground mustard'], + 'mayo': ['mayo', 'mayonnaise', 'miracle whip'], + 'soy_sauce': ['soy sauce', 'tamari', 'shoyu'], + 'salad_dressing': ['salad dressing', 'ranch', 'italian dressing', 'vinaigrette'], + 'condiments': [ + # brined / jarred items + 'dill chips', 'hamburger chips', 'gherkin', + 'olive', 'capers', 'jalapeño', 'jalapeno', 'pepperoncini', + 'pimiento', 'banana pepper', 'cornichon', + # sauces + 'hot sauce', 'hot pepper sauce', 'sriracha', 'cholula', + 'worcestershire', 'barbecue sauce', 'bbq sauce', + 'chipotle sauce', 'chipotle mayo', 'chipotle creamy', + 'salsa', 'chutney', 'relish', + 'teriyaki', 'hoisin', 'oyster sauce', 'fish sauce', + 'miso', 'ssamjang', 'gochujang', 'doenjang', + 'soybean paste', 'fermented soybean', + # nut butters / spreads + 'peanut butter', 'almond butter', 'tahini', 'hummus', + # seasoning mixes + 'seasoning', 'spice blend', 'borracho', + # other shelf-stable sauces + 'yuzu', 'ponzu', 'lizano', + ], + # ── Soy / fermented proteins ────────────────────────────────────────── + 'tempeh': ['tempeh'], + 'tofu': ['tofu', 'bean curd'], + # ── Dairy ───────────────────────────────────────────────────────────── 'milk': ['milk', 'whole milk', '2% milk', 'skim milk', 'almond milk', 'oat milk', 'soy milk'], - 'cheese': ['cheese', 'cheddar', 'mozzarella', 'swiss', 'parmesan', 'feta', 'gouda'], + 'cheese': ['cheese', 'cheddar', 'mozzarella', 'swiss', 'parmesan', 'feta', 'gouda', 'velveeta'], 'yogurt': ['yogurt', 'greek yogurt', 'yoghurt'], 'butter': ['butter', 'margarine'], - 'cream': ['cream', 'heavy cream', 'whipping cream', 'sour cream'], + # Bare 'cream' removed — "cream of X" is canned_goods (matched above). + 'cream': ['heavy cream', 'whipping cream', 'sour cream', 'crème fraîche', + 'cream cheese', 'whipped topping', 'whipped cream'], 'eggs': ['eggs', 'egg'], - 'beef': ['beef', 'steak', 'roast', 'brisket', 'ribeye', 'sirloin'], - 'pork': ['pork', 'bacon', 'ham', 'sausage', 'pork chop'], - 'chicken': ['chicken', 'chicken breast', 'chicken thigh', 'chicken wings'], - 'turkey': ['turkey', 'turkey breast', 'ground turkey'], - 'ground_meat': ['ground beef', 'ground pork', 'ground chicken', 'hamburger'], - 'fish': ['fish', 'cod', 'tilapia', 'halibut'], + # ── Raw proteins ────────────────────────────────────────────────────── + # After canned/frozen so "canned chicken" is already resolved above. 'salmon': ['salmon'], 'shrimp': ['shrimp', 'prawns'], - 'leafy_greens': ['lettuce', 'spinach', 'kale', 'arugula', 'mixed greens', 'salad'], + 'fish': ['fish', 'cod', 'tilapia', 'halibut', 'pollock'], + # Specific chicken cuts only — bare 'chicken' handled in generic fallback + 'chicken': ['chicken breast', 'chicken thigh', 'chicken wings', 'chicken leg', + 'whole chicken', 'rotisserie chicken', 'raw chicken'], + 'turkey': ['turkey breast', 'whole turkey'], + 'ground_meat': ['ground beef', 'ground pork', 'ground chicken', 'ground turkey', + 'ground lamb', 'ground bison'], + 'pork': ['pork', 'bacon', 'ham', 'pork chop', 'pork loin'], + 'beef': ['beef', 'steak', 'brisket', 'ribeye', 'sirloin', 'roast beef'], + 'deli_meat': ['deli', 'sliced turkey', 'sliced ham', 'lunch meat', 'cold cuts', + 'prosciutto', 'salami', 'pepperoni'], + # ── Produce ─────────────────────────────────────────────────────────── + 'leafy_greens': ['lettuce', 'spinach', 'kale', 'arugula', 'mixed greens'], 'berries': ['strawberries', 'blueberries', 'raspberries', 'blackberries'], 'apples': ['apple', 'apples'], 'bananas': ['banana', 'bananas'], 'citrus': ['orange', 'lemon', 'lime', 'grapefruit', 'tangerine'], - 'bread': ['bread', 'loaf', 'baguette', 'roll', 'bagel', 'bun'], - 'bakery': ['muffin', 'croissant', 'donut', 'danish', 'pastry'], - 'deli_meat': ['deli', 'sliced turkey', 'sliced ham', 'lunch meat', 'cold cuts'], - 'frozen_vegetables': ['frozen veg', 'frozen corn', 'frozen peas', 'frozen broccoli'], - 'frozen_fruit': ['frozen berries', 'frozen mango', 'frozen strawberries'], - 'ice_cream': ['ice cream', 'gelato', 'frozen yogurt'], - 'pasta': ['pasta', 'spaghetti', 'penne', 'macaroni', 'noodles'], - 'rice': ['rice', 'brown rice', 'white rice', 'jasmine'], + # ── Bakery ──────────────────────────────────────────────────────────── + 'bakery': [ + 'muffin', 'croissant', 'donut', 'danish', 'puff pastry', 'pastry puff', + 'cinnamon roll', 'dinner roll', 'parkerhouse roll', 'scone', + ], + 'bread': ['bread', 'loaf', 'baguette', 'bagel', 'bun', 'pita', 'naan', + 'english muffin', 'sourdough'], + # ── Dry pantry staples ──────────────────────────────────────────────── + 'pasta': ['pasta', 'spaghetti', 'penne', 'macaroni', 'noodles', 'couscous', 'orzo'], + 'rice': ['rice', 'brown rice', 'white rice', 'jasmine rice', 'basmati', + 'spanish rice', 'rice mix'], 'cereal': ['cereal', 'granola', 'oatmeal'], - 'chips': ['chips', 'crisps', 'tortilla chips'], - 'cookies': ['cookies', 'biscuits', 'crackers'], - 'ketchup': ['ketchup', 'catsup'], - 'mustard': ['mustard'], - 'mayo': ['mayo', 'mayonnaise', 'miracle whip'], - 'salad_dressing': ['salad dressing', 'ranch', 'italian dressing', 'vinaigrette'], - 'soy_sauce': ['soy sauce', 'tamari'], - 'juice': ['juice', 'orange juice', 'apple juice'], - 'soda': ['soda', 'pop', 'cola', 'sprite', 'pepsi', 'coke'], + 'chips': ['chips', 'crisps', 'tortilla chips', 'pretzel', 'popcorn'], + 'cookies': ['cookies', 'biscuits', 'crackers', 'graham cracker', 'wafer'], + # ── Beverages ───────────────────────────────────────────────────────── + 'juice': ['juice', 'orange juice', 'apple juice', 'lemonade'], + 'soda': ['soda', 'cola', 'sprite', 'pepsi', 'coke', 'carbonated soft drink'], } def __init__(self) -> None: @@ -176,8 +284,13 @@ class ExpirationPredictor: product_name: str, product_category: Optional[str] = None, tags: Optional[List[str]] = None, + location: Optional[str] = None, ) -> Optional[str]: - """Determine category from product name, existing category, and tags.""" + """Determine category from product name, existing category, and tags. + + location is used as a last-resort hint: unknown items in the freezer + default to frozen_foods rather than dry_goods. + """ if product_category: cat = product_category.lower().strip() if cat in self.SHELF_LIFE: @@ -197,21 +310,36 @@ class ExpirationPredictor: if any(kw in name for kw in keywords): return category + # Generic single-word fallbacks — checked after the keyword dict so + # multi-word phrases (e.g. "canned chicken") already matched above. for words, fallback in [ - (['meat', 'beef', 'pork', 'chicken'], 'meat'), + (['frozen'], 'frozen_foods'), + (['canned', 'tinned'], 'canned_goods'), + # bare 'chicken' / 'sausage' / 'ham' kept here so raw-meat names + # that don't appear in the specific keyword lists still resolve. + (['chicken', 'turkey'], 'poultry'), + (['sausage', 'ham', 'bacon'], 'pork'), + (['beef', 'steak'], 'beef'), + (['meat', 'pork'], 'meat'), (['vegetable', 'veggie', 'produce'], 'vegetables'), (['fruit'], 'fruits'), (['dairy'], 'dairy'), - (['frozen'], 'frozen_foods'), ]: if any(w in name for w in words): return fallback + # Location-aware final fallback: unknown item in a freezer → frozen_foods. + # This handles unlabelled frozen products (e.g. "Birthday Littles", + # "Pulled BBQ Crumbles") without requiring every brand name to be listed. + canon_loc = self._normalize_location(location or '') + if canon_loc == 'freezer': + return 'frozen_foods' + return 'dry_goods' def get_shelf_life_info(self, category: str, location: str) -> Optional[int]: """Shelf life in days for a given category + location, or None.""" - return self.SHELF_LIFE.get(category.lower().strip(), {}).get(location) + return self._lookup_days(category, location) def list_categories(self) -> List[str]: return list(self.SHELF_LIFE.keys()) @@ -224,8 +352,18 @@ class ExpirationPredictor: # ── Private helpers ─────────────────────────────────────────────────────── + def _normalize_location(self, location: str) -> str: + """Resolve location aliases to canonical names.""" + loc = location.lower().strip() + return self.LOCATION_ALIASES.get(loc, loc) + def _lookup_days(self, category: Optional[str], location: str) -> Optional[int]: - """Pure deterministic lookup — no I/O.""" + """Pure deterministic lookup — no I/O. + + Normalises location aliases (e.g. garage_freezer → freezer) and uses + a context-aware fallback order so pantry items don't accidentally get + fridge shelf-life and vice versa. + """ if not category: return None cat = category.lower().strip() @@ -237,13 +375,19 @@ class ExpirationPredictor: else: return None - days = self.SHELF_LIFE[cat].get(location) - if days is None: - for loc in ('fridge', 'pantry', 'freezer', 'cabinet'): - days = self.SHELF_LIFE[cat].get(loc) - if days is not None: - break - return days + canon_loc = self._normalize_location(location) + shelf = self.SHELF_LIFE[cat] + + # Try the canonical location first, then work through the + # context-aware fallback chain for that location type. + fallback_order = self.LOCATION_FALLBACK.get( + canon_loc, (canon_loc, 'pantry', 'fridge', 'cabinet', 'freezer') + ) + for loc in fallback_order: + days = shelf.get(loc) + if days is not None: + return days + return None def _llm_predict_days( self, diff --git a/app/tiers.py b/app/tiers.py index 975aaea..0d16a9e 100644 --- a/app/tiers.py +++ b/app/tiers.py @@ -43,7 +43,13 @@ KIWI_FEATURES: dict[str, str] = { def can_use(feature: str, tier: str, has_byok: bool = False) -> bool: - """Return True if the given tier can access the feature.""" + """Return True if the given tier can access the feature. + + The 'local' tier is assigned to dev-bypass and non-cloud sessions — + it has unrestricted access to all features. + """ + if tier == "local": + return True return _can_use( feature, tier,