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