feat(api): R3-A recipe engine — search, tags, family scope, recommendations

- GET /api/recipes: added cuisine, protein, dietary, ingredient, max_time,
  spice_max, calorie_max query params
- GET /api/recipes?family_profile_id=… hides never-suggest blocklist recipes
- GET /api/recipes/recommended: returns feedback-driven recipe suggestions
- Update docs: remove completed open items from planner-algorithm.md
This commit is contained in:
2026-05-24 19:45:46 -07:00
parent ae32e650ce
commit c96b41ec26
3 changed files with 120 additions and 3 deletions
+117 -1
View File
@@ -6,10 +6,11 @@ from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from rapidfuzz import fuzz, process
from sqlalchemy import or_
from sqlalchemy.orm import Session
from app.database import get_db
from app.models import Ingredient, Recipe
from app.models import FamilyProfile, Ingredient, NeverSuggest, Recipe
from app.schemas.recipe import (
RecipeCreate,
RecipeRead,
@@ -19,6 +20,7 @@ from app.schemas.recipe import (
ResolveIngredientResponse,
)
from app.security import require_admin
from app.services.feedback_analyzer import FeedbackAnalyzer
public_router = APIRouter(prefix="/api/recipes", tags=["recipes"])
@@ -66,9 +68,84 @@ def _serialize(row: Recipe) -> dict:
}
@public_router.get("/recommended", response_model=List[RecipeRead])
def list_recommended_recipes(
family_profile_id: UUID = Query(...),
limit: int = Query(default=10, le=50),
db: Session = Depends(get_db),
):
from datetime import date
analyzer = FeedbackAnalyzer(lookback_weeks=4)
result = analyzer.analyze(db, family_profile_id, today=date.today())
# Collect IDs to exclude: never-suggest recipes
blocked = {
r[0]
for r in db.query(NeverSuggest.recipe_id)
.filter(
NeverSuggest.family_profile_id == family_profile_id,
NeverSuggest.recipe_id.isnot(None),
)
.all()
}
# Start from all recipes
query = db.query(Recipe).filter(~Recipe.id.in_(blocked)) if blocked else db.query(Recipe)
# Apply positive signal filters
pos_cuisines = {s.value for s in result.positive_signals if s.type == "prefer_cuisine"}
pos_proteins = {s.value for s in result.positive_signals if s.type == "prefer_protein"}
if pos_cuisines:
query = query.filter(
or_(*[
Recipe.cuisine_tags.overlap([c.lower()])
for c in pos_cuisines
])
)
if pos_proteins:
query = query.filter(
or_(*[
Recipe.protein_type.ilike(p)
for p in pos_proteins
])
)
# Always include top-rated recipes even if they don't match signals
rows = query.limit(limit * 2).all()
ids_seen = set()
ordered = []
# Push top-rated first
top_rated = result.top_rated_recipe_names or []
if top_rated:
top_rows = db.query(Recipe).filter(
Recipe.name.in_(top_rated),
(~Recipe.id.in_(blocked) if blocked else True),
).limit(limit).all()
for r in top_rows:
if r.id not in ids_seen:
ids_seen.add(r.id)
ordered.append(r)
# Fill with signal-matched recipes
for r in rows:
if r.id not in ids_seen and len(ordered) < limit:
ids_seen.add(r.id)
ordered.append(r)
return [_serialize(r) for r in ordered]
@public_router.get("", response_model=List[RecipeRead])
def list_recipes(
q: Optional[str] = Query(default=None),
cuisine: Optional[str] = Query(default=None),
protein: Optional[str] = Query(default=None),
dietary: Optional[str] = Query(default=None),
ingredient: Optional[str] = Query(default=None),
family_profile_id: Optional[UUID] = Query(default=None),
max_time: Optional[int] = Query(default=None, ge=0),
spice_max: Optional[int] = Query(default=None, ge=0, le=5),
calorie_max: Optional[int] = Query(default=None, ge=0),
limit: int = Query(default=100, le=500),
db: Session = Depends(get_db),
):
@@ -76,6 +153,45 @@ def list_recipes(
if q:
like = f"%{q.lower()}%"
query = query.filter(Recipe.name.ilike(like))
if cuisine:
query = query.filter(Recipe.cuisine_tags.overlap([cuisine.lower()]))
if protein:
query = query.filter(Recipe.protein_type.ilike(protein))
if dietary:
query = query.filter(Recipe.dietary_tags.overlap([dietary.lower()]))
if ingredient:
ing_ids = [
r[0] for r in db.query(Ingredient.id).filter(Ingredient.name.ilike(f"%{ingredient.lower()}%")).all()
]
if ing_ids:
query = query.filter(
or_(
*[
Recipe.ingredients.contains([{"ingredient_id": str(iid)}])
for iid in ing_ids
]
)
)
if max_time is not None:
query = query.filter(
(Recipe.prep_time_minutes + Recipe.cook_time_minutes) <= max_time
)
if spice_max is not None:
query = query.filter(Recipe.spice_level <= spice_max)
if calorie_max is not None:
query = query.filter(Recipe.calories_per_serving <= calorie_max)
if family_profile_id:
blocked = {
r[0]
for r in db.query(NeverSuggest.recipe_id)
.filter(
NeverSuggest.family_profile_id == family_profile_id,
NeverSuggest.recipe_id.isnot(None),
)
.all()
}
if blocked:
query = query.filter(~Recipe.id.in_(blocked))
rows = query.order_by(Recipe.name).limit(limit).all()
return [_serialize(r) for r in rows]