Files
food-ai/docs/Tech.md
dbastrikin 0dbda0cd57 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>
2026-02-21 22:49:29 +02:00

825 lines
52 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# FoodAI — Техническая архитектура
## 1. Стек технологий
| Компонент | Технология | Обоснование |
|-----------|-----------|-------------|
| Backend | Go | Высокая производительность, встроенная конкурентность (горутины для очередей), строгая типизация, простой деплой (один бинарник) |
| База данных | PostgreSQL | Надёжная реляционная БД, JSONB для гибких структур (нутриенты, шаги рецептов), полнотекстовый поиск, зрелая экосистема |
| Клиент | Flutter (Android, iOS, Web) | Единая кодовая база на три платформы, нативная производительность на мобильных, зрелая экосистема виджетов |
| Авторизация | Firebase Auth | Бесплатно до 50K MAU, email + Google + Apple из коробки, официальный Go SDK, отличная интеграция с Flutter |
| AI (vision + текст) | Google Gemini 2.5 Flash | Единая модель для распознавания фото (чеки, продукты, блюда), генерации рецептов, меню и замены ингредиентов. Free tier для разработки |
| Изображения | Pexels API | Фото к рецептам. Бесплатно до 20K req/мес, коммерческое использование без атрибуции |
| Хранение файлов | S3-совместимое (MinIO / Cloud Storage) | Фото блюд от пользователей, загружаемые для распознавания |
---
## 2. Архитектура системы
```
┌──────────────────────────────────────────────────────────┐
│ Flutter Client │
│ (Android / iOS / Web) │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Firebase │ │ REST │ │ File │ │
│ │ Auth SDK │ │ Client │ │ Upload │ │
│ └─────┬────┘ └─────┬────┘ └─────┬────┘ │
└────────┼─────────────┼─────────────┼─────────────────────┘
│ │ │
│ idToken │ JWT │ multipart
▼ ▼ ▼
┌──────────────────────────────────────────────────────────┐
│ Go Backend │
│ │
│ ┌─────────────┐ ┌──────────────┐ ┌────────────────┐ │
│ │ Auth │ │ API │ │ AI Queue │ │
│ │ Middleware │ │ Handlers │ │ Manager │ │
│ │ │ │ │ │ │ │
│ │ Firebase │ │ /products │ │ Priority │ │
│ │ Token │ │ /recipes │ │ Queues: │ │
│ │ Verify │ │ /menu │ │ - paid (fast) │ │
│ │ → JWT │ │ /diary │ │ - free (slow) │ │
│ └──────┬──────┘ │ /ai │ │ │ │
│ │ │ /shopping │ │ Rate Limiter │ │
│ │ └──────┬───────┘ │ Budget Guard │ │
│ │ │ └───────┬────────┘ │
│ │ │ │ │
│ ┌──────┴────────────────┴──────────────────┴────────┐ │
│ │ Service Layer │ │
│ │ │ │
│ │ ┌──────────┐ ┌───────────┐ ┌──────────────────┐ │ │
│ │ │ Product │ │ Recipe │ │ AI Service │ │ │
│ │ │ Service │ │ Service │ │ (interface) │ │ │
│ │ └────┬─────┘ └─────┬─────┘ └────┬─────────────┘ │ │
│ └───────┼─────────────┼────────────┼────────────────┘ │
│ │ │ │ │
└──────────┼─────────────┼────────────┼────────────────────┘
│ │ │
▼ │ ▼
┌──────────────────┐ │ ┌──────────────────┐
│ │ │ │ │
│ PostgreSQL │ │ │ Gemini API │
│ │ │ │ (Flash / │
│ - users │ │ │ Flash-Lite) │
│ - products │ │ │ │
│ - recipes │ │ └──────────────────┘
│ - menu_plans │ │
│ - meal_diary │ ▼
│ - reviews │ ┌──────────────────┐
│ - ai_tasks │ │ │
│ │ │ Pexels API │
└──────────────────┘ │ (фото к рец.) │
│ │
└──────────────────┘
```
---
## 3. Авторизация
### Поток авторизации
```
Flutter Firebase Go Backend
│ │ │
│ 1. signInWithGoogle() │ │
│ ──────────────────────► │ │
│ │ │
│ 2. Firebase idToken │ │
│ ◄────────────────────── │ │
│ │ │
│ 3. POST /auth/login │ │
│ {firebase_token} │ │
│ ─────────────────────────┼─────────────────► │
│ │ │
│ │ 4. VerifyIDToken() │
│ │ ◄─────────────── │
│ │ ───────────────► │
│ │ (uid, email) │
│ │ │
│ │ 5. Upsert user │
│ │ в PostgreSQL │
│ │ │
│ 6. {jwt, refresh_token, │ │
│ user} │ │
│ ◄────────────────────────┼────────────────── │
│ │ │
│ 7. Сохранить JWT │ │
│ в Secure Storage │ │
```
### Детали
- **Firebase Auth** обрабатывает все провайдеры (email/password, Google, Apple) на стороне клиента.
- **Go backend** получает Firebase `idToken`, верифицирует его через Firebase Admin SDK (`firebase.google.com/go/v4/auth`), извлекает `uid`, `email`, `name`.
- **Backend выдаёт собственный JWT** (подписанный секретом сервера) с `user_id`, `role` (free/paid), `exp`. Это позволяет не зависеть от Firebase при каждом API-запросе.
- **Refresh token** хранится в БД, ротируется при каждом использовании.
- **Apple Sign-In** обязателен на iOS при наличии любого стороннего входа (политика App Store). Firebase Auth обрабатывает Apple прозрачно.
- Flutter-пакеты: `firebase_auth`, `google_sign_in`, `sign_in_with_apple`.
---
## 4. AI-подсистема (ядро приложения)
### Абстракция
AI-провайдер скрыт за интерфейсами. Это позволяет заменить Gemini на OpenAI или специализированный API без изменения бизнес-логики.
```
┌─────────────────────────────────────────┐
│ AI Service (interface) │
│ │
│ FoodRecognizer │
│ ├── RecognizeReceipt(image) → []Item │
│ ├── RecognizeProducts(image) → []Item │
│ └── RecognizeDish(image) → DishInfo │
│ │
│ RecipeGenerator │
│ ├── GenerateRecipes(req) → []Recipe │
│ └── SuggestSubstitutions(req) → []Sub │
│ │
│ MenuPlanner │
│ └── GenerateMenu(req) → MenuPlan │
│ │
│ NutritionEstimator │
│ └── EstimateNutrition(dish) → KBJU │
│ │
└──────────────┬──────────────────────────┘
┌────────┴────────┐
▼ ▼
┌───────────┐ ┌───────────┐
│ Gemini │ │ OpenAI │
│ Adapter │ │ Adapter │
│ (active) │ │ (резерв) │
└───────────┘ └───────────┘
```
### AI-задачи: детали
#### 4.1. Распознавание чека
- **Модель:** Gemini 2.5 Flash (vision)
- **Вход:** фото чека (JPEG/PNG)
- **Промпт:** системная инструкция + фото → structured JSON
- **Выход:**
```json
{
"items": [
{
"name": "Молоко 2.5%",
"quantity": 1,
"unit": "л",
"category": "dairy",
"price": 49.0,
"confidence": 0.95
}
],
"unrecognized": [
{ "raw_text": "ТОВ АРТИК 1ШТ", "price": 89.0 }
]
}
```
- **Стратегия промпта:** «Ты — OCR-система для чеков из продуктовых магазинов. Извлеки список продуктов. Для каждого определи название, количество, единицу измерения, категорию. Если не можешь распознать позицию — добавь в unrecognized с оригинальным текстом. Ответ строго в JSON.»
- **Fallback:** если confidence < 0.5 — элемент идёт в `unrecognized`.
#### 4.2. Распознавание продуктов (фото)
- **Модель:** Gemini 2.5 Flash (vision)
- **Вход:** фото продуктов (холодильник, стол)
- **Выход:** аналогичная структура (без цены), но с приблизительным весом/количеством
- **Нюанс:** поддержка нескольких фото — результаты объединяются на бэкенде (дедупликация по названию + суммирование количеств).
#### 4.3. Распознавание блюда
- **Модель:** Gemini 2.5 Flash (vision)
- **Вход:** фото готового блюда
- **Выход:**
```json
{
"dish_name": "Паста Карбонара",
"weight_grams": 450,
"calories": 580,
"protein": 24,
"fat": 28,
"carbs": 56,
"confidence": 0.85,
"similar_dishes": ["Паста с беконом", "Спагетти алла Грича"]
}
```
- **Нюанс:** `similar_dishes` используется для поиска похожих рецептов в нашей БД (по названию, fuzzy search).
#### 4.4. Генерация рецептов / подбор меню
- **Модель:** Gemini 2.5 Flash-Lite (текст) — дешевле, vision не нужен
- **Ключевой принцип:** Gemini генерирует рецепты с нуля на основе контекста пользователя. Статическая база рецептов не нужна.
```
1. Backend собирает контекст: продукты пользователя (с учётом сроков хранения),
профиль (цель, КБЖУ), предпочтения кухни, диетические ограничения
2. Gemini генерирует полные рецепты: название, ингредиенты с граммовками,
шаги, КБЖУ на порцию, image_query для Pexels
3. Backend параллельно запрашивает фото из Pexels по image_query
4. Результат возвращается клиенту; при сохранении → пишется в saved_recipes
```
- **Промпт для рекомендаций:**
```
Ты — диетолог-повар. Предложи 5 рецептов на русском языке.
Профиль:
- Цель: похудение, 1800 ккал/день
- Ограничения: без орехов
- Предпочтения: русская, азиатская кухня
Доступные продукты (приоритет — скоро истекают):
- Куриное филе 500г (истекает завтра ⚠)
- Морковь 3 шт · Рис 400г · Яйца 4 шт
Верни ТОЛЬКО валидный JSON без markdown. Для каждого рецепта:
title, description, cuisine, difficulty, prep_time_min, cook_time_min,
servings, image_query (EN), ingredients, steps, tags, nutrition_per_serving.
```
- **Выход:** массив рецептов с `image_query` — бэкенд дозапрашивает Pexels и возвращает готовый объект с `image_url`.
#### 4.5. AI-генерация персональных рецептов
Отдельный кейс — когда в БД нет подходящего рецепта из имеющихся продуктов:
- **Модель:** Gemini 2.5 Flash-Lite
- **Промпт:** «Из продуктов [список] предложи рецепт. Формат: JSON с названием, ингредиентами (с граммовками), шагами, калорийностью, БЖУ.»
- **Результат:** сохраняется в БД как `source = 'ai_generated'`, помечается в UI как «AI-рецепт».
- **Валидация нутриентов:** значения КБЖУ от Gemini считаются приблизительными и помечаются «≈» в UI. Точная верификация через USDA FoodData Central запланирована в будущем (см. TODO.md).
#### 4.6. Замена ингредиентов
- **Модель:** Gemini 2.5 Flash-Lite
- **Вход:** ингредиент + доступные продукты
- **Выход:** `[{ "original": "пекорино", "substitute": "пармезан", "ratio": "1:1", "note": "Менее острый вкус" }]`
- **Кеширование:** результаты замен кешируются в БД (таблица `ingredient_substitutions`), чтобы не запрашивать AI повторно для одинаковых пар.
---
## 5. Маппинг ингредиентов (нормализация продуктов пользователя)
### Проблема
Пользователи вводят продукты свободным текстом: "куриное филе", "куриная грудка", "курица без кости". Это одно и то же. Нужен слой нормализации — чтобы при генерации рекомендаций Gemini понимал, что у пользователя есть, и чтобы не дублировались продукты.
### Решение: таблица `ingredient_mappings`
Каноническая таблица с алиасами на русском и английском. Заполняется по мере работы приложения через Gemini (рантайм-дополнение).
```
┌───────────────────────────────────────────────────────┐
│ ingredient_mappings │
│───────────────────────────────────────────────────────│
│ id UUID │
│ canonical_name "chicken_breast" │
│ canonical_name_ru "куриная грудка" │
│ aliases (JSONB) ["куриное филе", "куриная грудка", │
│ "грудка курицы", "chicken breast"]│
│ category "meat" │
│ default_unit "g" │
│ calories_per_100g 165 (≈ от Gemini) │
│ protein_per_100g 31 │
│ fat_per_100g 3.6 │
│ storage_days 3 │
└───────────────────────────────────────────────────────┘
```
### Как работает связка
```
Продукт юзера ingredient_mappings Промпт для Gemini
────────────── ─────────────────── ──────────────────
"Куриное филе" │ canonical_name_ru:
│ │ "куриная грудка"
│ Fuzzy search │ │
└────по aliases ────► canonical_name ──────────►│
"chicken_breast" Gemini понимает
что это за продукт
```
### Потоки по сценариям
#### Сценарий 1: Добавление продукта (чек/фото → запасы)
```
1. Gemini возвращает: "Куриное филе, 500г"
2. Backend: fuzzy search по ingredient_mappings.aliases
SELECT * FROM ingredient_mappings
WHERE aliases @> '"куриное филе"'::jsonb
OR similarity(canonical_name, 'куриное филе') > 0.3
3. Найдено → product.mapping_id = ingredient_mappings.id
Автоподставляются: category, default_unit, storage_days, нутриенты
4. Не найдено → разовый запрос к Gemini:
"К какому каноническому ингредиенту относится 'филе индейки'?
Ответь JSON: { canonical_name, category, calories_per_100g, ... }"
Результат сохраняется в ingredient_mappings (новая строка)
Следующий юзер с таким же продуктом — AI не нужен
```
#### Сценарий 2: Проверка "есть ли ингредиент рецепта в запасах"
```
1. Рецепт "Паста Карбонара" содержит ингредиент: canonical_name = "spaghetti"
2. products: ищем WHERE mapping_id IN (
SELECT id FROM ingredient_mappings WHERE canonical_name = 'spaghetti'
)
3. Найдено в products → отметка ✅ (есть в запасах)
Не найдено → отметка ❌ (нет) или 🔄 (есть замена через ingredient_substitutions)
```
#### Сценарий 3: Рекомендации рецептов "из моих продуктов"
```
1. Продукты юзера → через mapping_id → набор canonical_names_ru
["куриная грудка", "рис", "морковь", "лук"]
2. Backend формирует промпт для Gemini:
- профиль пользователя (цель, КБЖУ-норма, ограничения)
- список продуктов с количеством и сроками
- приоритет: продукты, истекающие скоро
3. Gemini генерирует 5 рецептов с нуля, используя имеющиеся продукты.
Каждый рецепт содержит image_query (EN) для запроса Pexels.
4. Backend параллельно запрашивает Pexels по image_query.
5. Результат: персонализированные рецепты с фото.
Статическая база рецептов не нужна.
```
#### Сценарий 4: Распознавание блюда (фото → калории)
```
1. Gemini определяет: "Паста Карбонара"
2. Backend: full-text search по recipes.title
SELECT * FROM recipes
WHERE to_tsvector('russian', title) @@ plainto_tsquery('russian', 'Паста Карбонара')
ORDER BY review_count DESC LIMIT 5
3. Найдено → привязка к рецепту (точные нутриенты из БД)
Не найдено → используются нутриенты от Gemini (помечены "≈ приблизительно")
```
#### Сценарий 5: Генерация меню
```
1. Backend собирает контекст:
- профиль (цель, КБЖУ-норма, кухни, ограничения)
- продукты пользователя (canonical_names, количество, сроки)
- период меню (например, 7 дней × 3 приёма пищи)
2. Gemini генерирует полное меню с нуля:
- 7 дней, 3 приёма пищи в день
- каждый слот: полный рецепт (ингредиенты, шаги, КБЖУ, image_query)
- баланс КБЖУ в рамках дневной нормы
- приоритет продуктам с истекающим сроком
3. Backend параллельно запрашивает Pexels для каждого рецепта.
4. Меню сохраняется в menu_plans + menu_items.
Рецепты из меню — в saved_recipes (source = 'ai').
→ Gemini генерирует контент полностью. Никакой статической базы не нужно.
```
### Наполнение таблицы маппингов
| Этап | Источник | Объём |
|------|----------|-------|
| Начальный seed | Ручное составление топ-200 базовых ингредиентов | Aliases на русском и английском |
| Рантайм-дополнение | Gemini (при нераспознанном продукте) | По мере роста юзеров |
| Пользовательская обратная связь | Юзер корректирует продукт на экране редактирования | Новые aliases |
Со временем маппинг растёт, и AI для распознавания ингредиентов вызывается всё реже.
### Сводная таблица: где Gemini, а где нет
| Задача | Gemini? | Как стыкуется с БД |
|--------|---------|---------------------|
| OCR чека → продукты | Да (vision) | Fuzzy match по `ingredient_mappings.aliases` |
| Фото продуктов → список | Да (vision) | То же |
| Фото блюда → калории | Да (vision) | Результат логируется в `ai_tasks` |
| Рекомендации рецептов из продуктов | **Да (текст)** | Продукты передаются в промпт; результат → `saved_recipes` |
| Генерация меню на неделю | Да (текст) | Генерирует с нуля; сохраняется в `menu_plans` + `menu_items` |
| Замена ингредиентов | Да (текст) | Кеш в `ingredient_substitutions` |
| Нераспознанный ингредиент | Да (текст, разово) | Результат сохраняется в `ingredient_mappings` |
---
## 6. Система очередей AI-запросов
### Проблема
Gemini API имеет ограничения по RPM (requests per minute) и стоит денег. Нужно:
1. Не допустить перерасход бюджета
2. Дать приоритет платным пользователям
3. Не блокировать систему при пиковых нагрузках
### Архитектура
```
Входящий AI-запрос
┌─────────────────────┐
│ Rate Limiter │
│ (per-user) │
│ │
│ Free: 20 req/час │
│ Paid: 100 req/час │
└──────────┬──────────┘
┌──────┴──────┐
│ │
Paid user Free user
│ │
▼ ▼
┌─────────────┐ ┌─────────────┐
│ Paid Queue │ │ Free Queue │
│ │ │ │
│ N воркеров │ │ 1 воркер │
│ (быстро) │ │ (медленно) │
└──────┬──────┘ └──────┬──────┘
│ │
└───────┬───────┘
┌─────────────────────┐
│ Budget Guard │
│ │
│ Daily limit: $X │
│ Current: $Y │
│ │
│ Y >= X? → REJECT │
│ Y >= 0.8X? → WARN │
└──────────┬──────────┘
┌─────────────────────┐
│ Gemini API Call │
│ │
│ + Track cost │
│ + Log to ai_tasks │
└─────────────────────┘
```
### Компоненты
#### Rate Limiter (per-user)
- Алгоритм: token bucket (горутина + каналы в Go).
- Лимиты хранятся в конфигурации, разделены по тарифу (free/paid).
- При превышении: HTTP 429 + заголовок `Retry-After`.
- Клиент показывает: «Слишком много запросов. Попробуйте через N секунд.»
#### Priority Queues
- **Paid Queue:** N воркеров (горутин), обрабатывающих запросы параллельно. N зависит от лимита RPM Gemini API (например, при лимите 60 RPM и двух очередях — 50 RPM на paid, 10 RPM на free).
- **Free Queue:** 1 воркер, обрабатывает запросы последовательно. Пользователи free-тарифа ждут дольше, но результат получают.
- Реализация: `chan AITask` в Go с горутинами-воркерами. Без внешних брокеров сообщений на данном этапе.
- При масштабировании: миграция на Redis Streams или NATS.
#### Budget Guard
- **Daily budget cap:** максимальная сумма затрат на Gemini API в день (например, $50).
- **Учёт затрат:** каждый запрос логируется с оценочной стоимостью (input_tokens × price + output_tokens × price). Gemini API возвращает `usage_metadata` с точными токенами.
- **Пороги:**
- 80% бюджета: предупреждение в логи, приоритет только paid-очереди.
- 100% бюджета: free-очередь останавливается, paid продолжает (из резерва).
- 120% бюджета (абсолютный лимит): все AI-запросы отклоняются.
- **Graceful degradation:** при отклонении запроса клиент показывает: «AI-распознавание временно недоступно. Вы можете добавить продукты вручную.»
#### Таблица `ai_tasks` (лог)
| Поле | Тип | Описание |
|------|-----|----------|
| id | UUID | |
| user_id | UUID | FK → users |
| task_type | ENUM | receipt_ocr, product_recognition, dish_recognition, recipe_generation, menu_planning, substitution |
| status | ENUM | queued, processing, completed, failed, rejected |
| priority | ENUM | free, paid |
| input_tokens | INT | Токены на вход (от API) |
| output_tokens | INT | Токены на выход (от API) |
| estimated_cost | DECIMAL | Оценочная стоимость в $ |
| queue_time_ms | INT | Время ожидания в очереди |
| process_time_ms | INT | Время обработки |
| created_at | TIMESTAMP | |
| completed_at | TIMESTAMP | |
Используется для: аналитики затрат, мониторинга, оптимизации промптов.
---
## 7. Рецепты и контент
### Источники рецептов
Статической базы рецептов нет. Все рецепты генерируются Gemini on-demand и сохраняются пользователями.
```
┌─────────────────────────────────────────────────┐
│ Recipe Service │
│ │
│ ┌──────────────────────┐ ┌──────────────────┐ │
│ │ AI-generated │ │ User Recipes │ │
│ │ (Gemini) │ │ (будущее) │ │
│ │ │ │ │ │
│ │ source: 'ai' │ │ source: 'user' │ │
│ └──────────┬───────────┘ └────────┬─────────┘ │
│ │ │ │
│ └──────────┬────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────┐ │
│ │ saved_recipes │ │
│ │ (per-user) │ │
│ └──────────────────┘ │
└─────────────────────────────────────────────────┘
```
### Принцип работы с рецептами
- **Генерация:** Gemini создаёт рецепты с нуля на основе контекста пользователя (продукты, профиль, цели).
- **Фото:** Pexels API по `image_query` (EN), которую Gemini включает в ответ.
- **КБЖУ:** приблизительные значения от Gemini, помечаются «≈» в UI.
- **Сохранение:** пользователь тапает ❤ → рецепт сохраняется в `saved_recipes`. До сохранения нигде не хранится.
- **Отсутствие глобального каталога:** каждый пользователь видит только свои сохранённые рецепты и свежие рекомендации. Полноценный каталог с поиском — в TODO.md (требует постоянной базы).
### Согласованность рецептов
- Все данные рецепта (ингредиенты, шаги, КБЖУ, image_url) приходят от Gemini+Pexels в одном запросе.
- Сохранённый рецепт хранится целиком в `saved_recipes` как JSONB — нет риска рассинхронизации.
- КБЖУ помечены как приблизительные — пользователь понимает точность данных.
---
## 8. Доменные сущности (схема БД)
```
┌──────────────┐ ┌──────────────────┐
│ users │ │ products │
│──────────────│ │──────────────────│
│ id │◄──┐ │ id │
│ firebase_uid │ │ │ user_id (FK) │──► users
│ email │ │ │ mapping_id (FK) │──► ingredient_mappings
│ name │ │ │ name │
│ avatar_url │ │ │ quantity │
│ height_cm │ │ │ unit │
│ weight_kg │ │ │ category │
│ age │ │ │ storage_days │
│ gender │ │ │ added_at │
│ activity │ │ │ expires_at │
│ goal │ │ │ (computed) │
│ │ │ └──────────────────┘
│ plan (free/ │ │
│ paid) │ │ ┌──────────────────┐
│ preferences │ │ │ recipes │
│ (JSONB) │ │ │──────────────────│
│ created_at │ │ │ id │
└──────────────┘ │ │ source │
│ │ title │
│ │ description │
│ │ cuisine │
│ │ difficulty │
│ │ prep_time_min │
│ │ calories │
│ │ protein │
│ │ fat │
│ │ carbs │
│ │ servings │
│ │ image_url │
│ │ ingredients │
│ │ (JSONB) │
│ │ steps (JSONB) │
│ │ tags (JSONB) │
│ │ avg_rating │
│ │ review_count │
│ │ created_by │──► users (NULL для ai-generated)
│ └──────────────────┘
┌──────────────────┤ ┌──────────────────┐
│ menu_plans │ │ menu_items │
│──────────────────│ │──────────────────│
│ id │ │ id │
│ user_id (FK) ───┘ │ menu_plan_id(FK) │──► menu_plans
│ week_start │ │ day_of_week │
│ created_at │ │ meal_type │
│ template_name │ │ recipe_id (FK) │──► recipes
│ (NULL если не │ │ servings │
│ шаблон) │ └──────────────────┘
└──────────────────┘
┌──────────────────┐
│ meal_diary │
│──────────────────│
│ id │
│ user_id (FK) │──► users
│ date │
│ meal_type │
│ recipe_id (FK) │──► recipes (NULL для ручного ввода)
│ name │
│ portions │
│ calories │
│ protein │
│ fat │
│ carbs │
│ source │
│ (menu/photo/ │
│ manual/recipe) │
│ created_at │
└──────────────────┘
┌──────────────────┐ ┌──────────────────┐
│ reviews │ │ shopping_lists │
│──────────────────│ │──────────────────│
│ id │ │ id │
│ user_id (FK) │──►│ user_id (FK) │──► users
│ recipe_id (FK) │──►│ menu_plan_id(FK) │──► menu_plans
│ rating (1-5) │ │ items (JSONB) │
│ text │ │ created_at │
│ photo_url │ └──────────────────┘
│ created_at │
└──────────────────┘ ┌──────────────────┐
│ water_tracker │
│──────────────────│
│ id │
│ user_id (FK) │──► users
│ date │
│ glasses │
└──────────────────┘
┌──────────────────┐ ┌──────────────────────────┐
│ ai_tasks │ │ ingredient_substitutions │
│──────────────────│ │──────────────────────────│
│ (см. раздел 6) │ │ id │
└──────────────────┘ │ original_mapping_id (FK) │──► ingredient_mappings
│ substitute_mapping_id(FK)│──► ingredient_mappings
│ ratio │
│ note │
│ created_at │
└──────────────────────────┘
┌────────────────────────────┐
│ ingredient_mappings │
│────────────────────────────│
│ id │
│ canonical_name │
│ canonical_name_ru │
│ aliases (JSONB) │
│ category │
│ default_unit │
│ calories_per_100g │
│ protein_per_100g │
│ fat_per_100g │
│ carbs_per_100g │
│ storage_days │
│ created_at │
└────────────────────────────┘
```
### Ключевые решения по схеме
- **`products.mapping_id`** — FK на `ingredient_mappings`. Связывает продукт пользователя с каноническим ингредиентом. Через эту связь определяется наличие ингредиентов рецепта в запасах. Может быть NULL (если продукт не удалось сопоставить).
- **`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: ai, user. Определяет происхождение рецепта.
- **`meal_diary.source`** — откуда записано: из меню, через фото, вручную, из рецепта.
- **`shopping_lists.items`** — JSONB: `[{ "name": "...", "amount": 1, "unit": "l", "category": "dairy", "checked": false, "manual": false }]`.
---
## 9. API-дизайн (ключевые эндпоинты)
### Авторизация
| Метод | Путь | Описание |
|-------|------|----------|
| POST | `/auth/login` | Firebase token → JWT |
| POST | `/auth/refresh` | Обновить JWT |
| POST | `/auth/logout` | Инвалидировать refresh token |
### Продукты
| Метод | Путь | Описание |
|-------|------|----------|
| GET | `/products` | Список продуктов (фильтры: category, expiring) |
| POST | `/products` | Добавить продукт вручную |
| POST | `/products/batch` | Массовое добавление (после распознавания) |
| PUT | `/products/{id}` | Обновить (вес, количество, срок) |
| PATCH | `/products/{id}/consume` | Частичное использование |
| DELETE | `/products/{id}` | Удалить |
| DELETE | `/products` | Очистить все (с подтверждением в query param) |
| GET | `/products/storage-defaults` | Дефолтные сроки хранения по категориям |
| PUT | `/products/storage-defaults` | Обновить дефолтные сроки |
### AI-операции
| Метод | Путь | Описание |
|-------|------|----------|
| POST | `/ai/recognize-receipt` | Фото чека → список продуктов |
| POST | `/ai/recognize-products` | Фото продуктов → список |
| POST | `/ai/recognize-dish` | Фото блюда → калории, БЖУ |
| POST | `/ai/suggest-recipes` | Подбор рецептов из продуктов |
| POST | `/ai/generate-menu` | Генерация меню на период |
| POST | `/ai/substitute` | Замена ингредиента |
| GET | `/ai/tasks/{id}` | Статус AI-задачи (для polling) |
Все `/ai/*` эндпоинты возвращают `{ task_id }` (HTTP 202 Accepted). Клиент получает результат через polling `/ai/tasks/{id}` или через WebSocket (будущее улучшение). Это позволяет обрабатывать запросы асинхронно через очереди.
### Рекомендации
| Метод | Путь | Описание |
|-------|------|----------|
| GET | `/recommendations?count=5` | Персональные рекомендации (Gemini + Pexels) |
### Сохранённые рецепты
| Метод | Путь | Описание |
|-------|------|----------|
| GET | `/saved-recipes` | Список сохранённых рецептов |
| POST | `/saved-recipes` | Сохранить рецепт |
| GET | `/saved-recipes/{id}` | Карточка сохранённого рецепта |
| DELETE | `/saved-recipes/{id}` | Удалить из сохранённых |
### Меню
| Метод | Путь | Описание |
|-------|------|----------|
| GET | `/menu?week=2026-W08` | Меню на неделю |
| PUT | `/menu/items/{id}` | Обновить слот меню |
| POST | `/menu/items` | Добавить блюдо в слот |
| DELETE | `/menu/items/{id}` | Убрать блюдо |
| POST | `/menu/templates` | Сохранить как шаблон |
| GET | `/menu/templates` | Список шаблонов |
| POST | `/menu/from-template/{id}` | Применить шаблон |
### Дневник питания
| Метод | Путь | Описание |
|-------|------|----------|
| GET | `/diary?date=2026-02-15` | Записи за день |
| POST | `/diary` | Добавить запись |
| PUT | `/diary/{id}` | Изменить (порция, и т.д.) |
| DELETE | `/diary/{id}` | Удалить запись |
### Список покупок
| Метод | Путь | Описание |
|-------|------|----------|
| GET | `/shopping-list` | Текущий список |
| POST | `/shopping-list/generate` | Сгенерировать из меню |
| PUT | `/shopping-list/items/{index}` | Обновить позицию |
| PATCH | `/shopping-list/items/{index}/check` | Отметить купленным |
| POST | `/shopping-list/items` | Добавить вручную |
### Статистика
| Метод | Путь | Описание |
|-------|------|----------|
| GET | `/stats?period=week` | Статистика за период |
| GET | `/stats/water?date=2026-02-15` | Трекер воды |
| PUT | `/stats/water` | Обновить стаканы |
### Профиль
| Метод | Путь | Описание |
|-------|------|----------|
| GET | `/profile` | Данные профиля |
| PUT | `/profile` | Обновить |
| PUT | `/profile/preferences` | Обновить предпочтения кухонь |
| PUT | `/profile/restrictions` | Обновить ограничения |
---
## 10. Оценка затрат
### Стоимость по стадиям (в месяц)
| Компонент | 1K MAU | 10K MAU | 50K MAU |
|-----------|--------|---------|---------|
| Firebase Auth | $0 | $0 | $0 (лимит 50K) |
| Gemini API | $50150 | $5001 500 | $2 5007 500 |
| Pexels API | $0 | $0 | $0 (Free tier) |
| Хостинг (Cloud Run / Fly.io) | $1020 | $50100 | $200500 |
| PostgreSQL (managed) | $15 | $50 | $150300 |
| S3 (фото) | $5 | $20 | $50100 |
| **Итого** | **$80190** | **$6201 670** | **$2 9008 400** |
### Оптимизация затрат на AI
- **Кеширование результатов:** замены ингредиентов, распознавание типовых блюд.
- **Batch-запросы:** при генерации меню на неделю — один запрос вместо семи.
- **Снижение detail для фото:** Gemini поддерживает `low` / `high` detail. Для чеков `high`, для фото продуктов `low` достаточно.
- **Мониторинг:** дашборд затрат по `ai_tasks` — какие задачи дорогие, где можно оптимизировать промпты.