# 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` — какие задачи дорогие, где можно оптимизировать промпты.