add server: FastAPI + Docker

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>
This commit is contained in:
2026-06-07 00:08:07 +02:00
parent d5205ff8b6
commit 3d330655a6
10 changed files with 238 additions and 0 deletions
+110
View File
@@ -0,0 +1,110 @@
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."
)