From e45b07c203e48e5da2b959f4b0bd62ff1eb9d051 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Fri, 24 Apr 2026 17:08:45 -0700 Subject: [PATCH] feat: expand secondary use windows + dietary constraint filter (kiwi#110) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds 10 new secondary use entries and corrects all 8 existing ones. New: apples/soft, leafy_greens/wilting, tomatoes/soft, cooked_pasta/day-old, cooked_potatoes/day-old, yogurt/tangy, cream/sour, wine/open, cooked_beans/day-old, cooked_meat/leftover. Corrections: milk uses (specific recipes, not 'baking'/'sauces'); dairy uses expanded; cheese label well-aged→rind-ready with named dishes (minestrone, ribollita); rice uses (onigiri, arancini, congee); tortillas warning added; bakery uses and synonyms expanded to named pastries; bananas synonyms (spotty/brown/black/mushy); rice synonyms (old rice). New fields on every SECONDARY_WINDOW entry: - discard_signs: qualitative cues for when the item has gone past its secondary window (shown in UI alongside uses) - constraints_exclude: dietary labels that suppress the entry entirely (wine suppressed for halal/alcohol-free) ExpirationPredictor.filter_secondary_by_constraints() applies constraint suppression; _enrich_item() now accepts user_constraints and passes secondary_discard_signs through to the API response. --- app/api/endpoints/inventory.py | 9 +- app/models/schemas/inventory.py | 1 + app/services/expiration_predictor.py | 146 +++++++++++++++++++++++++-- app/services/recipe/recipe_engine.py | 48 +++++++-- 4 files changed, 185 insertions(+), 19 deletions(-) diff --git a/app/api/endpoints/inventory.py b/app/api/endpoints/inventory.py index 475013c..16fc6cc 100644 --- a/app/api/endpoints/inventory.py +++ b/app/api/endpoints/inventory.py @@ -43,8 +43,8 @@ router = APIRouter() # ── Helpers ─────────────────────────────────────────────────────────────────── -def _enrich_item(item: dict) -> dict: - """Attach computed fields: opened_expiry_date, secondary_state/uses/warning.""" +def _enrich_item(item: dict, user_constraints: list[str] | None = None) -> dict: + """Attach computed fields: opened_expiry_date, secondary_state/uses/warning/discard_signs.""" from datetime import date, timedelta opened = item.get("opened_date") if opened: @@ -58,13 +58,16 @@ def _enrich_item(item: dict) -> dict: if "opened_expiry_date" not in item: item = {**item, "opened_expiry_date": None} - # Secondary use window — check sell-by date (not opened expiry) + # Secondary use window — check sell-by date (not opened expiry). + # Apply dietary constraint filter (e.g. wine suppressed for halal/alcohol-free). sec = _predictor.secondary_state(item.get("category"), item.get("expiration_date")) + sec = _predictor.filter_secondary_by_constraints(sec, user_constraints or []) item = { **item, "secondary_state": sec["label"] if sec else None, "secondary_uses": sec["uses"] if sec else None, "secondary_warning": sec["warning"] if sec else None, + "secondary_discard_signs": sec["discard_signs"] if sec else None, } return item diff --git a/app/models/schemas/inventory.py b/app/models/schemas/inventory.py index 31b766a..147938f 100644 --- a/app/models/schemas/inventory.py +++ b/app/models/schemas/inventory.py @@ -122,6 +122,7 @@ class InventoryItemResponse(BaseModel): secondary_state: Optional[str] = None secondary_uses: Optional[List[str]] = None secondary_warning: Optional[str] = None + secondary_discard_signs: Optional[str] = None status: str notes: Optional[str] disposal_reason: Optional[str] = None diff --git a/app/services/expiration_predictor.py b/app/services/expiration_predictor.py index ead15cf..25ee190 100644 --- a/app/services/expiration_predictor.py +++ b/app/services/expiration_predictor.py @@ -157,54 +157,160 @@ class ExpirationPredictor: # These are NOT spoilage extensions — they describe a qualitative state # change where the ingredient is specifically suited for certain preparations. # Sources: USDA FoodKeeper, food science, culinary tradition. + # + # Fields: + # window_days — days past nominal expiry still usable in secondary state + # label — short UI label for the state + # uses — recipe contexts suited to this state (shown in UI) + # warning — safety note, calm tone, None if none needed + # discard_signs — qualitative signs the item has gone past the secondary window + # constraints_exclude — dietary constraint labels that suppress this entry entirely + # (e.g. alcohol-containing items suppressed for halal/alcohol-free) SECONDARY_WINDOW: dict[str, dict] = { 'bread': { 'window_days': 5, 'label': 'stale', 'uses': ['croutons', 'stuffing', 'bread pudding', 'French toast', 'panzanella'], 'warning': 'Check for mold before use — discard if any is visible.', + 'discard_signs': 'Visible mold (any colour), or unpleasant smell beyond dry/yeasty.', + 'constraints_exclude': [], }, 'bakery': { 'window_days': 3, 'label': 'day-old', - 'uses': ['French toast', 'bread pudding', 'crumbles'], + 'uses': ['French toast', 'bread pudding', 'crumbles', 'trifle base', 'cake pops', 'streusel topping', 'bread crumbs'], 'warning': 'Check for mold before use — discard if any is visible.', + 'discard_signs': 'Visible mold, sliminess, or strong sour smell.', + 'constraints_exclude': [], }, 'bananas': { 'window_days': 5, 'label': 'overripe', 'uses': ['banana bread', 'smoothies', 'pancakes', 'muffins'], 'warning': None, + 'discard_signs': 'Leaking liquid, fermented smell, or mold on skin.', + 'constraints_exclude': [], }, 'milk': { 'window_days': 3, 'label': 'sour', - 'uses': ['pancakes', 'quick breads', 'baking', 'sauces'], + 'uses': ['pancakes', 'scones', 'waffles', 'muffins', 'quick breads', 'béchamel', 'baked mac and cheese'], 'warning': 'Use only in cooked recipes — do not drink.', + 'discard_signs': 'Chunky texture, strong unpleasant smell beyond tangy, or visible separation with grey colour.', + 'constraints_exclude': [], }, 'dairy': { 'window_days': 2, 'label': 'sour', - 'uses': ['pancakes', 'quick breads', 'baking'], + 'uses': ['pancakes', 'scones', 'quick breads', 'muffins', 'waffles'], 'warning': 'Use only in cooked recipes — do not drink.', + 'discard_signs': 'Strong unpleasant smell, unusual colour, or chunky texture.', + 'constraints_exclude': [], }, 'cheese': { 'window_days': 14, - 'label': 'well-aged', - 'uses': ['broth', 'soups', 'risotto', 'gratins'], + 'label': 'rind-ready', + 'uses': ['parmesan broth', 'minestrone', 'ribollita', 'risotto', 'polenta', 'bean soups', 'gratins'], 'warning': None, + 'discard_signs': 'Soft or wet texture on hard cheese, pink or black mold (white/green surface mold on hard cheese can be cut off with 1cm margin).', + 'constraints_exclude': [], }, 'rice': { 'window_days': 2, 'label': 'day-old', - 'uses': ['fried rice', 'rice bowls', 'rice porridge'], + 'uses': ['fried rice', 'onigiri', 'rice porridge', 'congee', 'arancini', 'stuffed peppers', 'rice fritters'], 'warning': 'Refrigerate immediately after cooking — do not leave at room temp.', + 'discard_signs': 'Slimy texture, unusual smell, or more than 4 days since cooking.', + 'constraints_exclude': [], }, 'tortillas': { 'window_days': 5, 'label': 'stale', 'uses': ['chilaquiles', 'migas', 'tortilla soup', 'casserole'], + 'warning': 'Check for mold, especially if stored in a sealed bag — discard if any is visible.', + 'discard_signs': 'Visible mold (check seams and edges), or strong sour smell.', + 'constraints_exclude': [], + }, + # ── New entries ────────────────────────────────────────────────────── + 'apples': { + 'window_days': 7, + 'label': 'soft', + 'uses': ['applesauce', 'apple butter', 'baked apples', 'apple crisp', 'smoothies', 'chutney'], 'warning': None, + 'discard_signs': 'Large bruised areas with fermented smell, visible mold, or liquid leaking from skin.', + 'constraints_exclude': [], + }, + 'leafy_greens': { + 'window_days': 2, + 'label': 'wilting', + 'uses': ['sautéed greens', 'soups', 'smoothies', 'frittata', 'pasta add-in', 'stir fry'], + 'warning': None, + 'discard_signs': 'Slimy texture, strong unpleasant smell, or yellowed and mushy leaves.', + 'constraints_exclude': [], + }, + 'tomatoes': { + 'window_days': 4, + 'label': 'soft', + 'uses': ['roasted tomatoes', 'tomato sauce', 'shakshuka', 'bruschetta', 'soup', 'salsa'], + 'warning': None, + 'discard_signs': 'Broken skin with liquid pooling, mold, or fermented smell.', + 'constraints_exclude': [], + }, + 'cooked_pasta': { + 'window_days': 3, + 'label': 'day-old', + 'uses': ['pasta frittata', 'pasta salad', 'baked pasta', 'soup add-in', 'fried pasta cakes'], + 'warning': 'Refrigerate within 2 hours of cooking.', + 'discard_signs': 'Slimy texture, off smell, or more than 4 days since cooking.', + 'constraints_exclude': [], + }, + 'cooked_potatoes': { + 'window_days': 3, + 'label': 'day-old', + 'uses': ['potato pancakes', 'hash browns', 'potato soup', 'gnocchi', 'twice-baked potatoes', 'croquettes'], + 'warning': 'Refrigerate within 2 hours of cooking.', + 'discard_signs': 'Slimy texture, off smell, or more than 4 days since cooking.', + 'constraints_exclude': [], + }, + 'yogurt': { + 'window_days': 7, + 'label': 'tangy', + 'uses': ['marinades', 'flatbreads', 'smoothies', 'tzatziki', 'baked goods', 'salad dressings'], + 'warning': None, + 'discard_signs': 'Pink or orange discolouration, visible mold, or strongly unpleasant smell (not just tangy).', + 'constraints_exclude': [], + }, + 'cream': { + 'window_days': 2, + 'label': 'sour', + 'uses': ['soups', 'sauces', 'scones', 'quick breads', 'mashed potatoes'], + 'warning': 'Use in cooked recipes only. Discard if the smell is strongly unpleasant rather than tangy.', + 'discard_signs': 'Strong unpleasant smell beyond tangy, unusual colour, or chunky texture.', + 'constraints_exclude': [], + }, + 'wine': { + 'window_days': 4, + 'label': 'open', + 'uses': ['pan sauces', 'braises', 'risotto', 'marinades', 'poaching liquid', 'wine reduction'], + 'warning': None, + 'discard_signs': 'Strong vinegar smell (still usable in braises/marinades), or visible cloudiness with off-smell.', + 'constraints_exclude': ['halal', 'alcohol-free'], + }, + 'cooked_beans': { + 'window_days': 3, + 'label': 'day-old', + 'uses': ['refried beans', 'bean soup', 'bean fritters', 'hummus', 'bean dip', 'grain bowls'], + 'warning': 'Refrigerate within 2 hours of cooking.', + 'discard_signs': 'Slimy texture, off smell, or more than 4 days since cooking.', + 'constraints_exclude': [], + }, + 'cooked_meat': { + 'window_days': 2, + 'label': 'leftover', + 'uses': ['grain bowls', 'tacos', 'soups', 'fried rice', 'sandwiches', 'hash', 'pasta add-in'], + 'warning': 'Refrigerate within 2 hours of cooking.', + 'discard_signs': 'Off smell, slimy texture, or more than 3–4 days since cooking.', + 'constraints_exclude': [], }, } @@ -223,10 +329,15 @@ class ExpirationPredictor: ) -> dict | None: """Return secondary use info if the item is in its post-expiry secondary window. - Returns a dict with label, uses, warning, days_past, and window_days when the - item is past its nominal expiry date but still within the secondary use window. + Returns a dict with label, uses, warning, discard_signs, constraints_exclude, + days_past, and window_days when the item is past its nominal expiry date but + still within the secondary use window. Returns None in all other cases (unknown category, no window defined, not yet expired, or past the secondary window). + + Callers should apply constraints_exclude against user dietary constraints + and suppress the result entirely if any excluded constraint is active. + See filter_secondary_by_constraints(). """ if not category or not expiry_date: return None @@ -243,6 +354,8 @@ class ExpirationPredictor: 'label': entry['label'], 'uses': list(entry['uses']), 'warning': entry['warning'], + 'discard_signs': entry.get('discard_signs'), + 'constraints_exclude': list(entry.get('constraints_exclude') or []), 'days_past': days_past, 'window_days': entry['window_days'], } @@ -250,6 +363,23 @@ class ExpirationPredictor: pass return None + @staticmethod + def filter_secondary_by_constraints( + sec: dict | None, + user_constraints: list[str], + ) -> dict | None: + """Suppress secondary state entirely if any excluded constraint is active. + + Call after secondary_state() when user dietary constraints are available. + Returns sec unchanged when no constraints match, or None when suppressed. + """ + if sec is None: + return None + excluded = sec.get('constraints_exclude') or [] + if any(c.lower() in [e.lower() for e in excluded] for c in user_constraints): + return None + return sec + # Keyword lists are checked in declaration order — most specific first. # Rules: # - canned/processed goods BEFORE raw-meat terms (canned chicken != raw chicken) diff --git a/app/services/recipe/recipe_engine.py b/app/services/recipe/recipe_engine.py index ebb1da3..ca643b7 100644 --- a/app/services/recipe/recipe_engine.py +++ b/app/services/recipe/recipe_engine.py @@ -165,14 +165,46 @@ _PANTRY_LABEL_SYNONYMS: dict[str, str] = { # Each key is (category_in_SECONDARY_WINDOW, label_returned_by_secondary_state). # Values are additional strings added to the pantry set for FTS coverage. _SECONDARY_STATE_SYNONYMS: dict[tuple[str, str], list[str]] = { - ("bread", "stale"): ["stale bread", "day-old bread", "old bread", "dried bread"], - ("bakery", "day-old"): ["day-old bread", "stale bread", "stale pastry"], - ("bananas", "overripe"): ["overripe bananas", "very ripe banana", "ripe bananas", "mashed banana"], - ("milk", "sour"): ["sour milk", "slightly sour milk", "buttermilk"], - ("dairy", "sour"): ["sour milk", "slightly sour milk"], - ("cheese", "well-aged"): ["parmesan rind", "cheese rind", "aged cheese"], - ("rice", "day-old"): ["day-old rice", "leftover rice", "cold rice", "cooked rice"], - ("tortillas", "stale"): ["stale tortillas", "dried tortillas", "day-old tortillas"], + # ── Existing entries (corrected) ───────────────────────────────────────── + ("bread", "stale"): ["stale bread", "day-old bread", "old bread", "dried bread"], + ("bakery", "day-old"): ["day-old bread", "stale bread", "stale pastry", + "day-old croissant", "stale croissant", "day-old muffin", + "stale cake", "old pastry", "day-old baguette"], + ("bananas", "overripe"): ["overripe bananas", "very ripe bananas", "spotty bananas", + "brown bananas", "black bananas", "mushy bananas", + "mashed banana", "ripe bananas"], + ("milk", "sour"): ["sour milk", "slightly sour milk", "buttermilk", + "soured milk", "off milk", "milk gone sour"], + ("dairy", "sour"): ["sour milk", "slightly sour milk", "soured milk"], + ("cheese", "rind-ready"): ["parmesan rind", "cheese rind", "aged cheese", + "hard cheese rind", "parmigiano rind", "grana padano rind", + "pecorino rind", "dry cheese"], + ("rice", "day-old"): ["day-old rice", "leftover rice", "cold rice", "cooked rice", + "old rice"], + ("tortillas", "stale"): ["stale tortillas", "dried tortillas", "day-old tortillas"], + # ── New entries ────────────────────────────────────────────────────────── + ("apples", "soft"): ["soft apples", "mealy apples", "overripe apples", + "bruised apples", "mushy apple"], + ("leafy_greens", "wilting"):["wilted spinach", "wilted greens", "limp lettuce", + "wilted kale", "tired greens"], + ("tomatoes", "soft"): ["overripe tomatoes", "very ripe tomatoes", "ripe tomatoes", + "soft tomatoes", "bruised tomatoes"], + ("cooked_pasta", "day-old"):["leftover pasta", "cooked pasta", "day-old pasta", + "cold pasta", "pre-cooked pasta"], + ("cooked_potatoes", "day-old"): ["leftover potatoes", "cooked potatoes", "day-old potatoes", + "mashed potatoes", "baked potatoes"], + ("yogurt", "tangy"): ["sour yogurt", "tangy yogurt", "past-date yogurt", + "older yogurt", "well-cultured yogurt"], + ("cream", "sour"): ["slightly soured cream", "cultured cream", + "heavy cream gone sour", "soured cream"], + ("wine", "open"): ["open wine", "leftover wine", "day-old wine", + "cooking wine", "red wine", "white wine"], + ("cooked_beans", "day-old"):["leftover beans", "cooked beans", "day-old beans", + "cold beans", "pre-cooked beans", + "cooked chickpeas", "cooked lentils"], + ("cooked_meat", "leftover"):["leftover chicken", "shredded chicken", "leftover beef", + "cooked chicken", "pulled chicken", "leftover pork", + "cooked meat", "rotisserie chicken"], }