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,
|
"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,
|
||||||
|
|||||||
@@ -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,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,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}
|
||||||
|
|
||||||
|
|||||||
@@ -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.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,
|
||||||
|
|||||||
Reference in New Issue
Block a user