import json import logging from contextlib import asynccontextmanager from fastapi import Depends, FastAPI, HTTPException, Header from fastapi.middleware.cors import CORSMiddleware from openai import AsyncOpenAI from .config import settings from .models import RecipeRequest, RecipeResponse from .prompt import SYSTEM_PROMPT logger = logging.getLogger("uvicorn") _client: AsyncOpenAI | None = None @asynccontextmanager async def lifespan(app: FastAPI): global _client _client = AsyncOpenAI( api_key=settings.nvidia_api_key, base_url=settings.nvidia_base_url, timeout=float(settings.nvidia_timeout_seconds), ) yield await _client.close() app = FastAPI(title="Recipe AI Server", lifespan=lifespan) app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"], ) def _check_password(x_app_password: str = Header(...)): if x_app_password != settings.app_password: raise HTTPException(status_code=401, detail="Password non valida") @app.get("/health") async def health(): return {"status": "ok", "provider": "nvidia", "model": settings.nvidia_model} @app.post("/auth/test", dependencies=[Depends(_check_password)]) async def auth_test(): return {"status": "ok"} @app.post("/recipes/generate", response_model=RecipeResponse, dependencies=[Depends(_check_password)]) async def generate_recipes(request: RecipeRequest): user_message = _build_user_message(request) for attempt in range(3): try: stream = await _client.chat.completions.create( model=settings.nvidia_model, messages=[ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": user_message}, ], temperature=settings.nvidia_temperature, top_p=settings.nvidia_top_p, max_tokens=settings.nvidia_max_tokens, extra_body={ "chat_template_kwargs": {"enable_thinking": True}, "reasoning_budget": settings.nvidia_reasoning_budget, }, stream=True, ) content = "" async for chunk in stream: if not chunk.choices: continue delta = chunk.choices[0].delta if delta.content is not None: content += delta.content data = json.loads(content.strip()) return RecipeResponse(**data) except json.JSONDecodeError: if attempt == 2: raise HTTPException(status_code=502, detail="Il modello non ha prodotto JSON valido dopo 3 tentativi") logger.warning("Tentativo %d: JSON non valido, retry...", attempt + 1) except Exception as e: raise HTTPException(status_code=502, detail=str(e)) def _build_user_message(request: RecipeRequest) -> str: ingredients_str = "\n".join( f"- {ing.name}: {ing.quantity} {ing.unit}" for ing in request.ingredients ) p = request.preferences return ( f"Ingredienti disponibili:\n{ingredients_str}\n\n" f"Preferenze:\n" f"- Porzioni: {p.servings}\n" f"- Tempo massimo: {p.maxTimeMinutes} minuti\n" f"- Difficoltà: {p.difficulty}\n" f"- Dieta: {p.diet}\n" f"- Allergie: {', '.join(p.allergies) or 'nessuna'}\n" f"- Alimenti da evitare: {', '.join(p.avoid) or 'nessuno'}\n" f"- Strumenti disponibili: {', '.join(p.tools) or 'standard'}\n\n" "Genera 3 ricette in JSON." )