"""Recipe Discovery — queries external APIs (Spoonacular, TheMealDB) for recipes. Takes discovery queries from FeedbackAnalyzer and fetches normalized recipe candidates. """ from __future__ import annotations import logging import time from dataclasses import dataclass from decimal import Decimal from typing import List, Optional, Any import requests from app.config import settings logger = logging.getLogger(__name__) _SPOONACULAR_SEARCH_URL = "https://api.spoonacular.com/recipes/complexSearch" _SPOONACULAR_INFO_URL = "https://api.spoonacular.com/recipes/{id}/information" _THEMEALDB_SEARCH_URL = "https://www.themealdb.com/api/json/v1/1/search.php" _RATE_LIMIT_SECS = 1.0 # polite gap between calls _MAX_RESULTS_PER_QUERY = 5 # cap to stay within free quota @dataclass class ExternalRecipe: name: str external_source: str external_id: str image_url: Optional[str] description: Optional[str] prep_time_minutes: Optional[int] cook_time_minutes: Optional[int] servings: int cuisine_tags: List[str] dietary_tags: List[str] protein_type: Optional[str] calories_per_serving: Optional[int] ingredients: List[dict] # [{"name": str, "qty": float, "unit": str}] instructions: List[str] source_url: Optional[str] class RecipeDiscoveryService: """Fetch recipes from external sources.""" def __init__(self) -> None: self.api_key = getattr(settings, "SPOONACULAR_API_KEY", "") self.enabled = bool(self.api_key) self._points_used = 0 def discover(self, queries: List[str]) -> List[ExternalRecipe]: """Run all discovery queries and return unique recipes.""" if not self.enabled: logger.warning("RecipeDiscovery: SPOONACULAR_API_KEY not set — skipping") return [] all_recipes: List[ExternalRecipe] = [] seen_ids: set[str] = set() for query in queries: if self._points_used >= 140: # stay under 150/day free tier logger.warning("RecipeDiscovery: quota near limit (%d/150), stopping", self._points_used) break recipes = self._search_spoonacular(query) for r in recipes: key = f"{r.external_source}:{r.external_id}" if key not in seen_ids: seen_ids.add(key) all_recipes.append(r) time.sleep(_RATE_LIMIT_SECS) logger.info("RecipeDiscovery: %d unique recipes from %d queries", len(all_recipes), len(queries)) return all_recipes def _search_spoonacular(self, query: str) -> List[ExternalRecipe]: """Search Spoonacular and return normalized recipes.""" params = { "apiKey": self.api_key, "query": query, "number": _MAX_RESULTS_PER_QUERY, "addRecipeInformation": "true", "fillIngredients": "true", "instructionsRequired": "true", } try: resp = requests.get(_SPOONACULAR_SEARCH_URL, params=params, timeout=30) resp.raise_for_status() except requests.RequestException as exc: logger.warning("Spoonacular search failed for %r: %s", query, exc) return [] data = resp.json() results = data.get("results", []) total = data.get("totalResults", 0) # complexSearch = 1 point + 0.01 per result self._points_used += 1 + len(results) * 0.01 logger.info("Spoonacular: %r → %d/%d results", query, len(results), total) out = [] for item in results: ext_id = str(item.get("id")) if not ext_id: continue # Try to get full info for ingredients + instructions full = self._fetch_recipe_info(ext_id) if full: normalized = self._normalize_spoonacular(item, full) if normalized: out.append(normalized) time.sleep(0.5) # between info calls else: # Fallback: info endpoint failed, use search summary only normalized = self._normalize_spoonacular(item, item) if normalized: out.append(normalized) return out def _fetch_recipe_info(self, recipe_id: str) -> dict | None: """Fetch detailed recipe info from Spoonacular.""" url = _SPOONACULAR_INFO_URL.format(id=recipe_id) params = { "apiKey": self.api_key, "includeNutrition": "false", } try: resp = requests.get(url, params=params, timeout=30) resp.raise_for_status() except requests.RequestException as exc: logger.warning("Spoonacular info failed for %s: %s", recipe_id, exc) return None # info endpoint = 1 point self._points_used += 1 return resp.json() def _normalize_spoonacular(self, summary: dict, full: dict) -> ExternalRecipe | None: """Convert Spoonacular response into our ExternalRecipe dataclass.""" title = summary.get("title") or full.get("title") if not title: return None # Ingredients from full info ingredients = [] for ing in full.get("extendedIngredients", []): qty = ing.get("amount") unit = ing.get("unit", "") name = ing.get("name", "") if qty is not None and name: ingredients.append({"name": name, "qty": float(qty), "unit": unit}) # Instructions instructions = [] analyzed = full.get("analyzedInstructions", []) if analyzed: for step in analyzed[0].get("steps", []): instructions.append(step.get("step", "")) else: raw = full.get("instructions", "") if raw: instructions = [raw] # single blob # Cuisines + diets cuisines = [c.lower() for c in (summary.get("cuisines") or full.get("cuisines", [])) if c] diets = [d.lower() for d in (summary.get("diets") or full.get("diets", [])) if d] # Protein type inference from ingredient names or summary tags protein = _infer_protein(food=full) # Times prep = full.get("preparationMinutes") cook = full.get("cookingMinutes") if prep is None and "readyInMinutes" in full: prep = full["readyInMinutes"] # use total as proxy return ExternalRecipe( name=title, external_source="spoonacular", external_id=str(summary.get("id") or full.get("id")), image_url=summary.get("image") or full.get("image"), description=full.get("summary"), # HTML summary; caller strips tags prep_time_minutes=int(prep) if prep else None, cook_time_minutes=int(cook) if cook else None, servings=int(full.get("servings", 4)), cuisine_tags=cuisines, dietary_tags=diets, protein_type=protein, calories_per_serving=None, # would require nutrition endpoint ingredients=ingredients, instructions=instructions or ["See source for instructions."], source_url=full.get("sourceUrl") or full.get("spoonacularSourceUrl"), ) def _infer_protein(food: dict) -> Optional[str]: """Infer protein_type from recipe data.""" title = (food.get("title") or "").lower() ings = " ".join( i.get("name", "").lower() for i in food.get("extendedIngredients", []) ) proteins = { "chicken": ["chicken"], "beef": ["beef", "steak", "ground beef"], "pork": ["pork", "bacon", "ham"], "fish": ["salmon", "tilapia", "cod", "fish fillet"], "shrimp": ["shrimp", "prawn"], "turkey": ["turkey"], "lamb": ["lamb"], "vegetarian": ["tofu", "tempeh", "vegetarian"], } for ptype, keywords in proteins.items(): for kw in keywords: if kw in title or kw in ings: return ptype return None