Public Access
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:
@@ -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
@@ -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`.
|
||||||
|
|||||||
Reference in New Issue
Block a user