"""Sprint 12 — external recipe search (Spoonacular) + import. This is a thin HTTP layer on top of the pre-existing Spoonacular free-tier API. The service-class `RecipeDiscoveryService` in `app.services.recipe_discovery` is the bulk-orchestrator used by the offline FeedbackAnalyzer; we re-implement the call shape here because the webui wants: 1. A search that returns *summary* data (no info endpoint call) so 10 results cost 1.1 points, not 11.1. 2. An import that fetches the full info for ONE recipe and writes a local Recipe row. Quota: Spoonacular free tier = 150 points/day. complexSearch = 1 + 0.01 per result. /information = 1 point. We gate at 140 to leave a safety margin and return 503 once exhausted. """ from __future__ import annotations import logging import threading from typing import List, Optional import requests from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy.orm import Session from app.config import settings from app.database import get_db from app.models import FamilyProfile, Ingredient, Recipe from app.schemas import ( RecipeImportRequest, RecipeSearchHit, ) from app.security import require_session logger = logging.getLogger(__name__) router = APIRouter() # Quota counter: a per-process point budget. Survives across # requests in the same uvicorn worker. Process restart resets to 0 # (so the operator can recover by bouncing the backend). Thread-safe # with a single lock — the counter is touched on every request and # we don't want a race between two parallel searches. _quota_lock = threading.Lock() _points_used: float = 0.0 _DAILY_LIMIT: float = 140.0 # 150 free, leave 10pt safety margin _INFO_URL = "https://api.spoonacular.com/recipes/{id}/information" _SEARCH_URL = "https://api.spoonacular.com/recipes/complexSearch" def _points_available() -> float: with _quota_lock: return _DAILY_LIMIT - _points_used def _charge_points(pts: float) -> None: global _points_used with _quota_lock: _points_used += pts def _ensure_spoonacular_configured() -> str: """Return the API key, or 503 if unconfigured.""" key = settings.SPOONACULAR_API_KEY if not key: logger.error("SPOONACULAR_API_KEY not configured") raise HTTPException( status_code=503, detail="SPOONACULAR_API_KEY not configured; set it in the backend env", ) return key def _normalize_spoonacular_summary(item: dict) -> Optional[RecipeSearchHit]: """Map a complexSearch result item into RecipeSearchHit.""" ext_id = str(item.get("id") or "") title = item.get("title") if not ext_id or not title: return None cuisines = [str(c).lower() for c in (item.get("cuisines") or []) if c] diets = [str(d).lower() for d in (item.get("diets") or []) if d] return RecipeSearchHit( external_id=ext_id, external_source="spoonacular", name=title, image_url=item.get("image"), source_url=item.get("sourceUrl") or item.get("spoonacularSourceUrl"), prep_time_minutes=int(item["preparationMinutes"]) if item.get("preparationMinutes") else None, cook_time_minutes=int(item["cookingMinutes"]) if item.get("cookingMinutes") else None, servings=int(item["servings"]) if item.get("servings") else None, cuisine_tags=cuisines, dietary_tags=diets, protein_type=None, # would need to call /information or _infer_protein ) @router.get("/search", response_model=List[RecipeSearchHit]) def search_recipes( q: str = Query(..., min_length=1, max_length=200), limit: int = Query(10, ge=1, le=25), _user: str = Depends(require_session), ) -> List[RecipeSearchHit]: """Search Spoonacular's complexSearch index and return a summary list (no per-recipe /information call). The frontend uses this for the "Search the web" panel.""" if _points_available() < 1.05: # 1 base + 0.01 * 5 average raise HTTPException( status_code=503, detail="spoonacular daily quota reached; try again tomorrow", ) api_key = _ensure_spoonacular_configured() try: resp = requests.get( _SEARCH_URL, params={ "apiKey": api_key, "query": q, "number": limit, "addRecipeInformation": "true", "fillIngredients": "true", "instructionsRequired": "true", }, timeout=15, ) resp.raise_for_status() except requests.RequestException as exc: logger.warning("Spoonacular search failed for %r: %s", q, exc) raise HTTPException( status_code=502, detail=f"spoonacular search failed: {exc}", ) from exc data = resp.json() results = data.get("results", []) # 1 base + 0.01 per result cost = 1.0 + len(results) * 0.01 _charge_points(cost) logger.info("Spoonacular search %r → %d hits (%.2f pts, total %.1f/%.0f)", q, len(results), cost, _points_used, _DAILY_LIMIT) out: List[RecipeSearchHit] = [] for item in results: hit = _normalize_spoonacular_summary(item) if hit: out.append(hit) return out def _infer_protein_simple(title: str, ingredient_names: List[str]) -> Optional[str]: """Lightweight protein inference; mirrors recipe_discovery._infer_protein but doesn't import the service (to avoid pulling in the rest of the offline path).""" text = (title + " " + " ".join(ingredient_names)).lower() 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, kws in proteins.items(): for kw in kws: if kw in text: return ptype return None def _upsert_ingredient(db: Session, name: str) -> Ingredient: """Idempotent insert by name_lower. Mirrors the public POST /api/ingredients logic without the HTTP roundtrip.""" name_lower = name.lower() existing = db.query(Ingredient).filter(Ingredient.name_lower == name_lower).first() if existing: return existing row = Ingredient(name=name, name_lower=name_lower, aliases=[]) db.add(row) try: db.commit() db.refresh(row) except Exception: db.rollback() existing = db.query(Ingredient).filter(Ingredient.name_lower == name_lower).first() if existing: return existing raise return row @router.post("/import", response_model=dict, status_code=201) def import_recipe( payload: RecipeImportRequest, db: Session = Depends(get_db), _user: str = Depends(require_session), ) -> dict: """Import a Spoonacular recipe into the local library. One /information call (1 point) + ingredient upserts + Recipe insert. Returns the new recipe id.""" if payload.external_source != "spoonacular": raise HTTPException(status_code=400, detail=f"unknown external_source: {payload.external_source}") if _points_available() < 1.05: raise HTTPException(status_code=503, detail="spoonacular daily quota reached; try again tomorrow") api_key = _ensure_spoonacular_configured() # 1) Reject duplicates existing = ( db.query(Recipe) .filter(Recipe.external_source == "spoonacular", Recipe.external_id == payload.external_id) .first() ) if existing: raise HTTPException(status_code=409, detail=f"recipe already imported: {existing.id}") # 2) Fetch full info try: resp = requests.get( _INFO_URL.format(id=payload.external_id), params={"apiKey": api_key, "includeNutrition": "false"}, timeout=15, ) resp.raise_for_status() except requests.RequestException as exc: logger.warning("Spoonacular /information failed for %s: %s", payload.external_id, exc) raise HTTPException(status_code=502, detail=f"spoonacular info failed: {exc}") from exc full = resp.json() _charge_points(1.0) logger.info("Spoonacular import %s (1 pt, total %.1f/%.0f)", payload.external_id, _points_used, _DAILY_LIMIT) # 3) Resolve ingredients (upsert) ingredient_names: List[str] = [] ingredients_json: List[dict] = [] for ing in full.get("extendedIngredients", []): name = (ing.get("name") or ing.get("originalName") or "").strip() if not name: continue ingredient_names.append(name) local = _upsert_ingredient(db, name) qty = ing.get("amount") ingredients_json.append({ "ingredient_id": str(local.id), "name": local.name, "qty": float(qty) if qty is not None else 1.0, "unit": ing.get("unit", "") or None, "notes": None, }) # 4) Extract instructions instructions: List[str] = [] analyzed = full.get("analyzedInstructions", []) if analyzed: for step in analyzed[0].get("steps", []): txt = step.get("step", "") if txt: instructions.append(txt) if not instructions: raw = full.get("instructions", "") if raw: instructions = [raw] # 5) Build the Recipe title = full.get("title") or "Untitled" cuisines = [str(c).lower() for c in (full.get("cuisines") or []) if c] diets = [str(d).lower() for d in (full.get("diets") or []) if d] # 6) Resolve family_profile_id (require_session auto-fills first profile) profile = db.query(FamilyProfile).first() if not profile: raise HTTPException(status_code=404, detail="family profile not found") row = Recipe( family_profile_id=profile.id, name=title, description=full.get("summary"), image_url=full.get("image"), image_source="spoonacular", prep_time_minutes=int(full["preparationMinutes"]) if full.get("preparationMinutes") else None, cook_time_minutes=int(full["cookingMinutes"]) if full.get("cookingMinutes") else None, servings=int(full.get("servings", 4)), cuisine_tags=cuisines, dietary_tags=diets, protein_type=_infer_protein_simple(title, ingredient_names), ingredients=ingredients_json, side_dishes=[], instructions=instructions or ["See source for instructions."], source_url=full.get("sourceUrl") or full.get("spoonacularSourceUrl"), scraped_at=None, is_manually_added=True, external_source="spoonacular", external_id=payload.external_id, discovery_reason="user imported via webui", ) db.add(row) db.commit() db.refresh(row) return { "id": str(row.id), "name": row.name, "external_id": row.external_id, "ingredients_imported": len(ingredients_json), }