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
+3
View File
@@ -147,6 +147,9 @@ nginx/ssl/*.pem
# Node # Node
node_modules/ node_modules/
# Local data dumps
mealplanner_postgres_data.tar.gz
# R2-B email console outbox (local-only spike artifact) # R2-B email console outbox (local-only spike artifact)
backend/var/ backend/var/
+117 -1
View File
@@ -6,10 +6,11 @@ from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from rapidfuzz import fuzz, process from rapidfuzz import fuzz, process
from sqlalchemy import or_
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.database import get_db 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 ( from app.schemas.recipe import (
RecipeCreate, RecipeCreate,
RecipeRead, RecipeRead,
@@ -19,6 +20,7 @@ from app.schemas.recipe import (
ResolveIngredientResponse, ResolveIngredientResponse,
) )
from app.security import require_admin from app.security import require_admin
from app.services.feedback_analyzer import FeedbackAnalyzer
public_router = APIRouter(prefix="/api/recipes", tags=["recipes"]) 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]) @public_router.get("", response_model=List[RecipeRead])
def list_recipes( def list_recipes(
q: Optional[str] = Query(default=None), 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), limit: int = Query(default=100, le=500),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
@@ -76,6 +153,45 @@ def list_recipes(
if q: if q:
like = f"%{q.lower()}%" like = f"%{q.lower()}%"
query = query.filter(Recipe.name.ilike(like)) 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() rows = query.order_by(Recipe.name).limit(limit).all()
return [_serialize(r) for r in rows] return [_serialize(r) for r in rows]
@@ -1960,8 +1960,6 @@ git commit -m "docs: phase 9 complete - planner algorithm shipped"
## Open items (deferred, tracked here) ## 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. - `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. - Per-member ingredient blocklists. Schema currently uses household-level NeverSuggest only.
- Tunable weights via admin UI. Today they live in `planner/config.py`. - Tunable weights via admin UI. Today they live in `planner/config.py`.