docs: update README, env example, and design docs

- backend/.env.example: add GEMINI_API_KEY and PEXELS_API_KEY placeholders
- backend/Makefile: add test-integration to PHONY targets
- backend/README.md: document external API keys, import/translate commands
- docs/Design.md, docs/Tech.md: reflect Iteration 1 implementation and future plans

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
dbastrikin
2026-02-21 22:49:29 +02:00
parent e57ff8e06c
commit 0dbda0cd57
5 changed files with 296 additions and 201 deletions

View File

@@ -953,98 +953,93 @@
---
## 9. Каталог рецептов
## 9. AI-рекомендации рецептов
Поиск и просмотр рецептов с фильтрацией и персональными рекомендациями.
Персонализированные рецепты, сгенерированные Gemini на основе продуктов пользователя, цели и предпочтений. Статического каталога нет — каждый запрос даёт новую подборку.
```
┌─────────────────────────────────────┐
│ Рецепты
│ Рецепты [🔄]
├─────────────────────────────────────┤
│ ┌─────────────────────────────────┐ │
│ │ 🔍 Найти рецепт... │ │
│ └─────────────────────────────────┘ │
│ │
[Из моих продуктов] [Фильтры ▾]
Для вас сегодня
На основе ваших продуктов │
│ │
Для вас
┌──────────┐ ┌──────────┐
│ │ [фото] │ │ [фото] │
│ │ Том Ям │ │ Пад Тай │
│ │ ★4.8 │ │ ★4.6 │
│ │ 320 ккал │ │ 450 ккал │
│ │ Есть всё✓│ │ -2 прод. │
│ └──────────┘ └──────────┘
┌─────────────────────────────┐
│ [фото блюда]
│ │
│ │ Куриная грудка с овощами
│ │ ≈ 420 ккал · 35 мин · Лёгко│
│ │ Б: 48г Ж: 12г У: 18г
│ │ [♡] │
│ └─────────────────────────────┘ │
│ │
Готовили недавно
┌─────────────────────────────┐
│ │ [фото блюда] │ │
│ │ │ │
│ │ Рисовый суп с яйцом │ │
│ │ ≈ 310 ккал · 20 мин · Лёгко│ │
│ │ Б: 18г Ж: 8г У: 42г │ │
│ │ [♡] │ │
│ └─────────────────────────────┘ │
│ │
│ ┌─────────────────────────────┐ │
│ │ [фото блюда] │ │
│ │ │ │
│ │ Морковный суп-пюре │ │
│ │ ≈ 180 ккал · 25 мин · Лёгко│ │
│ │ Б: 4г Ж: 7г У: 26г │ │
│ │ [♡] │ │
│ └─────────────────────────────┘ │
│ │
│ ┌─────────────────────────────┐ │
│ │ + ещё 2 рецепта │ │
│ └─────────────────────────────┘ │
│ │
│ Сохранённые рецепты │
│ ┌────────┐ ┌────────┐ ┌────────┐ │
│ │[фото] │ │[фото] │ │[фото] │ │
│ │Карбон. │ │Борщ │ │Цезарь │ │
│ │♡ saved │ │♡ saved │ │♡ saved │ │
│ └────────┘ └────────┘ └────────┘ │
Все рецепты │
│ ┌──────────┐ ┌──────────┐ │
│ │ [фото] │ │ [фото] │ │
│ │ │ │ │ │
│ │ Ризотто │ │ Борщ │ │
│ │ ★4.5 │ │ ★4.9 │ │
│ │ 480 ккал │ │ 350 ккал │ │
│ │ 50 мин │ │ 90 мин │ │
│ │ Сложная │ │ Средняя │ │
│ └──────────┘ └──────────┘ │
│ │
│ ┌──────────┐ ┌──────────┐ │
│ │ ... │ │ ... │ │
│ └──────────┘ └──────────┘ │
[Все сохранённые →]
│ │
├─────────────────────────────────────┤
│ [Главная] [Продукты] [Меню] [Рецепты] [Профиль] │
└─────────────────────────────────────┘
```
### Панель фильтров (раскрывается по тапу «Фильтры ▾»)
### Скелетон при загрузке (24 сек)
```
┌─────────────────────────────────────┐
Фильтры [Сброс]
Рецепты
├─────────────────────────────────────┤
│ │
Приём пищи
│ [Завтрак] [Обед] [Ужин] [Перекус] │
Подбираем рецепты...
│ │
Кухня
[Русская] [Азиатская] [Европейская]
[Средиземноморская] [Американская]
┌─────────────────────────────┐
│ ░░░░░░░░░░░░░░░░░░░░░░░░░ │
│ ░░░░░░░░░░░░░░░░░░░░░░░░░ │
│ │ ░░░░░░░░ ░░░░░ ░░░░░░░░ │ │
│ └─────────────────────────────┘ │
│ ┌─────────────────────────────┐ │
│ │ ░░░░░░░░░░░░░░░░░░░░░░░░░ │ │
│ │ ░░░░░░░░░░░░░░░░░░░░░░░░░ │ │
│ │ ░░░░░░░░ ░░░░░ ░░░░░░░░ │ │
│ └─────────────────────────────┘ │
│ │
│ Сложность │
│ [Простая] [Средняя] [Сложная] │
│ │
│ Время приготовления │
│ [до 15 мин] [до 30 мин] [до 60 мин]│
│ [более 60 мин] │
│ │
│ Калорийность (на порцию) │
│ ○────────────● до 500 ккал │
│ │
│ Диета │
│ [Вегетар.] [Безглютен.] [Низкокал.] │
│ [Кето] [Без лактозы] │
│ │
│ ┌───────────────────────────────┐ │
│ │ Показать 24 рецепта │ │
│ └───────────────────────────────┘ │
└─────────────────────────────────────┘
```
### Элементы и поведение
- **Строка поиска:** текстовый поиск по названию рецепта и ингредиентам.
- **Кнопка «Из моих продуктов»:** toggle-фильтр. При включении показывает только рецепты, которые можно приготовить из имеющихся продуктов (полностью или частично). Рецепты сортируются по доле имеющихся ингредиентов. На каждой карточке — пометка: «Есть всё ✓» или «-N прод.».
- **Кнопка «Фильтры»:** раскрывает панель фильтров (bottom sheet). Фильтры — chip-кнопки с множественным выбором. Слайдер для калорийности. Кнопка «Показать N рецептов» — применяет фильтры и закрывает панель. Кнопка «Сброс» — очищает все фильтры.
- **Секция «Для вас»:** горизонтальная карусель. Персональные рекомендации на основе: предпочтений кухонь, истории оценок, имеющихся продуктов (особенно с истекающим сроком). Алгоритм: продукты с истекающим сроком > полное совпадение ингредиентов > предпочтения кухни > высокий рейтинг.
- **Секция «Готовили недавно»:** горизонтальный ряд из последних 5 приготовленных рецептов. Быстрый доступ для повтора. Не показывается, если нет истории.
- **Секция «Все рецепты»:** сетка 2 колонки. Каждая карточка — фото, название, рейтинг (звёзды), калорийность, время, сложность. Тап — переход в карточку рецепта.
- **Бесконечный скролл:** подгрузка рецептов по мере прокрутки.
- **Кнопка [🔄] «Обновить»:** принудительная перегенерация рекомендаций. Gemini + Pexels вызываются заново. Skeleton показывается на время генерации (24 сек).
- **Карточка рецепта:** фото (Pexels), название, приблизительное КБЖУ (помечено «≈»), время приготовления, сложность. Тап → карточка рецепта.
- **Кнопка [♡]:** сохранить рецепт. Рецепт записывается в `saved_recipes`. Иконка заполняется, haptic feedback.
- **Секция «Сохранённые рецепты»:** горизонтальный ряд последних сохранённых. Кнопка «Все сохранённые →» переходит на экран всех сохранённых. Не показывается, если нет сохранённых.
- **Автоматическая генерация:** при открытии вкладки (если с последней генерации прошло > 30 мин или изменились продукты).
- **КБЖУ «≈»:** пиктограмма «~» перед числами; тап → tooltip «Приблизительно, рассчитано AI».
---

View File

@@ -8,10 +8,9 @@
| База данных | PostgreSQL | Надёжная реляционная БД, JSONB для гибких структур (нутриенты, шаги рецептов), полнотекстовый поиск, зрелая экосистема |
| Клиент | Flutter (Android, iOS, Web) | Единая кодовая база на три платформы, нативная производительность на мобильных, зрелая экосистема виджетов |
| Авторизация | Firebase Auth | Бесплатно до 50K MAU, email + Google + Apple из коробки, официальный Go SDK, отличная интеграция с Flutter |
| AI (vision) | Google Gemini 2.5 Flash | Лучшее соотношение цена/качество для распознавания еды (~$0.15/1M input tokens), бесплатный tier для разработки, прецедент CalCam |
| AI (текст) | Google Gemini 2.5 Flash-Lite | Самый дешёвый вариант для текстовых задач ($0.10/1M input tokens) — генерация рецептов, меню, замены ингредиентов |
| База рецептов | Spoonacular API | 365K+ рецептов с нутриентами, фото, ингредиентами, шагами. $29/мес на старте |
| Хранение файлов | S3-совместимое (MinIO / Cloud Storage) | Фото блюд от пользователей, фото рецептов |
| AI (vision + текст) | Google Gemini 2.5 Flash | Единая модель для распознавания фото (чеки, продукты, блюда), генерации рецептов, меню и замены ингредиентов. Free tier для разработки |
| Изображения | Pexels API | Фото к рецептам. Бесплатно до 20K req/мес, коммерческое использование без атрибуции |
| Хранение файлов | S3-совместимое (MinIO / Cloud Storage) | Фото блюд от пользователей, загружаемые для распознавания |
---
@@ -69,8 +68,8 @@
│ - meal_diary │ ▼
│ - reviews │ ┌──────────────────┐
│ - ai_tasks │ │ │
│ │ │ Spoonacular
└──────────────────┘ │ API
│ │ │ Pexels API
└──────────────────┘ │ (фото к рец.)
│ │
└──────────────────┘
```
@@ -218,35 +217,37 @@ AI-провайдер скрыт за интерфейсами. Это позв
#### 4.4. Генерация рецептов / подбор меню
- **Модель:** Gemini 2.5 Flash-Lite (текст) — дешевле, vision не нужен
- **Ключевой принцип:** AI НЕ генерирует рецепты с нуля. Вместо этого:
- **Ключевой принцип:** Gemini генерирует рецепты с нуля на основе контекста пользователя. Статическая база рецептов не нужна.
```
1. Backend выбирает из БД кандидатов-рецептов (по фильтрам: кухня, сложность, время, ингредиенты)
2. AI получает список кандидатов (ID + название + ингредиенты + калории) + контекст юзера
3. AI ранжирует, комбинирует в меню, предлагает замены
4. Backend возвращает полные рецепты по ID из БД
1. Backend собирает контекст: продукты пользователя (с учётом сроков хранения),
профиль (цель, КБЖУ), предпочтения кухни, диетические ограничения
2. Gemini генерирует полные рецепты: название, ингредиенты с граммовками,
шаги, КБЖУ на порцию, image_query для Pexels
3. Backend параллельно запрашивает фото из Pexels по image_query
4. Результат возвращается клиенту; при сохранении → пишется в saved_recipes
```
- **Промпт для подбора меню:**
- **Промпт для рекомендаций:**
```
Контекст пользователя:
- Цель: 2100 ккал/день
Ты — диетолог-повар. Предложи 5 рецептов на русском языке.
Профиль:
- Цель: похудение, 1800 ккал/день
- Ограничения: без орехов
- Предпочтения: русская, азиатская кухня
- Продукты в наличии: [список с количествами и сроками]
Доступные рецепты (ID, название, калории, основные ингредиенты):
[список из 50100 кандидатов из БД]
Доступные продукты (приоритет — скоро истекают):
- Куриное филе 500г (истекает завтра ⚠)
- Морковь 3 шт · Рис 400г · Яйца 4 шт
Задача: составь меню на 7 дней (завтрак, обед, ужин, перекус).
Приоритет: использовать продукты с истекающим сроком.
Ответ: JSON с recipe_id для каждого слота.
Верни ТОЛЬКО валидный JSON без markdown. Для каждого рецепта:
title, description, cuisine, difficulty, prep_time_min, cook_time_min,
servings, image_query (EN), ingredients, steps, tags, nutrition_per_serving.
```
- **Выход:** массив `{ day, meal_type, recipe_id }` — бэкенд подтягивает полные данные рецептов из БД.
Это гарантирует согласованность: AI не придумывает рецепты, а выбирает из проверенной базы.
- **Выход:** массив рецептов с `image_query` — бэкенд дозапрашивает Pexels и возвращает готовый объект с `image_url`.
#### 4.5. AI-генерация персональных рецептов
@@ -255,7 +256,7 @@ AI-провайдер скрыт за интерфейсами. Это позв
- **Модель:** Gemini 2.5 Flash-Lite
- **Промпт:** «Из продуктов [список] предложи рецепт. Формат: JSON с названием, ингредиентами (с граммовками), шагами, калорийностью, БЖУ.»
- **Результат:** сохраняется в БД как `source = 'ai_generated'`, помечается в UI как «AI-рецепт».
- **Валидация нутриентов:** бэкенд пересчитывает калории/БЖУ по справочнику (USDA/Spoonacular nutrition data) и корректирует, если AI ошибся более чем на 20%.
- **Валидация нутриентов:** значения КБЖУ от Gemini считаются приблизительными и помечаются «≈» в UI. Точная верификация через USDA FoodData Central запланирована в будущем (см. TODO.md).
#### 4.6. Замена ингредиентов
@@ -266,15 +267,15 @@ AI-провайдер скрыт за интерфейсами. Это позв
---
## 5. Маппинг ингредиентов (связь AI ↔ БД рецептов)
## 5. Маппинг ингредиентов (нормализация продуктов пользователя)
### Проблема
Gemini оперирует свободным текстом ("куриное филе", "помидоры черри"), а Spoonacular — структурированными ID ингредиентов. Продукты пользователя — тоже свободный текст. Нужен слой, который связывает все три мира.
Пользователи вводят продукты свободным текстом: "куриное филе", "куриная грудка", "курица без кости". Это одно и то же. Нужен слой нормализации — чтобы при генерации рекомендаций Gemini понимал, что у пользователя есть, и чтобы не дублировались продукты.
### Решение: таблица `ingredient_mappings`
Каноническая таблица ингредиентов с алиасами на разных языках и привязкой к Spoonacular ID.
Каноническая таблица с алиасами на русском и английском. Заполняется по мере работы приложения через Gemini (рантайм-дополнение).
```
┌───────────────────────────────────────────────────────┐
@@ -282,16 +283,14 @@ Gemini оперирует свободным текстом ("куриное ф
│───────────────────────────────────────────────────────│
│ id UUID │
│ canonical_name "chicken_breast" │
spoonacular_id 1015062
canonical_name_ru "куриная грудка"
│ aliases (JSONB) ["куриное филе", "куриная грудка", │
│ "филе курицы", "chicken breast",
│ "chicken fillet"] │
│ "грудка курицы", "chicken breast"]
│ category "meat" │
│ default_unit "g" │
│ calories_per_100g 165
│ calories_per_100g 165 (≈ от Gemini)
│ protein_per_100g 31 │
│ fat_per_100g 3.6 │
│ carbs_per_100g 0 │
│ storage_days 3 │
└───────────────────────────────────────────────────────┘
```
@@ -299,18 +298,15 @@ Gemini оперирует свободным текстом ("куриное ф
### Как работает связка
```
Продукт юзера ingredient_mappings Рецепт из Spoonacular
────────────── ─────────────────── ─────────────────────
Продукт юзера ingredient_mappings Промпт для Gemini
────────────── ─────────────────── ──────────────────
"Куриное филе" Ингредиент рецепта:
spoonacular_id: 1015062
│ Fuzzy search
└────по aliases ───► canonical_name: ─── по spoonacular_id
"chicken_breast"
MATCH: оба
ссылаются на
одну сущность
"Куриное филе" │ canonical_name_ru:
│ │ "куриная грудка"
│ Fuzzy search
└────по aliases ───► canonical_name ──────────►│
"chicken_breast" Gemini понимает
что это за продукт
```
### Потоки по сценариям
@@ -335,32 +331,32 @@ Gemini оперирует свободным текстом ("куриное ф
#### Сценарий 2: Проверка "есть ли ингредиент рецепта в запасах"
```
1. Рецепт "Паста Карбонара" имеет ингредиент: spoonacular_id = 1015062
2. ingredient_mappings: spoonacular_id 1015062 → canonical_name "chicken_breast"
3. products: mapping_id → ingredient_mappings.id WHERE canonical_name = "chicken_breast"
4. Найдено в products → отметка ✅ (есть в запасах)
Не найдено → отметка ❌ (нет) или 🔄 (есть замена)
1. Рецепт "Паста Карбонара" содержит ингредиент: canonical_name = "spaghetti"
2. products: ищем WHERE mapping_id IN (
SELECT id FROM ingredient_mappings WHERE canonical_name = 'spaghetti'
)
3. Найдено в products → отметка ✅ (есть в запасах)
Не найдено → отметка ❌ (нет) или 🔄 (есть замена через ingredient_substitutions)
```
#### Сценарий 3: Поиск рецептов "из моих продуктов"
#### Сценарий 3: Рекомендации рецептов "из моих продуктов"
```
1. Продукты юзера → через mapping_id → набор canonical_names
["chicken_breast", "rice_white", "carrot", "onion"]
1. Продукты юзера → через mapping_id → набор canonical_names_ru
["куриная грудка", "рис", "морковь", "лук"]
2. SQL-запрос по рецептам:
SELECT r.*,
(SELECT count(*) FROM jsonb_array_elements(r.ingredients) i
WHERE i->>'mapping_id' IN (выбранные mapping_id)) as matched,
jsonb_array_length(r.ingredients) as total
FROM recipes r
ORDER BY matched::float / total DESC
2. Backend формирует промпт для Gemini:
- профиль пользователя (цель, КБЖУ-норма, ограничения)
- список продуктов с количеством и сроками
- приоритет: продукты, истекающие скоро
3. Если в локальной БД мало результатов — дозапрос Spoonacular:
GET /recipes/findByIngredients?ingredients=chicken,rice,carrot,onion
Новые рецепты сохраняются в нашу БД
3. Gemini генерирует 5 рецептов с нуля, используя имеющиеся продукты.
Каждый рецепт содержит image_query (EN) для запроса Pexels.
4. Gemini НЕ участвует — это чистый SQL + Spoonacular
4. Backend параллельно запрашивает Pexels по image_query.
5. Результат: персонализированные рецепты с фото.
Статическая база рецептов не нужна.
```
#### Сценарий 4: Распознавание блюда (фото → калории)
@@ -378,24 +374,30 @@ Gemini оперирует свободным текстом ("куриное ф
#### Сценарий 5: Генерация меню
```
1. Backend отбирает кандидатов из БД (SQL: фильтры + наличие ингредиентов)
2. Формирует промпт с recipe_id:
"ID:42 Борщ (550ккал, ингр: свёкла✅ картофель✅ говядина✅ лук✅)
ID:87 Том Ям (320ккал, ингр: креветки❌ лемонграсс❌ кокос.молоко❌)"
3. Gemini ранжирует и возвращает recipe_id
4. Backend подгружает полные данные по ID
1. Backend собирает контекст:
- профиль (цель, КБЖУ-норма, кухни, ограничения)
- продукты пользователя (canonical_names, количество, сроки)
- период меню (например, 7 дней × 3 приёма пищи)
Gemini работает ТОЛЬКО с ID из нашей БД
→ Никакой рассинхронизации
2. Gemini генерирует полное меню с нуля:
- 7 дней, 3 приёма пищи в день
- каждый слот: полный рецепт (ингредиенты, шаги, КБЖУ, image_query)
- баланс КБЖУ в рамках дневной нормы
- приоритет продуктам с истекающим сроком
3. Backend параллельно запрашивает Pexels для каждого рецепта.
4. Меню сохраняется в menu_plans + menu_items.
Рецепты из меню — в saved_recipes (source = 'ai').
→ Gemini генерирует контент полностью. Никакой статической базы не нужно.
```
### Наполнение таблицы маппингов
| Этап | Источник | Объём |
|------|----------|-------|
| Начальный импорт | Spoonacular Ingredient API | ~1 000 базовых ингредиентов |
| Ручная локализация | Перевод топ-200 ингредиентов | Aliases на русском |
| Batch-перевод | Gemini Flash-Lite (оффлайн) | Остальные aliases |
| Начальный seed | Ручное составление топ-200 базовых ингредиентов | Aliases на русском и английском |
| Рантайм-дополнение | Gemini (при нераспознанном продукте) | По мере роста юзеров |
| Пользовательская обратная связь | Юзер корректирует продукт на экране редактирования | Новые aliases |
@@ -407,9 +409,9 @@ Gemini оперирует свободным текстом ("куриное ф
|--------|---------|---------------------|
| OCR чека → продукты | Да (vision) | Fuzzy match по `ingredient_mappings.aliases` |
| Фото продуктов → список | Да (vision) | То же |
| Фото блюда → калории | Да (vision) | Full-text search по `recipes.title` |
| Поиск рецептов из продуктов | **Нет** | SQL по `mapping_id` + Spoonacular `findByIngredients` |
| Ранжировка/подбор меню | Да (текст) | Получает `recipe_id` из БД, возвращает `recipe_id` |
| Фото блюда → калории | Да (vision) | Результат логируется в `ai_tasks` |
| Рекомендации рецептов из продуктов | **Да (текст)** | Продукты передаются в промпт; результат → `saved_recipes` |
| Генерация меню на неделю | Да (текст) | Генерирует с нуля; сохраняется в `menu_plans` + `menu_items` |
| Замена ингредиентов | Да (текст) | Кеш в `ingredient_substitutions` |
| Нераспознанный ингредиент | Да (текст, разово) | Результат сохраняется в `ingredient_mappings` |
@@ -520,50 +522,42 @@ Gemini API имеет ограничения по RPM (requests per minute) и
### Источники рецептов
Статической базы рецептов нет. Все рецепты генерируются Gemini on-demand и сохраняются пользователями.
```
┌────────────────────────────────────────────────────
│ Recipe Service
│ ┌──────────────┐ ┌──────────────┐ ┌──────────┐ │
│ │ Spoonacular │ │ AI-generated │ │ User │ │
│ │ Import │ │ Recipes │ │ Recipes │ │
│ │ │ │ │ │ │ │
│ │ source: source: source: │ │
│ 'spoonacular'│ │ 'ai' │ │ 'user' │
└──────┬───────┘ └──────┬───────┘ └────┬─────┘
│ │
└────────┬────────┘───────────────┘
┌────────────────┐
Единая таблица │
│ recipes в БД │
└────────────────┘ │
└────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────┐
│ Recipe Service │
│ │
│ ┌──────────────────────┐ ┌──────────────────┐ │
│ │ AI-generated │ │ User Recipes │ │
│ │ (Gemini) │ (будущее) │ │
│ │ │ │ │ │
│ │ source: 'ai' │ │ source: 'user' │ │
└──────────┬───────────┘ └────────┬─────────┘
│ │
└──────────┬────────────┘
┌──────────────────┐
saved_recipes
│ (per-user)
└──────────────────┘
└─────────────────────────────────────────────────┘
```
### Импорт из Spoonacular
### Принцип работы с рецептами
- **Начальный импорт:** 5 00010 000 самых популярных рецептов (по рейтингу).
- **Данные:** название, описание, ингредиенты с граммовками, шаги приготовления, калории, БЖУ, время, сложность, кухня, фото, теги.
- **Хранение:** в нашей БД (PostgreSQL). Не зависим от Spoonacular API при показе рецептов пользователю.
- **Синхронизация:** фоновый джоб раз в неделю — обновление данных, добавление новых рецептов из популярных.
- **Spoonacular API** используется также для: расширенного поиска (когда в локальной БД нет подходящего), справочника нутриентов, данных по ингредиентам.
- **Локализация:** рецепты из Spoonacular на английском. Перевод — через Gemini Flash-Lite (batch, оффлайн). Переведённые рецепты кешируются в БД.
- **Генерация:** Gemini создаёт рецепты с нуля на основе контекста пользователя (продукты, профиль, цели).
- **Фото:** Pexels API по `image_query` (EN), которую Gemini включает в ответ.
- **КБЖУ:** приблизительные значения от Gemini, помечаются «≈» в UI.
- **Сохранение:** пользователь тапает ❤ → рецепт сохраняется в `saved_recipes`. До сохранения нигде не хранится.
- **Отсутствие глобального каталога:** каждый пользователь видит только свои сохранённые рецепты и свежие рекомендации. Полноценный каталог с поиском — в TODO.md (требует постоянной базы).
### Согласованность AI и рецептов
### Согласованность рецептов
**Критическое правило:** AI при подборе меню работает ТОЛЬКО с рецептами из нашей БД.
Поток:
1. Backend выбирает кандидатов из БД по фильтрам (SQL-запрос: кухня, сложность, калории, ингредиенты).
2. Backend формирует промпт с ID и метаданными кандидатов.
3. AI ранжирует и компонует меню, возвращая `recipe_id`.
4. Backend подгружает полные данные рецептов по ID.
Это гарантирует: точные нутриенты, наличие фото, корректные шаги, возможность оставить отзыв.
**Исключение:** AI-генерация нового рецепта «из того, что есть» — когда в БД нет подходящего. Такой рецепт сохраняется в БД как `source = 'ai'` и доступен другим пользователям после модерации (по рейтингу).
- Все данные рецепта (ингредиенты, шаги, КБЖУ, image_url) приходят от Gemini+Pexels в одном запросе.
- Сохранённый рецепт хранится целиком в `saved_recipes` как JSONB — нет риска рассинхронизации.
- КБЖУ помечены как приблизительные — пользователь понимает точность данных.
---
@@ -591,7 +585,6 @@ Gemini API имеет ограничения по RPM (requests per minute) и
│ (JSONB) │ │ │──────────────────│
│ created_at │ │ │ id │
└──────────────┘ │ │ source │
│ │ spoonacular_id │
│ │ title │
│ │ description │
│ │ cuisine │
@@ -609,7 +602,7 @@ Gemini API имеет ограничения по RPM (requests per minute) и
│ │ tags (JSONB) │
│ │ avg_rating │
│ │ review_count │
│ │ created_by │──► users (NULL для spoonacular)
│ │ created_by │──► users (NULL для ai-generated)
│ └──────────────────┘
┌──────────────────┤ ┌──────────────────┐
@@ -678,7 +671,7 @@ Gemini API имеет ограничения по RPM (requests per minute) и
│────────────────────────────│
│ id │
│ canonical_name │
spoonacular_id (UNIQUE)
canonical_name_ru
│ aliases (JSONB) │
│ category │
│ default_unit │
@@ -694,13 +687,13 @@ Gemini API имеет ограничения по RPM (requests per minute) и
### Ключевые решения по схеме
- **`products.mapping_id`** — FK на `ingredient_mappings`. Связывает продукт пользователя с каноническим ингредиентом. Через эту связь определяется наличие ингредиентов рецепта в запасах. Может быть NULL (если продукт не удалось сопоставить).
- **`ingredient_mappings`** — каноническая таблица ингредиентов. `aliases` (JSONB) содержит все варианты написания на разных языках. `spoonacular_id` связывает с ингредиентами рецептов из Spoonacular. Нутриенты на 100г используются для пересчёта калорийности AI-генерированных рецептов. `storage_days` — дефолтный срок хранения для категории.
- **`ingredient_mappings`** — каноническая таблица ингредиентов. `canonical_name_ru` — русское название. `aliases` (JSONB) содержит все варианты написания на разных языках. Нутриенты на 100г используются для пересчёта калорийности при необходимости. `storage_days` — дефолтный срок хранения для категории. Наполняется вручную (seed) и автоматически через Gemini при нераспознанных продуктах.
- **`ingredient_substitutions`** — кеш замен ингредиентов. Ссылается на `ingredient_mappings` по обоим ингредиентам (оригинал и замена). Один раз определённая AI-замена переиспользуется для всех пользователей.
- **`products.storage_days`** — период хранения после покупки (не фиксированная дата). `expires_at` вычисляется как `added_at + storage_days`. При отображении: «осталось X дней». Дефолт берётся из `ingredient_mappings.storage_days` при привязке.
- **`products.unit`** — ENUM: g, kg, ml, l, pcs, bunch, pack. Фронтенд отображает локализованные названия. Дефолт берётся из `ingredient_mappings.default_unit`.
- **`recipes.ingredients`** — JSONB массив: `[{ "name": "...", "amount": 200, "unit": "g", "optional": false }]`. JSONB позволяет гибко хранить без нормализации, при этом поддерживая поиск (`@>` оператор).
- **`recipes.steps`** — JSONB массив: `[{ "order": 1, "title": "...", "description": "...", "image_url": "...", "timer_seconds": null }]`. `timer_seconds` не null → на шаге отображается таймер.
- **`recipes.source`** — ENUM: spoonacular, ai, user. Определяет происхождение рецепта.
- **`recipes.source`** — ENUM: ai, user. Определяет происхождение рецепта.
- **`meal_diary.source`** — откуда записано: из меню, через фото, вручную, из рецепта.
- **`shopping_lists.items`** — JSONB: `[{ "name": "...", "amount": 1, "unit": "l", "category": "dairy", "checked": false, "manual": false }]`.
@@ -744,24 +737,20 @@ Gemini API имеет ограничения по RPM (requests per minute) и
Все `/ai/*` эндпоинты возвращают `{ task_id }` (HTTP 202 Accepted). Клиент получает результат через polling `/ai/tasks/{id}` или через WebSocket (будущее улучшение). Это позволяет обрабатывать запросы асинхронно через очереди.
### Рецепты
### Рекомендации
| Метод | Путь | Описание |
|-------|------|----------|
| GET | `/recipes` | Каталог (фильтры, поиск, пагинация) |
| GET | `/recipes/{id}` | Карточка рецепта |
| GET | `/recipes/recommended` | Персональные рекомендации |
| GET | `/recipes/recent` | Недавно приготовленные |
| POST | `/recipes` | Создать пользовательский рецепт |
| POST | `/recipes/{id}/favorite` | Добавить в избранное |
| DELETE | `/recipes/{id}/favorite` | Убрать из избранного |
| GET | `/recommendations?count=5` | Персональные рекомендации (Gemini + Pexels) |
### Отзывы
### Сохранённые рецепты
| Метод | Путь | Описание |
|-------|------|----------|
| GET | `/recipes/{id}/reviews` | Отзывы к рецепту |
| POST | `/recipes/{id}/reviews` | Написать отзыв |
| GET | `/saved-recipes` | Список сохранённых рецептов |
| POST | `/saved-recipes` | Сохранить рецепт |
| GET | `/saved-recipes/{id}` | Карточка сохранённого рецепта |
| DELETE | `/saved-recipes/{id}` | Удалить из сохранённых |
### Меню
@@ -821,11 +810,11 @@ Gemini API имеет ограничения по RPM (requests per minute) и
|-----------|--------|---------|---------|
| Firebase Auth | $0 | $0 | $0 (лимит 50K) |
| Gemini API | $50150 | $5001 500 | $2 5007 500 |
| Spoonacular | $29 | $149 | $149 |
| Pexels API | $0 | $0 | $0 (Free tier) |
| Хостинг (Cloud Run / Fly.io) | $1020 | $50100 | $200500 |
| PostgreSQL (managed) | $15 | $50 | $150300 |
| S3 (фото) | $5 | $20 | $50100 |
| **Итого** | **$110220** | **$7701 820** | **$3 0508 550** |
| **Итого** | **$80190** | **$6201 670** | **$2 9008 400** |
### Оптимизация затрат на AI