feat: implement Iteration 1 — AI recipe recommendations
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>
This commit is contained in:
326
docs/plans/Iteration_1.md
Normal file
326
docs/plans/Iteration_1.md
Normal file
@@ -0,0 +1,326 @@
|
||||
# Итерация 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.
|
||||
Reference in New Issue
Block a user