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

@@ -12,3 +12,7 @@ JWT_REFRESH_DURATION=720h
# Server # Server
PORT=8080 PORT=8080
ALLOWED_ORIGINS=http://localhost:3000 ALLOWED_ORIGINS=http://localhost:3000
# External APIs
GEMINI_API_KEY=your-gemini-key
PEXELS_API_KEY=your-pexels-key

View File

@@ -1,4 +1,4 @@
.PHONY: run test lint migrate-up migrate-down migrate-create migrate-status docker-up docker-down docker-logs .PHONY: run test test-integration lint migrate-up migrate-down migrate-create migrate-status docker-up docker-down docker-logs
ifneq (,$(wildcard .env)) ifneq (,$(wildcard .env))
include .env include .env

View File

@@ -1,6 +1,6 @@
# FoodAI Backend # FoodAI Backend
Go REST API с авторизацией через Firebase, JWT и PostgreSQL. Go REST API с авторизацией через Firebase, JWT и PostgreSQL. Включает инфраструктуру импорта и перевода справочных данных (ингредиенты, рецепты).
## Стек ## Стек
@@ -10,6 +10,8 @@ Go REST API с авторизацией через Firebase, JWT и PostgreSQL.
- **goose** — миграции - **goose** — миграции
- **golang-jwt/v5** — JWT - **golang-jwt/v5** — JWT
- **Firebase Admin SDK** — верификация токенов - **Firebase Admin SDK** — верификация токенов
- **cobra** — CLI для команд импорта
- **generative-ai-go** — Gemini API (переводы)
## Требования ## Требования
@@ -37,6 +39,8 @@ cp .env.example .env
| `JWT_REFRESH_DURATION` | Время жизни refresh-токена | `720h` | | `JWT_REFRESH_DURATION` | Время жизни refresh-токена | `720h` |
| `PORT` | Порт сервера | `8080` | | `PORT` | Порт сервера | `8080` |
| `ALLOWED_ORIGINS` | CORS-разрешённые источники | `http://localhost:3000` | | `ALLOWED_ORIGINS` | CORS-разрешённые источники | `http://localhost:3000` |
| `SPOONACULAR_API_KEY` | Ключ Spoonacular API (нужен для команд `import`) | — |
| `GEMINI_API_KEY` | Ключ Gemini API (нужен для команд `translate`) | — |
### 2. Запуск через Docker Compose ### 2. Запуск через Docker Compose
@@ -61,12 +65,14 @@ make run
## Команды ## Команды
### Сервер и тесты
| Команда | Описание | | Команда | Описание |
|---|---| |---|---|
| `make run` | Запустить сервер в режиме разработки | | `make run` | Запустить сервер в режиме разработки |
| `make test` | Запустить unit-тесты | | `make test` | Unit-тесты |
| `make test-integration` | Запустить интеграционные тесты (требует Docker) | | `make test-integration` | Интеграционные тесты (требует Docker) |
| `make lint` | Проверить код через golangci-lint | | `make lint` | Проверка через golangci-lint |
| `make docker-up` | Поднять PostgreSQL + приложение | | `make docker-up` | Поднять PostgreSQL + приложение |
| `make docker-down` | Остановить контейнеры | | `make docker-down` | Остановить контейнеры |
| `make docker-logs` | Логи приложения | | `make docker-logs` | Логи приложения |
@@ -75,6 +81,40 @@ make run
| `make migrate-status` | Статус миграций | | `make migrate-status` | Статус миграций |
| `make migrate-create name=<name>` | Создать новую миграцию | | `make migrate-create name=<name>` | Создать новую миграцию |
### Импорт данных (требует `SPOONACULAR_API_KEY`)
| Команда | Описание |
|---|---|
| `make import-ingredients` | Импортировать ~1 000 ингредиентов |
| `make import-recipes` | Импортировать ~5 000 рецептов |
| `make import-recipes-full` | Импортировать ~10 000 рецептов |
### Перевод (требует `GEMINI_API_KEY`)
| Команда | Описание |
|---|---|
| `make translate-recipes` | Перевести рецепты на русский |
| `make translate-ingredients` | Перевести топ-200 ингредиентов |
| `make import-all` | Полный пайплайн: ингредиенты → рецепты → переводы |
Все команды импорта идемпотентны (`ON CONFLICT DO UPDATE`) — можно запускать повторно. Для возобновления прерванного импорта используйте флаги `--skip-queries` / `--offset`.
### CLI напрямую
```bash
# Тестовый прогон (без сохранения в БД)
go run ./cmd/import import ingredients --limit 50 --dry-run
go run ./cmd/import import recipes --count 100 --dry-run
# Возобновление импорта
go run ./cmd/import import ingredients --limit 1000 --skip-queries 10
go run ./cmd/import import recipes --count 5000 --offset 2000
# Перевод части рецептов
go run ./cmd/import translate recipes --limit 1000
go run ./cmd/import translate ingredients --top 50
```
## API ## API
### Публичные эндпоинты ### Публичные эндпоинты
@@ -120,18 +160,85 @@ curl -X PUT http://localhost:8080/profile \
``` ```
backend/ backend/
├── cmd/server/ # Точка входа ├── cmd/
│ ├── server/ # HTTP-сервер (точка входа)
│ └── import/ # CLI для импорта и перевода данных (Cobra)
├── internal/ ├── internal/
│ ├── auth/ # Firebase-верификация, JWT, сервис и хэндлер авторизации │ ├── auth/ # Firebase-верификация, JWT, сервис и хэндлер авторизации
│ ├── config/ # Конфигурация через переменные окружения │ ├── config/ # Конфигурация через переменные окружения
│ ├── database/ # Подключение к PostgreSQL (pgxpool) │ ├── database/ # Подключение к PostgreSQL (pgxpool)
│ ├── ingredient/ # Модель, репозиторий, сервис импорта ингредиентов
│ ├── middleware/ # RequestID, Logging, Recovery, CORS, Auth │ ├── middleware/ # RequestID, Logging, Recovery, CORS, Auth
│ ├── recipe/ # Модель, репозиторий, сервис импорта рецептов
│ ├── server/ # Роутер (chi) │ ├── server/ # Роутер (chi)
│ ├── spoonacular/ # HTTP-клиент Spoonacular API (интерфейс + реализация)
│ ├── testutil/ # Вспомогательные утилиты для тестов │ ├── testutil/ # Вспомогательные утилиты для тестов
│ ├── translation/ # Gemini-переводчик, сервис батчевого перевода
│ └── user/ # Модель, репозиторий, сервис, хэндлер, расчёт калорий │ └── user/ # Модель, репозиторий, сервис, хэндлер, расчёт калорий
├── migrations/ # SQL-миграции (goose) ├── migrations/
│ ├── 001_create_users.sql
│ ├── 002_create_ingredient_mappings.sql # GIN-индекс по aliases
│ └── 003_create_recipes.sql # FTS + GIN по ingredients/tags
├── .env.example ├── .env.example
├── docker-compose.yml ├── docker-compose.yml
├── Dockerfile ├── Dockerfile
└── Makefile └── Makefile
``` ```
## Схема БД
### `ingredient_mappings`
Канонический справочник ингредиентов. Каждая запись — один вид продукта.
| Поле | Тип | Описание |
|---|---|---|
| `id` | UUID | Первичный ключ |
| `canonical_name` | varchar | Нормализованное EN-название (`chicken_breast`) |
| `canonical_name_ru` | varchar | Русское название (`куриная грудка`) |
| `spoonacular_id` | integer | Уникальный ID из Spoonacular |
| `aliases` | JSONB | Массив альтернативных названий (EN + RU) |
| `category` | varchar | `produce`, `dairy`, `meat`, `seafood`, `grains`, `spices`, `canned`, `frozen`, `beverages`, `other` |
| `default_unit` | varchar | Единица измерения по умолчанию (`g`, `ml`) |
| `calories_per_100g` | decimal | Нутриенты на 100 г |
| `storage_days` | integer | Типичный срок хранения (дни) |
Индексы: GIN по `aliases` (поиск `@>`), `canonical_name`, `category`, UNIQUE по `spoonacular_id`.
### `recipes`
Каталог рецептов. Заполняется из Spoonacular, переводится через Gemini.
| Поле | Тип | Описание |
|---|---|---|
| `id` | UUID | Первичный ключ |
| `source` | enum | `spoonacular`, `ai`, `user` |
| `spoonacular_id` | integer | Уникальный ID из Spoonacular |
| `title` / `title_ru` | varchar | Название EN/RU |
| `difficulty` | enum | `easy` (≤30 мин), `medium` (≤60 мин), `hard` |
| `ingredients` | JSONB | Массив `{spoonacular_id, mapping_id, name, amount, unit}` |
| `steps` | JSONB | Массив `{number, description, description_ru, timer_seconds}` |
| `tags` | JSONB | `["vegetarian", "gluten-free", "meal:dinner", ...]` |
| `calories_per_serving` | decimal | Нутриенты на порцию |
| `avg_rating` / `review_count` | decimal/int | Рейтинг (обновляется при отзывах) |
Индексы: GIN по `ingredients` (поиск по `mapping_id`), GIN по `tags`, FTS по `title + title_ru`.
## Тесты
```bash
# Unit-тесты (~69 тестов, ~13 сек)
make test
# Интеграционные тесты (PostgreSQL в Docker через testcontainers)
make test-integration
```
| Пакет | Unit | Integration |
|---|---|---|
| `auth` | 17 | 10 |
| `ingredient` | 9 | 5 |
| `middleware` | 10 | — |
| `recipe` | 12 | 7 |
| `translation` | 6 | — |
| `user` | 14 | 12 |

View File

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

View File

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