diff --git a/.gitignore b/.gitignore index a84a2fa..65da816 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/server/.env.example b/server/.env.example new file mode 100644 index 0000000..6665018 --- /dev/null +++ b/server/.env.example @@ -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 diff --git a/server/Dockerfile b/server/Dockerfile new file mode 100644 index 0000000..d086f5c --- /dev/null +++ b/server/Dockerfile @@ -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"] diff --git a/server/app/__init__.py b/server/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/app/config.py b/server/app/config.py new file mode 100644 index 0000000..880c04d --- /dev/null +++ b/server/app/config.py @@ -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() diff --git a/server/app/main.py b/server/app/main.py new file mode 100644 index 0000000..2c9534c --- /dev/null +++ b/server/app/main.py @@ -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." + ) diff --git a/server/app/models.py b/server/app/models.py new file mode 100644 index 0000000..234b822 --- /dev/null +++ b/server/app/models.py @@ -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] diff --git a/server/app/prompt.py b/server/app/prompt.py new file mode 100644 index 0000000..1e9cac1 --- /dev/null +++ b/server/app/prompt.py @@ -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..."] + } + ] +}""" diff --git a/server/docker-compose.yml b/server/docker-compose.yml new file mode 100644 index 0000000..9838ae4 --- /dev/null +++ b/server/docker-compose.yml @@ -0,0 +1,8 @@ +services: + server: + build: . + ports: + - "8000:8000" + env_file: + - .env + restart: unless-stopped diff --git a/server/requirements.txt b/server/requirements.txt new file mode 100644 index 0000000..e817ddc --- /dev/null +++ b/server/requirements.txt @@ -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