feat: expand secondary use windows + dietary constraint filter (kiwi#110)

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.
This commit is contained in:
pyr0ball 2026-04-24 17:08:45 -07:00
parent b5eb8e4772
commit e45b07c203
4 changed files with 185 additions and 19 deletions

View file

@ -43,8 +43,8 @@ router = APIRouter()
# ── Helpers ─────────────────────────────────────────────────────────────────── # ── Helpers ───────────────────────────────────────────────────────────────────
def _enrich_item(item: dict) -> dict: def _enrich_item(item: dict, user_constraints: list[str] | None = None) -> dict:
"""Attach computed fields: opened_expiry_date, secondary_state/uses/warning.""" """Attach computed fields: opened_expiry_date, secondary_state/uses/warning/discard_signs."""
from datetime import date, timedelta from datetime import date, timedelta
opened = item.get("opened_date") opened = item.get("opened_date")
if opened: if opened:
@ -58,13 +58,16 @@ def _enrich_item(item: dict) -> dict:
if "opened_expiry_date" not in item: if "opened_expiry_date" not in item:
item = {**item, "opened_expiry_date": None} 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.secondary_state(item.get("category"), item.get("expiration_date"))
sec = _predictor.filter_secondary_by_constraints(sec, user_constraints or [])
item = { item = {
**item, **item,
"secondary_state": sec["label"] if sec else None, "secondary_state": sec["label"] if sec else None,
"secondary_uses": sec["uses"] if sec else None, "secondary_uses": sec["uses"] if sec else None,
"secondary_warning": sec["warning"] if sec else None, "secondary_warning": sec["warning"] if sec else None,
"secondary_discard_signs": sec["discard_signs"] if sec else None,
} }
return item return item

View file

@ -122,6 +122,7 @@ class InventoryItemResponse(BaseModel):
secondary_state: Optional[str] = None secondary_state: Optional[str] = None
secondary_uses: Optional[List[str]] = None secondary_uses: Optional[List[str]] = None
secondary_warning: Optional[str] = None secondary_warning: Optional[str] = None
secondary_discard_signs: Optional[str] = None
status: str status: str
notes: Optional[str] notes: Optional[str]
disposal_reason: Optional[str] = None disposal_reason: Optional[str] = None

View file

@ -157,54 +157,160 @@ class ExpirationPredictor:
# These are NOT spoilage extensions — they describe a qualitative state # These are NOT spoilage extensions — they describe a qualitative state
# change where the ingredient is specifically suited for certain preparations. # change where the ingredient is specifically suited for certain preparations.
# Sources: USDA FoodKeeper, food science, culinary tradition. # 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] = { SECONDARY_WINDOW: dict[str, dict] = {
'bread': { 'bread': {
'window_days': 5, 'window_days': 5,
'label': 'stale', 'label': 'stale',
'uses': ['croutons', 'stuffing', 'bread pudding', 'French toast', 'panzanella'], 'uses': ['croutons', 'stuffing', 'bread pudding', 'French toast', 'panzanella'],
'warning': 'Check for mold before use — discard if any is visible.', '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': { 'bakery': {
'window_days': 3, 'window_days': 3,
'label': 'day-old', '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.', 'warning': 'Check for mold before use — discard if any is visible.',
'discard_signs': 'Visible mold, sliminess, or strong sour smell.',
'constraints_exclude': [],
}, },
'bananas': { 'bananas': {
'window_days': 5, 'window_days': 5,
'label': 'overripe', 'label': 'overripe',
'uses': ['banana bread', 'smoothies', 'pancakes', 'muffins'], 'uses': ['banana bread', 'smoothies', 'pancakes', 'muffins'],
'warning': None, 'warning': None,
'discard_signs': 'Leaking liquid, fermented smell, or mold on skin.',
'constraints_exclude': [],
}, },
'milk': { 'milk': {
'window_days': 3, 'window_days': 3,
'label': 'sour', '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.', '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': { 'dairy': {
'window_days': 2, 'window_days': 2,
'label': 'sour', 'label': 'sour',
'uses': ['pancakes', 'quick breads', 'baking'], 'uses': ['pancakes', 'scones', 'quick breads', 'muffins', 'waffles'],
'warning': 'Use only in cooked recipes — do not drink.', 'warning': 'Use only in cooked recipes — do not drink.',
'discard_signs': 'Strong unpleasant smell, unusual colour, or chunky texture.',
'constraints_exclude': [],
}, },
'cheese': { 'cheese': {
'window_days': 14, 'window_days': 14,
'label': 'well-aged', 'label': 'rind-ready',
'uses': ['broth', 'soups', 'risotto', 'gratins'], 'uses': ['parmesan broth', 'minestrone', 'ribollita', 'risotto', 'polenta', 'bean soups', 'gratins'],
'warning': None, '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': { 'rice': {
'window_days': 2, 'window_days': 2,
'label': 'day-old', '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.', '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': { 'tortillas': {
'window_days': 5, 'window_days': 5,
'label': 'stale', 'label': 'stale',
'uses': ['chilaquiles', 'migas', 'tortilla soup', 'casserole'], '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, '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 34 days since cooking.',
'constraints_exclude': [],
}, },
} }
@ -223,10 +329,15 @@ class ExpirationPredictor:
) -> dict | None: ) -> dict | None:
"""Return secondary use info if the item is in its post-expiry secondary window. """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 Returns a dict with label, uses, warning, discard_signs, constraints_exclude,
item is past its nominal expiry date but still within the secondary use window. 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 Returns None in all other cases (unknown category, no window defined, not yet
expired, or past the secondary window). 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: if not category or not expiry_date:
return None return None
@ -243,6 +354,8 @@ class ExpirationPredictor:
'label': entry['label'], 'label': entry['label'],
'uses': list(entry['uses']), 'uses': list(entry['uses']),
'warning': entry['warning'], 'warning': entry['warning'],
'discard_signs': entry.get('discard_signs'),
'constraints_exclude': list(entry.get('constraints_exclude') or []),
'days_past': days_past, 'days_past': days_past,
'window_days': entry['window_days'], 'window_days': entry['window_days'],
} }
@ -250,6 +363,23 @@ class ExpirationPredictor:
pass pass
return None 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. # Keyword lists are checked in declaration order — most specific first.
# Rules: # Rules:
# - canned/processed goods BEFORE raw-meat terms (canned chicken != raw chicken) # - canned/processed goods BEFORE raw-meat terms (canned chicken != raw chicken)

View file

@ -165,14 +165,46 @@ _PANTRY_LABEL_SYNONYMS: dict[str, str] = {
# Each key is (category_in_SECONDARY_WINDOW, label_returned_by_secondary_state). # Each key is (category_in_SECONDARY_WINDOW, label_returned_by_secondary_state).
# Values are additional strings added to the pantry set for FTS coverage. # Values are additional strings added to the pantry set for FTS coverage.
_SECONDARY_STATE_SYNONYMS: dict[tuple[str, str], list[str]] = { _SECONDARY_STATE_SYNONYMS: dict[tuple[str, str], list[str]] = {
("bread", "stale"): ["stale bread", "day-old bread", "old bread", "dried bread"], # ── Existing entries (corrected) ─────────────────────────────────────────
("bakery", "day-old"): ["day-old bread", "stale bread", "stale pastry"], ("bread", "stale"): ["stale bread", "day-old bread", "old bread", "dried bread"],
("bananas", "overripe"): ["overripe bananas", "very ripe banana", "ripe bananas", "mashed banana"], ("bakery", "day-old"): ["day-old bread", "stale bread", "stale pastry",
("milk", "sour"): ["sour milk", "slightly sour milk", "buttermilk"], "day-old croissant", "stale croissant", "day-old muffin",
("dairy", "sour"): ["sour milk", "slightly sour milk"], "stale cake", "old pastry", "day-old baguette"],
("cheese", "well-aged"): ["parmesan rind", "cheese rind", "aged cheese"], ("bananas", "overripe"): ["overripe bananas", "very ripe bananas", "spotty bananas",
("rice", "day-old"): ["day-old rice", "leftover rice", "cold rice", "cooked rice"], "brown bananas", "black bananas", "mushy bananas",
("tortillas", "stale"): ["stale tortillas", "dried tortillas", "day-old tortillas"], "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"],
} }