From 935071951656cc57c646c1e778482d5b70dead37 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Sat, 25 Apr 2026 23:18:16 -0700 Subject: [PATCH] feat(recipes): LLM style classifier (#27) + cooked leftovers shelf-life (#112) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/api/endpoints/recipes.py | 31 +++ app/api/endpoints/saved_recipes.py | 26 ++ app/models/schemas/recipe.py | 8 + app/services/leftovers_predictor.py | 233 ++++++++++++++++++ app/services/recipe/style_classifier.py | 139 +++++++++++ frontend/src/components/RecipeDetailPanel.vue | 74 +++++- frontend/src/components/SaveRecipeModal.vue | 28 ++- frontend/src/services/api.ts | 8 + 8 files changed, 544 insertions(+), 3 deletions(-) create mode 100644 app/services/leftovers_predictor.py create mode 100644 app/services/recipe/style_classifier.py diff --git a/app/api/endpoints/recipes.py b/app/api/endpoints/recipes.py index 4f33842..eceef43 100644 --- a/app/api/endpoints/recipes.py +++ b/app/api/endpoints/recipes.py @@ -16,6 +16,7 @@ from app.db.store import Store from app.models.schemas.recipe import ( AssemblyTemplateOut, BuildRequest, + LeftoversResponse, RecipeJobStatus, RecipeRequest, RecipeResult, @@ -608,3 +609,33 @@ async def get_recipe(recipe_id: int, session: CloudUser = Depends(get_session)) "estimated_time_min": None, "time_effort": _time_effort_out, } + + +@router.post("/{recipe_id}/leftovers", response_model=LeftoversResponse) +async def get_leftovers_shelf_life( + recipe_id: int, + session: CloudUser = Depends(get_session), +) -> LeftoversResponse: + """Return cooked-leftover shelf-life estimate for a recipe. + + Free tier: deterministic lookup (FDA/USDA table). + Deterministic path always runs; no tier gate needed. + """ + def _get(db_path: Path, rid: int) -> LeftoversResponse: + from app.services.leftovers_predictor import predict_leftovers_from_row + store = Store(db_path) + try: + recipe = store.get_recipe(rid) + finally: + store.close() + if recipe is None: + raise HTTPException(status_code=404, detail="Recipe not found.") + result = predict_leftovers_from_row(recipe) + return LeftoversResponse( + fridge_days=result.fridge_days, + freeze_days=result.freeze_days, + freeze_by_day=result.freeze_by_day, + storage_advice=result.storage_advice, + ) + + return await asyncio.to_thread(_get, session.db, recipe_id) diff --git a/app/api/endpoints/saved_recipes.py b/app/api/endpoints/saved_recipes.py index 8f9616e..76f3692 100644 --- a/app/api/endpoints/saved_recipes.py +++ b/app/api/endpoints/saved_recipes.py @@ -5,6 +5,7 @@ import asyncio from pathlib import Path from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel from app.cloud_session import CloudUser, get_session from app.db.store import Store @@ -18,6 +19,10 @@ from app.models.schemas.saved_recipe import ( ) from app.tiers import can_use + +class StyleClassifyResponse(BaseModel): + suggested_tags: list[str] + router = APIRouter() @@ -98,6 +103,27 @@ async def list_saved_recipes( return await asyncio.to_thread(_in_thread, session.db, _run) +# ── style classifier (Paid / BYOK) ─────────────────────────────────────────── + +@router.post("/{recipe_id}/classify-style", response_model=StyleClassifyResponse) +async def classify_style( + recipe_id: int, + session: CloudUser = Depends(get_session), +) -> StyleClassifyResponse: + if not can_use("style_classifier", session.tier, getattr(session, "has_byok", False)): + raise HTTPException(status_code=403, detail="Style classifier requires Paid tier or BYOK.") + + def _run(store: Store) -> StyleClassifyResponse: + recipe = store.get_recipe(recipe_id) + if recipe is None: + raise HTTPException(status_code=404, detail="Recipe not found.") + from app.services.recipe.style_classifier import classify_style as _classify + tags = _classify(recipe) + return StyleClassifyResponse(suggested_tags=tags) + + return await asyncio.to_thread(_in_thread, session.db, _run) + + # ── collections (Paid) ──────────────────────────────────────────────────────── @router.get("/collections", response_model=list[CollectionSummary]) diff --git a/app/models/schemas/recipe.py b/app/models/schemas/recipe.py index f87661f..4eb6ddd 100644 --- a/app/models/schemas/recipe.py +++ b/app/models/schemas/recipe.py @@ -4,6 +4,14 @@ from __future__ import annotations from pydantic import BaseModel, Field +class LeftoversResponse(BaseModel): + """Cooked-leftover shelf-life estimate returned by POST /recipes/{id}/leftovers.""" + fridge_days: int + freeze_days: int | None = None # None = not recommended + freeze_by_day: int | None = None # day number from cook date to freeze by + storage_advice: str + + class StepAnalysis(BaseModel): """Active/passive classification for one direction step.""" is_passive: bool diff --git a/app/services/leftovers_predictor.py b/app/services/leftovers_predictor.py new file mode 100644 index 0000000..d30583d --- /dev/null +++ b/app/services/leftovers_predictor.py @@ -0,0 +1,233 @@ +# 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], + ) diff --git a/app/services/recipe/style_classifier.py b/app/services/recipe/style_classifier.py new file mode 100644 index 0000000..269c47a --- /dev/null +++ b/app/services/recipe/style_classifier.py @@ -0,0 +1,139 @@ +# app/services/recipe/style_classifier.py +# BSL 1.1 — LLM feature +"""LLM style-tag classifier for saved recipes. + +Reads recipe title, ingredients, and directions and suggests 3–5 style tags +from the curated vocabulary shared with SaveRecipeModal.vue. + +Cloud (CF_ORCH_URL set): allocates a cf-text service via cf-orch (2 GB VRAM). +Local: falls back to LLMRouter (ollama / vllm / openai-compat). +""" +from __future__ import annotations + +import json +import logging +import os +import re +from contextlib import nullcontext +from typing import Any + +logger = logging.getLogger(__name__) + +_SERVICE_TYPE = "cf-text" +_TTL_S = 60.0 +_CALLER = "kiwi-style-classify" + +# Canonical vocabulary — must stay in sync with SUGGESTED_TAGS in SaveRecipeModal.vue. +STYLE_TAG_VOCAB: frozenset[str] = frozenset({ + "comforting", "light", "spicy", "umami", "sweet", "savory", "rich", + "crispy", "creamy", "hearty", "quick", "hands-off", "meal-prep-friendly", + "fancy", "one-pot", +}) + +_SYSTEM_PROMPT = """\ +You are a culinary tagger. Given a recipe, suggest 3 to 5 style tags that best \ +describe its character. You MUST only use tags from this list: + +comforting, light, spicy, umami, sweet, savory, rich, crispy, creamy, hearty, \ +quick, hands-off, meal-prep-friendly, fancy, one-pot + +Return ONLY a JSON array of strings, no explanation. Example: +["comforting", "hearty", "one-pot"] +""" + + +def _build_router(): + """Return (router, context_manager) for style classify tasks. + + Tries cf-orch cf-text allocation first; falls back to LLMRouter. + Returns (None, nullcontext) if no backend is available. + """ + cf_orch_url = os.environ.get("CF_ORCH_URL") + if cf_orch_url: + try: + from app.services.meal_plan.llm_router import _OrchTextRouter # reuse adapter + from circuitforge_orch.client import CFOrchClient + client = CFOrchClient(cf_orch_url) + ctx = client.allocate(service=_SERVICE_TYPE, ttl_s=_TTL_S, caller=_CALLER) + alloc = ctx.__enter__() + if alloc is not None: + return _OrchTextRouter(alloc.url), ctx + except Exception as exc: + logger.debug("cf-orch allocation failed for style classify, falling back: %s", exc) + + try: + from circuitforge_core.llm.router import LLMRouter + return LLMRouter(), nullcontext(None) + except FileNotFoundError: + logger.debug("LLMRouter: no llm.yaml — style classifier LLM disabled") + return None, nullcontext(None) + except Exception as exc: + logger.debug("LLMRouter init failed: %s", exc) + return None, nullcontext(None) + + +def _parse_tags(raw: str) -> list[str]: + """Extract valid vocab tags from raw LLM output. + + Tries JSON parse first; falls back to extracting any vocab word present + in the response text so minor formatting deviations still work. + """ + # Strip markdown fences + raw = re.sub(r"```[a-z]*", "", raw).strip() + try: + parsed = json.loads(raw) + if isinstance(parsed, list): + return [t for t in parsed if isinstance(t, str) and t in STYLE_TAG_VOCAB][:5] + except (json.JSONDecodeError, ValueError): + pass + + # Fallback: scan for vocab words + found = [t for t in STYLE_TAG_VOCAB if re.search(rf"\b{re.escape(t)}\b", raw, re.IGNORECASE)] + return sorted(found, key=lambda t: raw.lower().index(t.lower()))[:5] + + +def classify_style(recipe: dict[str, Any]) -> list[str]: + """Return 3–5 suggested style tags for *recipe*. + + *recipe* is a Store row dict with at least ``title``, ``ingredient_names`` + (list[str]), and ``directions`` (list[str] or str). + + Returns an empty list if no LLM backend is available. + """ + router, ctx = _build_router() + if router is None: + return [] + + title = recipe.get("title") or "Unknown" + ingredients = recipe.get("ingredient_names") or [] + if isinstance(ingredients, str): + try: + ingredients = json.loads(ingredients) + except Exception: + ingredients = [ingredients] + + directions = recipe.get("directions") or [] + if isinstance(directions, str): + try: + directions = json.loads(directions) + except Exception: + directions = [directions] + + user_prompt = ( + f"Recipe: {title}\n" + f"Ingredients: {', '.join(str(i) for i in ingredients[:20])}\n" + f"Steps: {' '.join(str(d) for d in directions[:8])[:600]}" + ) + + try: + with ctx: + raw = router.complete( + system=_SYSTEM_PROMPT, + user=user_prompt, + max_tokens=64, + temperature=0.3, + ) + return _parse_tags(raw) + except Exception as exc: + logger.warning("Style classifier LLM call failed: %s", exc) + return [] diff --git a/frontend/src/components/RecipeDetailPanel.vue b/frontend/src/components/RecipeDetailPanel.vue index 51c8eee..1de54a9 100644 --- a/frontend/src/components/RecipeDetailPanel.vue +++ b/frontend/src/components/RecipeDetailPanel.vue @@ -276,6 +276,31 @@ Enjoy your meal! Recipe dismissed from suggestions. + + +
+ Working out storage info… +
+
+
+ Leftovers + +
+
+
+ ❄️ + Fridge: {{ leftovers.fridge_days }} day{{ leftovers.fridge_days !== 1 ? 's' : '' }} +
+
+ 🧊 + Freezer: {{ leftovers.freeze_days }} day{{ leftovers.freeze_days !== 1 ? 's' : '' }} +
+
+

+ Freeze by day {{ leftovers.freeze_by_day }} for best results. +

+

{{ leftovers.storage_advice }}

+