3d330655a6
Python 3.11 + FastAPI con tre endpoint (/health, /auth/test, /recipes/generate). Autenticazione via header X-App-Password, validazione input Pydantic, chiamata in streaming a NVIDIA NIM (nemotron-3-ultra-550b-a55b) con thinking. Dockerizzato con docker-compose; configurazione tramite .env. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
111 lines
3.6 KiB
Python
111 lines
3.6 KiB
Python
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."
|
|
)
|