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:
dbastrikin
2026-02-21 22:43:29 +02:00
parent 24219b611e
commit e57ff8e06c
41 changed files with 5994 additions and 353 deletions

326
docs/plans/Iteration_1.md Normal file
View 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-загрузка (24 сек)
└── 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 (~13 сек)
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.61.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.