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
This commit is contained in:
parent
9c4d8b7883
commit
9350719516
8 changed files with 544 additions and 3 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
233
app/services/leftovers_predictor.py
Normal file
233
app/services/leftovers_predictor.py
Normal file
|
|
@ -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],
|
||||
)
|
||||
139
app/services/recipe/style_classifier.py
Normal file
139
app/services/recipe/style_classifier.py
Normal file
|
|
@ -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 []
|
||||
|
|
@ -276,6 +276,31 @@
|
|||
<span class="cook-success-icon">✓</span>
|
||||
Enjoy your meal! Recipe dismissed from suggestions.
|
||||
<button class="btn btn-secondary btn-sm mt-xs" @click="$emit('close')">Close</button>
|
||||
|
||||
<!-- Leftover shelf-life section -->
|
||||
<div v-if="leftoversLoading" class="leftovers-panel text-sm text-secondary mt-sm">
|
||||
Working out storage info…
|
||||
</div>
|
||||
<div v-else-if="leftovers && !leftoversDismissed" class="leftovers-panel mt-sm">
|
||||
<div class="leftovers-header flex-between">
|
||||
<span class="text-sm font-semibold">Leftovers</span>
|
||||
<button class="btn-icon btn-xs" @click="leftoversDismissed = true" aria-label="Dismiss storage info">✕</button>
|
||||
</div>
|
||||
<div class="leftovers-grid mt-xs">
|
||||
<div class="leftovers-cell">
|
||||
<span class="leftovers-icon">❄️</span>
|
||||
<span class="text-sm">Fridge: <strong>{{ leftovers.fridge_days }} day{{ leftovers.fridge_days !== 1 ? 's' : '' }}</strong></span>
|
||||
</div>
|
||||
<div v-if="leftovers.freeze_days !== null" class="leftovers-cell">
|
||||
<span class="leftovers-icon">🧊</span>
|
||||
<span class="text-sm">Freezer: <strong>{{ leftovers.freeze_days }} day{{ leftovers.freeze_days !== 1 ? 's' : '' }}</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="leftovers.freeze_by_day" class="text-xs text-secondary mt-xs">
|
||||
Freeze by day {{ leftovers.freeze_by_day }} for best results.
|
||||
</p>
|
||||
<p class="text-xs text-secondary mt-xs">{{ leftovers.storage_advice }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<template v-else>
|
||||
<button class="btn btn-secondary" @click="$emit('close')">Back</button>
|
||||
|
|
@ -329,7 +354,7 @@
|
|||
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import { useRecipesStore } from '../stores/recipes'
|
||||
import { useSavedRecipesStore } from '../stores/savedRecipes'
|
||||
import { inventoryAPI } from '../services/api'
|
||||
import { inventoryAPI, recipesAPI } from '../services/api'
|
||||
import type { RecipeSuggestion, GroceryLink, StepAnalysis } from '../services/api'
|
||||
import SaveRecipeModal from './SaveRecipeModal.vue'
|
||||
|
||||
|
|
@ -386,6 +411,12 @@ const isSaved = computed(() => savedStore.isSaved(props.recipe.id))
|
|||
|
||||
const cookDone = ref(false)
|
||||
|
||||
// ── Leftover shelf-life ────────────────────────────────────
|
||||
type LeftoversData = { fridge_days: number; freeze_days: number | null; freeze_by_day: number | null; storage_advice: string }
|
||||
const leftovers = ref<LeftoversData | null>(null)
|
||||
const leftoversLoading = ref(false)
|
||||
const leftoversDismissed = ref(false)
|
||||
|
||||
// ── Cook mode ─────────────────────────────────────────────
|
||||
const cookModeActive = ref(false)
|
||||
const cookStep = ref(0) // 0-indexed
|
||||
|
|
@ -622,10 +653,20 @@ function groceryLinkFor(ingredient: string): GroceryLink | undefined {
|
|||
return props.groceryLinks.find((l) => l.ingredient.toLowerCase() === needle)
|
||||
}
|
||||
|
||||
function handleCook() {
|
||||
async function handleCook() {
|
||||
recipesStore.logCook(props.recipe.id, props.recipe.title)
|
||||
cookDone.value = true
|
||||
emit('cooked', props.recipe)
|
||||
if (props.recipe.id) {
|
||||
leftoversLoading.value = true
|
||||
try {
|
||||
leftovers.value = await recipesAPI.getLeftovers(props.recipe.id)
|
||||
} catch {
|
||||
// Silently skip — shelf life is supplemental info, not critical
|
||||
} finally {
|
||||
leftoversLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
@ -1558,4 +1599,33 @@ details[open].steps-collapsible .steps-collapsible-summary::before {
|
|||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.leftovers-panel {
|
||||
background: var(--color-surface-alt, var(--color-surface));
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--spacing-sm);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.leftovers-header {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.leftovers-grid {
|
||||
display: flex;
|
||||
gap: var(--spacing-md);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.leftovers-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.leftovers-icon {
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -46,7 +46,14 @@
|
|||
|
||||
<!-- Style tags -->
|
||||
<div class="form-group">
|
||||
<label class="form-label">Style tags</label>
|
||||
<div class="flex-between mb-xs">
|
||||
<label class="form-label" style="margin-bottom: 0;">Style tags</label>
|
||||
<button
|
||||
class="btn btn-secondary btn-xs"
|
||||
:disabled="classifying"
|
||||
@click="suggestTags"
|
||||
>{{ classifying ? 'Suggesting…' : 'Suggest tags' }}</button>
|
||||
</div>
|
||||
<div class="tags-wrap flex flex-wrap gap-xs mb-xs">
|
||||
<span
|
||||
v-for="tag in localTags"
|
||||
|
|
@ -89,6 +96,7 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import { useSavedRecipesStore } from '../stores/savedRecipes'
|
||||
import { savedRecipesAPI } from '../services/api'
|
||||
|
||||
const SUGGESTED_TAGS = [
|
||||
'comforting', 'light', 'spicy', 'umami', 'sweet', 'savory', 'rich',
|
||||
|
|
@ -140,6 +148,7 @@ const localTags = ref<string[]>([...(existing.value?.style_tags ?? [])])
|
|||
const hoverRating = ref<number | null>(null)
|
||||
const tagInput = ref('')
|
||||
const saving = ref(false)
|
||||
const classifying = ref(false)
|
||||
|
||||
const unusedSuggestions = computed(() =>
|
||||
SUGGESTED_TAGS.filter((s) => !localTags.value.includes(s))
|
||||
|
|
@ -174,6 +183,23 @@ function onTagKey(e: KeyboardEvent) {
|
|||
}
|
||||
}
|
||||
|
||||
async function suggestTags() {
|
||||
classifying.value = true
|
||||
try {
|
||||
const suggestions = await savedRecipesAPI.classifyStyle(props.recipeId)
|
||||
// Merge suggestions into localTags — new ones only, preserving user's existing tags
|
||||
for (const tag of suggestions) {
|
||||
if (!localTags.value.includes(tag)) {
|
||||
localTags.value = [...localTags.value, tag]
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Silently ignore — tier gate returns 403, no LLM returns empty list
|
||||
} finally {
|
||||
classifying.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
saving.value = true
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -694,6 +694,10 @@ export const recipesAPI = {
|
|||
const response = await api.get(`/recipes/${id}`)
|
||||
return response.data
|
||||
},
|
||||
async getLeftovers(id: number): Promise<{ fridge_days: number; freeze_days: number | null; freeze_by_day: number | null; storage_advice: string }> {
|
||||
const response = await api.post(`/recipes/${id}/leftovers`)
|
||||
return response.data
|
||||
},
|
||||
async listStaples(dietary?: string): Promise<Staple[]> {
|
||||
const response = await api.get('/staples/', { params: dietary ? { dietary } : undefined })
|
||||
return response.data
|
||||
|
|
@ -857,6 +861,10 @@ export const savedRecipesAPI = {
|
|||
async removeFromCollection(collection_id: number, saved_recipe_id: number): Promise<void> {
|
||||
await api.delete(`/recipes/saved/collections/${collection_id}/members/${saved_recipe_id}`)
|
||||
},
|
||||
async classifyStyle(recipe_id: number): Promise<string[]> {
|
||||
const response = await api.post(`/recipes/saved/${recipe_id}/classify-style`)
|
||||
return response.data.suggested_tags
|
||||
},
|
||||
}
|
||||
|
||||
// --- Meal Plan types ---
|
||||
|
|
|
|||
Loading…
Reference in a new issue