chore: inventory endpoint cleanup, expiry predictor, tiers, gitignore test artifacts
This commit is contained in:
parent
1f819c4ee0
commit
8fec5b6402
4 changed files with 212 additions and 42 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -19,3 +19,6 @@ dist/
|
|||
|
||||
# Data directories
|
||||
data/
|
||||
|
||||
# Test artifacts (MagicMock sqlite files from pytest)
|
||||
<MagicMock*
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
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:
|
||||
break
|
||||
return days
|
||||
return None
|
||||
|
||||
def _llm_predict_days(
|
||||
self,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue