chore: inventory endpoint cleanup, expiry predictor, tiers, gitignore test artifacts

This commit is contained in:
pyr0ball 2026-04-02 22:12:51 -07:00
parent a22a249280
commit 8b7f4e7ea2
4 changed files with 212 additions and 42 deletions

3
.gitignore vendored
View file

@ -19,3 +19,6 @@ dist/
# Data directories # Data directories
data/ data/
# Test artifacts (MagicMock sqlite files from pytest)
<MagicMock*

View file

@ -369,6 +369,23 @@ async def list_tags(
# ── Stats ───────────────────────────────────────────────────────────────────── # ── Stats ─────────────────────────────────────────────────────────────────────
@router.post("/recalculate-expiry")
async def recalculate_expiry(
session: CloudUser = Depends(get_session),
store: Store = Depends(get_store),
) -> 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) @router.get("/stats", response_model=InventoryStats)
async def get_inventory_stats(store: Store = Depends(get_store)): async def get_inventory_stats(store: Store = Depends(get_store)):
def _stats(): def _stats():

View file

@ -21,6 +21,29 @@ logger = logging.getLogger(__name__)
class ExpirationPredictor: class ExpirationPredictor:
"""Predict expiration dates based on product category and storage location.""" """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 # Default shelf life in days by category and location
# Sources: USDA FoodKeeper app, FDA guidelines # Sources: USDA FoodKeeper app, FDA guidelines
SHELF_LIFE = { SHELF_LIFE = {
@ -39,6 +62,8 @@ class ExpirationPredictor:
'poultry': {'fridge': 2, 'freezer': 270}, 'poultry': {'fridge': 2, 'freezer': 270},
'chicken': {'fridge': 2, 'freezer': 270}, 'chicken': {'fridge': 2, 'freezer': 270},
'turkey': {'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}, 'ground_meat': {'fridge': 2, 'freezer': 120},
# Seafood # Seafood
'fish': {'fridge': 2, 'freezer': 180}, 'fish': {'fridge': 2, 'freezer': 180},
@ -59,9 +84,9 @@ class ExpirationPredictor:
'bread': {'pantry': 5, 'freezer': 90}, 'bread': {'pantry': 5, 'freezer': 90},
'bakery': {'pantry': 3, 'fridge': 7, 'freezer': 90}, 'bakery': {'pantry': 3, 'fridge': 7, 'freezer': 90},
# Frozen # Frozen
'frozen_foods': {'freezer': 180}, 'frozen_foods': {'freezer': 180, 'fridge': 3},
'frozen_vegetables': {'freezer': 270}, 'frozen_vegetables': {'freezer': 270, 'fridge': 4},
'frozen_fruit': {'freezer': 365}, 'frozen_fruit': {'freezer': 365, 'fridge': 4},
'ice_cream': {'freezer': 60}, 'ice_cream': {'freezer': 60},
# Pantry Staples # Pantry Staples
'canned_goods': {'pantry': 730, 'cabinet': 730}, 'canned_goods': {'pantry': 730, 'cabinet': 730},
@ -91,44 +116,127 @@ class ExpirationPredictor:
'prepared_foods': {'fridge': 4, 'freezer': 90}, '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 = { CATEGORY_KEYWORDS = {
# ── Frozen prepared foods ─────────────────────────────────────────────
# Before raw protein entries so plant-based frozen products don't
# inherit 23 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'], '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'], 'yogurt': ['yogurt', 'greek yogurt', 'yoghurt'],
'butter': ['butter', 'margarine'], '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'], 'eggs': ['eggs', 'egg'],
'beef': ['beef', 'steak', 'roast', 'brisket', 'ribeye', 'sirloin'], # ── Raw proteins ──────────────────────────────────────────────────────
'pork': ['pork', 'bacon', 'ham', 'sausage', 'pork chop'], # After canned/frozen so "canned chicken" is already resolved above.
'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'],
'salmon': ['salmon'], 'salmon': ['salmon'],
'shrimp': ['shrimp', 'prawns'], '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'], 'berries': ['strawberries', 'blueberries', 'raspberries', 'blackberries'],
'apples': ['apple', 'apples'], 'apples': ['apple', 'apples'],
'bananas': ['banana', 'bananas'], 'bananas': ['banana', 'bananas'],
'citrus': ['orange', 'lemon', 'lime', 'grapefruit', 'tangerine'], 'citrus': ['orange', 'lemon', 'lime', 'grapefruit', 'tangerine'],
'bread': ['bread', 'loaf', 'baguette', 'roll', 'bagel', 'bun'], # ── Bakery ────────────────────────────────────────────────────────────
'bakery': ['muffin', 'croissant', 'donut', 'danish', 'pastry'], 'bakery': [
'deli_meat': ['deli', 'sliced turkey', 'sliced ham', 'lunch meat', 'cold cuts'], 'muffin', 'croissant', 'donut', 'danish', 'puff pastry', 'pastry puff',
'frozen_vegetables': ['frozen veg', 'frozen corn', 'frozen peas', 'frozen broccoli'], 'cinnamon roll', 'dinner roll', 'parkerhouse roll', 'scone',
'frozen_fruit': ['frozen berries', 'frozen mango', 'frozen strawberries'], ],
'ice_cream': ['ice cream', 'gelato', 'frozen yogurt'], 'bread': ['bread', 'loaf', 'baguette', 'bagel', 'bun', 'pita', 'naan',
'pasta': ['pasta', 'spaghetti', 'penne', 'macaroni', 'noodles'], 'english muffin', 'sourdough'],
'rice': ['rice', 'brown rice', 'white rice', 'jasmine'], # ── 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'], 'cereal': ['cereal', 'granola', 'oatmeal'],
'chips': ['chips', 'crisps', 'tortilla chips'], 'chips': ['chips', 'crisps', 'tortilla chips', 'pretzel', 'popcorn'],
'cookies': ['cookies', 'biscuits', 'crackers'], 'cookies': ['cookies', 'biscuits', 'crackers', 'graham cracker', 'wafer'],
'ketchup': ['ketchup', 'catsup'], # ── Beverages ─────────────────────────────────────────────────────────
'mustard': ['mustard'], 'juice': ['juice', 'orange juice', 'apple juice', 'lemonade'],
'mayo': ['mayo', 'mayonnaise', 'miracle whip'], 'soda': ['soda', 'cola', 'sprite', 'pepsi', 'coke', 'carbonated soft drink'],
'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'],
} }
def __init__(self) -> None: def __init__(self) -> None:
@ -176,8 +284,13 @@ class ExpirationPredictor:
product_name: str, product_name: str,
product_category: Optional[str] = None, product_category: Optional[str] = None,
tags: Optional[List[str]] = None, tags: Optional[List[str]] = None,
location: Optional[str] = None,
) -> Optional[str]: ) -> 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: if product_category:
cat = product_category.lower().strip() cat = product_category.lower().strip()
if cat in self.SHELF_LIFE: if cat in self.SHELF_LIFE:
@ -197,21 +310,36 @@ class ExpirationPredictor:
if any(kw in name for kw in keywords): if any(kw in name for kw in keywords):
return category 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 [ 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'), (['vegetable', 'veggie', 'produce'], 'vegetables'),
(['fruit'], 'fruits'), (['fruit'], 'fruits'),
(['dairy'], 'dairy'), (['dairy'], 'dairy'),
(['frozen'], 'frozen_foods'),
]: ]:
if any(w in name for w in words): if any(w in name for w in words):
return fallback 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' return 'dry_goods'
def get_shelf_life_info(self, category: str, location: str) -> Optional[int]: def get_shelf_life_info(self, category: str, location: str) -> Optional[int]:
"""Shelf life in days for a given category + location, or None.""" """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]: def list_categories(self) -> List[str]:
return list(self.SHELF_LIFE.keys()) return list(self.SHELF_LIFE.keys())
@ -224,8 +352,18 @@ class ExpirationPredictor:
# ── Private helpers ─────────────────────────────────────────────────────── # ── 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]: 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: if not category:
return None return None
cat = category.lower().strip() cat = category.lower().strip()
@ -237,13 +375,19 @@ class ExpirationPredictor:
else: else:
return None return None
days = self.SHELF_LIFE[cat].get(location) canon_loc = self._normalize_location(location)
if days is None: shelf = self.SHELF_LIFE[cat]
for loc in ('fridge', 'pantry', 'freezer', 'cabinet'):
days = self.SHELF_LIFE[cat].get(loc) # Try the canonical location first, then work through the
if days is not None: # context-aware fallback chain for that location type.
break fallback_order = self.LOCATION_FALLBACK.get(
return days 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( def _llm_predict_days(
self, self,

View file

@ -43,7 +43,13 @@ KIWI_FEATURES: dict[str, str] = {
def can_use(feature: str, tier: str, has_byok: bool = False) -> bool: 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( return _can_use(
feature, feature,
tier, tier,