Public Access
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:
@@ -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")
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user