Files
food-ai/docs/plans/Iteration_1.md
dbastrikin e57ff8e06c 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>
2026-02-21 22:43:29 +02:00

10 KiB
Raw Blame History

Итерация 1: AI-рекомендации рецептов

Цель: реализовать ключевую функцию — персонализированные рецепты, сгенерированные Gemini с фотографиями из Pexels, и возможность их сохранять.

Зависимости: Итерация 0 (авторизация, профиль, БД).

Ориентир: 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 Интерфейс

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 Поиск фото

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 Параллельный запрос для рецептов

// Для каждого рецепта — параллельный запрос к 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

-- 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:

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.