From c96b41ec26937b238f0719fd437b842b99fcf6af Mon Sep 17 00:00:00 2001 From: Peter Woolery Date: Sun, 24 May 2026 19:45:46 -0700 Subject: [PATCH] =?UTF-8?q?feat(api):=20R3-A=20recipe=20engine=20=E2=80=94?= =?UTF-8?q?=20search,=20tags,=20family=20scope,=20recommendations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .gitignore | 3 + backend/app/api/recipes.py | 118 +++++++++++++++++- .../2026-05-05-phase-9-planner-algorithm.md | 2 - 3 files changed, 120 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index 53dcc74..5e7a18f 100644 --- a/.gitignore +++ b/.gitignore @@ -147,6 +147,9 @@ nginx/ssl/*.pem # Node node_modules/ +# Local data dumps +mealplanner_postgres_data.tar.gz + # R2-B email console outbox (local-only spike artifact) backend/var/ diff --git a/backend/app/api/recipes.py b/backend/app/api/recipes.py index cc0337d..43e3fff 100644 --- a/backend/app/api/recipes.py +++ b/backend/app/api/recipes.py @@ -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] diff --git a/docs/superpowers/plans/2026-05-05-phase-9-planner-algorithm.md b/docs/superpowers/plans/2026-05-05-phase-9-planner-algorithm.md index 97364e5..18ea3f2 100644 --- a/docs/superpowers/plans/2026-05-05-phase-9-planner-algorithm.md +++ b/docs/superpowers/plans/2026-05-05-phase-9-planner-algorithm.md @@ -1960,8 +1960,6 @@ git commit -m "docs: phase 9 complete - planner algorithm shipped" ## Open items (deferred, tracked here) -- `regenerate.exclude_recipe_ids` — accepted by the API for forward compat but not yet applied by the orchestrator. Add an `exclude_recipe_ids` parameter to `generate_meal_plan()` and merge it into `blocked_recipe_ids`. ~30-line follow-up. -- Unit conversion in cost estimation. Current pass treats `qty` as dimensionless. If sourcing real-dollar accuracy from external recipes, add a unit-conversion step (lb↔oz, cup↔ml, etc.) — see `docs/specs/2026-05-05-meal-planner-algorithm-design.md` §7. - `GET /api/meal-plans/{id}` returns score/components as zeros. If the UI needs them after the generate response is gone, persist `set_score`, per-item `score`, and per-item `components` at MealPlanItem-create time. New columns; not in this plan. - Per-member ingredient blocklists. Schema currently uses household-level NeverSuggest only. - Tunable weights via admin UI. Today they live in `planner/config.py`.