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
139 lines
4.7 KiB
Python
139 lines
4.7 KiB
Python
# 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 []
|