feat(recipes): LLM style classifier (#27) + cooked leftovers shelf-life (#112)
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

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:
pyr0ball 2026-04-25 23:18:16 -07:00
parent 9c4d8b7883
commit 9350719516
8 changed files with 544 additions and 3 deletions

View file

@ -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)

View file

@ -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])

View file

@ -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

View 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],
)

View 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 35 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 35 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 []

View file

@ -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>

View file

@ -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 {

View file

@ -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 ---