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

644
docs/Flow.md Normal file
View File

@@ -0,0 +1,644 @@
# Flow: взаимодействие пользователя → бэкенда → сторонних API
## Содержание
1. [Архитектура системы](#1-архитектура-системы)
2. [Ключевой принцип: сторонние API только там где нужны](#2-ключевой-принцип)
3. [Flow 1: Аутентификация](#3-flow-1-аутентификация)
4. [Flow 2: Обновление токена](#4-flow-2-обновление-токена)
5. [Flow 3: Профиль пользователя](#5-flow-3-профиль-пользователя)
6. [Flow 4: Рекомендации рецептов](#6-flow-4-рекомендации-рецептов)
7. [Flow 5: Сохранённые рецепты](#7-flow-5-сохранённые-рецепты)
8. [Flow 6: Управление продуктами (Итерация 2)](#8-flow-6-управление-продуктами-итерация-2)
9. [Flow 7: Распознавание продуктов (Итерация 3)](#9-flow-7-распознавание-продуктов-итерация-3)
10. [Flow 8: Планирование меню (Итерация 4)](#10-flow-8-планирование-меню-итерация-4)
11. [Анализ потребления сторонних API](#11-анализ-потребления-сторонних-api)
12. [Количество запросов к бэкенду по сценариям](#12-количество-запросов-к-бэкенду-по-сценариям)
13. [Сводная таблица](#13-сводная-таблица)
---
## 1. Архитектура системы
```
┌────────────────────────────────────────────────────────────────────┐
│ Flutter Client │
│ (Android / iOS / Web) │
│ - Firebase Auth (Google / Apple / Email) │
│ - Dio HTTP Client + Auth Interceptor │
│ - FlutterSecureStorage (токены) │
│ - Riverpod (состояние) │
└────────────────────────┬───────────────────────────────────────────┘
│ HTTPS, Bearer JWT
┌────────────────────────▼───────────────────────────────────────────┐
│ Go Backend (chi v5) │
│ - JWT middleware (HS256, верификация без Firebase в рантайме) │
│ - Gemini API (генерация рекомендаций рецептов) │
│ - Pexels API (подбор фотографий к рецептам) │
│ - Firebase Admin SDK (только при логине) │
│ - Калькулятор КБЖУ (Mifflin-St Jeor, локально) │
└──────┬─────────────────────────────────────────────────────────────┘
┌──────▼──────────────────────────────────────────────────────────────┐
│ PostgreSQL 15 │
│ users · saved_recipes · products (Iter.2) · ingredient_mappings │
└─────────────────────────────────────────────────────────────────────┘
Сторонние API:
┌────────────────────┐ ┌─────────────────────────────────────┐
│ Firebase Auth │ │ Google Gemini 2.0 Flash │
│ только логин │ │ генерация рецептов-рекомендаций │
└────────────────────┘ └─────────────────────────────────────┘
┌────────────────────┐
│ Pexels API │
│ фото к рецептам │
└────────────────────┘
```
---
## 2. Ключевой принцип
**Сторонние API вызываются только при конкретных пользовательских действиях**, а не фоновых задачах:
| API | Когда вызывается | Частота |
|-----|-----------------|---------|
| **Firebase Auth** | Только `POST /auth/login` | 1 раз за сессию |
| **Gemini** | `GET /recommendations`, `POST /ai/recognize-*`, `POST /ai/generate-menu` | По запросу пользователя |
| **Pexels** | Внутри рекомендаций и генерации меню | 1 вызов на рецепт |
Все токены, профили и сохранённые рецепты хранятся в PostgreSQL и раздаются **без внешних вызовов**.
---
## 3. Flow 1: Аутентификация
### 3.1 Первый вход (Google OAuth)
```
Пользователь Flutter Firebase Go Backend PostgreSQL
│ │ │ │ │
│── тапает "Войти" ─►│ │ │ │
│ │── signInWithGoogle()►│ │ │
│ │◄── idToken ─────────│ │ │
│ │ │ │ │
│ │── POST /auth/login ─────────────────────►│ │
│ │ { firebase_token: idToken } │ │
│ │ │◄── VerifyIDToken() ─│ │
│ │ │──► { uid, email } ──►│ │
│ │ │ │── UPSERT users ──►│
│ │ │ │◄── User{id, plan}─│
│ │ │ │── генерирует JWT │
│ │ │ │── UPDATE refresh ─►│
│ │◄── { access_token, refresh_token, user } ─│ │
│ │── сохраняет в SecureStorage │ │
│◄── Home Screen ───│ │ │ │
```
**Запросы на бэкенд:** 1
**Вызовов Firebase:** 1 (VerifyIDToken — серверная верификация)
**SQL:** 2 (UPSERT users + UPDATE refresh_token)
### 3.2 Последующие запросы (с JWT)
```
Flutter ── GET /profile ──► middleware.Auth
└── ValidateAccessToken() — локально, HMAC HS256
Firebase НЕ вызывается
──► handler ──► SELECT users WHERE id=$1
```
**Вызовов Firebase:** 0 (JWT верифицируется локально по секрету)
---
## 4. Flow 2: Обновление токена
Происходит автоматически в `AuthInterceptor` при 401 или истечении токена (15 мин TTL).
```
Flutter (AuthInterceptor) Go Backend PostgreSQL
│ │ │
│── POST /auth/refresh ──────►│ │
│ { refresh_token } │── SELECT users WHERE ──►│
│ │ refresh_token=$1 AND │
│ │ token_expires_at>now()│
│ │◄── User ───────────────│
│ │── новый JWT + UUID │
│ │── UPDATE users ────────►│
│◄── { access_token, │ │
│ refresh_token } │ │
│── повторяет исходный запрос│ │
```
**Запросов к Firebase:** 0
**SQL:** 2 (SELECT + UPDATE)
---
## 5. Flow 3: Профиль пользователя
### Просмотр
```
Flutter → GET /profile → SELECT * FROM users WHERE id=$1
```
**Запросов на бэкенд:** 1 | **SQL:** 1
### Обновление (онбординг)
```
Flutter → PUT /profile → Go Backend
{ height_cm, weight_kg, │
age, gender, │── Mifflin-St Jeor (локально):
activity, goal } │ BMR = 10W + 6.25H - 5A + offset
│ TDEE = BMR × activity_factor
│ Calories = TDEE ± goal_delta
│── UPDATE users SET daily_calories=...
```
**Запросов к сторонним API:** 0 (всё считается на Go)
**Запросов на бэкенд:** 1 | **SQL:** 1
---
## 6. Flow 4: Рекомендации рецептов
Центральный flow приложения. Вызывается когда пользователь открывает экран рекомендаций.
### 6.1 Полный flow
```
Пользователь Flutter Go Backend Gemini Pexels PostgreSQL
│ │ │ │ │ │
│── открывает │ │ │ │ │
│ экран ───►│ │ │ │ │
│ │── GET /recommendations ─────────►│ │ │
│ │ ?count=5 │ │ │
│ │ │ │ │
│ │ │── SELECT user ──────────────────────────────── ►│
│ │ │ profile + products (Iter.2) ◄────────────────│
│ │ │ │ │ │
│ │ │── GenerateContent(prompt) ────►│ │
│ │ │ prompt содержит: │ │
│ │ │ - цель пользователя │ │
│ │ │ - дневные калории │ │
│ │ │ - список продуктов (Iter.2) │ │
│ │ │ - N=5 рецептов │ │
│ │ │◄── JSON: [Recipe×5] ───────────│ │
│ │ │ каждый с image_query │ │
│ │ │ │ │ │
│ │ │ для каждого рецепта: │ │
│ │ │── GET /v1/search?query=... ─────────────────── ►│
│ │ │ Authorization: Pexels key │ │
│ │ │◄── { photos[0].src.medium } ───────────────────│
│ │ │ │ │ │
│◄── [Recipe×5 c image_url] ─│ │ │ │
```
### 6.2 Структура промпта для Gemini
```
Ты — диетолог-повар. Предложи {N} рецептов на русском языке.
Профиль пользователя:
- Цель: похудение
- Дневная норма калорий: 1800 ккал
- Ограничения: без глютена (если есть в preferences)
[Итерация 2+] Доступные продукты:
- куриная грудка (500г)
- помидоры (3 шт)
- ...
Требования к каждому рецепту:
- Калорийность на порцию: не более 600 ккал
- Время приготовления: до 40 минут
- Укажи КБЖУ на порцию (приблизительно)
Верни ТОЛЬКО валидный JSON массив без markdown:
[{
"title": "Название рецепта",
"description": "Краткое описание (2-3 предложения)",
"cuisine": "mediterranean",
"difficulty": "easy|medium|hard",
"prep_time_min": 10,
"cook_time_min": 20,
"servings": 2,
"image_query": "grilled chicken breast vegetables mediterranean",
"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
}
}]
```
### 6.3 Кэширование рекомендаций
Рекомендации **не сохраняются** автоматически. При каждом открытии экрана генерируются заново. Это дает:
- Свежесть контента
- Учёт изменившихся продуктов (Итерация 2)
Возможная оптимизация в будущем: кэшировать последний набор рекомендаций в Redis/памяти на 30 минут, инвалидировать при обновлении продуктов.
---
## 7. Flow 5: Сохранённые рецепты
### 7.1 Сохранить рекомендацию
```
Пользователь тапает ❤️ на рецепте
Flutter → POST /saved-recipes → PostgreSQL
{ полный JSON рецепта } └── INSERT INTO saved_recipes
(user_id, title, steps,
ingredients, nutrition,
image_url, ...)
← { id, saved_at }
```
**Запросов на бэкенд:** 1
**Вызовов Gemini/Pexels:** 0 (данные уже есть в клиенте)
**SQL:** 1
### 7.2 Список сохранённых
```
Flutter → GET /saved-recipes → SELECT * FROM saved_recipes
WHERE user_id=$1
ORDER BY saved_at DESC
```
**Запросов на бэкенд:** 1 | **SQL:** 1
### 7.3 Удалить из сохранённых
```
Flutter → DELETE /saved-recipes/{id} → DELETE FROM saved_recipes
WHERE id=$1 AND user_id=$2
```
**Запросов на бэкенд:** 1 | **SQL:** 1
---
## 8. Flow 6: Управление продуктами (Итерация 2)
### 8.1 Открытие списка продуктов
```
Flutter → GET /products → SELECT * FROM products
WHERE user_id=$1
ORDER BY expires_at ASC
```
**Запросов на бэкенд:** 1
### 8.2 Добавление продукта с автодополнением
```
Пользователь вводит "кур" (debounce 300мс)
Flutter → GET /ingredients/search?q=кур → PostgreSQL
├── ILIKE на canonical_name_ru
├── GIN на aliases
└── pg_trgm similarity
Пользователь выбирает "Куриная грудка"
│ поля автозаполняются локально
Flutter → POST /products → INSERT INTO products
{ mapping_id, expires_at GENERATED ALWAYS AS
name, quantity, (added_at + storage_days days)
unit, category,
storage_days }
```
**Запросов на бэкенд:** 35 (поиск, debounce) + 1 (создание)
**Вызовов сторонних API:** 0
### 8.3 Связь продуктов с рекомендациями (Итерация 2+)
После того как у пользователя есть продукты, `GET /recommendations` включает их в промпт для Gemini. Рекомендации становятся персонализированными: "что приготовить из того, что есть".
---
## 9. Flow 7: Распознавание продуктов (Итерация 3)
Пользователь фотографирует чек, холодильник или готовое блюдо — Gemini Vision распознаёт содержимое и заполняет список продуктов.
### 9.1 Распознавание чека
```
Пользователь Flutter Go Backend Gemini PostgreSQL
│ │ │ │ │
│─ фото чека►│ │ │ │
│ │── POST /ai/recognize-receipt ─────────►│ │
│ │ multipart/form-data: image │ │
│ │ │── GenerateContent ►│ │
│ │ │ prompt: OCR чека │ │
│ │ │◄── JSON: [{name, │ │
│ │ │ qty, unit, │ │
│ │ │ category, │ │
│ │ │ confidence}] │ │
│ │ │ │ │
│ │ │ fuzzy match по ingredient_mappings►│
│ │ │◄── mapping_id ─────────────────────│
│ │ │ │ │
│◄── [{name, mapping_id, qty, │ │ │
│ unit, storage_days}] ──────│ │ │
│ │ │ │ │
│─ подтверждает список ─────────►│ │ │
│ POST /products/batch │── INSERT products ►│ │
```
**Запросов на бэкенд:** 2 (recognize + batch insert)
**Gemini:** 1 (vision)
**SQL:** 1 SELECT (fuzzy match) + 1 INSERT batch
### 9.2 Распознавание фото продуктов (холодильник/стол)
Аналогичен чеку, но без цены. Поддерживает несколько фото:
```
Пользователь делает 13 фото холодильника
Flutter → POST /ai/recognize-products → Gemini Vision
(multipart, несколько фото) └── анализирует каждое фото
└── объединяет результаты
(дедупликация по canonical_name)
Backend:
1. Для каждого фото → 1 Gemini-запрос (параллельно)
2. Объединение списков, дедупликация (суммирование количества)
3. Fuzzy match по ingredient_mappings
4. Возврат клиенту для подтверждения
5. POST /products/batch → INSERT
```
**Gemini:** 13 (по числу фото, параллельно)
### 9.3 Распознавание блюда (фото → калории)
```
Пользователь Flutter Go Backend Gemini PostgreSQL
│ │ │ │ │
│─ фото блюда►│ │ │ │
│ │── POST /ai/recognize-dish ────────────►│ │
│ │ │── GenerateContent ►│ │
│ │ │ prompt: распознай│ │
│ │ │ блюдо, КБЖУ │ │
│ │ │◄── {dish_name, │ │
│ │ │ weight_g, │ │
│ │ │ calories, │ │
│ │ │ protein, fat, │ │
│ │ │ carbs, │ │
│ │ │ confidence} │ │
│ │ │ │ │
│ │ │ Опционально: поиск│ │
│ │ │ в saved_recipes │ │
│ │ │ по dish_name │ │
│ │ │ │ │
│◄── {dish, calories, КБЖУ≈, │ │ │
│ matched_recipe?} ──────────│ │ │
│ │ │ │ │
│─ добавить в дневник? ─────────►│ │ │
│ POST /diary │── INSERT meal_diary►│ │
```
**Запросов на бэкенд:** 2 (recognize + diary)
**Gemini:** 1 (vision)
---
## 10. Flow 8: Планирование меню (Итерация 4)
Пользователь запрашивает меню на неделю — Gemini генерирует полный план питания с рецептами на основе продуктов, профиля и целей пользователя.
### 10.1 Генерация меню
```
Пользователь Flutter Go Backend Gemini Pexels PostgreSQL
│ │ │ │ │ │
│─ «Составить │ │ │ │ │
│ меню» ──►│ │ │ │ │
│ │── POST /ai/generate-menu ─────────────►│ │ │
│ │ { period: "week", │ │ │
│ │ meals_per_day: 3 } │ │ │
│ │ │ │ │ │
│ │ SELECT user profile + products ────────────────────────►│
│ │ ◄── {goal, КБЖУ, products list} ───────────────────────│
│ │ │ │ │ │
│ │ │── GenerateContent ►│ │ │
│ │ │ промпт: │ │ │
│ │ │ - профиль юзера │ │ │
│ │ │ - продукты │ │ │
│ │ │ - период 7 дней │ │ │
│ │ │ - 3 приёма/день │ │ │
│ │ │◄── JSON: 21 recipe │ │ │
│ │ │ каждый с │ │ │
│ │ │ image_query │ │ │
│ │ │ │ │ │
│ │ │ для каждого рецепта (параллельно): │
│ │ │── GET /v1/search?query=... ──────────────────────►│
│ │ │◄── photo_url ────────────────────────────────────│
│ │ │ │ │ │
│ │ │── INSERT menu_plans + menu_items ───────────────►│
│ │ │── INSERT saved_recipes (рецепты меню) ──────────►│
│ │ │ │ │ │
│◄── {menu_plan_id, │ │ │ │
│ days: [{day, meals: [ │ │ │ │
│ {meal_type, recipe} │ │ │ │
│ ]}]} ──────────────────────│ │ │ │
```
**Gemini:** 1 (большой промпт, ~21 рецепт)
**Pexels:** до 21 (параллельно; на практике повторяющиеся query кешируются)
**SQL:** 1 SELECT + batch INSERT menu_plans/items + batch INSERT saved_recipes
### 10.2 Просмотр и редактирование меню
```
Flutter → GET /menu?week=2026-W08 → SELECT menu_plans, menu_items WHERE user_id=$1 AND week_start=$2
LEFT JOIN saved_recipes ON menu_items.recipe_id
Flutter → PUT /menu/items/{id} → UPDATE menu_items SET recipe_id=$1
Flutter → DELETE /menu/items/{id} → DELETE menu_items WHERE id=$1 AND user_id=$2
```
**Запросов к бэкенду:** 13 | **Gemini:** 0 | **SQL:** 12
### 10.3 Список покупок из меню
```
Flutter → POST /shopping-list/generate → Go Backend
{ menu_plan_id } ├── SELECT menu_items JOIN saved_recipes
│ WHERE menu_plan_id=$1
├── Агрегация ингредиентов:
│ суммирование по canonical_name
│ вычитание того, что уже есть в products
└── INSERT/UPDATE shopping_lists
Gemini НЕ участвует. Чистая SQL-агрегация.
```
**Gemini:** 0 | **SQL:** 3
---
## 11. Анализ потребления сторонних API
### 9.1 Firebase Auth
| Сценарий | Вызовов |
|----------|---------|
| Логин | 1 (VerifyIDToken) |
| Обычный запрос с JWT | **0** |
| Refresh токена | **0** |
### 9.2 Gemini (Google Gemini 2.0 Flash)
**Тарифы Free tier:**
| Параметр | Значение |
|----------|----------|
| RPM | 15 (Flash) / 30 (Flash-Lite) |
| Запросов/день | 1 500 |
| Токенов/минуту | 1 000 000 |
**Расход на запрос рекомендаций (5 рецептов):**
| Метрика | Значение |
|---------|----------|
| Input токены (промпт + продукты) | ~500800 |
| Output токены (5 рецептов JSON) | ~1 5002 500 |
| Gemini-запросов | **1** |
| Стоимость (Flash, платный) | ~$0.0003 |
**Дневное потребление при 100 активных пользователях (каждый открывает рекомендации 3 раза/день):**
- 100 × 3 = 300 Gemini-запросов/день
- Free tier: 1 500/день → **хватает на ~5× текущую нагрузку**
- Платный Flash: ~$0.09/день = ~$2.7/мес
### 9.3 Pexels API
**Тарифы:**
| Тариф | Запросов/час | Запросов/мес |
|-------|-------------|-------------|
| Free | 200 | 20 000 |
**Расход:**
- 1 рекомендация = 5 рецептов = **5 Pexels-запросов**
- 100 пользователей × 3 рекомендации = 300 запросов/час пик
- ⚠️ **200 req/hour лимит может стать узким местом при пиковой нагрузке**
**Стратегия:** кэшировать image_url в `saved_recipes`, для несохранённых рекомендаций — запрашивать при генерации. При росте нагрузки — кэшировать по `image_query` в Redis (большинство запросов повторяются: "grilled chicken", "pasta carbonara", etc.).
---
## 12. Количество запросов к бэкенду по сценариям
### Первый запуск (новый пользователь)
| Шаг | Endpoint | Сторонний API |
|-----|----------|---------------|
| Вход через Google | POST /auth/login | Firebase (1×) |
| Загрузка профиля | GET /profile | — |
| Онбординг | PUT /profile | — |
| Первые рекомендации | GET /recommendations | Gemini (1×) + Pexels (5×) |
| **Итого** | **4** | **Firebase×1, Gemini×1, Pexels×5** |
### Обычная сессия
| Шаг | Endpoint | Кол-во |
|-----|----------|--------|
| Refresh токена (если истёк) | POST /auth/refresh | 01 |
| Открыть рекомендации | GET /recommendations | 1 |
| Сохранить рецепт | POST /saved-recipes | 1 |
| Открыть сохранённые | GET /saved-recipes | 1 |
| **Итого** | **34** | **Gemini×1, Pexels×5** |
### Сценарий: пользователь не взаимодействует с рекомендациями
```
Открывает приложение → просматривает сохранённые рецепты
Запросов: 1 GET /saved-recipes
Сторонних API: 0
SQL: 1
```
### Детальный breakdown: GET /recommendations
```
1. SELECT users WHERE id=$1 → 1 SQL
2. [Iter.2+] SELECT products WHERE user_id=$1 → 1 SQL
3. Gemini.GenerateContent(prompt) → 1 Gemini req (~13 сек)
4. Pexels.Search(image_query) × 5 (параллельно) → 5 Pexels req (параллельно)
5. Формирование ответа и отдача → 0 SQL
Итого: 12 SQL + 1 Gemini + 5 Pexels
Время ответа: ~24 секунды (доминирует Gemini latency)
```
---
## 13. Сводная таблица
### Сторонние API в рантайме
| API | Trigger | Вызовов на запрос | Free tier (день) |
|-----|---------|------------------|-----------------|
| Firebase Auth | POST /auth/login | 1 | Без ограничений |
| Gemini Flash | GET /recommendations | 1 | 1 500 |
| Gemini Flash | POST /ai/recognize-receipt | 1 | — |
| Gemini Flash | POST /ai/recognize-products | 13 (фото) | — |
| Gemini Flash | POST /ai/recognize-dish | 1 | — |
| Gemini Flash | POST /ai/generate-menu | 1 | — |
| Pexels | GET /recommendations | 5 (параллельно) | ~667 рекомендаций |
| Pexels | POST /ai/generate-menu | до 21 (параллельно) | — |
### Запросы к бэкенду
| Сценарий | Бэкенд | Firebase | Gemini | Pexels |
|----------|--------|----------|--------|--------|
| Первый вход | 1 | 1 | 0 | 0 |
| Просмотр профиля | 1 | 0 | 0 | 0 |
| Обновление профиля | 1 | 0 | 0 | 0 |
| Рекомендации | 1 | 0 | 1 | 5 |
| Сохранить рецепт | 1 | 0 | 0 | 0 |
| Список сохранённых | 1 | 0 | 0 | 0 |
| Удалить из сохранённых | 1 | 0 | 0 | 0 |
| Refresh токена | 1 | 0 | 0 | 0 |
| Распознавание чека | 2 | 0 | 1 | 0 |
| Распознавание фото продуктов | 2 | 0 | 13 | 0 |
| Распознавание блюда | 2 | 0 | 1 | 0 |
| Генерация меню (неделя) | 1 | 0 | 1 | до 21 |
| Просмотр меню | 1 | 0 | 0 | 0 |
| Список покупок из меню | 1 | 0 | 0 | 0 |
### Ключевые выводы
1. **Критические пути требуют Gemini + Pexels:** рекомендации (24 сек), распознавание продуктов (13 сек), генерация меню (510 сек). Во всех случаях нужна skeleton-загрузка в UI.
2. **Pexels — потенциальный bottleneck** при масштабировании (200 req/hour). Особенно при генерации меню (до 21 вызова). Решается кэшированием image_url по query-строке в Redis.
3. **Всё остальное работает без внешних зависимостей** — отказ Gemini/Pexels не роняет авторизацию, профиль, сохранённые рецепты, меню (просмотр/редактирование).
4. **КБЖУ приблизительные** — Gemini генерирует оценочные значения. Для MVP этого достаточно; точные данные требуют интеграции с верифицированной базой (USDA FoodData Central, см. TODO.md).
5. **Gemini Free tier (1 500 req/day):** распознавание продуктов (3 AI-операции) + рекомендации (1) + меню (1) = ~5 Gemini-запросов на активного пользователя. Free tier хватает на 300 DAU.

89
docs/TODO.md Normal file
View File

@@ -0,0 +1,89 @@
# TODO: будущие улучшения
Функционал, сознательно отложенный. Основные AI-фичи (рекомендации, распознавание, меню) реализуются через Gemini в итерациях 14.
---
## База данных рецептов и нутриентов
### Верифицированная база нутриентов
Сейчас КБЖУ генерирует Gemini (приблизительно, помечается «≈» в UI). Для пользователей с медицинскими показаниями (диабет, ожирение) нужна точность:
- **USDA FoodData Central** — государственная база США, бесплатно, 300K+ продуктов, верифицированы лабораторно
- API: `api.nal.usda.gov/fdc/v1/` (ключ бесплатный)
- Данные используются как reference при генерации Gemini
- **Open Food Facts** — community база для упакованных продуктов со штрих-кодами
### Постоянная база рецептов с поиском
Сейчас рецепты генерируются on-demand и хранятся только если сохранены. В будущем:
- Постоянная база 5K50K рецептов с FTS-поиском
- Фильтрация, рейтинги, отзывы, история просмотров
- Каталог с пагинацией
- Возможные источники: Spoonacular (коммерческая лицензия), собственная редакция + Gemini
---
## Функциональность
### Поиск по рецептам
Когда будет постоянная база — полноценный поиск:
- Full-text search по названию и ингредиентам (PostgreSQL tsvector, индексы уже в схеме)
- Фильтры: кухня, сложность, время, КБЖУ, диетические теги
- "Что можно приготовить из этих продуктов" — SQL-запрос по mapping_id
### Дневник питания и статистика
- Запись что съедено за день (из рецепта, из меню, вручную, фото)
- Автоподсчёт КБЖУ за день, прогресс к норме
- Графики за день / неделю / месяц
- Быстрое добавление перекусов через поиск
### Рейтинги и отзывы рецептов
- Оценка и отзыв после готовки
- Поля `avg_rating` и `review_count` уже есть в схеме `recipes`
- Реализовать когда появится постоянная база
### Шаблоны меню
- Сохранить удачное меню как шаблон (например «Рабочая неделя»)
- Повторное применение с учётом текущих продуктов
### Пользовательские рецепты
- Создать и сохранить собственный рецепт
- Доступен в личном каталоге, не виден другим (или можно поделиться)
---
## Технический долг
### Кэширование
- **Redis** для кэша Pexels image_url по query-строке (сейчас: новый Pexels-запрос при каждой генерации)
- **Кэш рекомендаций** на 30 минут — не перегенерировать если продукты не изменились
### Оффлайн-режим
- Кэшировать последние рекомендации и меню локально (Hive/SharedPreferences)
- Сохранённые рецепты — полностью оффлайн
### Уведомления
- Push-уведомления о продуктах, срок которых истекает завтра
- Напоминание приготовить по плану меню
### Монетизация
- **Free tier:** N рекомендаций/день, без меню на неделю
- **Premium:** неограниченные рекомендации, планировщик меню, расширенная аналитика, приоритетная очередь Gemini
### Масштабирование Gemini при росте
При 10 000 DAU × 5 AI-запросов/день = 50 000 запросов/день:
- Gemini Flash: ~$0.0003/запрос → **$15/день = $450/мес**
- Оптимизация: батчинг, кэширование, rate limiting по плану

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.

241
docs/plans/Iteration_2.md Normal file
View 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
View 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: 13 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.01.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.01.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.01.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 сканирование/день:
- 100300 дополнительных Gemini-запросов/день
- Free tier (1 500/день) остаётся в запасе

367
docs/plans/Iteration_4.md Normal file
View 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 на время генерации (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.

View File

@@ -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 00010 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 00010 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: 13 фото → параллельные 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:** итерации 02 (авторизация + рекомендации + продукты) — пользователь получает персонализированные рецепты.
### 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.5x3x при добавлении из рецепта. Пересчёт калорий/БЖУ |
| 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
Минимально жизнеспособный продукт — итерации **06**:
- Авторизация, продукты, AI-распознавание, рецепты, меню, список покупок
- Позволяет пройти основной пользовательский сценарий: купил продукты → сфотографировал чек → получил меню → составил список покупок
- **68 stories** из 110 (62%)
Итерации 710 — расширение до полного продукта.
**Полный продукт:** итерации 04 — полный цикл: сфотографировал чек → получил меню → список покупок → дневник питания.