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:
+12
@@ -1,2 +1,14 @@
|
||||
CLAUDE.md
|
||||
blueprint.md
|
||||
|
||||
# Server
|
||||
server/.env
|
||||
server/__pycache__/
|
||||
server/app/__pycache__/
|
||||
server/.venv/
|
||||
|
||||
# Client
|
||||
client/node_modules/
|
||||
client/dist/
|
||||
client/android/
|
||||
client/ios/
|
||||
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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()
|
||||
@@ -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."
|
||||
)
|
||||
@@ -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]
|
||||
@@ -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..."]
|
||||
}
|
||||
]
|
||||
}"""
|
||||
@@ -0,0 +1,8 @@
|
||||
services:
|
||||
server:
|
||||
build: .
|
||||
ports:
|
||||
- "8000:8000"
|
||||
env_file:
|
||||
- .env
|
||||
restart: unless-stopped
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user