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
|
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/
|
||||||
|
|||||||
@@ -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