Public Access
- Add SideDish/SideDishIngredient schemas and recipe.side_dishes JSONB column
- Add recipe_enrichment.py service using Ollama LLM to:
- Rewrite vague instructions with specific temps, quantities, timing, sauce breakdowns
- Suggest 1-2 complementary side dishes with ingredients & prep notes
- Wire enrichment into recipe_ingestion.py discovery pipeline
- Add admin trigger endpoint /api/recipes/{id}/enrich for on-demand enrichment
- Migration 0014: Add side_dishes JSONB to recipe table
- Fix schemas/__init__.py imports: restore RecipeBase/Create/Read exports, add datetime/date for PydanticOptional compatibility
- Deployed to docker-willester and migrated to alembic 0014
341 lines
11 KiB
Python
341 lines
11 KiB
Python
from __future__ import annotations
|
|
|
|
import re
|
|
from typing import List, Optional
|
|
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 FamilyProfile, Ingredient, NeverSuggest, Recipe
|
|
from app.schemas.recipe import (
|
|
RecipeCreate,
|
|
RecipeRead,
|
|
RecipeUpdate,
|
|
ResolveIngredientCandidate,
|
|
ResolveIngredientRequest,
|
|
ResolveIngredientResponse,
|
|
)
|
|
from app.security import require_admin
|
|
from app.services.feedback_analyzer import FeedbackAnalyzer
|
|
|
|
|
|
public_router = APIRouter(prefix="/api/recipes", tags=["recipes"])
|
|
admin_router = APIRouter(
|
|
prefix="/api/admin/recipes",
|
|
tags=["recipes-admin"],
|
|
dependencies=[Depends(require_admin)],
|
|
)
|
|
|
|
|
|
def _validate_ingredient_ids(db: Session, ingredient_refs) -> None:
|
|
ids = [ref.ingredient_id for ref in ingredient_refs]
|
|
found = (
|
|
db.query(Ingredient.id).filter(Ingredient.id.in_(ids)).all()
|
|
)
|
|
found_ids = {row[0] for row in found}
|
|
missing = [str(i) for i in ids if i not in found_ids]
|
|
if missing:
|
|
raise HTTPException(
|
|
status_code=422,
|
|
detail={"error": "unknown ingredient_ids", "missing": missing},
|
|
)
|
|
|
|
|
|
def _serialize(row: Recipe) -> dict:
|
|
return {
|
|
"id": row.id,
|
|
"name": row.name,
|
|
"description": row.description,
|
|
"image_url": row.image_url,
|
|
"prep_time_minutes": row.prep_time_minutes or 0,
|
|
"cook_time_minutes": row.cook_time_minutes or 0,
|
|
"servings": row.servings,
|
|
"cuisine_tags": list(row.cuisine_tags or []),
|
|
"dietary_tags": list(row.dietary_tags or []),
|
|
"protein_type": row.protein_type,
|
|
"spice_level": row.spice_level,
|
|
"calories_per_serving": row.calories_per_serving,
|
|
"ingredients": row.ingredients or [],
|
|
"side_dishes": row.side_dishes or [],
|
|
"instructions": list(row.instructions or []),
|
|
"source_url": row.source_url,
|
|
"external_source": row.external_source,
|
|
"external_id": row.external_id,
|
|
"discovery_reason": row.discovery_reason,
|
|
}
|
|
|
|
|
|
@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),
|
|
):
|
|
query = db.query(Recipe)
|
|
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]
|
|
|
|
|
|
@public_router.get("/{recipe_id}", response_model=RecipeRead)
|
|
def get_recipe(recipe_id: UUID, db: Session = Depends(get_db)):
|
|
row = db.query(Recipe).filter(Recipe.id == recipe_id).first()
|
|
if row is None:
|
|
raise HTTPException(status_code=404, detail="recipe not found")
|
|
return _serialize(row)
|
|
|
|
|
|
@admin_router.post("", response_model=RecipeRead, status_code=status.HTTP_201_CREATED)
|
|
def create_recipe(payload: RecipeCreate, db: Session = Depends(get_db)):
|
|
_validate_ingredient_ids(db, payload.ingredients)
|
|
row = Recipe(
|
|
name=payload.name,
|
|
description=payload.description,
|
|
image_url=payload.image_url,
|
|
prep_time_minutes=payload.prep_time_minutes,
|
|
cook_time_minutes=payload.cook_time_minutes,
|
|
servings=payload.servings,
|
|
cuisine_tags=payload.cuisine_tags,
|
|
dietary_tags=payload.dietary_tags,
|
|
protein_type=payload.protein_type,
|
|
spice_level=payload.spice_level,
|
|
calories_per_serving=payload.calories_per_serving,
|
|
ingredients=[ref.model_dump(mode="json") for ref in payload.ingredients],
|
|
instructions=payload.instructions,
|
|
source_url=payload.source_url,
|
|
is_manually_added=True,
|
|
)
|
|
db.add(row)
|
|
db.commit()
|
|
db.refresh(row)
|
|
return _serialize(row)
|
|
|
|
|
|
@admin_router.patch("/{recipe_id}", response_model=RecipeRead)
|
|
def update_recipe(recipe_id: UUID, payload: RecipeUpdate, db: Session = Depends(get_db)):
|
|
row = db.query(Recipe).filter(Recipe.id == recipe_id).first()
|
|
if row is None:
|
|
raise HTTPException(status_code=404, detail="recipe not found")
|
|
data = payload.model_dump(exclude_unset=True)
|
|
if "ingredients" in data and data["ingredients"] is not None:
|
|
_validate_ingredient_ids(db, payload.ingredients)
|
|
row.ingredients = [ref.model_dump(mode="json") for ref in payload.ingredients]
|
|
data.pop("ingredients")
|
|
for field, value in data.items():
|
|
setattr(row, field, value)
|
|
db.commit()
|
|
db.refresh(row)
|
|
return _serialize(row)
|
|
|
|
|
|
@admin_router.delete("/{recipe_id}", status_code=status.HTTP_204_NO_CONTENT, response_class=Response)
|
|
def delete_recipe(recipe_id: UUID, db: Session = Depends(get_db)):
|
|
row = db.query(Recipe).filter(Recipe.id == recipe_id).first()
|
|
if row is None:
|
|
raise HTTPException(status_code=404, detail="recipe not found")
|
|
db.delete(row)
|
|
db.commit()
|
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
|
|
|
|
|
_KNOWN_UNITS = {
|
|
"tsp", "tbsp", "cup", "cups", "oz", "ounce", "ounces",
|
|
"lb", "lbs", "pound", "pounds", "g", "kg", "ml", "l",
|
|
"clove", "cloves", "pinch", "dash", "ea", "each",
|
|
}
|
|
|
|
_QTY_UNIT_RE = re.compile(
|
|
r"^\s*(?P<qty>\d+(?:\.\d+)?(?:/\d+)?)\s*(?P<unit>[a-zA-Z]+)?\s+(?P<rest>.+)$"
|
|
)
|
|
|
|
|
|
def _parse_qty_unit(text: str) -> tuple[Optional[float], Optional[str], str]:
|
|
m = _QTY_UNIT_RE.match(text)
|
|
if not m:
|
|
return None, None, text.strip()
|
|
qty_raw = m.group("qty")
|
|
if "/" in qty_raw:
|
|
num, denom = qty_raw.split("/")
|
|
qty = float(num) / float(denom)
|
|
else:
|
|
qty = float(qty_raw)
|
|
unit = m.group("unit")
|
|
rest = m.group("rest").strip()
|
|
if unit and unit.lower() not in _KNOWN_UNITS:
|
|
rest = f"{unit} {rest}"
|
|
unit = None
|
|
return qty, unit.lower() if unit else None, rest
|
|
|
|
|
|
@admin_router.post("/resolve-ingredient", response_model=ResolveIngredientResponse)
|
|
def resolve_ingredient(
|
|
payload: ResolveIngredientRequest,
|
|
db: Session = Depends(get_db),
|
|
) -> ResolveIngredientResponse:
|
|
qty, unit, rest = _parse_qty_unit(payload.text)
|
|
rows = db.query(Ingredient).all()
|
|
if not rows:
|
|
return ResolveIngredientResponse(
|
|
parsed_qty=qty,
|
|
parsed_unit=unit,
|
|
parsed_text=rest,
|
|
candidates=[],
|
|
)
|
|
|
|
pool: list[tuple[str, UUID, Optional[str]]] = []
|
|
for row in rows:
|
|
pool.append((row.name, row.id, row.aisle))
|
|
for alias in row.aliases or []:
|
|
pool.append((alias, row.id, row.aisle))
|
|
|
|
scored = process.extract(
|
|
rest,
|
|
[name for name, _, _ in pool],
|
|
scorer=fuzz.WRatio,
|
|
limit=10,
|
|
)
|
|
seen: set[UUID] = set()
|
|
candidates: list[ResolveIngredientCandidate] = []
|
|
for matched_name, score, idx in scored:
|
|
_, ing_id, aisle = pool[idx]
|
|
if ing_id in seen:
|
|
continue
|
|
seen.add(ing_id)
|
|
candidates.append(
|
|
ResolveIngredientCandidate(
|
|
ingredient_id=ing_id,
|
|
name=matched_name,
|
|
score=score / 100.0,
|
|
aisle=aisle,
|
|
)
|
|
)
|
|
if len(candidates) >= 3:
|
|
break
|
|
|
|
return ResolveIngredientResponse(
|
|
parsed_qty=qty,
|
|
parsed_unit=unit,
|
|
parsed_text=rest,
|
|
candidates=candidates,
|
|
)
|