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

52 KiB
Raw Blame History

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
  • Выход:
{
  "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)
  • Вход: фото готового блюда
  • Выход:
{
  "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 — какие задачи дорогие, где можно оптимизировать промпты.