Files
food-ai/docs/plans/Iteration_4.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

368 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Итерация 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 на время генерации (510 сек)
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
```
┌─────────────────────────────────────┐
│ Меню [← Пред] [След →]│
│ Неделя 1622 февраля │
├─────────────────────────────────────┤
│ │
│ Понедельник, 16 фев 1 780 ккал │
│ ┌──────────────────────────────┐ │
│ │ 🌅 Завтрак ≈320 ккал │ │
│ │ [фото] Овсянка с яблоком │ │
│ │ [Изменить]│ │
│ ├──────────────────────────────┤ │
│ │ ☀ Обед ≈680 ккал │ │
│ │ [фото] Куриный суп │ │
│ │ [Изменить]│ │
│ ├──────────────────────────────┤ │
│ │ 🌙 Ужин ≈780 ккал │ │
│ │ [фото] Рис с овощами │ │
│ │ [Изменить]│ │
│ └──────────────────────────────┘ │
│ │
│ Вторник, 17 фев 1 820 ккал │
│ ┌──────────────────────────────┐ │
│ │ ... │ │
│ └──────────────────────────────┘ │
│ │
│ ┌─────────────────────────────┐ │
│ │ ✨ Сгенерировать новое меню │ │
│ └─────────────────────────────┘ │
│ ┌─────────────────────────────┐ │
│ │ 🛒 Список покупок │ │
│ └─────────────────────────────┘ │
│ │
├─────────────────────────────────────┤
│ [Главная] [Продукты] [Меню●] [Рецепты] [Профиль] │
└─────────────────────────────────────┘
```
### Skeleton при генерации (510 сек)
```
┌─────────────────────────────────────┐
│ Меню │
├─────────────────────────────────────┤
│ │
│ Составляем меню на неделю... │
│ Учитываем ваши продукты и цели │
│ │
│ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │
│ ░░░░░░░░░ ░░░░░░░░░░░░░░░░░░░ │
│ ░░░░░░░░░░░░░░░ ░░░░░░░░░░░░░░ │
│ │
│ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │
│ ░░░░░░░░░ ░░░░░░░░░░░░░░░░░░░ │
│ │
└─────────────────────────────────────┘
```
---
## 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.