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
+6
View File
@@ -0,0 +1,6 @@
__pycache__
*.pyc
*.pyo
.env*
tests/
.pytest_cache/
+21
View File
@@ -189,6 +189,27 @@ def get_stats(db: Session = Depends(get_db)):
} }
@router.post("/trigger-images", status_code=200)
def trigger_images(
limit: int = 10,
missing_only: bool = True,
force: bool = False,
recipe_id: Optional[UUID] = None,
db: Session = Depends(get_db),
):
from app.services.image_generation import generate_images_batch
recipe_ids = [str(recipe_id)] if recipe_id else None
result = generate_images_batch(
db,
recipe_ids=recipe_ids,
missing_only=missing_only,
force=force,
limit=limit,
)
return result
@router.post("/trigger-discovery", status_code=200) @router.post("/trigger-discovery", status_code=200)
def trigger_discovery( def trigger_discovery(
family_profile_id: UUID, family_profile_id: UUID,
+1 -3
View File
@@ -92,9 +92,6 @@ def regenerate(payload: RegenerateRequest, db: Session = Depends(get_db)) -> Gen
if payload.relax_max_meal_cost is not None: if payload.relax_max_meal_cost is not None:
config = replace(config, max_meal_cost=payload.relax_max_meal_cost) config = replace(config, max_meal_cost=payload.relax_max_meal_cost)
# exclude_recipe_ids accepted for forward-compat but not yet honored.
# See plan §Open items.
db.query(MealPlan).filter( db.query(MealPlan).filter(
MealPlan.family_profile_id == payload.family_profile_id, MealPlan.family_profile_id == payload.family_profile_id,
MealPlan.week_start_date == payload.week_start_date, MealPlan.week_start_date == payload.week_start_date,
@@ -106,6 +103,7 @@ def regenerate(payload: RegenerateRequest, db: Session = Depends(get_db)) -> Gen
family_id=payload.family_profile_id, family_id=payload.family_profile_id,
week_start_date=payload.week_start_date, week_start_date=payload.week_start_date,
config=config, config=config,
exclude_recipe_ids=set(payload.exclude_recipe_ids) if payload.exclude_recipe_ids else None,
) )
items = ( items = (
db.query(MealPlanItem) db.query(MealPlanItem)
+4
View File
@@ -1,4 +1,5 @@
from fastapi import FastAPI, Depends from fastapi import FastAPI, Depends
from fastapi.staticfiles import StaticFiles
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy import text from sqlalchemy import text
from app.database import get_db from app.database import get_db
@@ -14,6 +15,9 @@ app = FastAPI(
version="0.1.0", version="0.1.0",
) )
# Serve generated recipe images from local filesystem.
app.mount("/static", StaticFiles(directory="static"), name="static")
@app.get("/health") @app.get("/health")
def health_check(db: Session = Depends(get_db)): def health_check(db: Session = Depends(get_db)):
+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, week_start_date: date,
config: PlannerConfig = DEFAULT, config: PlannerConfig = DEFAULT,
today: Optional[date] = None, today: Optional[date] = None,
exclude_recipe_ids: Optional[Set[UUID]] = None,
) -> GenerationResult: ) -> GenerationResult:
today = today or date.today() today = today or date.today()
family = db.query(FamilyProfile).filter(FamilyProfile.id == family_id).first() 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") raise ValueError(f"family_profile {family_id} not found")
recipes = db.query(Recipe).all() recipes = db.query(Recipe).all()
exclude_set = exclude_recipe_ids or set()
recipe_dicts = [ recipe_dicts = [
{ {
"id": r.id, "id": r.id,
@@ -120,6 +122,7 @@ def generate_meal_plan(
"servings": r.servings or 4, "servings": r.servings or 4,
} }
for r in recipes for r in recipes
if r.id not in exclude_set
] ]
recipe_ingredient_ids: Dict[UUID, Set[UUID]] = {} recipe_ingredient_ids: Dict[UUID, Set[UUID]] = {}
+1
View File
@@ -0,0 +1 @@
# utils package
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env python3
"""Batch-generate recipe images using OpenAI DALL-E 3.
Usage:
python scripts/generate_images.py --limit 10 --missing-only
python scripts/generate_images.py --recipe-id <uuid>
"""
from __future__ import annotations
import argparse
import os
import sys
from pathlib import Path
# Ensure imports resolve regardless of cwd.
_ = sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from app.database import get_db as _get_db
from app.services.image_generation import generate_images_batch
def main() -> int:
parser = argparse.ArgumentParser(description="Generate recipe images via DALL-E 3")
parser.add_argument("--limit", type=int, default=10, help="Max recipes to process")
parser.add_argument("--missing-only", action="store_true", default=True, help="Only recipes without images")
parser.add_argument("--force", action="store_true", default=False, help="Overwrite existing images")
parser.add_argument("--recipe-id", type=str, default=None, help="Generate for a single recipe")
args = parser.parse_args()
# FastAPI DB dependency is a generator; consume it.
# Ensure project-root .env is discoverable before importing config.
project_root = Path(__file__).resolve().parent.parent.parent
os.environ.setdefault("DATABASE_URL", os.environ.get("DATABASE_URL", ""))
os.environ.setdefault("AI_IMAGE_ENABLED", os.environ.get("AI_IMAGE_ENABLED", "true"))
os.environ.setdefault("AI_IMAGE_API_KEY", os.environ.get("AI_IMAGE_API_KEY", ""))
os.environ.setdefault("OPENAI_API_KEY", os.environ.get("OPENAI_API_KEY", ""))
db_gen = _get_db()
db = next(db_gen)
recipe_ids = [args.recipe_id] if args.recipe_id else None
result = generate_images_batch(
db,
recipe_ids=recipe_ids,
missing_only=args.missing_only,
force=args.force,
limit=args.limit,
)
print(f"Total: {result['total']}")
print(f"Succeeded: {result['succeeded']}")
print(f"Failed: {result['failed']}")
print(f"Skipped: {result['skipped']}")
for rid, info in result["results"].items():
print(f" {rid}: {info['status']} {info['url'] or ''}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

+9
View File
@@ -19,6 +19,8 @@ services:
- SWIFTLY_API_BASE=${SWIFTLY_API_BASE:-https://prod.swiftlyapi.net} - SWIFTLY_API_BASE=${SWIFTLY_API_BASE:-https://prod.swiftlyapi.net}
- SWIFTLY_CATEGORIES_URL=${SWIFTLY_CATEGORIES_URL:-https://luckysupermarkets.com/categories} - SWIFTLY_CATEGORIES_URL=${SWIFTLY_CATEGORIES_URL:-https://luckysupermarkets.com/categories}
- AI_IMAGE_ENABLED=${AI_IMAGE_ENABLED:-false} - AI_IMAGE_ENABLED=${AI_IMAGE_ENABLED:-false}
- AI_IMAGE_API_KEY=${OPENAI_API_KEY:-}
- OPENAI_API_KEY=${OPENAI_API_KEY:-}
- LOG_LEVEL=${LOG_LEVEL:-INFO} - LOG_LEVEL=${LOG_LEVEL:-INFO}
- SECRET_KEY=${SECRET_KEY} - SECRET_KEY=${SECRET_KEY}
- ADMIN_TOKEN=${ADMIN_TOKEN} - ADMIN_TOKEN=${ADMIN_TOKEN}
@@ -35,6 +37,8 @@ services:
interval: 30s interval: 30s
timeout: 10s timeout: 10s
retries: 3 retries: 3
volumes:
- ./backend/static:/app/static:rw
scheduler: scheduler:
build: build:
@@ -53,6 +57,8 @@ services:
- SWIFTLY_API_BASE=${SWIFTLY_API_BASE:-https://prod.swiftlyapi.net} - SWIFTLY_API_BASE=${SWIFTLY_API_BASE:-https://prod.swiftlyapi.net}
- SWIFTLY_CATEGORIES_URL=${SWIFTLY_CATEGORIES_URL:-https://luckysupermarkets.com/categories} - SWIFTLY_CATEGORIES_URL=${SWIFTLY_CATEGORIES_URL:-https://luckysupermarkets.com/categories}
- AI_IMAGE_ENABLED=${AI_IMAGE_ENABLED:-false} - AI_IMAGE_ENABLED=${AI_IMAGE_ENABLED:-false}
- AI_IMAGE_API_KEY=${OPENAI_API_KEY:-}
- OPENAI_API_KEY=${OPENAI_API_KEY:-}
- LOG_LEVEL=${LOG_LEVEL:-INFO} - LOG_LEVEL=${LOG_LEVEL:-INFO}
- SECRET_KEY=${SECRET_KEY} - SECRET_KEY=${SECRET_KEY}
- ADMIN_TOKEN=${ADMIN_TOKEN} - ADMIN_TOKEN=${ADMIN_TOKEN}
@@ -64,6 +70,8 @@ services:
db: db:
condition: service_healthy condition: service_healthy
restart: unless-stopped restart: unless-stopped
volumes:
- ./backend/static:/app/static:rw
frontend: frontend:
build: build:
@@ -98,6 +106,7 @@ services:
volumes: volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- ./nginx/ssl:/etc/nginx/ssl:ro - ./nginx/ssl:/etc/nginx/ssl:ro
- ./backend/static:/app/static:ro
depends_on: depends_on:
- frontend - frontend
- backend - backend
+3
View File
@@ -0,0 +1,3 @@
node_modules
dist
.env*
Binary file not shown.
+7
View File
@@ -21,6 +21,13 @@ http {
proxy_cache_bypass $http_upgrade; proxy_cache_bypass $http_upgrade;
} }
location /static/ {
set $backend_upstream http://backend:8000;
proxy_pass $backend_upstream;
proxy_http_version 1.1;
proxy_set_header Host $host;
}
location / { location / {
set $frontend_upstream http://frontend:80; set $frontend_upstream http://frontend:80;
proxy_pass $frontend_upstream; proxy_pass $frontend_upstream;