Public Access
One-line follow-up to Sprint 16. The _DAILY_LIMIT=140.0 in
recipe_search.py:48 was set assuming Spoonacular's free tier
was 150 pts/day. Sprint 15 round 1 (commit a3c89bf) hit the
real cap (50 pts/day) at query 28 — the 140 gate let
requests through to the upstream that Spoonacular then
402'd at, wasting user-facing time. Sprint 15 round 1
documented this as a follow-up ticket.
Fix: _DAILY_LIMIT = 45.0 (5pt safety margin under the real
50-pt free tier). Backend now 503s at the gate before
hitting the upstream roundtrip, giving the user a clear
"try again tomorrow" message instead of a 502 with
upstream detail.
Verified: docker compose up -d --build backend green.
GET /api/recipes/search?q=test&limit=1 returns 502
(Spoonacular 402 upstream — expected when at the cap).
The gate at 45 prevents the user from making a 47th
request that would 503 instead of 502.
No pre-existing WIP files touched. No new runtime
dependencies. No migration. Deploy: git pull +
docker compose up -d --build backend (no frontend
rebuild, no .env change).
309 lines
11 KiB
Python
309 lines
11 KiB
Python
"""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 = 45.0 # 50 free, leave 5pt safety margin (corrected from 140; Sprint 15 + Sprint 16)
|
|
_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),
|
|
}
|