# Итерация 1: AI-рекомендации рецептов **Цель:** реализовать ключевую функцию — персонализированные рецепты, сгенерированные Gemini с фотографиями из Pexels, и возможность их сохранять. **Зависимости:** Итерация 0 (авторизация, профиль, БД). **Ориентир:** [Summary.md](./Summary.md) --- ## Структура задач ``` 1.1 Backend: Gemini-клиент ├── 1.1.1 Пакет internal/gemini (интерфейс + адаптер) ├── 1.1.2 GenerateRecipes(ctx, prompt) → []Recipe └── 1.1.3 Retry-стратегия (невалидный JSON → повтор с уточнением) 1.2 Backend: Pexels-клиент ├── 1.2.1 Пакет internal/pexels └── 1.2.2 SearchPhoto(ctx, query) → image_url 1.3 Backend: saved_recipes ├── 1.3.1 Миграция: таблица saved_recipes ├── 1.3.2 Repository (CRUD) └── 1.3.3 Service layer 1.4 Backend: эндпоинты рекомендаций ├── 1.4.1 GET /recommendations?count=5 └── 1.4.2 Формирование промпта из профиля пользователя 1.5 Backend: эндпоинты saved_recipes ├── 1.5.1 POST /saved-recipes ├── 1.5.2 GET /saved-recipes ├── 1.5.3 GET /saved-recipes/{id} └── 1.5.4 DELETE /saved-recipes/{id} 1.6 Flutter: экран рекомендаций ├── 1.6.1 RecommendationsScreen (список карточек) ├── 1.6.2 Skeleton-загрузка (2–4 сек) └── 1.6.3 Кнопка сохранить (♡) 1.7 Flutter: карточка рецепта └── 1.7.1 RecipeDetailScreen (фото, КБЖУ≈, ингредиенты, шаги) 1.8 Flutter: сохранённые рецепты └── 1.8.1 SavedRecipesScreen (список с удалением) ``` --- ## 1.1 Gemini-клиент ### 1.1.1 Структура пакета ``` internal/ └── gemini/ ├── client.go # HTTP-клиент к Gemini API ├── recipe.go # GenerateRecipes() └── client_test.go ``` ### 1.1.2 Интерфейс ```go type RecipeGenerator interface { GenerateRecipes(ctx context.Context, req RecipeRequest) ([]Recipe, error) } type RecipeRequest struct { UserGoal string // "weight_loss" | "maintain" | "gain" DailyCalories int Restrictions []string // ["gluten_free", "vegetarian"] CuisinePrefs []string // ["russian", "asian"] Count int } type Recipe struct { Title string Description string Cuisine string Difficulty string // "easy" | "medium" | "hard" PrepTimeMin int CookTimeMin int Servings int ImageQuery string // EN, для Pexels Ingredients []Ingredient Steps []Step Tags []string Nutrition NutritionInfo // приблизительно } ``` ### 1.1.3 Промпт для рекомендаций ``` Ты — диетолог-повар. Предложи {N} рецептов на русском языке. Профиль пользователя: - Цель: {goal_ru} - Дневная норма калорий: {calories} ккал - Ограничения: {restrictions или "нет"} - Предпочтения: {cuisines или "любые"} Требования к каждому рецепту: - Калорийность на порцию: не более {per_meal_calories} ккал - Время приготовления: до 60 минут - Укажи КБЖУ на порцию (приблизительно) Верни ТОЛЬКО валидный JSON-массив без markdown-обёртки: [{ "title": "Название", "description": "2-3 предложения", "cuisine": "russian|asian|european|mediterranean|american|other", "difficulty": "easy|medium|hard", "prep_time_min": 10, "cook_time_min": 20, "servings": 2, "image_query": "dish name ingredients style", "ingredients": [{"name": "Куриная грудка", "amount": 300, "unit": "г"}], "steps": [{"number": 1, "description": "...", "timer_seconds": null}], "tags": ["высокий белок"], "nutrition_per_serving": { "calories": 420, "protein_g": 48, "fat_g": 12, "carbs_g": 18 } }] ``` ### 1.1.4 Retry-стратегия При получении невалидного JSON: 1. Первая попытка: обычный промпт 2. При ошибке парсинга — повтор с явным уточнением: «Предыдущий ответ не был валидным JSON. Верни ТОЛЬКО массив JSON без какого-либо текста до или после.» 3. Максимум 3 попытки, затем HTTP 503 --- ## 1.2 Pexels-клиент ### 1.2.1 Пакет ``` internal/ └── pexels/ ├── client.go # HTTP-клиент к Pexels API └── client_test.go ``` ### 1.2.2 Поиск фото ```go type PhotoSearcher interface { SearchPhoto(ctx context.Context, query string) (string, error) // image_url } ``` - Запрос: `GET https://api.pexels.com/v1/search?query={query}&per_page=1&orientation=landscape` - Авторизация: `Authorization: {PEXELS_API_KEY}` - Берём `photos[0].src.medium` (~1200×630) - При пустом ответе — возвращаем дефолтное фото (placeholder) ### 1.2.3 Параллельный запрос для рецептов ```go // Для каждого рецепта — параллельный запрос к Pexels var wg sync.WaitGroup for i, recipe := range recipes { wg.Add(1) go func(i int, r Recipe) { defer wg.Done() url, _ := pexels.SearchPhoto(ctx, r.ImageQuery) recipes[i].ImageURL = url }(i, recipe) } wg.Wait() ``` --- ## 1.3 Миграция: saved_recipes ```sql -- migrations/002_create_saved_recipes.sql -- +goose Up CREATE TABLE saved_recipes ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, title TEXT NOT NULL, description TEXT, cuisine TEXT, difficulty TEXT, prep_time_min INT, cook_time_min INT, servings INT, image_url TEXT, ingredients JSONB NOT NULL DEFAULT '[]', steps JSONB NOT NULL DEFAULT '[]', tags JSONB NOT NULL DEFAULT '[]', nutrition JSONB, -- {calories, protein_g, fat_g, carbs_g} source TEXT NOT NULL DEFAULT 'ai', saved_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX idx_saved_recipes_user_id ON saved_recipes(user_id); CREATE INDEX idx_saved_recipes_saved_at ON saved_recipes(user_id, saved_at DESC); -- +goose Down DROP TABLE saved_recipes; ``` --- ## 1.4 GET /recommendations ### Flow ``` 1. Получить JWT из middleware → user_id 2. SELECT users WHERE id=$1 → профиль (goal, daily_calories, preferences) 3. Формирование промпта из профиля 4. gemini.GenerateRecipes(ctx, req) → []Recipe (~1–3 сек) 5. Для каждого рецепта: pexels.SearchPhoto(ctx, image_query) (параллельно) 6. Вернуть JSON-массив рецептов с image_url ``` ### Request / Response ``` GET /recommendations?count=5 Response 200: [{ "title": "Куриная грудка с овощами", "description": "Лёгкое блюдо высокого белка...", "cuisine": "european", "difficulty": "easy", "prep_time_min": 10, "cook_time_min": 25, "servings": 2, "image_url": "https://images.pexels.com/...", "ingredients": [...], "steps": [...], "tags": ["высокий белок", "без глютена"], "nutrition_per_serving": { "calories": 420, "protein_g": 48, "fat_g": 12, "carbs_g": 18, "approximate": true } }] ``` --- ## 1.5 Эндпоинты saved_recipes | Метод | Путь | Описание | |-------|------|----------| | POST | `/saved-recipes` | Сохранить рецепт из рекомендаций | | GET | `/saved-recipes` | Список сохранённых (по saved_at DESC) | | GET | `/saved-recipes/{id}` | Один сохранённый рецепт | | DELETE | `/saved-recipes/{id}` | Удалить из сохранённых | POST `/saved-recipes` принимает полный объект рецепта (уже с image_url) и сохраняет в БД. Gemini и Pexels не вызываются. --- ## 1.6–1.8 Flutter ### RecommendationsScreen - При открытии вкладки: `GET /recommendations?count=5` - Skeleton-анимация пока идёт запрос - Карточка: фото (CachedNetworkImage), название, КБЖУ≈, время, сложность - Иконка ♡: тап → POST /saved-recipes → haptic feedback, иконка заполняется - Кнопка 🔄 в AppBar: перегенерировать рекомендации ### RecipeDetailScreen - Фото сверху (hero animation) - Метаданные: время, сложность, кухня - КБЖУ-блок с пометкой «≈ приблизительно» (тап → tooltip) - Список ингредиентов с количеством - Нумерованные шаги (с таймером если `timer_seconds != null`) - Кнопка «Сохранить» / «Сохранено» ### SavedRecipesScreen - GET /saved-recipes (пагинация) - Карточки с фото, название, КБЖУ - Свайп для удаления или кнопка корзины - Пустое состояние: «Сохраните рецепты, которые вам понравились» --- ## Конфигурация Новые переменные в `.env`: ``` GEMINI_API_KEY=your-gemini-api-key PEXELS_API_KEY=your-pexels-api-key ``` Обновить `internal/config/config.go`: ```go type Config struct { // ... GeminiAPIKey string `envconfig:"GEMINI_API_KEY" required:"true"` PexelsAPIKey string `envconfig:"PEXELS_API_KEY" required:"true"` } ``` --- ## Зависимости Go ``` go get github.com/google/generative-ai-go/genai@latest ``` Pexels — чистый HTTP (net/http), без SDK.