Backend:
- Add Groq LLM client (llama-3.3-70b) for recipe generation with JSON
retry strategy (retries only on parse errors, not API errors)
- Add Pexels client for parallel photo search per recipe
- Add saved_recipes table (migration 004) with JSONB fields
- Add GET /recommendations endpoint (profile-aware prompt building)
- Add POST/GET/GET{id}/DELETE /saved-recipes CRUD endpoints
- Wire gemini, pexels, recommendation, savedrecipe packages in main.go
Flutter:
- Add Recipe, SavedRecipe models with json_serializable
- Add RecipeService (getRecommendations, getSavedRecipes, save, delete)
- Add RecommendationsNotifier and SavedRecipesNotifier (Riverpod)
- Add RecommendationsScreen with skeleton loading and refresh FAB
- Add RecipeDetailScreen (SliverAppBar, nutrition tooltip, steps with timer)
- Add SavedRecipesScreen with Dismissible swipe-to-delete and empty state
- Update RecipesScreen to TabBar (Recommendations / Saved)
- Add /recipe-detail route outside ShellRoute (no bottom nav)
- Extend ApiClient with getList() and deleteVoid()
Project:
- Add CLAUDE.md with English-only rule for comments and commit messages
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
327 lines
10 KiB
Markdown
327 lines
10 KiB
Markdown
# Итерация 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.
|