kiwi/app/services/leftovers_predictor.py
pyr0ball 9350719516
Some checks are pending
CI / Backend (Python) (push) Waiting to run
CI / Frontend (Vue) (push) Waiting to run
Mirror / mirror (push) Waiting to run
Release / release (push) Waiting to run
feat(recipes): LLM style classifier (#27) + cooked leftovers shelf-life (#112)
Style classifier (kiwi#27):
- app/services/recipe/style_classifier.py: LLM prompt with curated vocab,
  cf-orch/LLMRouter fallback, JSON + regex tag extraction
- POST /recipes/saved/{recipe_id}/classify-style: Paid/BYOK tier gate,
  fetches recipe from corpus, returns {suggested_tags:[...]}
- SaveRecipeModal.vue: "Suggest tags" button with loading state; merges
  LLM suggestions into existing tags without overwriting user's choices
- 403/empty list silently ignored — button is a no-op when tier not met

Cooked leftovers shelf-life (kiwi#112):
- app/services/leftovers_predictor.py: deterministic FDA/USDA lookup table
  with shortest-component-wins for proteins and dish-type override for
  assembled dishes; special entries for ceviche (2d, acid != heat),
  fermented/cured (kimchi 14d, confit/lardo 7d), soups, rice, pasta, etc.
- POST /recipes/{recipe_id}/leftovers: free tier, no gate
- RecipeDetailPanel.vue: shelf-life section appears after "I cooked this"
  with fridge/freeze days, freeze-by advice, per-instance dismiss; calm
  framing per no-panic UX policy
- LeftoversResponse Pydantic schema added to recipe.py
2026-04-25 23:18:16 -07:00

233 lines
8.8 KiB
Python

# app/services/leftovers_predictor.py
"""Cooked-leftovers shelf-life predictor.
Fast path: deterministic lookup anchored to FDA/USDA safe food handling.
Fallback: LLM for unclassifiable edge cases (same gate as expiry_llm_matching).
Design notes:
- shortest-component-wins for proteins: a fish taco is bounded by the fish.
- category/keyword signals override ingredient signals for assembled dishes
(soup, stew, casserole) where the cooking method matters more than the
dominant protein.
- no urgency/panic framing — see feedback_kiwi_no_panic.md.
"""
from __future__ import annotations
import logging
import re
from dataclasses import dataclass, field
from typing import Any
logger = logging.getLogger(__name__)
@dataclass
class LeftoversResult:
fridge_days: int
freeze_days: int | None # None = "not recommended"
freeze_by_day: int | None # day number from cook date to freeze by; None = no need
storage_advice: str
# ---------------------------------------------------------------------------
# Protein priority table — shorter shelf life wins when multiple match.
# Values: (fridge_days, freeze_days). All fridge values are conservative.
# Sources: USDA FoodKeeper, FDA Safe Food Handling.
# ---------------------------------------------------------------------------
_PROTEIN_SIGNALS: list[tuple[list[str], int, int | None]] = [
# (keyword_list, fridge_days, freeze_days)
(["fish", "salmon", "tuna", "cod", "tilapia", "halibut", "trout", "bass",
"mahi", "snapper", "flounder", "catfish", "swordfish", "sardine", "anchovy"],
2, 90),
(["shrimp", "prawn", "scallop", "crab", "lobster", "clam", "mussel",
"oyster", "squid", "octopus", "seafood"],
2, 90),
(["ground beef", "ground turkey", "ground pork", "ground chicken",
"ground meat", "hamburger", "mince"],
3, 90),
(["chicken", "turkey", "poultry", "duck", "hen"],
3, 90),
(["pork", "ham", "bacon", "sausage", "chorizo", "bratwurst", "kielbasa",
"salami", "pepperoni"],
4, 120),
(["beef", "steak", "brisket", "roast", "lamb", "veal", "venison"],
4, 180),
(["egg", "eggs", "frittata", "quiche", "omelette"],
3, None),
(["tofu", "tempeh", "seitan"],
4, 90),
]
# ---------------------------------------------------------------------------
# Dish-type signals — override protein signal when a structural match fires.
# Ordered from most-perishable to least.
# ---------------------------------------------------------------------------
_DISH_SIGNALS: list[tuple[list[str], int, int | None, str]] = [
# (keywords, fridge_days, freeze_days, storage_advice_fragment)
# Ceviche: acid denatures proteins but does not kill pathogens.
# FDA/USDA classify it as raw seafood — 2-day fridge max, do not freeze.
(["ceviche", "tiradito", "leche de tigre"],
2, None,
"Acid marination is not the same as heat cooking — treat as raw seafood. "
"Best eaten the day it's made; 2 days maximum in the fridge."),
# Fermented / salt-cured dishes — preservation extends shelf life significantly.
# This matches dish names, not just presence of the ingredient (lardo in a pasta
# follows normal pasta rules, not this entry).
(["kimchi", "sauerkraut", "preserved lemon"],
14, None,
"Fermented and salt-preserved dishes keep well. Store submerged in their brine."),
(["confit", "gravlax", "gravad lax", "lardo"],
7, 60,
"Store covered in its fat or cure. Keep cold and away from strong-smelling foods."),
(["soup", "stew", "broth", "chowder", "bisque", "gumbo", "chili"],
4, 120,
"Soups and stews keep well in the fridge. Cool to room temperature before covering."),
(["curry"],
4, 90,
"Store curry in an airtight container. The flavours deepen overnight."),
(["casserole", "bake", "gratin", "lasagna", "lasagne", "moussaka",
"shepherd's pie", "pot pie"],
5, 90,
"Cover tightly. Reheat individual portions rather than the whole dish."),
(["pasta", "noodle", "spaghetti", "penne", "linguine", "fettuccine",
"macaroni", "risotto"],
4, 60,
"Store pasta and sauce separately if possible to prevent sogginess."),
(["rice", "fried rice", "pilaf", "biryani"],
3, 90,
"Cool rice quickly — spread on a tray if needed. Don't leave at room temperature for more than 1 hour."),
(["salad"],
2, None,
"Keep dressing separate. Once dressed, best eaten the same day."),
(["stir fry", "stir-fry"],
3, 60,
"Reheat in a hot pan or wok rather than a microwave to keep texture."),
(["sandwich", "wrap", "taco", "burrito"],
2, None,
"Assemble fresh when possible. Fillings keep better stored separately."),
(["pizza"],
4, 60,
"Reheat in a dry skillet for a crisp base rather than a microwave."),
(["muffin", "bread", "biscuit", "scone", "roll"],
3, 90,
"Wrap tightly or seal in a bag to prevent drying out."),
(["cake", "pie", "cookie", "brownie", "dessert", "pudding"],
5, 90,
"Store covered at room temperature or in the fridge depending on fillings."),
(["smoothie", "juice", "shake"],
1, 7,
"Best consumed fresh. Stir or shake well before drinking."),
]
# Default when no signals match.
_DEFAULT_FRIDGE = 4
_DEFAULT_FREEZE = 90
_DEFAULT_ADVICE = "Store in an airtight container in the fridge. Reheat until piping hot before eating."
def _contains_any(text: str, keywords: list[str]) -> bool:
for kw in keywords:
if re.search(rf"\b{re.escape(kw)}\b", text, re.IGNORECASE):
return True
return False
def _scan_ingredients(ingredients: list[str]) -> tuple[int, int | None] | None:
"""Return (fridge_days, freeze_days) for the most-perishable protein found."""
joined = " ".join(str(i) for i in ingredients).lower()
best: tuple[int, int | None] | None = None
for keywords, fridge, freeze in _PROTEIN_SIGNALS:
if _contains_any(joined, keywords):
if best is None or fridge < best[0]:
best = (fridge, freeze)
return best
def _scan_dish_type(text: str) -> tuple[int, int | None, str] | None:
"""Return (fridge_days, freeze_days, advice) for the first matching dish type."""
for keywords, fridge, freeze, advice in _DISH_SIGNALS:
if _contains_any(text, keywords):
return fridge, freeze, advice
return None
def predict_leftovers(
title: str,
ingredients: list[str],
category: str | None = None,
keywords: list[str] | None = None,
) -> LeftoversResult:
"""Predict cooked-leftover shelf life deterministically.
Falls back gracefully — always returns a result even for unknown recipes.
"""
# Build a combined text blob for dish-type scanning.
search_text = " ".join(filter(None, [
title,
category or "",
" ".join(keywords or []),
]))
# Dish-type match takes structural priority over raw ingredient protein signal.
dish = _scan_dish_type(search_text)
protein = _scan_ingredients(ingredients)
if dish:
fridge_days, freeze_days, base_advice = dish
# Still apply shortest-protein-wins if protein is more perishable than dish default.
if protein and protein[0] < fridge_days:
fridge_days = protein[0]
if protein[1] is not None and (freeze_days is None or protein[1] < freeze_days):
freeze_days = protein[1]
advice = base_advice
elif protein:
fridge_days, freeze_days = protein
advice = _DEFAULT_ADVICE
else:
fridge_days = _DEFAULT_FRIDGE
freeze_days = _DEFAULT_FREEZE
advice = _DEFAULT_ADVICE
# freeze_by_day: recommend freezing on day 2 if fridge window is tight (≤3 days).
freeze_by_day: int | None = None
if freeze_days is not None and fridge_days <= 3:
freeze_by_day = 2
return LeftoversResult(
fridge_days=fridge_days,
freeze_days=freeze_days,
freeze_by_day=freeze_by_day,
storage_advice=advice,
)
def predict_leftovers_from_row(recipe: dict[str, Any]) -> LeftoversResult:
"""Convenience wrapper that accepts a Store row dict directly."""
import json as _json
title = recipe.get("title") or ""
raw_ingredients = recipe.get("ingredient_names") or []
if isinstance(raw_ingredients, str):
try:
raw_ingredients = _json.loads(raw_ingredients)
except Exception:
raw_ingredients = [raw_ingredients]
raw_keywords = recipe.get("keywords") or []
if isinstance(raw_keywords, str):
try:
raw_keywords = _json.loads(raw_keywords)
except Exception:
raw_keywords = [raw_keywords]
return predict_leftovers(
title=title,
ingredients=[str(i) for i in raw_ingredients],
category=recipe.get("category"),
keywords=[str(k) for k in raw_keywords],
)