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 1f819c4ee0
commit 8fec5b6402
4 changed files with 212 additions and 42 deletions

3
.gitignore vendored
View file

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

View file

@ -369,6 +369,23 @@ async def list_tags(
# ── 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)
async def get_inventory_stats(store: Store = Depends(get_store)):
def _stats():

View file

@ -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 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'],
'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,

View file

@ -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,