From 0dbda0cd572392e0928a2fef2ac66d3d12e72b33 Mon Sep 17 00:00:00 2001 From: dbastrikin Date: Sat, 21 Feb 2026 22:49:29 +0200 Subject: [PATCH] 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 --- backend/.env.example | 4 + backend/Makefile | 2 +- backend/README.md | 119 +++++++++++++++++++- docs/Design.md | 119 ++++++++++---------- docs/Tech.md | 253 +++++++++++++++++++++---------------------- 5 files changed, 296 insertions(+), 201 deletions(-) diff --git a/backend/.env.example b/backend/.env.example index cba7ae4..6d74639 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -12,3 +12,7 @@ JWT_REFRESH_DURATION=720h # Server PORT=8080 ALLOWED_ORIGINS=http://localhost:3000 + +# External APIs +GEMINI_API_KEY=your-gemini-key +PEXELS_API_KEY=your-pexels-key diff --git a/backend/Makefile b/backend/Makefile index 7e4b200..30ad910 100644 --- a/backend/Makefile +++ b/backend/Makefile @@ -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)) include .env diff --git a/backend/README.md b/backend/README.md index c767e28..d8b9e13 100644 --- a/backend/README.md +++ b/backend/README.md @@ -1,6 +1,6 @@ # 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** — миграции - **golang-jwt/v5** — JWT - **Firebase Admin SDK** — верификация токенов +- **cobra** — CLI для команд импорта +- **generative-ai-go** — Gemini API (переводы) ## Требования @@ -37,6 +39,8 @@ cp .env.example .env | `JWT_REFRESH_DURATION` | Время жизни refresh-токена | `720h` | | `PORT` | Порт сервера | `8080` | | `ALLOWED_ORIGINS` | CORS-разрешённые источники | `http://localhost:3000` | +| `SPOONACULAR_API_KEY` | Ключ Spoonacular API (нужен для команд `import`) | — | +| `GEMINI_API_KEY` | Ключ Gemini API (нужен для команд `translate`) | — | ### 2. Запуск через Docker Compose @@ -61,12 +65,14 @@ make run ## Команды +### Сервер и тесты + | Команда | Описание | |---|---| | `make run` | Запустить сервер в режиме разработки | -| `make test` | Запустить unit-тесты | -| `make test-integration` | Запустить интеграционные тесты (требует Docker) | -| `make lint` | Проверить код через golangci-lint | +| `make test` | Unit-тесты | +| `make test-integration` | Интеграционные тесты (требует Docker) | +| `make lint` | Проверка через golangci-lint | | `make docker-up` | Поднять PostgreSQL + приложение | | `make docker-down` | Остановить контейнеры | | `make docker-logs` | Логи приложения | @@ -75,6 +81,40 @@ make run | `make migrate-status` | Статус миграций | | `make migrate-create 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 ### Публичные эндпоинты @@ -120,18 +160,85 @@ curl -X PUT http://localhost:8080/profile \ ``` backend/ -├── cmd/server/ # Точка входа +├── cmd/ +│ ├── server/ # HTTP-сервер (точка входа) +│ └── import/ # CLI для импорта и перевода данных (Cobra) ├── internal/ │ ├── auth/ # Firebase-верификация, JWT, сервис и хэндлер авторизации │ ├── config/ # Конфигурация через переменные окружения │ ├── database/ # Подключение к PostgreSQL (pgxpool) +│ ├── ingredient/ # Модель, репозиторий, сервис импорта ингредиентов │ ├── middleware/ # RequestID, Logging, Recovery, CORS, Auth +│ ├── recipe/ # Модель, репозиторий, сервис импорта рецептов │ ├── server/ # Роутер (chi) +│ ├── spoonacular/ # HTTP-клиент Spoonacular API (интерфейс + реализация) │ ├── testutil/ # Вспомогательные утилиты для тестов +│ ├── translation/ # Gemini-переводчик, сервис батчевого перевода │ └── 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 ├── docker-compose.yml ├── Dockerfile └── 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 | diff --git a/docs/Design.md b/docs/Design.md index cda9617..dc92708 100644 --- a/docs/Design.md +++ b/docs/Design.md @@ -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 мин │ │ -│ │ Сложная │ │ Средняя │ │ -│ └──────────┘ └──────────┘ │ -│ │ -│ ┌──────────┐ ┌──────────┐ │ -│ │ ... │ │ ... │ │ -│ └──────────┘ └──────────┘ │ +│ [Все сохранённые →] │ │ │ ├─────────────────────────────────────┤ │ [Главная] [Продукты] [Меню] [Рецепты] [Профиль] │ └─────────────────────────────────────┘ ``` -### Панель фильтров (раскрывается по тапу «Фильтры ▾») +### Скелетон при загрузке (2–4 сек) ``` ┌─────────────────────────────────────┐ -│ Фильтры [Сброс]│ +│ Рецепты │ ├─────────────────────────────────────┤ │ │ -│ Приём пищи │ -│ [Завтрак] [Обед] [Ужин] [Перекус] │ +│ Подбираем рецепты... │ │ │ -│ Кухня │ -│ [Русская] [Азиатская] [Европейская] │ -│ [Средиземноморская] [Американская] │ +│ ┌─────────────────────────────┐ │ +│ │ ░░░░░░░░░░░░░░░░░░░░░░░░░ │ │ +│ │ ░░░░░░░░░░░░░░░░░░░░░░░░░ │ │ +│ │ ░░░░░░░░ ░░░░░ ░░░░░░░░ │ │ +│ └─────────────────────────────┘ │ +│ ┌─────────────────────────────┐ │ +│ │ ░░░░░░░░░░░░░░░░░░░░░░░░░ │ │ +│ │ ░░░░░░░░░░░░░░░░░░░░░░░░░ │ │ +│ │ ░░░░░░░░ ░░░░░ ░░░░░░░░ │ │ +│ └─────────────────────────────┘ │ │ │ -│ Сложность │ -│ [Простая] [Средняя] [Сложная] │ -│ │ -│ Время приготовления │ -│ [до 15 мин] [до 30 мин] [до 60 мин]│ -│ [более 60 мин] │ -│ │ -│ Калорийность (на порцию) │ -│ ○────────────● до 500 ккал │ -│ │ -│ Диета │ -│ [Вегетар.] [Безглютен.] [Низкокал.] │ -│ [Кето] [Без лактозы] │ -│ │ -│ ┌───────────────────────────────┐ │ -│ │ Показать 24 рецепта │ │ -│ └───────────────────────────────┘ │ └─────────────────────────────────────┘ ``` ### Элементы и поведение -- **Строка поиска:** текстовый поиск по названию рецепта и ингредиентам. -- **Кнопка «Из моих продуктов»:** toggle-фильтр. При включении показывает только рецепты, которые можно приготовить из имеющихся продуктов (полностью или частично). Рецепты сортируются по доле имеющихся ингредиентов. На каждой карточке — пометка: «Есть всё ✓» или «-N прод.». -- **Кнопка «Фильтры»:** раскрывает панель фильтров (bottom sheet). Фильтры — chip-кнопки с множественным выбором. Слайдер для калорийности. Кнопка «Показать N рецептов» — применяет фильтры и закрывает панель. Кнопка «Сброс» — очищает все фильтры. -- **Секция «Для вас»:** горизонтальная карусель. Персональные рекомендации на основе: предпочтений кухонь, истории оценок, имеющихся продуктов (особенно с истекающим сроком). Алгоритм: продукты с истекающим сроком > полное совпадение ингредиентов > предпочтения кухни > высокий рейтинг. -- **Секция «Готовили недавно»:** горизонтальный ряд из последних 5 приготовленных рецептов. Быстрый доступ для повтора. Не показывается, если нет истории. -- **Секция «Все рецепты»:** сетка 2 колонки. Каждая карточка — фото, название, рейтинг (звёзды), калорийность, время, сложность. Тап — переход в карточку рецепта. -- **Бесконечный скролл:** подгрузка рецептов по мере прокрутки. +- **Кнопка [🔄] «Обновить»:** принудительная перегенерация рекомендаций. Gemini + Pexels вызываются заново. Skeleton показывается на время генерации (2–4 сек). +- **Карточка рецепта:** фото (Pexels), название, приблизительное КБЖУ (помечено «≈»), время приготовления, сложность. Тап → карточка рецепта. +- **Кнопка [♡]:** сохранить рецепт. Рецепт записывается в `saved_recipes`. Иконка заполняется, haptic feedback. +- **Секция «Сохранённые рецепты»:** горизонтальный ряд последних сохранённых. Кнопка «Все сохранённые →» переходит на экран всех сохранённых. Не показывается, если нет сохранённых. +- **Автоматическая генерация:** при открытии вкладки (если с последней генерации прошло > 30 мин или изменились продукты). +- **КБЖУ «≈»:** пиктограмма «~» перед числами; тап → tooltip «Приблизительно, рассчитано AI». --- diff --git a/docs/Tech.md b/docs/Tech.md index 00235d9..8364166 100644 --- a/docs/Tech.md +++ b/docs/Tech.md @@ -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, название, калории, основные ингредиенты): -[список из 50–100 кандидатов из БД] +Доступные продукты (приоритет — скоро истекают): +- Куриное филе 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 000–10 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 | $50–150 | $500–1 500 | $2 500–7 500 | -| Spoonacular | $29 | $149 | $149 | +| Pexels API | $0 | $0 | $0 (Free tier) | | Хостинг (Cloud Run / Fly.io) | $10–20 | $50–100 | $200–500 | | PostgreSQL (managed) | $15 | $50 | $150–300 | | S3 (фото) | $5 | $20 | $50–100 | -| **Итого** | **$110–220** | **$770–1 820** | **$3 050–8 550** | +| **Итого** | **$80–190** | **$620–1 670** | **$2 900–8 400** | ### Оптимизация затрат на AI