diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..e8539d8 --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,6 @@ +__pycache__ +*.pyc +*.pyo +.env* +tests/ +.pytest_cache/ diff --git a/backend/app/api/admin.py b/backend/app/api/admin.py index 5035a06..a491fb4 100644 --- a/backend/app/api/admin.py +++ b/backend/app/api/admin.py @@ -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) def trigger_discovery( family_profile_id: UUID, diff --git a/backend/app/api/meal_plans.py b/backend/app/api/meal_plans.py index ad6d735..4760c41 100644 --- a/backend/app/api/meal_plans.py +++ b/backend/app/api/meal_plans.py @@ -92,9 +92,6 @@ def regenerate(payload: RegenerateRequest, db: Session = Depends(get_db)) -> Gen if payload.relax_max_meal_cost is not None: 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( MealPlan.family_profile_id == payload.family_profile_id, 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, week_start_date=payload.week_start_date, config=config, + exclude_recipe_ids=set(payload.exclude_recipe_ids) if payload.exclude_recipe_ids else None, ) items = ( db.query(MealPlanItem) diff --git a/backend/app/main.py b/backend/app/main.py index 1c2ccf3..e79ba3a 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,4 +1,5 @@ from fastapi import FastAPI, Depends +from fastapi.staticfiles import StaticFiles from sqlalchemy.orm import Session from sqlalchemy import text from app.database import get_db @@ -14,6 +15,9 @@ app = FastAPI( version="0.1.0", ) +# Serve generated recipe images from local filesystem. +app.mount("/static", StaticFiles(directory="static"), name="static") + @app.get("/health") def health_check(db: Session = Depends(get_db)): diff --git a/backend/app/services/image_generation.py b/backend/app/services/image_generation.py new file mode 100644 index 0000000..cc250ee --- /dev/null +++ b/backend/app/services/image_generation.py @@ -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": , + "succeeded": , + "failed": , + "skipped": , + "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, + } diff --git a/backend/app/services/planner/generate.py b/backend/app/services/planner/generate.py index 75050b6..de675f8 100644 --- a/backend/app/services/planner/generate.py +++ b/backend/app/services/planner/generate.py @@ -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]] = {} diff --git a/backend/app/utils/__init__.py b/backend/app/utils/__init__.py new file mode 100644 index 0000000..db3e327 --- /dev/null +++ b/backend/app/utils/__init__.py @@ -0,0 +1 @@ +# utils package diff --git a/backend/scripts/generate_images.py b/backend/scripts/generate_images.py new file mode 100644 index 0000000..eefb077 --- /dev/null +++ b/backend/scripts/generate_images.py @@ -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 +""" +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()) diff --git a/backend/static/images/caprese_pasta_679cb47d.png b/backend/static/images/caprese_pasta_679cb47d.png new file mode 100644 index 0000000..e7ec5c3 Binary files /dev/null and b/backend/static/images/caprese_pasta_679cb47d.png differ diff --git a/backend/static/images/creamy_tuscan_chicken_b5d6eefe.png b/backend/static/images/creamy_tuscan_chicken_b5d6eefe.png new file mode 100644 index 0000000..16350e7 Binary files /dev/null and b/backend/static/images/creamy_tuscan_chicken_b5d6eefe.png differ diff --git a/backend/static/images/loaded_veggie_quesadillas_86549750.png b/backend/static/images/loaded_veggie_quesadillas_86549750.png new file mode 100644 index 0000000..f673ba5 Binary files /dev/null and b/backend/static/images/loaded_veggie_quesadillas_86549750.png differ diff --git a/backend/static/images/sheet-pan_chicken_thighs_with_roasted_vegetables_ae4e5a4c.png b/backend/static/images/sheet-pan_chicken_thighs_with_roasted_vegetables_ae4e5a4c.png new file mode 100644 index 0000000..cdd49d5 Binary files /dev/null and b/backend/static/images/sheet-pan_chicken_thighs_with_roasted_vegetables_ae4e5a4c.png differ diff --git a/backend/static/images/zucchini_and_spinach_frittata_cfec0a7c.png b/backend/static/images/zucchini_and_spinach_frittata_cfec0a7c.png new file mode 100644 index 0000000..04ebb78 Binary files /dev/null and b/backend/static/images/zucchini_and_spinach_frittata_cfec0a7c.png differ diff --git a/docker-compose.yml b/docker-compose.yml index 58f7f69..0a0689e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,6 +19,8 @@ services: - SWIFTLY_API_BASE=${SWIFTLY_API_BASE:-https://prod.swiftlyapi.net} - SWIFTLY_CATEGORIES_URL=${SWIFTLY_CATEGORIES_URL:-https://luckysupermarkets.com/categories} - 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} - SECRET_KEY=${SECRET_KEY} - ADMIN_TOKEN=${ADMIN_TOKEN} @@ -35,6 +37,8 @@ services: interval: 30s timeout: 10s retries: 3 + volumes: + - ./backend/static:/app/static:rw scheduler: build: @@ -53,6 +57,8 @@ services: - SWIFTLY_API_BASE=${SWIFTLY_API_BASE:-https://prod.swiftlyapi.net} - SWIFTLY_CATEGORIES_URL=${SWIFTLY_CATEGORIES_URL:-https://luckysupermarkets.com/categories} - 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} - SECRET_KEY=${SECRET_KEY} - ADMIN_TOKEN=${ADMIN_TOKEN} @@ -64,6 +70,8 @@ services: db: condition: service_healthy restart: unless-stopped + volumes: + - ./backend/static:/app/static:rw frontend: build: @@ -98,6 +106,7 @@ services: volumes: - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro - ./nginx/ssl:/etc/nginx/ssl:ro + - ./backend/static:/app/static:ro depends_on: - frontend - backend diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..54850f3 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,3 @@ +node_modules +dist +.env* diff --git a/mealplanner_postgres_data.tar.gz b/mealplanner_postgres_data.tar.gz new file mode 100644 index 0000000..77099b5 Binary files /dev/null and b/mealplanner_postgres_data.tar.gz differ diff --git a/nginx/nginx.conf b/nginx/nginx.conf index d7a035c..49dfc01 100644 --- a/nginx/nginx.conf +++ b/nginx/nginx.conf @@ -21,6 +21,13 @@ http { 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 / { set $frontend_upstream http://frontend:80; proxy_pass $frontend_upstream;