From 97b84a7cd8048b387586deb60c531f0775c99b2a Mon Sep 17 00:00:00 2001 From: Peter Woolery Date: Sun, 7 Jun 2026 16:20:20 -0700 Subject: [PATCH] =?UTF-8?q?feat(scripts):=20Sprint=2015=20round=202=20?= =?UTF-8?q?=E2=80=94=20seed=5Frecipes=5Fround2.py=20(50=20gap-filling=20qu?= =?UTF-8?q?eries)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 1 (commit a3c89bf) imported 18 recipes before hitting the 50-pt/day Spoonacular free-tier cap. User direction (2026-06-07): 'please add more meals to the potential list' / 'Pull in more recipes so we have a larger sample to generate from.' This is a fresh-quota run with a different query list focused on cuisines and meal types the round 1 list didn't cover: Indian (8) + Thai (6) + Chinese regional (6) + Soups & stews (6) + Salads (6) + Sandwiches/wraps (5) + Breakfast (5) + German/European (4) + French (4) = 50 queries. Same shape as scripts/seed_recipes.py: hits Spoonacular's complexSearch directly (avoids the broken backend route and the backend's quota counter), POSTs top hits to the backend's /api/recipes/import. Idempotent (409 on duplicate), 1.5 sec sleep, stops on 402. Result: 18 imported today. 12 queries returned no hits from Spoonacular's free-tier index (e.g. 'chana masala', 'thai basil chicken', 'dan dan noodles' — these are absent or premium-only). 1 query ('wedge salad') hit 402 mid-import. DB went 49 -> 67 total recipes (37 Spoonacular + 30 manual). LLM test (Sprint 13 endpoint, week 2026-07-20, prompt 'variety, mix of cuisines, family-friendly, no repeats'): {picked_count: 0, filled_count: 21, failed_count: 0} The library now covers all 21 slots of a week (was 19/21 + 2 failed in round 1). 4 weeks of planning now has a real library to pick from with 1.25x rotation. Re-running scripts/seed_recipes.py (round 1) tomorrow will add 30+ more — its query list has gaps the round 1 cap didn't reach (American + Mediterranean cuisines). --- scripts/seed_recipes_round2.py | 154 +++++++++++++++++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100755 scripts/seed_recipes_round2.py diff --git a/scripts/seed_recipes_round2.py b/scripts/seed_recipes_round2.py new file mode 100755 index 0000000..9bee9c7 --- /dev/null +++ b/scripts/seed_recipes_round2.py @@ -0,0 +1,154 @@ +#!/usr/bin/env python3 +"""Sprint 15 — Round 2: Seed 50 more family-friendly recipes. + +User direction (2026-06-06): "please add more meals to the potential +list" / "Pull in more recipes so we have a larger sample to generate +from." + +This is a follow-up to scripts/seed_recipes.py (round 1) which +imported 18 recipes before hitting the 50-pt/day free-tier cap. This +round focuses on cuisines and meal types the round 1 list didn't +cover: Indian, Thai, Chinese regional, soups/stews, salads, +sandwiches/wraps, breakfast, German/European, French. + +Same idempotent behavior as round 1: 409 from /api/recipes/import +means already imported; the script logs and continues. + +Cost: 50 × complexSearch (1.10 pts) + 50 × /information (1 pt) = +105 pts, ~2 days on free tier. Today: run until the cap hits. + +Run on the deploy host: + ssh docker-willester + cd /home/peter/MealPlanner + set -a && source .env && set +a + python3 scripts/seed_recipes_round2.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_round2") + +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 + +QUERIES: list[str] = [ + # Indian (8) + "chicken tikka masala", "butter chicken", "palak paneer", "chana masala", + "biryani", "dal", "samosa", "naan", + # Thai (6) + "green curry", "massaman curry", "tom yum soup", "mango sticky rice", + "papaya salad", "thai basil chicken", + # Chinese regional (6) + "mapo tofu", "hot and sour soup", "scallion pancakes", "soup dumplings", + "beef noodle soup", "dan dan noodles", + # Soups & stews (6) + "french onion soup", "clam chowder", "chicken noodle soup", "tomato soup", + "lentil soup", "butternut squash soup", + # Salads (6) + "caesar salad", "cobb salad", "nicoise salad", "wedge salad", + "pasta salad", "quinoa salad", + # Sandwiches / wraps (5) + "banh mi", "reuben sandwich", "club sandwich", "french dip", "gyro wrap", + # Breakfast (5) + "eggs benedict", "pancakes", "french toast", "omelette", "breakfast burrito", + # German / European (4) + "schnitzel", "spaetzle", "sauerbraten", "beef rouladen", + # French (4) + "coq au vin", "ratatouille", "beef bourguignon", "quiche lorraine", +] + + +def search_top_hit(query: str) -> Optional[dict]: + 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) -> str: + 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 (round 2) 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) + 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 + log.error("Backend quota exhausted. Stop at %d/%d.", i, len(QUERIES)) + break + elif 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())