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
+12
View File
@@ -1,2 +1,14 @@
CLAUDE.md CLAUDE.md
blueprint.md blueprint.md
# Server
server/.env
server/__pycache__/
server/app/__pycache__/
server/.venv/
# Client
client/node_modules/
client/dist/
client/android/
client/ios/
+12
View File
@@ -0,0 +1,12 @@
# Obbligatorie
NVIDIA_API_KEY=your_nvidia_api_key_here
APP_PASSWORD=your_shared_password_here
# Opzionali — i default sono in app/config.py
# NVIDIA_BASE_URL=https://integrate.api.nvidia.com/v1
# NVIDIA_MODEL=nvidia/nemotron-3-ultra-550b-a55b
# NVIDIA_TIMEOUT_SECONDS=120
# NVIDIA_MAX_TOKENS=16384
# NVIDIA_TEMPERATURE=1.0
# NVIDIA_TOP_P=0.95
# NVIDIA_REASONING_BUDGET=2048
+7
View File
@@ -0,0 +1,7 @@
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app/ ./app/
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
View File
+18
View File
@@ -0,0 +1,18 @@
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
nvidia_api_key: str
app_password: str
nvidia_base_url: str = "https://integrate.api.nvidia.com/v1"
nvidia_model: str = "nvidia/nemotron-3-ultra-550b-a55b"
nvidia_timeout_seconds: int = 120
nvidia_max_tokens: int = 16384
nvidia_temperature: float = 1.0
nvidia_top_p: float = 0.95
nvidia_reasoning_budget: int = 2048
model_config = {"env_file": ".env"}
settings = Settings()
+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."
)
+38
View File
@@ -0,0 +1,38 @@
from pydantic import BaseModel, Field
class Ingredient(BaseModel):
name: str = Field(..., max_length=100)
quantity: float = Field(..., gt=0)
unit: str = Field(..., max_length=50)
class Preferences(BaseModel):
servings: int = Field(2, ge=1, le=20)
maxTimeMinutes: int = Field(60, ge=5, le=300)
difficulty: str = Field("facile", max_length=50)
diet: str = Field("nessuna", max_length=100)
allergies: list[str] = Field(default_factory=list)
avoid: list[str] = Field(default_factory=list)
tools: list[str] = Field(default_factory=list)
class RecipeRequest(BaseModel):
ingredients: list[Ingredient] = Field(..., min_length=1, max_length=30)
preferences: Preferences = Field(default_factory=Preferences)
class Recipe(BaseModel):
title: str
servings: int
timeMinutes: int
difficulty: str
whyThisRecipe: str
usedIngredients: list[str]
optionalMissingIngredients: list[str]
steps: list[str]
notes: list[str]
class RecipeResponse(BaseModel):
recipes: list[Recipe]
+27
View File
@@ -0,0 +1,27 @@
SYSTEM_PROMPT = """Sei un assistente cuoco esperto. Il tuo compito è generare esattamente 3 ricette in JSON valido.
Regole:
- Usa prima gli ingredienti forniti dall'utente, nelle quantità indicate.
- Non inventare ingredienti principali non presenti nella lista.
- Rispetta rigorosamente allergie e alimenti da evitare.
- Adatta le ricette alle quantità indicate e al numero di porzioni.
- Rispetta il tempo massimo di preparazione e il livello di difficoltà.
- Produci passaggi operativi concreti e dettagliati.
- Restituisci SOLO JSON valido, senza testo aggiuntivo, senza markdown, senza commenti.
Formato risposta obbligatorio:
{
"recipes": [
{
"title": "...",
"servings": 2,
"timeMinutes": 25,
"difficulty": "facile",
"whyThisRecipe": "...",
"usedIngredients": ["250 g pasta", "2 zucchine"],
"optionalMissingIngredients": ["parmigiano"],
"steps": ["Passo 1...", "Passo 2..."],
"notes": ["Nota opzionale..."]
}
]
}"""
+8
View File
@@ -0,0 +1,8 @@
services:
server:
build: .
ports:
- "8000:8000"
env_file:
- .env
restart: unless-stopped
+6
View File
@@ -0,0 +1,6 @@
fastapi>=0.111.0
uvicorn[standard]>=0.29.0
pydantic>=2.7.0
pydantic-settings>=2.3.0
openai>=1.30.0
python-dotenv>=1.0.0