Public Access
feat: feedback-driven recipe discovery (auto-ingest via Spoonacular)
This commit is contained in:
@@ -0,0 +1,226 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user