kiwi/app/api/endpoints/recipe_scan.py
pyr0ball 2df17ec719
Some checks failed
CI / Frontend (Vue) (push) Waiting to run
CI / Backend (Python) (push) Waiting to run
Mirror / mirror (push) Has been cancelled
Release / release (push) Has been cancelled
feat(recipe-scan): add SSE streaming endpoint for cold-start progress feedback
POST /recipes/scan/stream emits live status events while cf-docuvision
allocates and processes, replacing the static spinner with phase-aware labels:
  allocating -> scanning -> structuring -> done|error

Uses asyncio.Queue bridge to route progress callbacks from the sync scanner
thread to the async SSE generator. Frontend updated to consume the stream via
fetch + ReadableStream (EventSource does not support POST multipart).

Closes kiwi#136 (companion to the docuvision routing fix).
2026-05-16 16:24:32 -07:00

371 lines
14 KiB
Python

"""Recipe scanner endpoints (kiwi#9).
POST /recipes/scan -- scan photo(s) -> structured recipe JSON (not saved)
POST /recipes/scan/save -- save a confirmed scanned recipe to user_recipes
GET /recipes/user -- list user-created recipes
GET /recipes/user/{id} -- get a single user recipe
DELETE /recipes/user/{id} -- delete a user recipe
BSL 1.1 -- recipe_scan requires Paid tier or BYOK.
"""
from __future__ import annotations
import asyncio
import json as _json
import logging
import uuid
from pathlib import Path
from typing import Annotated
import aiofiles
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from fastapi.responses import JSONResponse, StreamingResponse
from app.cloud_session import CloudUser, get_session
from app.core.config import settings
from app.db.session import get_store
from app.db.store import Store
from app.models.schemas.recipe_scan import (
ScannedIngredientSchema,
ScannedRecipeResponse,
ScannedRecipeSaveRequest,
UserRecipeResponse,
)
from app.tiers import can_use
logger = logging.getLogger(__name__)
router = APIRouter()
_ALLOWED_MIME_TYPES = {
"image/jpeg", "image/jpg", "image/png", "image/webp", "image/heic", "image/heif"
}
_MAX_FILE_SIZE_MB = 20
async def _save_upload_temp(file: UploadFile) -> Path:
"""Write upload to a temp path under UPLOAD_DIR. Caller is responsible for cleanup."""
settings.ensure_dirs()
dest = settings.UPLOAD_DIR / f"scan_{uuid.uuid4()}_{file.filename}"
async with aiofiles.open(dest, "wb") as f:
await f.write(await file.read())
return dest
def _result_to_response(result) -> ScannedRecipeResponse:
"""Convert ScannedRecipeResult (dataclass) to Pydantic response schema."""
return ScannedRecipeResponse(
title=result.title,
subtitle=result.subtitle,
servings=result.servings,
cook_time=result.cook_time,
source_note=result.source_note,
ingredients=[
ScannedIngredientSchema(
name=i.name,
qty=i.qty,
unit=i.unit,
raw=i.raw,
in_pantry=i.in_pantry,
)
for i in result.ingredients
],
steps=result.steps,
notes=result.notes,
tags=result.tags,
pantry_match_pct=result.pantry_match_pct,
confidence=result.confidence,
warnings=result.warnings,
)
def _row_to_user_recipe(row: dict) -> UserRecipeResponse:
"""Convert a store row dict to UserRecipeResponse."""
return UserRecipeResponse(
id=row["id"],
title=row["title"],
subtitle=row.get("subtitle"),
servings=row.get("servings"),
cook_time=row.get("cook_time"),
source_note=row.get("source_note"),
ingredients=[
ScannedIngredientSchema(**i) if isinstance(i, dict) else i
for i in (row.get("ingredients") or [])
],
steps=row.get("steps") or [],
notes=row.get("notes"),
tags=row.get("tags") or [],
source=row.get("source", "manual"),
pantry_match_pct=row.get("pantry_match_pct"),
created_at=row["created_at"],
)
# ── Scan endpoint ──────────────────────────────────────────────────────────────
@router.post("/scan", response_model=ScannedRecipeResponse)
async def scan_recipe(
files: Annotated[list[UploadFile], File(...)],
store: Store = Depends(get_store),
session: CloudUser = Depends(get_session),
):
"""Scan one or more recipe photos and return a structured recipe for review.
Accepts 1-4 images. Multi-page recipes (e.g. ingredients on page 1,
directions on page 2) work best when all pages are submitted together.
The response is NOT saved automatically -- the user reviews and edits it,
then calls POST /recipes/scan/save to persist.
Tier: Paid (or BYOK).
"""
if not can_use("recipe_scan", session.tier, session.has_byok):
raise HTTPException(
status_code=403,
detail=(
"Recipe scanning requires Paid tier or a configured vision backend (BYOK). "
"Set ANTHROPIC_API_KEY or connect to a cf-orch vision service."
),
)
if not files:
raise HTTPException(status_code=422, detail="At least one image file is required.")
if len(files) > 4:
raise HTTPException(status_code=422, detail="Maximum 4 images per scan request.")
for f in files:
ct = (f.content_type or "").lower()
if ct and ct not in _ALLOWED_MIME_TYPES:
raise HTTPException(
status_code=422,
detail=f"Unsupported file type: {ct}. Supported: JPEG, PNG, WebP, HEIC.",
)
# Save uploads to temp files
saved_paths: list[Path] = []
try:
for f in files:
saved_paths.append(await _save_upload_temp(f))
# Get pantry item names for cross-reference
inventory = await asyncio.to_thread(store.list_inventory)
pantry_names = [item["product_name"] for item in inventory if item.get("product_name")]
# Run scanner (blocks on VLM -- use to_thread)
from app.services.recipe.recipe_scanner import RecipeScanner
def _run_scan():
scanner = RecipeScanner()
return scanner.scan(saved_paths, pantry_names=pantry_names)
try:
result = await asyncio.to_thread(_run_scan)
except ValueError as exc:
msg = str(exc)
if "not_a_recipe" in msg:
raise HTTPException(
status_code=422,
detail="The image does not appear to contain a recipe. "
"Please photograph a recipe card, cookbook page, or handwritten note.",
)
raise HTTPException(status_code=422, detail=msg)
except RuntimeError as exc:
msg = str(exc)
logger.warning("Recipe scanner unavailable: %s", msg)
raise HTTPException(
status_code=503,
detail=(
"The recipe scanner is temporarily unavailable — "
"no vision backend could be reached. "
"Try again in a few minutes, or contact support if this persists."
),
)
return _result_to_response(result)
finally:
# Clean up temp files
for p in saved_paths:
try:
p.unlink(missing_ok=True)
except Exception:
pass
# ── SSE scan endpoint ─────────────────────────────────────────────────────────
async def _scan_recipe_sse(saved_paths: list[Path], pantry_names: list[str]):
"""Async generator yielding SSE events for a recipe scan.
Emits progress events while the vision service allocates and runs, then a
final "done" event containing the full recipe payload (same shape as the
ScannedRecipeResponse from POST /scan).
Events:
{"status": "allocating", "message": "..."}
{"status": "scanning", "message": "..."}
{"status": "structuring","message": "..."}
{"status": "done", "recipe": {...}}
{"status": "error", "message": "..."}
"""
queue: asyncio.Queue = asyncio.Queue()
loop = asyncio.get_running_loop()
def _run() -> None:
def cb(status: str, message: str) -> None:
loop.call_soon_threadsafe(queue.put_nowait, {"status": status, "message": message})
try:
from app.services.recipe.recipe_scanner import RecipeScanner
result = RecipeScanner().scan(saved_paths, pantry_names=pantry_names, progress_cb=cb)
recipe_dict = _result_to_response(result).model_dump()
loop.call_soon_threadsafe(queue.put_nowait, {"status": "done", "recipe": recipe_dict})
except ValueError as exc:
loop.call_soon_threadsafe(queue.put_nowait, {"status": "error", "message": str(exc)})
except RuntimeError as exc:
loop.call_soon_threadsafe(queue.put_nowait, {"status": "error", "message": str(exc)})
except Exception as exc:
logger.exception("Unexpected error in recipe scan thread")
loop.call_soon_threadsafe(queue.put_nowait, {"status": "error", "message": "Scan failed unexpectedly."})
scan_task = asyncio.ensure_future(asyncio.to_thread(_run))
try:
while True:
try:
event = await asyncio.wait_for(queue.get(), timeout=180.0)
except asyncio.TimeoutError:
yield f"data: {_json.dumps({'status': 'error', 'message': 'Scan timed out after 3 minutes.'})}\n\n"
break
yield f"data: {_json.dumps(event)}\n\n"
if event["status"] in ("done", "error"):
break
finally:
if not scan_task.done():
scan_task.cancel()
@router.post("/scan/stream")
async def scan_recipe_stream(
files: Annotated[list[UploadFile], File(...)],
store: Store = Depends(get_store),
session: CloudUser = Depends(get_session),
):
"""Scan recipe photos and stream SSE progress events during model load.
Use this endpoint instead of POST /scan when you need live feedback during
cold-start model loading (first request after a GPU-idle period can take
30-60 seconds for cf-docuvision to warm up).
Tier: Paid (or BYOK) — same gate as POST /scan.
"""
if not can_use("recipe_scan", session.tier, session.has_byok):
raise HTTPException(
status_code=403,
detail=(
"Recipe scanning requires Paid tier or a configured vision backend (BYOK). "
"Set ANTHROPIC_API_KEY or connect to a cf-orch vision service."
),
)
if not files:
raise HTTPException(status_code=422, detail="At least one image file is required.")
if len(files) > 4:
raise HTTPException(status_code=422, detail="Maximum 4 images per scan request.")
for f in files:
ct = (f.content_type or "").lower()
if ct and ct not in _ALLOWED_MIME_TYPES:
raise HTTPException(
status_code=422,
detail=f"Unsupported file type: {ct}. Supported: JPEG, PNG, WebP, HEIC.",
)
saved_paths: list[Path] = []
for f in files:
saved_paths.append(await _save_upload_temp(f))
inventory = await asyncio.to_thread(store.list_inventory)
pantry_names = [item["product_name"] for item in inventory if item.get("product_name")]
async def generate():
try:
async for chunk in _scan_recipe_sse(saved_paths, pantry_names):
yield chunk
finally:
for p in saved_paths:
try:
p.unlink(missing_ok=True)
except Exception:
pass
return StreamingResponse(generate(), media_type="text/event-stream")
# ── Save endpoint ──────────────────────────────────────────────────────────────
@router.post("/scan/save", response_model=UserRecipeResponse, status_code=201)
async def save_scanned_recipe(
body: ScannedRecipeSaveRequest,
store: Store = Depends(get_store),
session: CloudUser = Depends(get_session),
):
"""Save a user-reviewed (possibly edited) scanned recipe.
The body is the ScannedRecipeResponse (or a user-edited version of it).
Returns the persisted UserRecipe with an assigned ID.
Tier: Free (saving your own recipe doesn't require vision access).
"""
def _save():
return store.create_user_recipe(
title=body.title,
subtitle=body.subtitle,
servings=body.servings,
cook_time=body.cook_time,
source_note=body.source_note,
ingredients=[i.model_dump() for i in body.ingredients],
steps=body.steps,
notes=body.notes,
tags=body.tags,
source=body.source,
pantry_match_pct=None,
)
row = await asyncio.to_thread(_save)
return _row_to_user_recipe(row)
# ── User recipe list / get / delete ───────────────────────────────────────────
@router.get("/user", response_model=list[UserRecipeResponse])
async def list_user_recipes(
store: Store = Depends(get_store),
session: CloudUser = Depends(get_session),
):
"""List all user-created recipes (scanned + manually entered), newest first."""
rows = await asyncio.to_thread(store.list_user_recipes)
return [_row_to_user_recipe(r) for r in rows]
@router.get("/user/{recipe_id}", response_model=UserRecipeResponse)
async def get_user_recipe(
recipe_id: int,
store: Store = Depends(get_store),
session: CloudUser = Depends(get_session),
):
"""Get a single user recipe by ID."""
row = await asyncio.to_thread(store.get_user_recipe, recipe_id)
if not row:
raise HTTPException(status_code=404, detail="User recipe not found.")
return _row_to_user_recipe(row)
@router.delete("/user/{recipe_id}", status_code=204)
async def delete_user_recipe(
recipe_id: int,
store: Store = Depends(get_store),
session: CloudUser = Depends(get_session),
):
"""Delete a user recipe by ID."""
deleted = await asyncio.to_thread(store.delete_user_recipe, recipe_id)
if not deleted:
raise HTTPException(status_code=404, detail="User recipe not found.")
return JSONResponse(status_code=204, content=None)