- cloud_session.py: CLOUD_AUTH_BYPASS_IPS with CIDR support; X-Real-IP for Docker bridge NAT-aware client IP resolution; local-dev DB path under CLOUD_DATA_ROOT for bypass sessions - compose.cloud.yml: thread CLOUD_AUTH_BYPASS_IPS from shell env; document Docker bridge CIDR requirement in .env.example - nginx.cloud.conf + nginx.conf: client_max_body_size 20m for barcode uploads - barcode_scanner.py: EXIF orientation correction (PIL ImageOps.exif_transpose) before cv2 decode; rotation coverage extended to [90, 180, 270, 45, 135] to catch sideways barcodes the 270° case was missing - llm_recipe.py: CF-core VRAM lease acquire/release wrapping LLMRouter calls - tasks/runner.py + config.py: COORDINATOR_URL + recipe_llm VRAM budget (4GB) - recipes.py: per-request Store creation inside asyncio.to_thread worker to avoid SQLite check_same_thread violations - download_datasets.py: HF_PARQUET_FILES strategy for repos without dataset builders (lishuyang/recipepairs direct parquet download) - derive_substitutions.py: use recipepairs_recipes.parquet for ingredient lookup; numpy array detection; JSON category parsing - test_build_flavorgraph_index.py: rewritten for CSV-based index format - pyproject.toml: add Pillow>=10.0 for EXIF rotation support
67 lines
2.4 KiB
Python
67 lines
2.4 KiB
Python
"""Recipe suggestion endpoints."""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from pathlib import Path
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
|
|
from app.cloud_session import CloudUser, get_session
|
|
from app.db.store import Store
|
|
from app.models.schemas.recipe import RecipeRequest, RecipeResult
|
|
from app.services.recipe.recipe_engine import RecipeEngine
|
|
from app.tiers import can_use
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
def _suggest_in_thread(db_path: Path, req: RecipeRequest) -> RecipeResult:
|
|
"""Run recipe suggestion in a worker thread with its own Store connection.
|
|
|
|
SQLite connections cannot be shared across threads. This function creates
|
|
a fresh Store (and therefore a fresh sqlite3.Connection) in the same thread
|
|
where it will be used, avoiding ProgrammingError: SQLite objects created in
|
|
a thread can only be used in that same thread.
|
|
"""
|
|
store = Store(db_path)
|
|
try:
|
|
return RecipeEngine(store).suggest(req)
|
|
finally:
|
|
store.close()
|
|
|
|
|
|
@router.post("/suggest", response_model=RecipeResult)
|
|
async def suggest_recipes(
|
|
req: RecipeRequest,
|
|
session: CloudUser = Depends(get_session),
|
|
) -> RecipeResult:
|
|
# Inject session-authoritative tier/byok immediately — client-supplied values are ignored.
|
|
req = req.model_copy(update={"tier": session.tier, "has_byok": session.has_byok})
|
|
if req.level == 4 and not req.wildcard_confirmed:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="Level 4 (Wildcard) requires wildcard_confirmed=true.",
|
|
)
|
|
if req.level in (3, 4) and not can_use("recipe_suggestions", req.tier, req.has_byok):
|
|
raise HTTPException(
|
|
status_code=403,
|
|
detail="LLM recipe levels require Paid tier or a configured LLM backend.",
|
|
)
|
|
if req.style_id and not can_use("style_picker", req.tier):
|
|
raise HTTPException(status_code=403, detail="Style picker requires Paid tier.")
|
|
return await asyncio.to_thread(_suggest_in_thread, session.db, req)
|
|
|
|
|
|
@router.get("/{recipe_id}")
|
|
async def get_recipe(recipe_id: int, session: CloudUser = Depends(get_session)) -> dict:
|
|
def _get(db_path: Path, rid: int) -> dict | None:
|
|
store = Store(db_path)
|
|
try:
|
|
return store.get_recipe(rid)
|
|
finally:
|
|
store.close()
|
|
|
|
recipe = await asyncio.to_thread(_get, session.db, recipe_id)
|
|
if not recipe:
|
|
raise HTTPException(status_code=404, detail="Recipe not found.")
|
|
return recipe
|