feat(backend): recipe enrichment with side dishes & detailed instructions

- Add SideDish/SideDishIngredient schemas and recipe.side_dishes JSONB column
- Add recipe_enrichment.py service using Ollama LLM to:
  - Rewrite vague instructions with specific temps, quantities, timing, sauce breakdowns
  - Suggest 1-2 complementary side dishes with ingredients & prep notes
- Wire enrichment into recipe_ingestion.py discovery pipeline
- Add admin trigger endpoint /api/recipes/{id}/enrich for on-demand enrichment
- Migration 0014: Add side_dishes JSONB to recipe table
- Fix schemas/__init__.py imports: restore RecipeBase/Create/Read exports, add datetime/date for PydanticOptional compatibility
- Deployed to docker-willester and migrated to alembic 0014
This commit is contained in:
2026-05-28 06:42:46 -07:00
parent de51e2e8d3
commit c364b8b222
7 changed files with 243 additions and 1 deletions
+177
View File
@@ -0,0 +1,177 @@
"""Recipe Enrichment — uses LLM to generate complete meals and detailed instructions.
Transforms basic protein-centric recipes into complete meals with:
- Complementary side dishes (grain/starch + vegetables)
- Detailed step-by-step instructions with quantities, temperatures, timing
- Sauce breakdowns with specific measurements
Runs automatically during recipe ingestion so new recipes are complete meals.
"""
from __future__ import annotations
import json
import logging
import re
from typing import Dict, List, Optional, Any
import requests
from app.config import settings
logger = logging.getLogger(__name__)
_RATE_LIMIT_SECS = 0.5
class RecipeEnrichmentService:
"""Enrich recipes with sides and detailed instructions via LLM."""
def __init__(self) -> None:
self.enabled = bool(settings.OLLAMA_API_KEY)
def enrich(self, recipe_name: str, ingredients: List[dict], instructions: List[str]) -> dict:
"""Enrich a recipe. Returns dict with 'side_dishes' and 'instructions'.
On any failure, returns original instructions with no side dishes.
"""
if not self.enabled:
logger.info("RecipeEnrichment: OLLAMA_API_KEY not set — skipping enrichment")
return {"side_dishes": [], "instructions": instructions}
prompt = self._build_prompt(recipe_name, ingredients, instructions)
raw = self._call_llm(prompt)
if not raw:
return {"side_dishes": [], "instructions": instructions}
parsed = self._parse_response(raw)
if parsed is None:
return {"side_dishes": [], "instructions": instructions}
return parsed
def _build_prompt(self, recipe_name: str, ingredients: List[dict], instructions: List[str]) -> str:
"""Construct the enrichment prompt."""
ing_lines = "\n".join(
f"- {ing.get('name', '')}: {ing.get('qty', '')} {ing.get('unit', '')}".strip()
for ing in ingredients
)
inst_lines = "\n".join(f"{i+1}. {step}" for i, step in enumerate(instructions))
return (
f"You are a professional chef and recipe writer. Rewrite the following recipe "
f"into a complete, detailed meal plan with specific instructions.\n\n"
f"RECIPE: {recipe_name}\n\n"
f"CURRENT INGREDIENTS:\n{ing_lines}\n\n"
f"CURRENT INSTRUCTIONS:\n{inst_lines}\n\n"
f"TASK:\n"
f"1. Rewrite ALL instructions to be extremely specific for a home cook. Include:\n"
f" - Exact temperatures (in °F)\n"
f" - Precise quantities in each step\n"
f" - Exact timing (e.g., 'sauté for 3 minutes')\n"
f" - Sauce preparation broken into separate steps with exact measurements\n"
f" - Visual cues (e.g., 'until golden brown', 'until internal temp reaches 145°F')\n"
f" - Resting times\n\n"
f"2. Suggest 1-2 complementary side dishes that create a balanced, complete meal.\n"
f" For each side dish include:\n"
f" - Name (e.g., 'Garlic Butter Rice')\n"
f" - Ingredients list with quantities\n"
f" - Brief 2-3 sentence preparation description\n"
f" Choose grains/starches (rice, pasta, quinoa, couscous) and/or vegetables.\n\n"
f"RETURN FORMAT — Valid JSON only (no markdown, no commentary):\n"
f"{{\n"
f' "instructions": ["Step 1...", "Step 2..."],\n'
f' "side_dishes": [\n'
f' {{\n'
f' "name": "Side Dish Name",\n'
f' "ingredients": [\n'
f' {{"name": "ingredient name", "qty": 1.0, "unit": "cup"}}\n'
f' ],\n'
f' "prep_notes": "Brief preparation steps..."\n'
f' }}\n'
f' ]\n'
f"}}"
)
def _call_llm(self, prompt: str) -> Optional[str]:
"""Call Ollama LLM and return raw content string."""
try:
resp = requests.post(
f"{settings.OLLAMA_BASE_URL}/chat/completions",
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {settings.OLLAMA_API_KEY}",
},
json={
"model": settings.OLLAMA_MODEL,
"messages": [{"role": "user", "content": prompt}],
"max_tokens": 2000,
"temperature": 0.3,
},
timeout=60,
)
resp.raise_for_status()
except requests.RequestException as exc:
logger.warning("RecipeEnrichment LLM call failed: %s", exc)
return None
content = resp.json().get("choices", [{}])[0].get("message", {}).get("content", "")
# Strip thinking blocks that some models emit
content = re.sub(r"<think>.*?</think>", "", content, flags=re.DOTALL).strip()
return content
def _parse_response(self, raw: str) -> Optional[dict]:
"""Parse LLM response into structured dict. Handles markdown code blocks."""
# Try to extract JSON from markdown code block
code_block = re.search(r"```(?:json)?\s*(.*?)\s*```", raw, re.DOTALL)
if code_block:
raw = code_block.group(1).strip()
# Try to find JSON object boundaries
start = raw.find("{")
end = raw.rfind("}")
if start == -1 or end == -1 or end <= start:
logger.warning("RecipeEnrichment: no JSON object found in response")
return None
json_str = raw[start:end + 1]
try:
data = json.loads(json_str)
except json.JSONDecodeError as exc:
logger.warning("RecipeEnrichment: JSON parse failed: %s", exc)
return None
# Validate structure
if not isinstance(data.get("instructions"), list):
logger.warning("RecipeEnrichment: 'instructions' missing or not a list")
return None
side_dishes = data.get("side_dishes", [])
if not isinstance(side_dishes, list):
logger.warning("RecipeEnrichment: 'side_dishes' not a list")
side_dishes = []
# Validate side dish structure
validated_sides = []
for side in side_dishes:
if not isinstance(side, dict):
continue
if not side.get("name"):
continue
validated_sides.append({
"name": side["name"],
"ingredients": [
{
"name": ing.get("name", ""),
"qty": ing.get("qty", 0),
"unit": ing.get("unit", ""),
}
for ing in (side.get("ingredients") or [])
if ing.get("name")
],
"prep_notes": side.get("prep_notes", ""),
})
return {
"instructions": [str(step) for step in data["instructions"] if step],
"side_dishes": validated_sides,
}
+21 -1
View File
@@ -13,6 +13,7 @@ from rapidfuzz import fuzz
from sqlalchemy.dialects.postgresql import insert as _pg_insert
from sqlalchemy.orm import Session
from app.services.recipe_enrichment import RecipeEnrichmentService
from app.models import Ingredient, Recipe
logger = logging.getLogger(__name__)
@@ -24,6 +25,9 @@ _MAX_INGREDIENTS = 20
class RecipeIngestionService:
"""Ingest external recipes into our database."""
def __init__(self) -> None:
self.enrichment = RecipeEnrichmentService()
def ingest(
self,
db: Session,
@@ -78,6 +82,21 @@ class RecipeIngestionService:
top_signals = [s["value"] for s in analysis_dict.get("positive_signals", [])]
discovery_reason = f"Matched queries: {', '.join(queries[:2])}. Signals: {', '.join(top_signals[:2])}."
# --- Enrich: detailed instructions + side dishes ---
enriched = self.enrichment.enrich(ext.name, mapped_ingredients, instructions)
final_instructions = enriched.get("instructions") or instructions
side_dishes = enriched.get("side_dishes") or []
# Append side-dish ingredients to the main ingredient list so shopping lists pick them up
for side in side_dishes:
for sd_ing in side.get("ingredients", []):
mapped_ingredients.append({
"ingredient_id": None,
"name": sd_ing.get("name", ""),
"qty": sd_ing.get("qty"),
"unit": sd_ing.get("unit", ""),
})
recipe = Recipe(
id=_uuid_mod.uuid4(),
family_profile_id=family_profile_id,
@@ -93,7 +112,8 @@ class RecipeIngestionService:
protein_type=ext.protein_type,
calories_per_serving=ext.calories_per_serving,
ingredients=mapped_ingredients,
instructions=instructions,
side_dishes=side_dishes,
instructions=final_instructions,
source_url=ext.source_url,
external_source=ext.external_source,
external_id=ext.external_id,