Public Access
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:
+6
-3
@@ -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"])
|
||||
|
||||
Executable
+157
@@ -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())
|
||||
Reference in New Issue
Block a user