From c364b8b222904ee6d82b214df93f1b3f5aee37a3 Mon Sep 17 00:00:00 2001 From: Peter Woolery Date: Thu, 28 May 2026 06:42:46 -0700 Subject: [PATCH] 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 --- .../versions/0014_recipe_side_dishes.py | 28 +++ backend/app/api/recipes.py | 1 + backend/app/models/__init__.py | 1 + backend/app/schemas/__init__.py | 1 + backend/app/schemas/recipe.py | 14 ++ backend/app/services/recipe_enrichment.py | 177 ++++++++++++++++++ backend/app/services/recipe_ingestion.py | 22 ++- 7 files changed, 243 insertions(+), 1 deletion(-) create mode 100644 backend/alembic/versions/0014_recipe_side_dishes.py create mode 100644 backend/app/services/recipe_enrichment.py diff --git a/backend/alembic/versions/0014_recipe_side_dishes.py b/backend/alembic/versions/0014_recipe_side_dishes.py new file mode 100644 index 0000000..70057dd --- /dev/null +++ b/backend/alembic/versions/0014_recipe_side_dishes.py @@ -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") diff --git a/backend/app/api/recipes.py b/backend/app/api/recipes.py index 43e3fff..4f5aa2c 100644 --- a/backend/app/api/recipes.py +++ b/backend/app/api/recipes.py @@ -60,6 +60,7 @@ def _serialize(row: Recipe) -> dict: "spice_level": row.spice_level, "calories_per_serving": row.calories_per_serving, "ingredients": row.ingredients or [], + "side_dishes": row.side_dishes or [], "instructions": list(row.instructions or []), "source_url": row.source_url, "external_source": row.external_source, diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index d8a5e01..5cbcc90 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -178,6 +178,7 @@ class Recipe(Base): spice_level = Column(Integer) calories_per_serving = Column(Integer) ingredients = Column(JSONB, nullable=False) + side_dishes = Column(JSONB, default=list) instructions = Column(ARRAY(Text), nullable=False) source_url = Column(Text) scraped_at = Column(DateTime(timezone=True)) diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index a784613..5e7e0e8 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -1,5 +1,6 @@ from __future__ import annotations +from datetime import date, datetime from decimal import Decimal from enum import Enum from typing import Any, Dict, List, Optional diff --git a/backend/app/schemas/recipe.py b/backend/app/schemas/recipe.py index 70885d6..94c9839 100644 --- a/backend/app/schemas/recipe.py +++ b/backend/app/schemas/recipe.py @@ -14,6 +14,18 @@ class RecipeIngredientRef(BaseModel): 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): name: str = Field(min_length=1, max_length=300) description: Optional[str] = None @@ -49,6 +61,7 @@ class RecipeUpdate(BaseModel): calories_per_serving: Optional[int] = None ingredients: Optional[List[RecipeIngredientRef]] = None instructions: Optional[List[str]] = None + side_dishes: Optional[List[SideDish]] = None class RecipeRead(RecipeBase): @@ -56,6 +69,7 @@ class RecipeRead(RecipeBase): external_source: Optional[str] = None external_id: Optional[str] = None discovery_reason: Optional[str] = None + side_dishes: List[SideDish] = Field(default_factory=list) model_config = {"from_attributes": True} diff --git a/backend/app/services/recipe_enrichment.py b/backend/app/services/recipe_enrichment.py new file mode 100644 index 0000000..2bba3d5 --- /dev/null +++ b/backend/app/services/recipe_enrichment.py @@ -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".*?", "", 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, + } diff --git a/backend/app/services/recipe_ingestion.py b/backend/app/services/recipe_ingestion.py index 32921d9..58a9957 100644 --- a/backend/app/services/recipe_ingestion.py +++ b/backend/app/services/recipe_ingestion.py @@ -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,