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
@@ -0,0 +1,28 @@
"""Add side_dishes JSONB to recipe.
Revision ID: 0014
Revises: 0013
Create Date: 2026-05-27
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = "0014"
down_revision: Union[str, None] = "0013"
branch_labels: Union[Sequence[str], None] = None
depends_on: Union[Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"recipe",
sa.Column("side_dishes", postgresql.JSONB, nullable=True),
)
def downgrade() -> None:
op.drop_column("recipe", "side_dishes")
+1
View File
@@ -60,6 +60,7 @@ def _serialize(row: Recipe) -> dict:
"spice_level": row.spice_level, "spice_level": row.spice_level,
"calories_per_serving": row.calories_per_serving, "calories_per_serving": row.calories_per_serving,
"ingredients": row.ingredients or [], "ingredients": row.ingredients or [],
"side_dishes": row.side_dishes or [],
"instructions": list(row.instructions or []), "instructions": list(row.instructions or []),
"source_url": row.source_url, "source_url": row.source_url,
"external_source": row.external_source, "external_source": row.external_source,
+1
View File
@@ -178,6 +178,7 @@ class Recipe(Base):
spice_level = Column(Integer) spice_level = Column(Integer)
calories_per_serving = Column(Integer) calories_per_serving = Column(Integer)
ingredients = Column(JSONB, nullable=False) ingredients = Column(JSONB, nullable=False)
side_dishes = Column(JSONB, default=list)
instructions = Column(ARRAY(Text), nullable=False) instructions = Column(ARRAY(Text), nullable=False)
source_url = Column(Text) source_url = Column(Text)
scraped_at = Column(DateTime(timezone=True)) scraped_at = Column(DateTime(timezone=True))
+1
View File
@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
from datetime import date, datetime
from decimal import Decimal from decimal import Decimal
from enum import Enum from enum import Enum
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
+14
View File
@@ -14,6 +14,18 @@ class RecipeIngredientRef(BaseModel):
notes: Optional[str] = None notes: Optional[str] = None
class SideDishIngredient(BaseModel):
name: str
qty: float
unit: Optional[str] = None
class SideDish(BaseModel):
name: str
ingredients: List[SideDishIngredient]
prep_notes: Optional[str] = None
class RecipeBase(BaseModel): class RecipeBase(BaseModel):
name: str = Field(min_length=1, max_length=300) name: str = Field(min_length=1, max_length=300)
description: Optional[str] = None description: Optional[str] = None
@@ -49,6 +61,7 @@ class RecipeUpdate(BaseModel):
calories_per_serving: Optional[int] = None calories_per_serving: Optional[int] = None
ingredients: Optional[List[RecipeIngredientRef]] = None ingredients: Optional[List[RecipeIngredientRef]] = None
instructions: Optional[List[str]] = None instructions: Optional[List[str]] = None
side_dishes: Optional[List[SideDish]] = None
class RecipeRead(RecipeBase): class RecipeRead(RecipeBase):
@@ -56,6 +69,7 @@ class RecipeRead(RecipeBase):
external_source: Optional[str] = None external_source: Optional[str] = None
external_id: Optional[str] = None external_id: Optional[str] = None
discovery_reason: Optional[str] = None discovery_reason: Optional[str] = None
side_dishes: List[SideDish] = Field(default_factory=list)
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
+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.dialects.postgresql import insert as _pg_insert
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.services.recipe_enrichment import RecipeEnrichmentService
from app.models import Ingredient, Recipe from app.models import Ingredient, Recipe
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -24,6 +25,9 @@ _MAX_INGREDIENTS = 20
class RecipeIngestionService: class RecipeIngestionService:
"""Ingest external recipes into our database.""" """Ingest external recipes into our database."""
def __init__(self) -> None:
self.enrichment = RecipeEnrichmentService()
def ingest( def ingest(
self, self,
db: Session, db: Session,
@@ -78,6 +82,21 @@ class RecipeIngestionService:
top_signals = [s["value"] for s in analysis_dict.get("positive_signals", [])] 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])}." 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( recipe = Recipe(
id=_uuid_mod.uuid4(), id=_uuid_mod.uuid4(),
family_profile_id=family_profile_id, family_profile_id=family_profile_id,
@@ -93,7 +112,8 @@ class RecipeIngestionService:
protein_type=ext.protein_type, protein_type=ext.protein_type,
calories_per_serving=ext.calories_per_serving, calories_per_serving=ext.calories_per_serving,
ingredients=mapped_ingredients, ingredients=mapped_ingredients,
instructions=instructions, side_dishes=side_dishes,
instructions=final_instructions,
source_url=ext.source_url, source_url=ext.source_url,
external_source=ext.external_source, external_source=ext.external_source,
external_id=ext.external_id, external_id=ext.external_id,