kiwi/app/api/endpoints/saved_recipes.py
pyr0ball 9350719516
Some checks are pending
CI / Backend (Python) (push) Waiting to run
CI / Frontend (Vue) (push) Waiting to run
Mirror / mirror (push) Waiting to run
Release / release (push) Waiting to run
feat(recipes): LLM style classifier (#27) + cooked leftovers shelf-life (#112)
Style classifier (kiwi#27):
- app/services/recipe/style_classifier.py: LLM prompt with curated vocab,
  cf-orch/LLMRouter fallback, JSON + regex tag extraction
- POST /recipes/saved/{recipe_id}/classify-style: Paid/BYOK tier gate,
  fetches recipe from corpus, returns {suggested_tags:[...]}
- SaveRecipeModal.vue: "Suggest tags" button with loading state; merges
  LLM suggestions into existing tags without overwriting user's choices
- 403/empty list silently ignored — button is a no-op when tier not met

Cooked leftovers shelf-life (kiwi#112):
- app/services/leftovers_predictor.py: deterministic FDA/USDA lookup table
  with shortest-component-wins for proteins and dish-type override for
  assembled dishes; special entries for ceviche (2d, acid != heat),
  fermented/cured (kimchi 14d, confit/lardo 7d), soups, rice, pasta, etc.
- POST /recipes/{recipe_id}/leftovers: free tier, no gate
- RecipeDetailPanel.vue: shelf-life section appears after "I cooked this"
  with fridge/freeze days, freeze-by advice, per-instance dismiss; calm
  framing per no-panic UX policy
- LeftoversResponse Pydantic schema added to recipe.py
2026-04-25 23:18:16 -07:00

216 lines
7.5 KiB
Python

"""Saved recipe bookmark endpoints."""
from __future__ import annotations
import asyncio
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from app.cloud_session import CloudUser, get_session
from app.db.store import Store
from app.models.schemas.saved_recipe import (
CollectionMemberRequest,
CollectionRequest,
CollectionSummary,
SavedRecipeSummary,
SaveRecipeRequest,
UpdateSavedRecipeRequest,
)
from app.tiers import can_use
class StyleClassifyResponse(BaseModel):
suggested_tags: list[str]
router = APIRouter()
def _in_thread(db_path: Path, fn):
"""Run a Store operation in a worker thread with its own connection."""
store = Store(db_path)
try:
return fn(store)
finally:
store.close()
def _to_summary(row: dict, store: Store) -> SavedRecipeSummary:
collection_ids = store.get_saved_recipe_collection_ids(row["id"])
return SavedRecipeSummary(
id=row["id"],
recipe_id=row["recipe_id"],
title=row.get("title") or "",
saved_at=row["saved_at"],
notes=row.get("notes"),
rating=row.get("rating"),
style_tags=row.get("style_tags") or [],
collection_ids=collection_ids,
)
# ── save / unsave ─────────────────────────────────────────────────────────────
@router.post("", response_model=SavedRecipeSummary)
async def save_recipe(
req: SaveRecipeRequest,
session: CloudUser = Depends(get_session),
) -> SavedRecipeSummary:
def _run(store: Store) -> SavedRecipeSummary:
row = store.save_recipe(req.recipe_id, req.notes, req.rating)
return _to_summary(row, store)
return await asyncio.to_thread(_in_thread, session.db, _run)
@router.delete("/{recipe_id}", status_code=204)
async def unsave_recipe(
recipe_id: int,
session: CloudUser = Depends(get_session),
) -> None:
await asyncio.to_thread(
_in_thread, session.db, lambda s: s.unsave_recipe(recipe_id)
)
@router.patch("/{recipe_id}", response_model=SavedRecipeSummary)
async def update_saved_recipe(
recipe_id: int,
req: UpdateSavedRecipeRequest,
session: CloudUser = Depends(get_session),
) -> SavedRecipeSummary:
def _run(store: Store) -> SavedRecipeSummary:
if not store.is_recipe_saved(recipe_id):
raise HTTPException(status_code=404, detail="Recipe not saved.")
row = store.update_saved_recipe(
recipe_id, req.notes, req.rating, req.style_tags
)
return _to_summary(row, store)
return await asyncio.to_thread(_in_thread, session.db, _run)
@router.get("", response_model=list[SavedRecipeSummary])
async def list_saved_recipes(
sort_by: str = "saved_at",
collection_id: int | None = None,
session: CloudUser = Depends(get_session),
) -> list[SavedRecipeSummary]:
def _run(store: Store) -> list[SavedRecipeSummary]:
rows = store.get_saved_recipes(sort_by=sort_by, collection_id=collection_id)
return [_to_summary(r, store) for r in rows]
return await asyncio.to_thread(_in_thread, session.db, _run)
# ── style classifier (Paid / BYOK) ───────────────────────────────────────────
@router.post("/{recipe_id}/classify-style", response_model=StyleClassifyResponse)
async def classify_style(
recipe_id: int,
session: CloudUser = Depends(get_session),
) -> StyleClassifyResponse:
if not can_use("style_classifier", session.tier, getattr(session, "has_byok", False)):
raise HTTPException(status_code=403, detail="Style classifier requires Paid tier or BYOK.")
def _run(store: Store) -> StyleClassifyResponse:
recipe = store.get_recipe(recipe_id)
if recipe is None:
raise HTTPException(status_code=404, detail="Recipe not found.")
from app.services.recipe.style_classifier import classify_style as _classify
tags = _classify(recipe)
return StyleClassifyResponse(suggested_tags=tags)
return await asyncio.to_thread(_in_thread, session.db, _run)
# ── collections (Paid) ────────────────────────────────────────────────────────
@router.get("/collections", response_model=list[CollectionSummary])
async def list_collections(
session: CloudUser = Depends(get_session),
) -> list[CollectionSummary]:
# Free users can list (they'll always have zero — creating requires Paid).
# Returning 403 here breaks savedStore.load() via Promise.all for non-Paid users.
if not can_use("recipe_collections", session.tier):
return []
rows = await asyncio.to_thread(
_in_thread, session.db, lambda s: s.get_collections()
)
return [CollectionSummary(**r) for r in rows]
@router.post("/collections", response_model=CollectionSummary)
async def create_collection(
req: CollectionRequest,
session: CloudUser = Depends(get_session),
) -> CollectionSummary:
if not can_use("recipe_collections", session.tier):
raise HTTPException(
status_code=403,
detail="Collections require Paid tier.",
)
row = await asyncio.to_thread(
_in_thread, session.db,
lambda s: s.create_collection(req.name, req.description),
)
return CollectionSummary(**row)
@router.delete("/collections/{collection_id}", status_code=204)
async def delete_collection(
collection_id: int,
session: CloudUser = Depends(get_session),
) -> None:
if not can_use("recipe_collections", session.tier):
raise HTTPException(status_code=403, detail="Collections require Paid tier.")
await asyncio.to_thread(
_in_thread, session.db, lambda s: s.delete_collection(collection_id)
)
@router.patch("/collections/{collection_id}", response_model=CollectionSummary)
async def rename_collection(
collection_id: int,
req: CollectionRequest,
session: CloudUser = Depends(get_session),
) -> CollectionSummary:
if not can_use("recipe_collections", session.tier):
raise HTTPException(status_code=403, detail="Collections require Paid tier.")
row = await asyncio.to_thread(
_in_thread, session.db,
lambda s: s.rename_collection(collection_id, req.name, req.description),
)
if not row:
raise HTTPException(status_code=404, detail="Collection not found.")
return CollectionSummary(**row)
@router.post("/collections/{collection_id}/members", status_code=204)
async def add_to_collection(
collection_id: int,
req: CollectionMemberRequest,
session: CloudUser = Depends(get_session),
) -> None:
if not can_use("recipe_collections", session.tier):
raise HTTPException(status_code=403, detail="Collections require Paid tier.")
await asyncio.to_thread(
_in_thread, session.db,
lambda s: s.add_to_collection(collection_id, req.saved_recipe_id),
)
@router.delete(
"/collections/{collection_id}/members/{saved_recipe_id}", status_code=204
)
async def remove_from_collection(
collection_id: int,
saved_recipe_id: int,
session: CloudUser = Depends(get_session),
) -> None:
if not can_use("recipe_collections", session.tier):
raise HTTPException(status_code=403, detail="Collections require Paid tier.")
await asyncio.to_thread(
_in_thread, session.db,
lambda s: s.remove_from_collection(collection_id, saved_recipe_id),
)