feat(backend): wire exclude_recipe_ids, verify MealPlan votes schema, add image generation service

- api/meal_plans.py: /regenerate now passes exclude_recipe_ids into generate_meal_plan
- planner/generate.py: filter recipe_dicts by exclude_recipe_ids set
- image_generation.py: OpenAI gpt-image-1 client with prompt building, b64_json handling
- main.py: StaticFiles mount at /static for generated images
- admin.py: POST /api/admin/trigger-images endpoint for batch generation
- scripts/generate_images.py: CLI for batch image generation
- docker-compose.yml + nginx: volume mounts for static/images persistence
- Verify MealPlanItem.votes ↔ MealPlanVote relationship is correct; no model bug exists
This commit is contained in:
2026-05-24 19:31:26 -07:00
parent dcdb88595e
commit ae32e650ce
17 changed files with 348 additions and 3 deletions
+231
View File
@@ -0,0 +1,231 @@
"""OpenAI DALL-E image generation service for recipes.
Downloads generated images to `backend/static/images/` and returns
relative paths suitable for `Recipe.image_url`.
"""
from __future__ import annotations
import base64
import hashlib
import logging
import os
import time
from pathlib import Path
from typing import Optional
import httpx
from sqlalchemy.orm import Session
from app.config import settings
from app.models import Recipe
logger = logging.getLogger(__name__)
# Directory where generated images are persisted.
STATIC_IMAGES_DIR = Path(__file__).resolve().parent.parent.parent / "static" / "images"
PUBLIC_URL_PREFIX = "/static/images/"
def _build_prompt(recipe: Recipe) -> str:
"""Create a vivid, appetising DALL-E prompt from a recipe."""
name = recipe.name or "A delicious dish"
ingredients = recipe.ingredients or []
ingredient_names = [str(ing.get("name", "")) for ing in ingredients if ing.get("name")]
ingredient_str = ", ".join(ingredient_names[:6]) if ingredient_names else "fresh ingredients"
tags = recipe.cuisine_tags or []
tag_str = f", {', '.join(tags)} style" if tags else ""
return (
f"Professional food photography of {name}{tag_str}. "
f"Served on a clean white ceramic plate with natural lighting, "
f"shallow depth of field. Vibrant colours, appetising. "
f"Ingredients visible: {ingredient_str}. "
f"No text, no watermark."
)
def _sanitise_filename(recipe_name: str) -> str:
"""Return a filesystem-safe lowercase slug."""
keep = recipe_name.lower()
for ch in " /'\"?!:\\|;#":
keep = keep.replace(ch, "_")
return keep.strip("_")
def _openai_api_key() -> Optional[str]:
if settings.AI_IMAGE_PROVIDER and settings.AI_IMAGE_PROVIDER.lower() != "openai":
return None
return settings.AI_IMAGE_API_KEY or os.environ.get("OPENAI_API_KEY")
def generate_image_for_recipe(
recipe: Recipe,
db: Optional[Session] = None,
size: str = "1024x1024",
quality: str = "standard", # or "hd"
force: bool = False,
) -> Optional[str]:
"""Generate an image for *recipe* using DALL-E 3.
Returns the public static path (e.g. ``/static/images/caprese_pasta_a1b2.png``)
or ``None`` if generation is disabled / fails.
If *force* is ``False`` and the recipe already has an ``image_url`` that
looks like a local static path, the existing image is returned immediately.
"""
if not settings.AI_IMAGE_ENABLED:
logger.info("AI_IMAGE_ENABLED=false; skipping image generation for %s", recipe.name)
return None
api_key = _openai_api_key()
if not api_key:
logger.warning("No OpenAI API key configured; skipping image generation.")
return None
slug = _sanitise_filename(recipe.name)
digest_id = hashlib.sha256(str(recipe.id).encode()).hexdigest()[:8]
out_name = f"{slug}_{digest_id}.png"
out_path = STATIC_IMAGES_DIR / out_name
# Reuse existing image unless forced.
if not force and out_path.exists():
logger.debug("Image already exists for %s", recipe.name)
return f"{PUBLIC_URL_PREFIX}{out_name}"
if not force and recipe.image_url and recipe.image_url.startswith("/static/images/"):
return recipe.image_url
prompt = _build_prompt(recipe)
model = os.environ.get("AI_IMAGE_MODEL", "gpt-image-1")
logger.info("Generating image for '%s' via %s", recipe.name, model)
try:
payload = {
"model": model,
"prompt": prompt,
"size": size,
"n": 1,
}
# gpt-image-* uses low/medium/high/auto; dall-e uses standard/hd
if not model.startswith("gpt-image"):
payload["quality"] = quality
else:
# Map dall-e quality names to gpt-image quality names
quality_map = {"standard": "medium", "hd": "high"}
payload["quality"] = quality_map.get(quality, "auto")
resp = httpx.post(
"https://api.openai.com/v1/images/generations",
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
json=payload,
timeout=120.0,
)
except httpx.HTTPError as exc:
logger.error("Network error generating image for '%s': %s", recipe.name, exc)
return None
if resp.status_code != 200:
logger.error(
"DALL-E API error for '%s': HTTP %s %s",
recipe.name,
resp.status_code,
resp.text,
)
return None
try:
data = resp.json()["data"][0]
if "b64_json" in data:
image_bytes = base64.b64decode(data["b64_json"])
elif "url" in data:
image_url = data["url"]
img_resp = httpx.get(image_url, timeout=30.0)
if img_resp.status_code != 200:
logger.error("Failed downloading image for '%s': HTTP %s", recipe.name, img_resp.status_code)
return None
image_bytes = img_resp.content
else:
raise KeyError("no image data (url or b64_json)")
except (KeyError, IndexError) as exc:
logger.error("Unexpected DALL-E response for '%s': %s", recipe.name, exc)
return None
STATIC_IMAGES_DIR.mkdir(parents=True, exist_ok=True)
out_path.write_bytes(image_bytes)
public_path = f"{PUBLIC_URL_PREFIX}{out_name}"
# Persist on recipe row if a DB session was provided.
if db is not None:
recipe.image_url = public_path
db.add(recipe)
db.commit()
logger.info("Image saved for '%s' -> %s", recipe.name, public_path)
return public_path
def generate_images_batch(
db: Session,
recipe_ids: Optional[list[str]] = None,
missing_only: bool = False,
force: bool = False,
limit: int = 10,
) -> dict:
"""Batch-generate images for recipes.
Returns a dict::
{
"total": <int>,
"succeeded": <int>,
"failed": <int>,
"skipped": <int>,
"results": {"recipe_id": "image_url | null", …},
}
"""
from sqlalchemy import or_
query = db.query(Recipe)
if recipe_ids:
query = query.filter(Recipe.id.in_(recipe_ids))
if missing_only:
query = query.filter(
or_(
Recipe.image_url.is_(None),
Recipe.image_url == "",
)
)
recipes = query.limit(limit).all()
total = len(recipes)
succeeded = 0
failed = 0
skipped = 0
results = {}
for recipe in recipes:
recipe_id = str(recipe.id)
# Skip if already has local image unless forced.
if not force and recipe.image_url and recipe.image_url.startswith("/static/images/"):
skipped += 1
results[recipe_id] = {"status": "skipped", "url": recipe.image_url}
continue
url = generate_image_for_recipe(recipe, db=db, force=force)
if url:
succeeded += 1
results[recipe_id] = {"status": "ok", "url": url}
else:
failed += 1
results[recipe_id] = {"status": "failed", "url": None}
time.sleep(2) # rate-limiting courtesy between requests
return {
"total": total,
"succeeded": succeeded,
"failed": failed,
"skipped": skipped,
"results": results,
}
+3
View File
@@ -100,6 +100,7 @@ def generate_meal_plan(
week_start_date: date,
config: PlannerConfig = DEFAULT,
today: Optional[date] = None,
exclude_recipe_ids: Optional[Set[UUID]] = None,
) -> GenerationResult:
today = today or date.today()
family = db.query(FamilyProfile).filter(FamilyProfile.id == family_id).first()
@@ -107,6 +108,7 @@ def generate_meal_plan(
raise ValueError(f"family_profile {family_id} not found")
recipes = db.query(Recipe).all()
exclude_set = exclude_recipe_ids or set()
recipe_dicts = [
{
"id": r.id,
@@ -120,6 +122,7 @@ def generate_meal_plan(
"servings": r.servings or 4,
}
for r in recipes
if r.id not in exclude_set
]
recipe_ingredient_ids: Dict[UUID, Set[UUID]] = {}