- 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>
825 lines
52 KiB
Markdown
825 lines
52 KiB
Markdown
# 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 | $50–150 | $500–1 500 | $2 500–7 500 |
|
||
| 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 |
|
||
| **Итого** | **$80–190** | **$620–1 670** | **$2 900–8 400** |
|
||
|
||
### Оптимизация затрат на AI
|
||
|
||
- **Кеширование результатов:** замены ингредиентов, распознавание типовых блюд.
|
||
- **Batch-запросы:** при генерации меню на неделю — один запрос вместо семи.
|
||
- **Снижение detail для фото:** Gemini поддерживает `low` / `high` detail. Для чеков `high`, для фото продуктов `low` достаточно.
|
||
- **Мониторинг:** дашборд затрат по `ai_tasks` — какие задачи дорогие, где можно оптимизировать промпты.
|