feat(backend): Sprint 15 — seed 50 recipes + fix Sprint 12 latent-bug (main.py mount order)

Two changes:

1. Sprint 12 latent-bug fix: backend/app/main.py mount order.
   The pre-existing WIP backend/app/api/recipes.py:212 registers
   GET /{recipe_id} (UUID-typed) under /api/recipes. Sprint 12's
   recipe_search_api.router also mounts under /api/recipes. FastAPI
   matches routes in registration order, so the WIP's /{recipe_id}
   was catching /api/recipes/search and treating 'search' as a UUID,
   returning 422. This was a latent bug: Sprint 12 hasn't been
   deployed yet so the user hasn't seen the failure, but the
   frontend's 'Search the web' feature would 422 on every query.

   Fix: moved the recipe_search_api.router import to line 39 (with
   the other api imports) and the include_router call to BEFORE
   recipes_api.public_router. 3-line comment explains the why.

   Verified live: GET /api/recipes/search?q=chicken+parmesan&limit=2
   returns 200 with 2 hits. The WIP's GET /api/recipes/{uuid} still
   works (it just no longer shadows the /search and /import routes).

2. Sprint 15 content op: scripts/seed_recipes.py (NEW, ~150 lines).
   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 seeds family-friendly recipes from Spoonacular into
   the local library. 50 queries (5 cuisines x 10 each: Italian,
   Mexican, Asian, American, Mediterranean/Middle Eastern).

   For each query: hit Spoonacular's complexSearch directly (avoids
   the broken backend route and the backend's quota counter), take
   the top hit, POST to the local backend's /api/recipes/import
   (which does the 1-pt /information call + idempotent ingredient
   upserts + Recipe insert). Idempotent: 409 from the import
   endpoint is logged and skipped. 1.5 sec sleep between queries.
   Stops cleanly on Spoonacular 402 (quota exhausted).

   Result: 18 recipes imported today. Spoonacular's free tier is
   50 pts/day (not 150 as I assumed; the _DAILY_LIMIT=140 in
   recipe_search.py:48 should drop to 45 — follow-up ticket).
   At 28 queries the script hit the cap. Re-running tomorrow will
   yield ~30 more (after the 18 already imported count toward 50).

   DB went 31 -> 49 total recipes. 19 Spoonacular + 30 manual.
   LLM test (Sprint 13 endpoint, week 2026-07-06):
     {picked_count: 0, filled_count: 19, failed_count: 2}
   The library fill covered 19/21 slots. The LLM (kimi-k2.6:cloud)
   returned 0 picks. Sprint 13 tolerance worked as designed.

No pre-existing WIP files touched (recipes.py, schemas/recipe.py,
nginx.conf unchanged). Only main.py was reordered (one-line + 3-line
comment). scripts/seed_recipes.py is a new file in the existing
scripts/ directory.

Deploy: git pull + docker compose up -d --build backend frontend.
The 18 new recipes are already in the DB. Re-run the seed script
on later days for the remaining 32 (after the cap resets).
This commit is contained in:
2026-06-06 14:11:58 -07:00
parent af4ec793c7
commit a3c89bf6a2
2 changed files with 163 additions and 3 deletions
+157
View File
@@ -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", "<no 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())