diff --git a/backend/app/main.py b/backend/app/main.py index 7439af0..a90f5b7 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -35,12 +35,12 @@ def health_check_db(db: Session = Depends(get_db)): from app.api import profile, meals, shopping_list, pantry, admin, auth from app.api import ingredients as ingredients_api +from app.api import recipe_search as recipe_search_api from app.api import recipes as recipes_api from app.api import never_suggest as never_suggest_api from app.api import meal_plans as meal_plans_api from app.api import feedback as feedback_api from app.api import orchestrate as orchestrate_api -from app.api import recipe_search as recipe_search_api from app.api import llm_plan as llm_plan_api app.include_router(auth.router, prefix="/api/auth", tags=["auth"]) @@ -50,6 +50,11 @@ app.include_router(shopping_list.router, prefix="/api/shopping-list", tags=["sho app.include_router(pantry.router, prefix="/api/pantry", tags=["pantry"]) app.include_router(admin.router, prefix="/api/admin", tags=["admin"]) app.include_router(orchestrate_api.router, prefix="/api/orchestrate", tags=["orchestrate"]) +# Sprint 12: external recipe search (Spoonacular) + import. +# Mounted BEFORE recipes_api.public_router so /search and /import are +# not shadowed by the WIP's GET /{recipe_id} (which would 422 on a +# non-UUID "search" path segment). See sprint15-verification.md. +app.include_router(recipe_search_api.router, prefix="/api/recipes", tags=["recipes"]) app.include_router(ingredients_api.public_router) app.include_router(ingredients_api.admin_router) app.include_router(ingredients_api._match_admin_router) @@ -60,7 +65,5 @@ app.include_router(never_suggest_api.admin_router) app.include_router(meal_plans_api.admin_router) app.include_router(meal_plans_api.public_router) app.include_router(feedback_api.router, prefix="/api/feedback", tags=["feedback"]) -# Sprint 12: external recipe search (Spoonacular) + import. -app.include_router(recipe_search_api.router, prefix="/api/recipes", tags=["recipes"]) # Sprint 13: F9-lite — free-text meal-plan synthesis via Ollama Cloud. app.include_router(llm_plan_api.router, prefix="/api/llm", tags=["llm"]) diff --git a/scripts/seed_recipes.py b/scripts/seed_recipes.py new file mode 100755 index 0000000..8787327 --- /dev/null +++ b/scripts/seed_recipes.py @@ -0,0 +1,157 @@ +#!/usr/bin/env python3 +"""Sprint 15 — Seed ~50 family-friendly recipes from Spoonacular. + +User direction (2026-06-05): "Lets build out recipes for the coming +4 weeks in advance. In order to do this, lets add more recipes to +the list of available ones." + +The script hits Spoonacular's complexSearch directly (avoids the +Sprint 12 /api/recipes/search route, which would otherwise count +the search against the backend's daily quota). For each query, it +takes the top hit and POSTs to the local backend's +/api/recipes/import endpoint (which does the 1-pt /information +call, the idempotent ingredient upserts, and the Recipe insert). + +Idempotent: a 409 from the import endpoint means the recipe is +already in the local library; the script logs and continues. + +Cost: 50 × complexSearch (~1.10 pts each = 55 pts direct) + +50 × /information (1 pt each = 50 pts, charged to backend +counter) = ~105 Spoonacular pts total. Free tier is 150/day; +backend counter has a 140-pt safety cap (140/150). + +Run on the deploy host: + ssh docker-willester + cd /home/peter/MealPlanner + source .env && export $(cut -d= -f1 .env | xargs) # exports SPOONACULAR_API_KEY + python3 scripts/seed_recipes.py +""" +from __future__ import annotations + +import json +import logging +import os +import sys +import time +from typing import Optional + +import requests + +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") +log = logging.getLogger("seed_recipes") + +SPOONACULAR_KEY = os.environ.get("SPOONACULAR_API_KEY", "") +BACKEND_BASE = os.environ.get("MEALPLANNER_BACKEND", "http://localhost:8082") +SEARCH_URL = "https://api.spoonacular.com/recipes/complexSearch" +IMPORT_URL = f"{BACKEND_BASE}/api/recipes/import" +SLEEP_BETWEEN_QUERIES_S = 1.5 # 1 search + 1 import = ~2 pts / 1.5s = well under any rate limit + +QUERIES: list[str] = [ + # Italian (10) + "chicken parmesan", "spaghetti carbonara", "lasagna", "minestrone soup", + "pesto pasta", "chicken piccata", "mushroom risotto", "caprese salad", + "italian wedding soup", "eggplant parmesan", + # Mexican (10) + "chicken tacos", "beef enchiladas", "black bean burritos", "shrimp fajitas", + "chicken quesadilla", "taco salad", "sopa de tortilla", "carnitas", + "chicken tortilla soup", "huevos rancheros", + # Asian (10) + "chicken stir fry", "beef and broccoli", "pad thai", "fried rice", + "teriyaki salmon", "tofu curry", "chow mein", "spring rolls", + "pho", "kung pao chicken", + # American (10) + "chili", "meatloaf", "mac and cheese", "BBQ chicken", "pot roast", + "shepherd's pie", "chicken pot pie", "beef stew", "burgers", "pulled pork", + # Mediterranean / Middle Eastern (10) + "chicken shawarma", "falafel", "hummus bowl", "greek salad", "lamb kebabs", + "tabbouleh", "roasted vegetable wrap", "couscous", "stuffed peppers", "baked falafel", +] + + +def search_top_hit(query: str) -> Optional[dict]: + """Hit Spoonacular's complexSearch directly, return the top hit (or None).""" + try: + resp = requests.get( + SEARCH_URL, + params={ + "query": query, + "number": 1, + "addRecipeInformation": "true", + "instructionsRequired": "true", + "apiKey": SPOONACULAR_KEY, + }, + timeout=20, + ) + except requests.RequestException as exc: + log.warning("search request failed for %r: %s", query, exc) + return None + if resp.status_code == 402: + log.error("Spoonacular quota exhausted mid-run; stopping.") + sys.exit(1) + if resp.status_code != 200: + log.warning("search %r: HTTP %d %s", query, resp.status_code, resp.text[:200]) + return None + results = resp.json().get("results", []) + return results[0] if results else None + + +def import_recipe(external_id: str, query: str) -> str: + """POST to the backend's /api/recipes/import. Returns a status string.""" + try: + resp = requests.post( + IMPORT_URL, + json={"external_id": str(external_id), "external_source": "spoonacular"}, + timeout=30, + ) + except requests.RequestException as exc: + return f"request_error: {exc}" + if resp.status_code == 201: + body = resp.json() + return f"imported id={body.get('id')} name={body.get('name')!r}" + if resp.status_code == 409: + return "duplicate (already in library)" + if resp.status_code == 503: + return "quota_exhausted" + return f"HTTP {resp.status_code}: {resp.text[:200]}" + + +def main() -> int: + if not SPOONACULAR_KEY: + log.error("SPOONACULAR_API_KEY not set. export it from /home/peter/MealPlanner/.env first.") + return 2 + + log.info("Seeding %d recipes via %s", len(QUERIES), BACKEND_BASE) + + stats = {"imported": 0, "duplicate": 0, "no_hit": 0, "error": 0, "quota_exhausted": 0} + for i, q in enumerate(QUERIES, start=1): + hit = search_top_hit(q) + if not hit: + log.info("[%2d/%d] %-25r no hit", i, len(QUERIES), q) + stats["no_hit"] += 1 + else: + ext_id = hit["id"] + name = hit.get("title", "") + result = import_recipe(ext_id, q) + log.info("[%2d/%d] %-25r ext=%s %s name=%r", i, len(QUERIES), q, ext_id, result, name) + if result.startswith("imported"): + stats["imported"] += 1 + elif "duplicate" in result: + stats["duplicate"] += 1 + elif "quota" in result: + stats["quota_exhausted"] += 1 + return_summary_after = None + if "quota" in result: + log.error("Backend quota exhausted. Stop at %d/%d to leave room for re-runs.", i, len(QUERIES)) + stats["quota_exhausted"] += 1 + break + if result.startswith("HTTP") or result.startswith("request_error"): + stats["error"] += 1 + + time.sleep(SLEEP_BETWEEN_QUERIES_S) + + log.info("DONE. stats=%s", json.dumps(stats)) + return 0 + + +if __name__ == "__main__": + sys.exit(main())