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.
|
||||
241
docs/plans/Iteration_2.md
Normal file
241
docs/plans/Iteration_2.md
Normal file
@@ -0,0 +1,241 @@
|
||||
# Итерация 2: Управление продуктами
|
||||
|
||||
**Цель:** дать пользователю возможность вести список продуктов — вручную или через автодополнение. Рекомендации становятся персонализированными: Gemini учитывает имеющиеся продукты.
|
||||
|
||||
**Зависимости:** Итерация 1 (рекомендации должны принимать список продуктов).
|
||||
|
||||
**Ориентир:** [Summary.md](./Summary.md)
|
||||
|
||||
---
|
||||
|
||||
## Структура задач
|
||||
|
||||
```
|
||||
2.1 Backend: ingredient_mappings
|
||||
├── 2.1.1 Миграция: таблица ingredient_mappings
|
||||
├── 2.1.2 Seed: топ-200 базовых ингредиентов (JSON-файл)
|
||||
├── 2.1.3 Repository (поиск по aliases)
|
||||
└── 2.1.4 GET /ingredients/search?q=
|
||||
|
||||
2.2 Backend: products
|
||||
├── 2.2.1 Миграция: таблица products
|
||||
├── 2.2.2 Repository (CRUD)
|
||||
├── 2.2.3 Service layer
|
||||
├── 2.2.4 GET /products
|
||||
├── 2.2.5 POST /products
|
||||
├── 2.2.6 POST /products/batch
|
||||
├── 2.2.7 PUT /products/{id}
|
||||
├── 2.2.8 DELETE /products/{id}
|
||||
└── 2.2.9 GET /products/expiring (скоро истекают)
|
||||
|
||||
2.3 Backend: интеграция с рекомендациями
|
||||
└── 2.3.1 GET /recommendations — добавить продукты в промпт
|
||||
|
||||
2.4 Flutter: экран продуктов
|
||||
├── 2.4.1 ProductsScreen (список с истекающими)
|
||||
├── 2.4.2 Форма добавления с автодополнением (debounce 300мс)
|
||||
├── 2.4.3 Редактирование (количество, единица, срок)
|
||||
└── 2.4.4 Удаление (свайп или кнопка)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2.1 Ingredient Mappings
|
||||
|
||||
### 2.1.1 Миграция
|
||||
|
||||
```sql
|
||||
-- migrations/003_create_ingredient_mappings.sql
|
||||
|
||||
-- +goose Up
|
||||
CREATE TABLE ingredient_mappings (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
canonical_name TEXT NOT NULL UNIQUE,
|
||||
canonical_name_ru TEXT NOT NULL,
|
||||
aliases JSONB NOT NULL DEFAULT '[]',
|
||||
category TEXT NOT NULL,
|
||||
default_unit TEXT NOT NULL DEFAULT 'g',
|
||||
calories_per_100g DECIMAL(8,2),
|
||||
protein_per_100g DECIMAL(8,2),
|
||||
fat_per_100g DECIMAL(8,2),
|
||||
carbs_per_100g DECIMAL(8,2),
|
||||
storage_days INT NOT NULL DEFAULT 7,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_ingredient_mappings_aliases ON ingredient_mappings USING GIN(aliases);
|
||||
CREATE INDEX idx_ingredient_mappings_canonical_ru ON ingredient_mappings
|
||||
USING GIN(to_tsvector('russian', canonical_name_ru));
|
||||
CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
||||
CREATE INDEX idx_ingredient_mappings_trgm ON ingredient_mappings
|
||||
USING GIN(canonical_name_ru gin_trgm_ops);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE ingredient_mappings;
|
||||
```
|
||||
|
||||
### 2.1.2 Seed-данные
|
||||
|
||||
Файл `migrations/seed_ingredient_mappings.json` с топ-200 ингредиентами:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"canonical_name": "chicken_breast",
|
||||
"canonical_name_ru": "куриная грудка",
|
||||
"aliases": ["куриное филе", "куриная грудка", "грудка курицы", "chicken breast"],
|
||||
"category": "meat",
|
||||
"default_unit": "g",
|
||||
"calories_per_100g": 165,
|
||||
"protein_per_100g": 31,
|
||||
"fat_per_100g": 3.6,
|
||||
"carbs_per_100g": 0,
|
||||
"storage_days": 3
|
||||
},
|
||||
...
|
||||
]
|
||||
```
|
||||
|
||||
Seed применяется отдельным скриптом/Makefile-таргетом `make seed`.
|
||||
|
||||
### 2.1.3 Поиск
|
||||
|
||||
```go
|
||||
// GET /ingredients/search?q=кур&limit=10
|
||||
// Трёхуровневый поиск:
|
||||
// 1. Точное совпадение в aliases (@>)
|
||||
// 2. ILIKE на canonical_name_ru
|
||||
// 3. pg_trgm similarity > 0.3
|
||||
SELECT *
|
||||
FROM ingredient_mappings
|
||||
WHERE aliases @> to_jsonb(lower($1)::text)
|
||||
OR canonical_name_ru ILIKE '%' || $1 || '%'
|
||||
OR similarity(canonical_name_ru, $1) > 0.3
|
||||
ORDER BY similarity(canonical_name_ru, $1) DESC
|
||||
LIMIT $2
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2.2 Products
|
||||
|
||||
### 2.2.1 Миграция
|
||||
|
||||
```sql
|
||||
-- migrations/004_create_products.sql
|
||||
|
||||
-- +goose Up
|
||||
CREATE TABLE products (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
mapping_id UUID REFERENCES ingredient_mappings(id),
|
||||
name TEXT NOT NULL,
|
||||
quantity DECIMAL(10,2) NOT NULL DEFAULT 1,
|
||||
unit TEXT NOT NULL DEFAULT 'pcs',
|
||||
category TEXT,
|
||||
storage_days INT NOT NULL DEFAULT 7,
|
||||
added_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
expires_at TIMESTAMPTZ GENERATED ALWAYS AS
|
||||
(added_at + (storage_days || ' days')::INTERVAL) STORED
|
||||
);
|
||||
|
||||
CREATE INDEX idx_products_user_id ON products(user_id);
|
||||
CREATE INDEX idx_products_expires_at ON products(user_id, expires_at);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE products;
|
||||
```
|
||||
|
||||
### 2.2.2 Эндпоинты
|
||||
|
||||
**GET /products**
|
||||
|
||||
```json
|
||||
[{
|
||||
"id": "uuid",
|
||||
"name": "Куриная грудка",
|
||||
"quantity": 500,
|
||||
"unit": "g",
|
||||
"category": "meat",
|
||||
"expires_at": "2026-02-24T00:00:00Z",
|
||||
"days_left": 3,
|
||||
"expiring_soon": true
|
||||
}]
|
||||
```
|
||||
|
||||
Сортировка: `expires_at ASC` (сначала истекающие).
|
||||
`expiring_soon = true` если `days_left <= 3`.
|
||||
|
||||
**POST /products/batch**
|
||||
|
||||
Массовое добавление после распознавания (Итерация 3):
|
||||
|
||||
```json
|
||||
[{
|
||||
"name": "Куриная грудка",
|
||||
"quantity": 500,
|
||||
"unit": "g",
|
||||
"mapping_id": "uuid или null"
|
||||
}]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2.3 Интеграция с рекомендациями
|
||||
|
||||
Обновить `GET /recommendations`: если у пользователя есть продукты — включать их в промпт.
|
||||
|
||||
```go
|
||||
// Если продуктов нет — промпт без них (базовые рекомендации)
|
||||
// Если продукты есть — добавить секцию в промпт:
|
||||
|
||||
doступные продукты (приоритет — скоро истекают ⚠):
|
||||
- Куриная грудка 500г (истекает завтра ⚠)
|
||||
- Морковь 3 шт
|
||||
- Рис 400г
|
||||
- Яйца 4 шт
|
||||
|
||||
Предпочтительно использовать эти продукты в рецептах.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2.4 Flutter: экран продуктов
|
||||
|
||||
### ProductsScreen
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ Мои продукты [+ Добавить] │
|
||||
├─────────────────────────────────────┤
|
||||
│ ⚠ Истекает скоро │
|
||||
│ ┌──────────────────────────────┐ │
|
||||
│ │ 🥩 Куриная грудка 500 г │ │
|
||||
│ │ Осталось 1 день │ │
|
||||
│ └──────────────────────────────┘ │
|
||||
│ │
|
||||
│ Всё остальное │
|
||||
│ ┌──────────────────────────────┐ │
|
||||
│ │ 🥕 Морковь 3 шт │ │
|
||||
│ │ Осталось 5 дней │ │
|
||||
│ └──────────────────────────────┘ │
|
||||
│ ┌──────────────────────────────┐ │
|
||||
│ │ 🍚 Рис 400 г │ │
|
||||
│ │ Осталось 30 дней │ │
|
||||
│ └──────────────────────────────┘ │
|
||||
├─────────────────────────────────────┤
|
||||
│ [Главная] [Продукты●] [Меню] [Рецепты] [Профиль] │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Форма добавления
|
||||
|
||||
- Поле ввода с debounce 300мс → `GET /ingredients/search?q=`
|
||||
- Dropdown с результатами (canonical_name_ru + category)
|
||||
- При выборе: автозаполнение unit, storage_days из mapping
|
||||
- Поля: Количество, Единица (select: г/кг/мл/л/шт)
|
||||
- Кнопка «Добавить»
|
||||
|
||||
### Badge на вкладке «Продукты»
|
||||
|
||||
Количество продуктов с `days_left <= 3` отображается как badge над иконкой.
|
||||
328
docs/plans/Iteration_3.md
Normal file
328
docs/plans/Iteration_3.md
Normal file
@@ -0,0 +1,328 @@
|
||||
# Итерация 3: Распознавание продуктов (Gemini Vision)
|
||||
|
||||
**Цель:** пользователь фотографирует чек, холодильник или готовое блюдо — Gemini Vision автоматически определяет продукты и их количество, после чего пользователь подтверждает список и добавляет в запасы.
|
||||
|
||||
**Зависимости:** Итерация 2 (управление продуктами, ingredient_mappings).
|
||||
|
||||
---
|
||||
|
||||
## Структура задач
|
||||
|
||||
```
|
||||
3.1 Backend: AI-распознавание чека
|
||||
├── 3.1.1 POST /ai/recognize-receipt (multipart: image)
|
||||
├── 3.1.2 Промпт для OCR чека
|
||||
└── 3.1.3 Fuzzy match результатов по ingredient_mappings
|
||||
|
||||
3.2 Backend: AI-распознавание фото продуктов
|
||||
├── 3.2.1 POST /ai/recognize-products (multipart: 1–3 images)
|
||||
├── 3.2.2 Параллельные Gemini-запросы для нескольких фото
|
||||
└── 3.2.3 Дедупликация и объединение результатов
|
||||
|
||||
3.3 Backend: AI-распознавание блюда
|
||||
├── 3.3.1 POST /ai/recognize-dish (multipart: image)
|
||||
└── 3.3.2 Промпт для определения блюда и КБЖУ
|
||||
|
||||
3.4 Backend: нераспознанные продукты → Gemini
|
||||
└── 3.4.1 Если fuzzy match не найден → запрос к Gemini для классификации
|
||||
→ сохранение нового маппинга в ingredient_mappings
|
||||
|
||||
3.5 S3: загрузка фото
|
||||
├── 3.5.1 Конфигурация S3-совместимого хранилища (MinIO/Cloud Storage)
|
||||
└── 3.5.2 Сохранение фото на период обработки (TTL 24h)
|
||||
|
||||
3.6 Flutter: экран сканирования
|
||||
├── 3.6.1 ScanScreen (камера или галерея)
|
||||
├── 3.6.2 Выбор режима: чек / продукты / блюдо
|
||||
├── 3.6.3 Экран подтверждения списка продуктов
|
||||
└── 3.6.4 Экран результата распознавания блюда
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3.1 Распознавание чека
|
||||
|
||||
### Промпт
|
||||
|
||||
```
|
||||
Ты — OCR-система для чеков из продуктовых магазинов.
|
||||
|
||||
Проанализируй фото чека и извлеки список продуктов питания.
|
||||
Для каждого продукта определи:
|
||||
- name: название на русском языке (очисти от артикулов и кодов)
|
||||
- quantity: количество (число)
|
||||
- unit: единица (г, кг, мл, л, шт, уп)
|
||||
- category: dairy | meat | produce | bakery | frozen | beverages | other
|
||||
- confidence: 0.0–1.0
|
||||
|
||||
Позиции, которые не являются едой (бытовая химия, табак), пропусти.
|
||||
Позиции с непонятным текстом добавь в unrecognized.
|
||||
|
||||
Верни ТОЛЬКО валидный JSON без markdown:
|
||||
{
|
||||
"items": [
|
||||
{"name": "Молоко 2.5%", "quantity": 1, "unit": "л",
|
||||
"category": "dairy", "confidence": 0.95}
|
||||
],
|
||||
"unrecognized": [
|
||||
{"raw_text": "ТОВ АРТИК 1ШТ", "price": 89.0}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Fuzzy match
|
||||
|
||||
После получения списка от Gemini — для каждого item:
|
||||
|
||||
```go
|
||||
// 1. Поиск в ingredient_mappings
|
||||
mapping, found := ingredientRepo.FuzzyMatch(ctx, item.Name)
|
||||
|
||||
// 2. Нашли — используем mapping_id, подставляем дефолты (unit, storage_days)
|
||||
// 3. Не нашли → разовый запрос к Gemini для классификации:
|
||||
if !found {
|
||||
mapping = gemini.ClassifyIngredient(ctx, item.Name)
|
||||
ingredientRepo.Save(ctx, mapping) // новая строка в ingredient_mappings
|
||||
}
|
||||
```
|
||||
|
||||
### Response
|
||||
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"name": "Куриная грудка",
|
||||
"quantity": 500,
|
||||
"unit": "г",
|
||||
"category": "meat",
|
||||
"mapping_id": "uuid",
|
||||
"storage_days": 3,
|
||||
"confidence": 0.95
|
||||
}
|
||||
],
|
||||
"unrecognized": [
|
||||
{ "raw_text": "ТОВ АРТИК 1ШТ" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Клиент показывает список для подтверждения, затем вызывает `POST /products/batch`.
|
||||
|
||||
---
|
||||
|
||||
## 3.2 Распознавание фото продуктов
|
||||
|
||||
### Промпт (на каждое фото)
|
||||
|
||||
```
|
||||
Ты — система распознавания продуктов питания.
|
||||
|
||||
Посмотри на фото и определи все видимые продукты питания.
|
||||
Для каждого продукта оцени:
|
||||
- name: название на русском языке
|
||||
- quantity: приблизительное количество (число)
|
||||
- unit: единица (г, кг, мл, л, шт)
|
||||
- category: dairy | meat | produce | bakery | frozen | beverages | other
|
||||
- confidence: 0.0–1.0
|
||||
|
||||
Только продукты питания. Упаковки без содержимого — пропусти.
|
||||
|
||||
Верни ТОЛЬКО валидный JSON:
|
||||
{
|
||||
"items": [
|
||||
{"name": "Яйца", "quantity": 10, "unit": "шт",
|
||||
"category": "dairy", "confidence": 0.9}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Обработка нескольких фото
|
||||
|
||||
```go
|
||||
// Параллельные запросы к Gemini (по одному на фото)
|
||||
results := make([][]RecognizedItem, len(images))
|
||||
var wg sync.WaitGroup
|
||||
for i, img := range images {
|
||||
wg.Add(1)
|
||||
go func(i int, img []byte) {
|
||||
defer wg.Done()
|
||||
results[i], _ = gemini.RecognizeProducts(ctx, img)
|
||||
}(i, img)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
// Дедупликация: если canonical_name совпадает → суммируем quantity
|
||||
merged := mergeAndDeduplicate(results)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3.3 Распознавание блюда
|
||||
|
||||
### Промпт
|
||||
|
||||
```
|
||||
Ты — диетолог и кулинарный эксперт.
|
||||
|
||||
Посмотри на фото блюда и определи:
|
||||
- dish_name: название блюда на русском языке
|
||||
- weight_grams: приблизительный вес порции в граммах
|
||||
- calories: калорийность порции (приблизительно)
|
||||
- protein_g, fat_g, carbs_g: БЖУ на порцию
|
||||
- confidence: 0.0–1.0
|
||||
- similar_dishes: до 3 похожих блюд (для поиска рецептов)
|
||||
|
||||
Верни ТОЛЬКО валидный JSON:
|
||||
{
|
||||
"dish_name": "Паста Карбонара",
|
||||
"weight_grams": 350,
|
||||
"calories": 520,
|
||||
"protein_g": 22,
|
||||
"fat_g": 26,
|
||||
"carbs_g": 48,
|
||||
"confidence": 0.85,
|
||||
"similar_dishes": ["Паста с беконом", "Спагетти"]
|
||||
}
|
||||
```
|
||||
|
||||
### Response-flow
|
||||
|
||||
```
|
||||
POST /ai/recognize-dish → Gemini → {dish_name, calories≈, КБЖУ≈}
|
||||
↓
|
||||
Поиск в saved_recipes по dish_name (optional)
|
||||
↓
|
||||
Response: {dish_name, nutrition≈, matched_recipe?: {id, title}}
|
||||
```
|
||||
|
||||
Клиент показывает результат с возможностью добавить в дневник питания.
|
||||
|
||||
---
|
||||
|
||||
## 3.4 Классификация нераспознанного ингредиента
|
||||
|
||||
Когда fuzzy match не нашёл маппинг:
|
||||
|
||||
```
|
||||
Промпт:
|
||||
"Классифицируй продукт питания: '{name}'.
|
||||
Ответь JSON:
|
||||
{
|
||||
'canonical_name': 'turkey_breast',
|
||||
'canonical_name_ru': 'грудка индейки',
|
||||
'category': 'meat',
|
||||
'default_unit': 'g',
|
||||
'calories_per_100g': 135,
|
||||
'protein_per_100g': 29,
|
||||
'fat_per_100g': 1,
|
||||
'carbs_per_100g': 0,
|
||||
'storage_days': 3,
|
||||
'aliases': ['грудка индейки', 'филе индейки', 'turkey breast']
|
||||
}"
|
||||
```
|
||||
|
||||
Результат сохраняется в `ingredient_mappings`. Следующий пользователь с тем же продуктом — AI не вызывается.
|
||||
|
||||
---
|
||||
|
||||
## 3.5 S3-хранилище для фото
|
||||
|
||||
Фотографии загружаются на S3 (MinIO локально / Cloud Storage в проде):
|
||||
|
||||
```
|
||||
1. Клиент: PUT /upload → presigned URL
|
||||
2. Клиент загружает фото напрямую на S3
|
||||
3. Клиент: POST /ai/recognize-receipt { s3_key: "..." }
|
||||
4. Backend: скачивает фото с S3, отправляет в Gemini, удаляет (TTL 24h)
|
||||
```
|
||||
|
||||
Альтернатива для MVP: принимать base64 в теле запроса (для начала проще).
|
||||
|
||||
---
|
||||
|
||||
## 3.6 Flutter: экран сканирования
|
||||
|
||||
### ScanScreen (точка входа)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ [←] Добавить продукты │
|
||||
├─────────────────────────────────────┤
|
||||
│ │
|
||||
│ Выберите способ │
|
||||
│ │
|
||||
│ ┌───────────────────────────────┐ │
|
||||
│ │ 🧾 Сфотографировать чек │ │
|
||||
│ │ Распознаем все продукты │ │
|
||||
│ └───────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌───────────────────────────────┐ │
|
||||
│ │ 🥦 Сфотографировать продукты │ │
|
||||
│ │ Холодильник, стол, полка │ │
|
||||
│ └───────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌───────────────────────────────┐ │
|
||||
│ │ ✏️ Добавить вручную │ │
|
||||
│ └───────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Экран подтверждения
|
||||
|
||||
После распознавания — список с возможностью:
|
||||
- Редактировать количество/единицу каждого пункта
|
||||
- Удалить нераспознанные или ошибочные
|
||||
- Добавить вручную
|
||||
- Кнопка «Добавить всё» → POST /products/batch
|
||||
|
||||
### Экран результата блюда
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ [←] Распознано блюдо │
|
||||
├─────────────────────────────────────┤
|
||||
│ [фото блюда] │
|
||||
│ │
|
||||
│ Паста Карбонара │
|
||||
│ Уверенность: 85% │
|
||||
│ │
|
||||
│ ≈ 520 ккал (приблизительно) │
|
||||
│ Б: 22г · Ж: 26г · У: 48г │
|
||||
│ Вес порции: ~350г │
|
||||
│ │
|
||||
│ ┌───────────────────────────────┐ │
|
||||
│ │ 📓 Добавить в дневник │ │
|
||||
│ └───────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌───────────────────────────────┐ │
|
||||
│ │ 📖 Открыть рецепт │ │
|
||||
│ └───────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Новые конфигурационные переменные
|
||||
|
||||
```
|
||||
S3_ENDPOINT=http://localhost:9000
|
||||
S3_BUCKET=food-ai-uploads
|
||||
S3_ACCESS_KEY=minioadmin
|
||||
S3_SECRET_KEY=minioadmin
|
||||
```
|
||||
|
||||
## Оценка нагрузки на Gemini
|
||||
|
||||
| Действие | Gemini-вызовов |
|
||||
|----------|---------------|
|
||||
| Чек (1 фото) | 1 vision |
|
||||
| Фото продуктов (3 фото) | 3 vision (параллельно) |
|
||||
| Блюдо | 1 vision |
|
||||
| Нераспознанный ингредиент | 1 текст (разово) |
|
||||
|
||||
При 100 активных пользователях × 1 сканирование/день:
|
||||
- 100–300 дополнительных Gemini-запросов/день
|
||||
- Free tier (1 500/день) остаётся в запасе
|
||||
367
docs/plans/Iteration_4.md
Normal file
367
docs/plans/Iteration_4.md
Normal file
@@ -0,0 +1,367 @@
|
||||
# Итерация 4: Планирование меню
|
||||
|
||||
**Цель:** пользователь запрашивает меню на неделю — Gemini генерирует полный план питания (21 приём пищи) с учётом продуктов, целей и предпочтений. Из меню автоматически формируется список покупок.
|
||||
|
||||
**Зависимости:** Итерация 2 (продукты), Итерация 1 (сохранённые рецепты).
|
||||
|
||||
---
|
||||
|
||||
## Структура задач
|
||||
|
||||
```
|
||||
4.1 Backend: таблицы меню
|
||||
├── 4.1.1 Миграция: menu_plans, menu_items
|
||||
└── 4.1.2 Миграция: meal_diary
|
||||
|
||||
4.2 Backend: генерация меню
|
||||
├── 4.2.1 POST /ai/generate-menu
|
||||
├── 4.2.2 Промпт для Gemini (7 дней × 3 приёма)
|
||||
├── 4.2.3 Параллельный Pexels для 21 рецепта
|
||||
└── 4.2.4 Сохранение в menu_plans + menu_items + saved_recipes
|
||||
|
||||
4.3 Backend: CRUD меню
|
||||
├── 4.3.1 GET /menu?week=YYYY-WNN
|
||||
├── 4.3.2 PUT /menu/items/{id} (заменить рецепт)
|
||||
└── 4.3.3 DELETE /menu/items/{id}
|
||||
|
||||
4.4 Backend: список покупок
|
||||
├── 4.4.1 POST /shopping-list/generate (из меню)
|
||||
├── 4.4.2 GET /shopping-list
|
||||
└── 4.4.3 PATCH /shopping-list/items/{index}/check
|
||||
|
||||
4.5 Backend: дневник питания
|
||||
├── 4.5.1 GET /diary?date=YYYY-MM-DD
|
||||
├── 4.5.2 POST /diary (добавить запись)
|
||||
└── 4.5.3 DELETE /diary/{id}
|
||||
|
||||
4.6 Flutter: экран меню
|
||||
├── 4.6.1 MenuScreen (7-дневный вид)
|
||||
├── 4.6.2 Кнопка «Сгенерировать меню»
|
||||
├── 4.6.3 Редактирование слота (смена рецепта)
|
||||
└── 4.6.4 Skeleton на время генерации (5–10 сек)
|
||||
|
||||
4.7 Flutter: список покупок
|
||||
└── 4.7.1 ShoppingListScreen (с галочками)
|
||||
|
||||
4.8 Flutter: дневник питания
|
||||
├── 4.8.1 DiaryScreen (записи за день)
|
||||
└── 4.8.2 Добавление записи (из меню / вручную)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4.1 Миграции
|
||||
|
||||
### menu_plans и menu_items
|
||||
|
||||
```sql
|
||||
-- migrations/005_create_menu_plans.sql
|
||||
|
||||
-- +goose Up
|
||||
CREATE TABLE menu_plans (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
week_start DATE NOT NULL, -- понедельник недели
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE(user_id, week_start)
|
||||
);
|
||||
|
||||
CREATE TABLE menu_items (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
menu_plan_id UUID NOT NULL REFERENCES menu_plans(id) ON DELETE CASCADE,
|
||||
day_of_week INT NOT NULL CHECK (day_of_week BETWEEN 1 AND 7),
|
||||
meal_type TEXT NOT NULL CHECK (meal_type IN ('breakfast','lunch','dinner')),
|
||||
recipe_id UUID REFERENCES saved_recipes(id) ON DELETE SET NULL,
|
||||
recipe_data JSONB, -- snapshot рецепта (если saved_recipe удалён)
|
||||
UNIQUE(menu_plan_id, day_of_week, meal_type)
|
||||
);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE menu_items;
|
||||
DROP TABLE menu_plans;
|
||||
```
|
||||
|
||||
### meal_diary
|
||||
|
||||
```sql
|
||||
-- migrations/006_create_meal_diary.sql
|
||||
|
||||
-- +goose Up
|
||||
CREATE TABLE meal_diary (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
date DATE NOT NULL,
|
||||
meal_type TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
portions DECIMAL(5,2) NOT NULL DEFAULT 1,
|
||||
calories DECIMAL(8,2),
|
||||
protein_g DECIMAL(8,2),
|
||||
fat_g DECIMAL(8,2),
|
||||
carbs_g DECIMAL(8,2),
|
||||
source TEXT NOT NULL DEFAULT 'manual', -- manual|recipe|menu|photo
|
||||
recipe_id UUID REFERENCES saved_recipes(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_meal_diary_user_date ON meal_diary(user_id, date);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE meal_diary;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4.2 Генерация меню
|
||||
|
||||
### 4.2.1 Промпт для Gemini
|
||||
|
||||
```
|
||||
Ты — диетолог-повар. Составь меню на 7 дней для пользователя.
|
||||
|
||||
Профиль:
|
||||
- Цель: {goal_ru}
|
||||
- Дневная норма калорий: {calories} ккал
|
||||
- Ограничения: {restrictions или "нет"}
|
||||
- Предпочтения кухни: {cuisines или "любые"}
|
||||
|
||||
Продукты в наличии (приоритет — скоро истекают ⚠):
|
||||
{products_list}
|
||||
|
||||
Требования:
|
||||
- 3 приёма пищи в день: завтрак (25% КБЖУ), обед (40%), ужин (35%)
|
||||
- Разнообразие: не повторять рецепты
|
||||
- По возможности использовать имеющиеся продукты
|
||||
- КБЖУ рассчитать на 1 порцию (приблизительно)
|
||||
|
||||
Верни ТОЛЬКО валидный JSON без markdown:
|
||||
{
|
||||
"days": [
|
||||
{
|
||||
"day": 1,
|
||||
"meals": [
|
||||
{
|
||||
"meal_type": "breakfast",
|
||||
"recipe": {
|
||||
"title": "Овсяная каша с яблоком",
|
||||
"description": "...",
|
||||
"cuisine": "european",
|
||||
"difficulty": "easy",
|
||||
"prep_time_min": 5,
|
||||
"cook_time_min": 10,
|
||||
"servings": 1,
|
||||
"image_query": "oatmeal apple breakfast bowl",
|
||||
"ingredients": [...],
|
||||
"steps": [...],
|
||||
"tags": [...],
|
||||
"nutrition_per_serving": {
|
||||
"calories": 320, "protein_g": 8,
|
||||
"fat_g": 6, "carbs_g": 58
|
||||
}
|
||||
}
|
||||
},
|
||||
{ "meal_type": "lunch", "recipe": {...} },
|
||||
{ "meal_type": "dinner", "recipe": {...} }
|
||||
]
|
||||
},
|
||||
...
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2.2 Параллельный Pexels
|
||||
|
||||
21 Pexels-запрос выполняется параллельно (горутины). С учётом лимита 200 req/hour — для одного меню это 10% дневного бюджета. Повторяющиеся image_query (между пользователями) следует кэшировать в будущем (Redis, см. TODO.md).
|
||||
|
||||
### 4.2.3 Сохранение
|
||||
|
||||
```go
|
||||
// Транзакция:
|
||||
// 1. INSERT menu_plans (upsert по user_id + week_start)
|
||||
// 2. INSERT saved_recipes для каждого из 21 рецептов
|
||||
// 3. INSERT menu_items (21 записи с recipe_id → saved_recipes)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4.3 CRUD меню
|
||||
|
||||
### GET /menu?week=2026-W08
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "uuid",
|
||||
"week_start": "2026-02-16",
|
||||
"days": [
|
||||
{
|
||||
"day": 1,
|
||||
"date": "2026-02-16",
|
||||
"meals": [
|
||||
{
|
||||
"id": "menu_item_uuid",
|
||||
"meal_type": "breakfast",
|
||||
"recipe": {
|
||||
"id": "saved_recipe_uuid",
|
||||
"title": "Овсяная каша с яблоком",
|
||||
"image_url": "...",
|
||||
"calories": 320,
|
||||
"nutrition_per_serving": {...}
|
||||
}
|
||||
}
|
||||
],
|
||||
"total_calories": 1780
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### PUT /menu/items/{id}
|
||||
|
||||
Заменить рецепт в слоте меню. Тело: `{ "recipe_id": "uuid" }` — ID существующего сохранённого рецепта, или запрос нового от Gemini.
|
||||
|
||||
---
|
||||
|
||||
## 4.4 Список покупок
|
||||
|
||||
### POST /shopping-list/generate
|
||||
|
||||
```go
|
||||
// 1. SELECT menu_items JOIN saved_recipes WHERE menu_plan_id=$1
|
||||
// 2. Извлечь все ингредиенты из всех рецептов
|
||||
// 3. Агрегация по canonical_name (через ingredient_mappings):
|
||||
// суммирование количества для одинаковых ингредиентов
|
||||
// 4. Вычесть уже имеющееся в products (где quantity > 0)
|
||||
// 5. INSERT/UPSERT shopping_lists
|
||||
```
|
||||
|
||||
Gemini не участвует — чистая SQL-агрегация.
|
||||
|
||||
### GET /shopping-list
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"name": "Куриная грудка",
|
||||
"category": "meat",
|
||||
"amount": 1200,
|
||||
"unit": "г",
|
||||
"checked": false,
|
||||
"in_stock": 0
|
||||
},
|
||||
{
|
||||
"name": "Яйца",
|
||||
"category": "dairy",
|
||||
"amount": 12,
|
||||
"unit": "шт",
|
||||
"checked": false,
|
||||
"in_stock": 4
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4.6 Flutter: экран меню
|
||||
|
||||
### MenuScreen
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ Меню [← Пред] [След →]│
|
||||
│ Неделя 16–22 февраля │
|
||||
├─────────────────────────────────────┤
|
||||
│ │
|
||||
│ Понедельник, 16 фев 1 780 ккал │
|
||||
│ ┌──────────────────────────────┐ │
|
||||
│ │ 🌅 Завтрак ≈320 ккал │ │
|
||||
│ │ [фото] Овсянка с яблоком │ │
|
||||
│ │ [Изменить]│ │
|
||||
│ ├──────────────────────────────┤ │
|
||||
│ │ ☀ Обед ≈680 ккал │ │
|
||||
│ │ [фото] Куриный суп │ │
|
||||
│ │ [Изменить]│ │
|
||||
│ ├──────────────────────────────┤ │
|
||||
│ │ 🌙 Ужин ≈780 ккал │ │
|
||||
│ │ [фото] Рис с овощами │ │
|
||||
│ │ [Изменить]│ │
|
||||
│ └──────────────────────────────┘ │
|
||||
│ │
|
||||
│ Вторник, 17 фев 1 820 ккал │
|
||||
│ ┌──────────────────────────────┐ │
|
||||
│ │ ... │ │
|
||||
│ └──────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────┐ │
|
||||
│ │ ✨ Сгенерировать новое меню │ │
|
||||
│ └─────────────────────────────┘ │
|
||||
│ ┌─────────────────────────────┐ │
|
||||
│ │ 🛒 Список покупок │ │
|
||||
│ └─────────────────────────────┘ │
|
||||
│ │
|
||||
├─────────────────────────────────────┤
|
||||
│ [Главная] [Продукты] [Меню●] [Рецепты] [Профиль] │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Skeleton при генерации (5–10 сек)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ Меню │
|
||||
├─────────────────────────────────────┤
|
||||
│ │
|
||||
│ Составляем меню на неделю... │
|
||||
│ Учитываем ваши продукты и цели │
|
||||
│ │
|
||||
│ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │
|
||||
│ ░░░░░░░░░ ░░░░░░░░░░░░░░░░░░░ │
|
||||
│ ░░░░░░░░░░░░░░░ ░░░░░░░░░░░░░░ │
|
||||
│ │
|
||||
│ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │
|
||||
│ ░░░░░░░░░ ░░░░░░░░░░░░░░░░░░░ │
|
||||
│ │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4.7 Flutter: список покупок
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ [←] Список покупок [Поделиться]│
|
||||
├─────────────────────────────────────┤
|
||||
│ │
|
||||
│ Мясо │
|
||||
│ ☐ Куриная грудка 1.2 кг │
|
||||
│ ☐ Фарш говяжий 500 г │
|
||||
│ │
|
||||
│ Молочное │
|
||||
│ ☑ Яйца 12 шт │
|
||||
│ (4 шт есть дома) │
|
||||
│ ☐ Молоко 1 л │
|
||||
│ │
|
||||
│ Овощи │
|
||||
│ ☐ Морковь 3 шт │
|
||||
│ ☐ Лук репчатый 4 шт │
|
||||
│ │
|
||||
│ ┌───────────────────────────────┐ │
|
||||
│ │ + Добавить вручную │ │
|
||||
│ └───────────────────────────────┘ │
|
||||
│ │
|
||||
├─────────────────────────────────────┤
|
||||
│ Осталось купить: 8 позиций │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Оценка нагрузки
|
||||
|
||||
| Действие | Gemini | Pexels |
|
||||
|----------|--------|--------|
|
||||
| Генерация меню (неделя) | 1 (большой промпт) | до 21 |
|
||||
| Просмотр/редактирование меню | 0 | 0 |
|
||||
| Список покупок | 0 | 0 |
|
||||
| Дневник питания | 0 | 0 |
|
||||
|
||||
Генерация меню — самый тяжёлый запрос по Pexels. Происходит 1 раз в неделю на пользователя. При 100 DAU × 1/7 = ~14 генераций/день × 21 Pexels = 294 Pexels-запроса/день — в пределах лимита 200 req/hour.
|
||||
@@ -5,16 +5,12 @@
|
||||
| # | Итерация | Цель | Зависит от |
|
||||
|---|----------|------|------------|
|
||||
| 0 | Фундамент | Go-проект, БД, авторизация, Flutter-каркас | — |
|
||||
| 1 | Справочник ингредиентов и рецепты | Наполнение БД рецептами, маппинг ингредиентов | 0 |
|
||||
| 2 | Управление продуктами | CRUD продуктов, сроки хранения | 0 |
|
||||
| 3 | AI-ядро | Очереди, Gemini-адаптер, rate limiter, budget guard | 0 |
|
||||
| 4 | AI-распознавание | OCR чека, фото продуктов, фото блюд | 2, 3 |
|
||||
| 5 | Каталог рецептов | Поиск, фильтры, «из моих продуктов», замены | 1, 2 |
|
||||
| 6 | Планирование меню | Меню на неделю, AI-генерация, список покупок | 3, 5 |
|
||||
| 7 | Дневник питания | Записи, порции, трекер воды, калории | 5 |
|
||||
| 8 | Режим готовки | Пошаговая готовка, таймеры | 5 |
|
||||
| 9 | Рекомендации и статистика | Рекомендации на главной, графики, тренды | 6, 7 |
|
||||
| 10 | Полировка | Онбординг, пустые состояния, уведомления, отзывы | 9 |
|
||||
| 1 | AI-рекомендации рецептов | Gemini генерирует рецепты, Pexels фото, сохранение рецептов | 0 |
|
||||
| 2 | Управление продуктами | CRUD продуктов, сроки хранения, ingredient_mappings | 0 |
|
||||
| 3 | Распознавание продуктов | OCR чека, фото продуктов, фото блюд (Gemini Vision) | 1, 2 |
|
||||
| 4 | Планирование меню | Меню на неделю, AI-генерация, список покупок, дневник | 1, 2 |
|
||||
|
||||
Дальнейшие итерации определяются приоритетами после MVP. Функциональность из TODO.md (дневник статистики, режим готовки, полировка) — следующий горизонт.
|
||||
|
||||
## Карта зависимостей
|
||||
|
||||
@@ -23,57 +19,28 @@
|
||||
│ 0. Фундамент │
|
||||
└──────┬───────┘
|
||||
│
|
||||
┌────────────────┼────────────────┐
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌────────────────┐ ┌───────────┐ ┌────────────────┐
|
||||
│ 1. Справочник │ │ 2. Продук-│ │ 3. AI-ядро │
|
||||
│ ингредиентов │ │ ты │ │ (очереди, │
|
||||
│ + рецепты │ │ │ │ Gemini) │
|
||||
└───────┬────────┘ └─────┬─────┘ └───────┬────────┘
|
||||
│ │ │
|
||||
│ ┌────┴────┐ │
|
||||
│ │ │ │
|
||||
│ │ ┌────┴──────────┘
|
||||
│ │ │
|
||||
│ ▼ ▼
|
||||
│ ┌──────────────────┐
|
||||
│ │ 4. AI-распозна- │
|
||||
│ │ вание │
|
||||
│ └──────────────────┘
|
||||
│ │
|
||||
└─────┬─────┘
|
||||
│
|
||||
▼
|
||||
┌────────────────┐
|
||||
│ 5. Каталог │
|
||||
│ рецептов │
|
||||
└───────┬────────┘
|
||||
│
|
||||
┌──────────┼──────────┐
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌──────────┐ ┌──────────┐ ┌──────────┐
|
||||
│ 6. Меню │ │ 7. Днев- │ │ 8. Режим │
|
||||
│ + список │ │ ник пита-│ │ готовки │
|
||||
│ покупок │ │ ния │ │ │
|
||||
└─────┬────┘ └─────┬────┘ └──────────┘
|
||||
│ │
|
||||
└──────┬─────┘
|
||||
│
|
||||
▼
|
||||
┌────────────────┐
|
||||
│ 9. Рекоменда- │
|
||||
│ ции + стат-ка │
|
||||
└───────┬────────┘
|
||||
│
|
||||
▼
|
||||
┌────────────────┐
|
||||
│ 10. Полировка │
|
||||
└────────────────┘
|
||||
┌────────────┴────────────┐
|
||||
│ │
|
||||
▼ ▼
|
||||
┌────────────────────┐ ┌──────────────────┐
|
||||
│ 1. AI-рекомендации │ │ 2. Продукты │
|
||||
│ (Gemini+Pexels) │ │ + ingredient_ │
|
||||
│ saved_recipes │ │ mappings │
|
||||
└──────────┬─────────┘ └────────┬─────────┘
|
||||
│ │
|
||||
└────────────┬────────────┘
|
||||
│
|
||||
┌──────────┴──────────┐
|
||||
│ │
|
||||
▼ ▼
|
||||
┌────────────────────┐ ┌─────────────────────┐
|
||||
│ 3. Распознавание │ │ 4. Планирование │
|
||||
│ продуктов │ │ меню │
|
||||
│ (Gemini Vision) │ │ (Gemini+Pexels) │
|
||||
└────────────────────┘ └─────────────────────┘
|
||||
```
|
||||
|
||||
**Параллельная разработка:** итерации 1, 2, 3 могут выполняться параллельно. Итерации 6, 7, 8 — тоже параллельно после завершения 5.
|
||||
**Параллельная разработка:** итерации 1 и 2 могут выполняться параллельно. Итерации 3 и 4 — тоже параллельно после завершения 1 и 2.
|
||||
|
||||
---
|
||||
|
||||
@@ -115,33 +82,46 @@
|
||||
|
||||
---
|
||||
|
||||
## Итерация 1: Справочник ингредиентов и рецепты
|
||||
## Итерация 1: AI-рекомендации рецептов
|
||||
|
||||
**Цель:** наполнить БД каноническими ингредиентами и рецептами из Spoonacular. Это фундамент для всех фичей, связанных с рецептами, поиском и маппингом продуктов.
|
||||
> **Детальный план:** [Iteration_1.md](./Iteration_1.md)
|
||||
|
||||
**Цель:** реализовать ключевую функцию — персонализированные рецепты, сгенерированные Gemini, с фотографиями из Pexels и возможностью сохранять понравившиеся.
|
||||
|
||||
**Зависимости:** итерация 0.
|
||||
|
||||
### User Stories
|
||||
|
||||
#### Backend
|
||||
|
||||
| ID | Story | Описание |
|
||||
|----|-------|----------|
|
||||
| 1.1 | Таблица ingredient_mappings | Миграция: id, canonical_name, spoonacular_id, aliases (JSONB), category, default_unit, нутриенты на 100г, storage_days. Индексы: GIN по aliases, UNIQUE по spoonacular_id |
|
||||
| 1.2 | Импорт ингредиентов из Spoonacular | CLI-команда / джоб: запрос Spoonacular Ingredient API → сохранение ~1000 базовых ингредиентов в ingredient_mappings |
|
||||
| 1.3 | Таблица recipes | Миграция: id, source, spoonacular_id, title, description, cuisine, difficulty, prep_time_min, калории, БЖУ, servings, image_url, ingredients (JSONB), steps (JSONB), tags (JSONB), avg_rating, review_count, created_by. Индексы: GIN по ingredients, full-text по title |
|
||||
| 1.4 | Импорт рецептов из Spoonacular | CLI-команда / джоб: импорт 5 000–10 000 популярных рецептов. Маппинг ингредиентов рецепта на ingredient_mappings через spoonacular_id |
|
||||
| 1.5 | Перевод рецептов | Batch-джоб: перевод title, description, steps через Gemini Flash-Lite. Результат сохраняется в БД (поля title_ru, description_ru или отдельная таблица переводов) |
|
||||
| 1.6 | Базовая локализация aliases | Перевод aliases топ-200 ингредиентов на русский. Batch через Gemini или ручной маппинг |
|
||||
| 1.1 | Gemini-клиент | Пакет internal/gemini. Интерфейс RecipeGenerator. GenerateRecipes(prompt) → []Recipe. Retry на невалидный JSON |
|
||||
| 1.2 | Pexels-клиент | Пакет internal/pexels. SearchPhoto(query) → image_url. Параллельные запросы |
|
||||
| 1.3 | Таблица saved_recipes | Миграция: id, user_id, title, description, cuisine, difficulty, prep/cook_time_min, servings, image_url, ingredients (JSONB), steps (JSONB), tags (JSONB), nutrition (JSONB), source, saved_at |
|
||||
| 1.4 | GET /recommendations | Формирует промпт из профиля пользователя → Gemini → Pexels → ответ с image_url |
|
||||
| 1.5 | CRUD saved_recipes | POST /saved-recipes, GET /saved-recipes, GET /saved-recipes/{id}, DELETE /saved-recipes/{id} |
|
||||
|
||||
#### Flutter
|
||||
|
||||
| ID | Story | Описание |
|
||||
|----|-------|----------|
|
||||
| 1.6 | RecommendationsScreen | Список карточек, skeleton-загрузка, кнопка 🔄 для перегенерации |
|
||||
| 1.7 | RecipeDetailScreen | Фото, КБЖУ≈, ингредиенты, шаги, кнопка сохранить |
|
||||
| 1.8 | SavedRecipesScreen | Список с удалением, пустое состояние |
|
||||
|
||||
### Результат итерации
|
||||
- БД содержит ~1 000 ингредиентов с русскими алиасами и нутриентами
|
||||
- БД содержит 5 000–10 000 рецептов с переводами, ингредиентами, шагами, нутриентами
|
||||
- Каждый ингредиент рецепта связан с ingredient_mappings через spoonacular_id
|
||||
- Пользователь открывает вкладку «Рецепты» и видит 5 персонализированных рецептов с фото
|
||||
- Может сохранить рецепт, просмотреть детали, удалить из сохранённых
|
||||
- КБЖУ помечены «≈» как приблизительные
|
||||
|
||||
---
|
||||
|
||||
## Итерация 2: Управление продуктами
|
||||
|
||||
**Цель:** пользователь может вести список своих продуктов вручную — добавлять, редактировать, удалять, отслеживать сроки.
|
||||
> **Детальный план:** [Iteration_2.md](./Iteration_2.md)
|
||||
|
||||
**Цель:** пользователь может вести список своих продуктов вручную — добавлять через автодополнение (ingredient_mappings), редактировать, удалять, отслеживать сроки. Рекомендации становятся персонализированными: Gemini учитывает имеющиеся продукты.
|
||||
|
||||
**Зависимости:** итерация 0.
|
||||
|
||||
@@ -177,39 +157,13 @@
|
||||
|
||||
---
|
||||
|
||||
## Итерация 3: AI-ядро
|
||||
## Итерация 3: Распознавание продуктов
|
||||
|
||||
**Цель:** построить инфраструктуру для AI-запросов: очереди, rate limiter, budget guard, адаптер Gemini. После итерации можно отправлять AI-запросы через API с контролем расхода.
|
||||
> **Детальный план:** [Iteration_3.md](./Iteration_3.md)
|
||||
|
||||
**Зависимости:** итерация 0.
|
||||
**Цель:** пользователь фотографирует чек, холодильник или блюдо — Gemini Vision распознаёт продукты и помогает заполнить список запасов.
|
||||
|
||||
### User Stories
|
||||
|
||||
| ID | Story | Описание |
|
||||
|----|-------|----------|
|
||||
| 3.1 | AI Service интерфейсы | Go-интерфейсы: FoodRecognizer, RecipeGenerator, MenuPlanner, NutritionEstimator. Структуры запросов и ответов |
|
||||
| 3.2 | Gemini-адаптер | Реализация интерфейсов через Gemini API (google/generative-ai-go). Structured JSON output. Обработка ошибок, retries |
|
||||
| 3.3 | Таблица ai_tasks | Миграция: id, user_id, task_type, status, priority, input/output_tokens, estimated_cost, queue/process_time_ms, created_at, completed_at. Индексы по user_id, status, created_at |
|
||||
| 3.4 | Priority Queue Manager | Две очереди (chan в Go): paid (N воркеров), free (1 воркер). Распределение RPM между очередями. Горутины-воркеры |
|
||||
| 3.5 | Rate Limiter (per-user) | Token bucket на горутинах. Конфигурируемые лимиты по тарифу (free: 20 req/час, paid: 100 req/час). HTTP 429 при превышении |
|
||||
| 3.6 | Budget Guard | Подсчёт дневных затрат по ai_tasks. Пороги: 80% → warn, 100% → free stop, 120% → all stop. Счётчик сбрасывается в полночь |
|
||||
| 3.7 | AI API эндпоинты (заглушки) | `POST /ai/recognize-receipt`, `/ai/recognize-products`, `/ai/recognize-dish`, `/ai/suggest-recipes`, `/ai/generate-menu`, `/ai/substitute`. Возвращают task_id (HTTP 202). `GET /ai/tasks/{id}` для polling |
|
||||
| 3.8 | Логирование и мониторинг | Каждый AI-запрос логируется в ai_tasks с токенами и стоимостью. Эндпоинт `/admin/ai-stats` для просмотра затрат |
|
||||
|
||||
### Результат итерации
|
||||
- AI-запросы проходят через очередь с приоритетами
|
||||
- Paid-пользователи обслуживаются быстрее
|
||||
- Расход бюджета контролируется, при превышении — graceful degradation
|
||||
- Все запросы логируются с точной стоимостью
|
||||
- API эндпоинты принимают запросы и возвращают результат через polling
|
||||
|
||||
---
|
||||
|
||||
## Итерация 4: AI-распознавание
|
||||
|
||||
**Цель:** пользователь может фотографировать чеки, продукты и блюда — AI распознаёт и предлагает результат для корректировки.
|
||||
|
||||
**Зависимости:** итерации 2 (продукты), 3 (AI-ядро).
|
||||
**Зависимости:** итерации 1, 2.
|
||||
|
||||
### User Stories
|
||||
|
||||
@@ -217,36 +171,34 @@
|
||||
|
||||
| ID | Story | Описание |
|
||||
|----|-------|----------|
|
||||
| 4.1 | OCR чека | Реализация FoodRecognizer.RecognizeReceipt: фото → Gemini Flash (vision) → structured JSON (name, quantity, unit, category, price, confidence). Маппинг результатов на ingredient_mappings |
|
||||
| 4.2 | Распознавание продуктов (фото) | FoodRecognizer.RecognizeProducts: фото → Gemini Flash → JSON. Поддержка мультифото (объединение результатов, дедупликация). Маппинг на ingredient_mappings |
|
||||
| 4.3 | Распознавание блюда | FoodRecognizer.RecognizeDish: фото → Gemini Flash → dish_name, weight, calories, БЖУ, confidence. Full-text search по recipes.title для привязки к рецепту из БД |
|
||||
| 4.4 | Авто-маппинг нераспознанных | Если fuzzy match по aliases не нашёл ингредиент → разовый запрос к Gemini: определить canonical_name → сохранить в ingredient_mappings. Следующий запрос с таким же продуктом — без AI |
|
||||
| 4.5 | Загрузка фото | Эндпоинт для multipart upload фото. Сохранение в S3. Передача URL в AI-задачу |
|
||||
| 3.1 | OCR чека | POST /ai/recognize-receipt: фото → Gemini Flash (vision) → JSON (name, qty, unit, confidence). Fuzzy match по ingredient_mappings |
|
||||
| 3.2 | Фото продуктов | POST /ai/recognize-products: 1–3 фото → параллельные Gemini-запросы → дедупликация → JSON |
|
||||
| 3.3 | Распознавание блюда | POST /ai/recognize-dish: фото → Gemini → {dish_name, weight_g, КБЖУ≈, confidence} |
|
||||
| 3.4 | Авто-маппинг | Нераспознанный продукт → Gemini классифицирует → сохраняет в ingredient_mappings |
|
||||
| 3.5 | S3 / multipart | Загрузка фото: multipart или presigned URL |
|
||||
|
||||
#### Flutter
|
||||
|
||||
| ID | Story | Описание |
|
||||
|----|-------|----------|
|
||||
| 4.6 | Экран камеры (чек) | Видоискатель, кнопка съёмки, выбор из галереи. Отправка на backend |
|
||||
| 4.7 | Экран камеры (еда) | Переключатель «Готовое блюдо» / «Продукты». Съёмка, отправка |
|
||||
| 4.8 | Экран загрузки AI | Анимация «Распознаём...» с индикатором. Polling по task_id |
|
||||
| 4.9 | Экран корректировки (чек/фото продуктов) | Список распознанных продуктов. Инлайн-редактирование: название, количество, единица, категория, срок хранения. Чекбоксы, удаление, добавление вручную, «Сделать ещё фото». Предупреждения о дубликатах. CTA «Добавить в мои продукты» |
|
||||
| 4.10 | Экран результата (фото блюда) | Фото, название, калории, БЖУ. Подтверждение / корректировка. Слайдер порции. Выбор приёма пищи. CTA «Записать в дневник» |
|
||||
| 4.11 | Обработка ошибок AI | Экран «Не удалось распознать» → «Переснять» / «Ввести вручную» |
|
||||
| 3.6 | ScanScreen | Выбор режима: чек / продукты / блюдо. Камера + галерея |
|
||||
| 3.7 | Экран подтверждения | Список с инлайн-редактированием, удалением, «Добавить ещё фото», CTA «В запасы» |
|
||||
| 3.8 | Экран результата блюда | Фото, КБЖУ≈, кнопки «В дневник» / «Открыть рецепт» |
|
||||
|
||||
### Результат итерации
|
||||
- Пользователь фотографирует чек → получает список продуктов → корректирует → добавляет в запасы
|
||||
- Фотографирует холодильник (несколько фото) → то же
|
||||
- Фотографирует блюдо → видит калории и БЖУ → может записать в дневник
|
||||
- Нераспознанные ингредиенты автоматически добавляются в справочник
|
||||
- Сфотографировал чек → список продуктов → подтвердил → добавил в запасы
|
||||
- Сфотографировал холодильник → то же
|
||||
- Сфотографировал блюдо → КБЖУ≈ → можно добавить в дневник
|
||||
|
||||
---
|
||||
|
||||
## Итерация 5: Каталог рецептов
|
||||
## Итерация 4: Планирование меню
|
||||
|
||||
**Цель:** пользователь может просматривать, искать и фильтровать рецепты. Видит, какие ингредиенты есть в запасах, а каких не хватает. Может добавить рецепт в избранное.
|
||||
> **Детальный план:** [Iteration_4.md](./Iteration_4.md)
|
||||
|
||||
**Зависимости:** итерации 1 (рецепты в БД), 2 (продукты для проверки наличия).
|
||||
**Цель:** пользователь получает полное меню на неделю от Gemini с учётом продуктов, целей и предпочтений. Автоматически формируется список покупок.
|
||||
|
||||
**Зависимости:** итерации 1, 2.
|
||||
|
||||
### User Stories
|
||||
|
||||
@@ -254,246 +206,39 @@
|
||||
|
||||
| ID | Story | Описание |
|
||||
|----|-------|----------|
|
||||
| 5.1 | Поиск и фильтрация рецептов | `GET /recipes` — фильтры: cuisine, difficulty, prep_time, calories_max, meal_type, diet_tags. Full-text search по title. Пагинация (cursor-based) |
|
||||
| 5.2 | «Из моих продуктов» | Фильтр: сопоставление ingredients[].mapping_id с products.mapping_id пользователя. Ранжирование по доле совпадения. На каждом рецепте: «Есть всё ✓» / «-N прод.» |
|
||||
| 5.3 | Карточка рецепта с наличием | `GET /recipes/{id}` — рецепт + для каждого ингредиента: есть ✅ / нет ❌ / замена 🔄. Итог: «Всё есть» / «Не хватает N» |
|
||||
| 5.4 | Замены ингредиентов | При ❌ — поиск замены: сначала в таблице ingredient_substitutions, затем (если нет) — запрос к Gemini, результат кешируется |
|
||||
| 5.5 | Избранное | `POST /recipes/{id}/favorite`, `DELETE /recipes/{id}/favorite`. Таблица favorites (user_id, recipe_id). `GET /recipes?favorite=true` |
|
||||
| 5.6 | Дозапрос Spoonacular | Если в локальной БД мало результатов по фильтрам — запрос к Spoonacular API (findByIngredients, complexSearch). Новые рецепты сохраняются в БД |
|
||||
| 4.1 | Таблицы menu_plans, menu_items | Миграции. menu_items → saved_recipes |
|
||||
| 4.2 | Таблица meal_diary | Миграция. Записи приёмов пищи |
|
||||
| 4.3 | POST /ai/generate-menu | Gemini генерирует 21 рецепт, Pexels параллельно, сохранение в БД |
|
||||
| 4.4 | Menu CRUD | GET /menu?week=, PUT /menu/items/{id}, DELETE /menu/items/{id} |
|
||||
| 4.5 | Shopping list | POST /shopping-list/generate (SQL-агрегация без Gemini), GET, PATCH check |
|
||||
|
||||
#### Flutter
|
||||
|
||||
| ID | Story | Описание |
|
||||
|----|-------|----------|
|
||||
| 5.7 | Экран каталога рецептов | Сетка 2 колонки, поиск, chip-фильтры, кнопка «Из моих продуктов», панель фильтров (bottom sheet), бесконечный скролл |
|
||||
| 5.8 | Карточка рецепта | Фото, рейтинг, метаинформация (время/сложность/кухня), калории/БЖУ, регулятор порций, список ингредиентов с ✅/❌/🔄, описание. CTA «Начать готовить», «Добавить в меню» |
|
||||
| 5.9 | Замены ингредиентов | Строка «→ Замена: пармезан (есть)» под ингредиентом с 🔄 |
|
||||
| 5.10 | Кнопка «Добавить в список покупок» | Недостающие ингредиенты → формирование позиций для списка покупок |
|
||||
| 4.6 | MenuScreen | 7-дневный вид, skeleton на генерацию, кнопка «Сгенерировать» |
|
||||
| 4.7 | Замена рецепта | Тап «Изменить» → выбор из saved_recipes или перегенерация |
|
||||
| 4.8 | ShoppingListScreen | Список по категориям, чекбоксы, «Поделиться» |
|
||||
| 4.9 | DiaryScreen | Записи за день, «+ Добавить» |
|
||||
|
||||
### Результат итерации
|
||||
- Пользователь ищет рецепты, фильтрует по кухне/сложности/времени/калориям
|
||||
- Видит, что можно приготовить из имеющихся продуктов
|
||||
- Для каждого рецепта — отметки наличия ингредиентов и предложения замен
|
||||
- Может добавить рецепт в избранное
|
||||
- Пользователь получает меню на неделю одним запросом к Gemini
|
||||
- Все рецепты меню сохраняются в saved_recipes
|
||||
- Из меню автоматически формируется список покупок (то, чего нет в запасах)
|
||||
- Ведётся дневник питания
|
||||
|
||||
---
|
||||
|
||||
## Итерация 6: Планирование меню
|
||||
## Итоги
|
||||
|
||||
**Цель:** пользователь может составлять меню на неделю — вручную или через AI-генерацию. Формируется список покупок.
|
||||
| Итерация | Цель | Ключевые API |
|
||||
|----------|------|-------------|
|
||||
| 0. Фундамент | Auth, профиль, каркас | Firebase |
|
||||
| 1. AI-рекомендации | Рецепты + сохранение | Gemini, Pexels |
|
||||
| 2. Продукты | CRUD запасов, ingredient_mappings | — |
|
||||
| 3. Распознавание | OCR чека, фото продуктов/блюда | Gemini Vision |
|
||||
| 4. Меню | Недельное меню, список покупок | Gemini, Pexels |
|
||||
|
||||
**Зависимости:** итерации 3 (AI-ядро для генерации), 5 (каталог рецептов).
|
||||
**MVP:** итерации 0–2 (авторизация + рекомендации + продукты) — пользователь получает персонализированные рецепты.
|
||||
|
||||
### User Stories
|
||||
|
||||
#### Backend
|
||||
|
||||
| ID | Story | Описание |
|
||||
|----|-------|----------|
|
||||
| 6.1 | Таблицы menu_plans и menu_items | Миграции. menu_plans: id, user_id, week_start, template_name. menu_items: id, menu_plan_id, day_of_week, meal_type, recipe_id, servings |
|
||||
| 6.2 | Menu CRUD | `GET /menu?week=`, `POST /menu/items`, `PUT /menu/items/{id}`, `DELETE /menu/items/{id}`. Подсчёт калорий за день/неделю |
|
||||
| 6.3 | AI-генерация меню | `POST /ai/generate-menu`: backend отбирает кандидатов из БД (SQL по фильтрам + наличие ингредиентов) → формирует промпт с recipe_id → Gemini ранжирует → backend сохраняет menu_items |
|
||||
| 6.4 | Шаблоны меню | `POST /menu/templates` (сохранить), `GET /menu/templates` (список), `POST /menu/from-template/{id}` (применить). История прошлых меню |
|
||||
| 6.5 | Таблица shopping_lists | Миграция: id, user_id, menu_plan_id, items (JSONB). Автогенерация из меню: ингредиенты рецептов − имеющиеся продукты = список |
|
||||
| 6.6 | Shopping list API | `GET /shopping-list`, `POST /shopping-list/generate`, `PUT /shopping-list/items/{idx}`, `PATCH /shopping-list/items/{idx}/check`, `POST /shopping-list/items` (ручная позиция) |
|
||||
|
||||
#### Flutter
|
||||
|
||||
| ID | Story | Описание |
|
||||
|----|-------|----------|
|
||||
| 6.7 | Экран меню | Понедельный календарь, слоты по приёмам пищи, калорийность за день, drag-and-drop, контекстное меню (···), пустые слоты с подсказками |
|
||||
| 6.8 | Добавление блюда в слот | Модалка выбора дня + приёма пищи. Переход в каталог рецептов для выбора |
|
||||
| 6.9 | AI-генерация | Кнопка ⚡ → экран параметров (период, кухня, сложность, из моих продуктов, калории) → генерация → отображение результата с возможностью заменить отдельные блюда |
|
||||
| 6.10 | Шаблоны и история | Выпадающее меню: сохранить как шаблон, загрузить из шаблона, из истории |
|
||||
| 6.11 | Экран списка покупок | Список по категориям, чекбоксы, свайп-удаление, ручное добавление, итого, «Поделиться», «Пересчитать из меню» |
|
||||
| 6.12 | Переходный экран «Составить меню?» | После добавления продуктов (чек/фото) → предложение сгенерировать меню с выбором параметров |
|
||||
|
||||
### Результат итерации
|
||||
- Пользователь составляет меню на неделю — вручную или AI-генерацией
|
||||
- AI подбирает рецепты из нашей БД с учётом продуктов, калорий, предпочтений
|
||||
- Формируется список покупок (автоматически из меню − запасы)
|
||||
- Можно сохранять шаблоны и повторять удачные меню
|
||||
- После сканирования чека — плавный переход к генерации меню
|
||||
|
||||
---
|
||||
|
||||
## Итерация 7: Дневник питания
|
||||
|
||||
**Цель:** пользователь ведёт учёт съеденного — записывает приёмы пищи, отслеживает калории и БЖУ, регулирует порции.
|
||||
|
||||
**Зависимости:** итерация 5 (рецепты для добавления из каталога).
|
||||
|
||||
### User Stories
|
||||
|
||||
#### Backend
|
||||
|
||||
| ID | Story | Описание |
|
||||
|----|-------|----------|
|
||||
| 7.1 | Таблица meal_diary | Миграция: id, user_id, date, meal_type, recipe_id (nullable), name, portions, calories, protein, fat, carbs, source (menu/photo/manual/recipe), created_at |
|
||||
| 7.2 | Diary CRUD | `GET /diary?date=`, `POST /diary`, `PUT /diary/{id}`, `DELETE /diary/{id}`. Подсчёт итогов дня (калории, БЖУ) |
|
||||
| 7.3 | Из меню в дневник | При отметке «съедено» на главном экране → автосоздание записи в дневнике. Списание ингредиентов из продуктов |
|
||||
| 7.4 | Трекер воды | Таблица water_tracker (user_id, date, glasses). `GET /stats/water?date=`, `PUT /stats/water` |
|
||||
| 7.5 | База продуктов для быстрого поиска | Endpoint для поиска по ingredient_mappings: `GET /ingredients/search?q=банан` → название + нутриенты на порцию. Для перекусов без рецепта |
|
||||
|
||||
#### Flutter
|
||||
|
||||
| ID | Story | Описание |
|
||||
|----|-------|----------|
|
||||
| 7.6 | Экран дневника питания | Навигация по дням, круговой прогресс калорий, прогресс-бары БЖУ, приёмы пищи, порции, «+ Добавить» |
|
||||
| 7.7 | Модалка добавления | Варианты: сфотографировать, из меню, из каталога, из избранного, быстрый поиск продукта, вручную |
|
||||
| 7.8 | Указание порции | Слайдер 0.5x–3x при добавлении из рецепта. Пересчёт калорий/БЖУ |
|
||||
| 7.9 | Быстрый поиск продукта | Поле поиска → результаты из ingredient_mappings → тап → добавить в дневник с указанием количества |
|
||||
| 7.10 | Трекер воды | Ряд стаканов внизу дневника, тап = +1/-1 |
|
||||
| 7.11 | Главный экран — карточка калорий | Круговой прогресс, тап → переход в дневник |
|
||||
| 7.12 | Главный экран — «Сегодня в меню» | Список из menu_items на сегодня с чекбоксами «съедено» |
|
||||
|
||||
### Результат итерации
|
||||
- Пользователь записывает приёмы пищи: из меню, каталога, фото или вручную
|
||||
- Регулирует порции, видит калории и БЖУ за день
|
||||
- На главном экране — прогресс калорий и чекбоксы «съедено»
|
||||
- Трекер воды
|
||||
|
||||
---
|
||||
|
||||
## Итерация 8: Режим готовки
|
||||
|
||||
**Цель:** пользователь готовит блюдо по пошаговой инструкции с таймерами. После завершения — запись в дневник и оценка.
|
||||
|
||||
**Зависимости:** итерация 5 (карточка рецепта для запуска).
|
||||
|
||||
### User Stories
|
||||
|
||||
| ID | Story | Описание |
|
||||
|----|-------|----------|
|
||||
| 8.1 | Экран пошаговой готовки | Фото шага, заголовок, описание (крупный шрифт), навигация «Назад»/«Далее», свайп, точечный индикатор прогресса |
|
||||
| 8.2 | Таймеры | Кнопка «Запустить таймер» на шагах с timer_seconds. Обратный отсчёт. Пауза, стоп. Несколько таймеров параллельно |
|
||||
| 8.3 | Панель активных таймеров | Фиксирована внизу. Показывает все запущенные таймеры с оставшимся временем |
|
||||
| 8.4 | Уведомления таймера | Push-уведомление + звук при завершении таймера. Модалка «Готово!» |
|
||||
| 8.5 | Keep screen on | Экран не гаснет в режиме готовки (wakelock) |
|
||||
| 8.6 | Закрытие с подтверждением | Кнопка ✕ → «Прервать готовку?» |
|
||||
| 8.7 | Экран завершения | «Приятного аппетита!» → «Записать в дневник» (с выбором порций), «Оценить рецепт», «Поделиться фото». Автосписание ингредиентов из запасов |
|
||||
|
||||
### Результат итерации
|
||||
- Пользователь готовит по шагам с фото и описанием
|
||||
- Запускает таймеры (параллельно), получает уведомления
|
||||
- По завершении — запись в дневник, оценка рецепта, списание продуктов
|
||||
|
||||
---
|
||||
|
||||
## Итерация 9: Рекомендации и статистика
|
||||
|
||||
**Цель:** приложение проактивно рекомендует рецепты. Пользователь видит аналитику своего питания.
|
||||
|
||||
**Зависимости:** итерации 6 (меню), 7 (дневник — данные для статистики).
|
||||
|
||||
### User Stories
|
||||
|
||||
#### Рекомендации
|
||||
|
||||
| ID | Story | Описание |
|
||||
|----|-------|----------|
|
||||
| 9.1 | Рекомендации на главном экране | Карусель «Рекомендуем приготовить». Алгоритм: (1) рецепты из продуктов с истекающим сроком, (2) полное совпадение ингредиентов, (3) предпочтения кухни. Endpoint: `GET /recipes/recommended` |
|
||||
| 9.2 | «Готовили недавно» | Секция на главном экране и в каталоге. Endpoint: `GET /recipes/recent` — последние 5 приготовленных (из meal_diary с source=recipe) |
|
||||
| 9.3 | Секция «Для вас» в каталоге | Персональные рекомендации на основе: оценок, предпочтений кухонь, истории. Endpoint: `GET /recipes/recommended?section=personal` |
|
||||
| 9.4 | Подсказки в пустых слотах меню | При пустом слоте меню — рекомендация на основе оставшихся калорий + продукты с истекающим сроком |
|
||||
|
||||
#### Статистика
|
||||
|
||||
| ID | Story | Описание |
|
||||
|----|-------|----------|
|
||||
| 9.5 | Endpoint статистики | `GET /stats?period=week|month|3months` — калории/БЖУ по дням, средние, тренды, самые частые блюда. Агрегация по meal_diary |
|
||||
| 9.6 | Экран статистики | Переключатель периода, столбчатая диаграмма калорий, stacked bar БЖУ, тренды (↑↓→), топ блюд |
|
||||
| 9.7 | Переход из главного экрана | Тап по прогресс-бару калорий → дневник или статистика |
|
||||
|
||||
### Результат итерации
|
||||
- На главном экране — рекомендации (приоритет на истекающие продукты) и «Готовили недавно»
|
||||
- В каталоге — секция «Для вас»
|
||||
- В меню — умные подсказки в пустых слотах
|
||||
- Графики калорий и БЖУ за неделю/месяц/3 месяца
|
||||
|
||||
---
|
||||
|
||||
## Итерация 10: Полировка
|
||||
|
||||
**Цель:** довести приложение до продуктового качества — онбординг, пустые состояния, уведомления, отзывы, переходные экраны.
|
||||
|
||||
**Зависимости:** итерация 9.
|
||||
|
||||
### User Stories
|
||||
|
||||
#### Онбординг
|
||||
|
||||
| ID | Story | Описание |
|
||||
|----|-------|----------|
|
||||
| 10.1 | Экраны онбординга | 5 шагов: приветствие (свайп-карточки), параметры тела, цель + расчёт нормы, ограничения + предпочтения кухонь, предложение добавить продукты |
|
||||
| 10.2 | Сохранение данных онбординга | `PUT /profile` с параметрами из онбординга. Сохранение предпочтений кухонь в preferences |
|
||||
| 10.3 | Флаг прохождения онбординга | Показывать только при первом входе. Флаг в secure storage |
|
||||
|
||||
#### Пустые состояния и ошибки
|
||||
|
||||
| ID | Story | Описание |
|
||||
|----|-------|----------|
|
||||
| 10.4 | Пустые состояния всех экранов | Иллюстрация + текст + CTA для: продуктов, меню, дневника, статистики, рецептов (избранные) |
|
||||
| 10.5 | Состояния ошибок | Нет сети (баннер + оффлайн-данные), ошибка AI (переснять / ввести вручную), ошибка сервера (повторить) |
|
||||
| 10.6 | Toast с отменой | При удалении записи из дневника, продукта — toast «Удалено» + кнопка «Отменить» (5 сек) |
|
||||
|
||||
#### Уведомления
|
||||
|
||||
| ID | Story | Описание |
|
||||
|----|-------|----------|
|
||||
| 10.7 | Push-уведомления (FCM) | Интеграция Firebase Cloud Messaging. Flutter: запрос разрешений, обработка |
|
||||
| 10.8 | Уведомления о сроках продуктов | Backend: cron-джоб утром → push «Молоко — осталось 1 день. Использовать в рецепте?» |
|
||||
| 10.9 | Напоминания о приёмах пищи | По расписанию (настраиваемое): «Время обеда! В меню: ...» |
|
||||
| 10.10 | Вечернее напоминание о воде | «Вы выпили 5 из 8 стаканов воды сегодня» |
|
||||
|
||||
#### Отзывы
|
||||
|
||||
| ID | Story | Описание |
|
||||
|----|-------|----------|
|
||||
| 10.11 | Таблица reviews | Миграция: id, user_id, recipe_id, rating, text, photo_url, created_at. Пересчёт avg_rating, review_count в recipes |
|
||||
| 10.12 | API отзывов | `GET /recipes/{id}/reviews` (пагинация), `POST /recipes/{id}/reviews` |
|
||||
| 10.13 | UI отзывов | Секция в карточке рецепта, модалка написания отзыва (звёзды + текст + фото), полный список отзывов |
|
||||
|
||||
#### Профиль
|
||||
|
||||
| ID | Story | Описание |
|
||||
|----|-------|----------|
|
||||
| 10.14 | Экран профиля | Аватар, параметры, цель, ограничения, предпочтения кухонь, ссылки (статистика, избранное, отзывы, сроки хранения, настройки) |
|
||||
| 10.15 | Настройки приложения | Экран: уведомления (вкл/выкл по типам), тема (светлая/тёмная/системная), норма воды, язык |
|
||||
|
||||
### Результат итерации
|
||||
- Новый пользователь проходит онбординг и сразу получает персонализированный опыт
|
||||
- Все экраны имеют осмысленные пустые состояния
|
||||
- Ошибки обрабатываются gracefully
|
||||
- Push-уведомления о сроках, приёмах пищи, воде
|
||||
- Можно оставлять отзывы к рецептам
|
||||
- Полноценный профиль с настройками
|
||||
|
||||
---
|
||||
|
||||
## Итоги по объёму
|
||||
|
||||
| Итерация | Backend stories | Flutter stories | Всего |
|
||||
|----------|----------------|-----------------|-------|
|
||||
| 0. Фундамент | 6 | 4 | 10 |
|
||||
| 1. Ингредиенты + рецепты | 6 | 0 | 6 |
|
||||
| 2. Продукты | 6 | 6 | 12 |
|
||||
| 3. AI-ядро | 8 | 0 | 8 |
|
||||
| 4. AI-распознавание | 5 | 6 | 11 |
|
||||
| 5. Каталог рецептов | 6 | 4 | 10 |
|
||||
| 6. Меню + покупки | 6 | 6 | 12 |
|
||||
| 7. Дневник питания | 5 | 7 | 12 |
|
||||
| 8. Режим готовки | 0 | 7 | 7 |
|
||||
| 9. Рекомендации + стат-ка | 4 | 3 | 7 |
|
||||
| 10. Полировка | 5 | 10 | 15 |
|
||||
| **Итого** | **57** | **53** | **110** |
|
||||
|
||||
## Приоритеты для MVP
|
||||
|
||||
Минимально жизнеспособный продукт — итерации **0–6**:
|
||||
|
||||
- Авторизация, продукты, AI-распознавание, рецепты, меню, список покупок
|
||||
- Позволяет пройти основной пользовательский сценарий: купил продукты → сфотографировал чек → получил меню → составил список покупок
|
||||
- **68 stories** из 110 (62%)
|
||||
|
||||
Итерации 7–10 — расширение до полного продукта.
|
||||
**Полный продукт:** итерации 0–4 — полный цикл: сфотографировал чек → получил меню → список покупок → дневник питания.
|
||||
|
||||
Reference in New Issue
Block a user