feat: implement Iteration 1 — AI recipe recommendations

Backend:
- Add Groq LLM client (llama-3.3-70b) for recipe generation with JSON
  retry strategy (retries only on parse errors, not API errors)
- Add Pexels client for parallel photo search per recipe
- Add saved_recipes table (migration 004) with JSONB fields
- Add GET /recommendations endpoint (profile-aware prompt building)
- Add POST/GET/GET{id}/DELETE /saved-recipes CRUD endpoints
- Wire gemini, pexels, recommendation, savedrecipe packages in main.go

Flutter:
- Add Recipe, SavedRecipe models with json_serializable
- Add RecipeService (getRecommendations, getSavedRecipes, save, delete)
- Add RecommendationsNotifier and SavedRecipesNotifier (Riverpod)
- Add RecommendationsScreen with skeleton loading and refresh FAB
- Add RecipeDetailScreen (SliverAppBar, nutrition tooltip, steps with timer)
- Add SavedRecipesScreen with Dismissible swipe-to-delete and empty state
- Update RecipesScreen to TabBar (Recommendations / Saved)
- Add /recipe-detail route outside ShellRoute (no bottom nav)
- Extend ApiClient with getList() and deleteVoid()

Project:
- Add CLAUDE.md with English-only rule for comments and commit messages

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
dbastrikin
2026-02-21 22:43:29 +02:00
parent 24219b611e
commit e57ff8e06c
41 changed files with 5994 additions and 353 deletions

644
docs/Flow.md Normal file
View File

@@ -0,0 +1,644 @@
# Flow: взаимодействие пользователя → бэкенда → сторонних API
## Содержание
1. [Архитектура системы](#1-архитектура-системы)
2. [Ключевой принцип: сторонние API только там где нужны](#2-ключевой-принцип)
3. [Flow 1: Аутентификация](#3-flow-1-аутентификация)
4. [Flow 2: Обновление токена](#4-flow-2-обновление-токена)
5. [Flow 3: Профиль пользователя](#5-flow-3-профиль-пользователя)
6. [Flow 4: Рекомендации рецептов](#6-flow-4-рекомендации-рецептов)
7. [Flow 5: Сохранённые рецепты](#7-flow-5-сохранённые-рецепты)
8. [Flow 6: Управление продуктами (Итерация 2)](#8-flow-6-управление-продуктами-итерация-2)
9. [Flow 7: Распознавание продуктов (Итерация 3)](#9-flow-7-распознавание-продуктов-итерация-3)
10. [Flow 8: Планирование меню (Итерация 4)](#10-flow-8-планирование-меню-итерация-4)
11. [Анализ потребления сторонних API](#11-анализ-потребления-сторонних-api)
12. [Количество запросов к бэкенду по сценариям](#12-количество-запросов-к-бэкенду-по-сценариям)
13. [Сводная таблица](#13-сводная-таблица)
---
## 1. Архитектура системы
```
┌────────────────────────────────────────────────────────────────────┐
│ Flutter Client │
│ (Android / iOS / Web) │
│ - Firebase Auth (Google / Apple / Email) │
│ - Dio HTTP Client + Auth Interceptor │
│ - FlutterSecureStorage (токены) │
│ - Riverpod (состояние) │
└────────────────────────┬───────────────────────────────────────────┘
│ HTTPS, Bearer JWT
┌────────────────────────▼───────────────────────────────────────────┐
│ Go Backend (chi v5) │
│ - JWT middleware (HS256, верификация без Firebase в рантайме) │
│ - Gemini API (генерация рекомендаций рецептов) │
│ - Pexels API (подбор фотографий к рецептам) │
│ - Firebase Admin SDK (только при логине) │
│ - Калькулятор КБЖУ (Mifflin-St Jeor, локально) │
└──────┬─────────────────────────────────────────────────────────────┘
┌──────▼──────────────────────────────────────────────────────────────┐
│ PostgreSQL 15 │
│ users · saved_recipes · products (Iter.2) · ingredient_mappings │
└─────────────────────────────────────────────────────────────────────┘
Сторонние API:
┌────────────────────┐ ┌─────────────────────────────────────┐
│ Firebase Auth │ │ Google Gemini 2.0 Flash │
│ только логин │ │ генерация рецептов-рекомендаций │
└────────────────────┘ └─────────────────────────────────────┘
┌────────────────────┐
│ Pexels API │
│ фото к рецептам │
└────────────────────┘
```
---
## 2. Ключевой принцип
**Сторонние API вызываются только при конкретных пользовательских действиях**, а не фоновых задачах:
| API | Когда вызывается | Частота |
|-----|-----------------|---------|
| **Firebase Auth** | Только `POST /auth/login` | 1 раз за сессию |
| **Gemini** | `GET /recommendations`, `POST /ai/recognize-*`, `POST /ai/generate-menu` | По запросу пользователя |
| **Pexels** | Внутри рекомендаций и генерации меню | 1 вызов на рецепт |
Все токены, профили и сохранённые рецепты хранятся в PostgreSQL и раздаются **без внешних вызовов**.
---
## 3. Flow 1: Аутентификация
### 3.1 Первый вход (Google OAuth)
```
Пользователь Flutter Firebase Go Backend PostgreSQL
│ │ │ │ │
│── тапает "Войти" ─►│ │ │ │
│ │── signInWithGoogle()►│ │ │
│ │◄── idToken ─────────│ │ │
│ │ │ │ │
│ │── POST /auth/login ─────────────────────►│ │
│ │ { firebase_token: idToken } │ │
│ │ │◄── VerifyIDToken() ─│ │
│ │ │──► { uid, email } ──►│ │
│ │ │ │── UPSERT users ──►│
│ │ │ │◄── User{id, plan}─│
│ │ │ │── генерирует JWT │
│ │ │ │── UPDATE refresh ─►│
│ │◄── { access_token, refresh_token, user } ─│ │
│ │── сохраняет в SecureStorage │ │
│◄── Home Screen ───│ │ │ │
```
**Запросы на бэкенд:** 1
**Вызовов Firebase:** 1 (VerifyIDToken — серверная верификация)
**SQL:** 2 (UPSERT users + UPDATE refresh_token)
### 3.2 Последующие запросы (с JWT)
```
Flutter ── GET /profile ──► middleware.Auth
└── ValidateAccessToken() — локально, HMAC HS256
Firebase НЕ вызывается
──► handler ──► SELECT users WHERE id=$1
```
**Вызовов Firebase:** 0 (JWT верифицируется локально по секрету)
---
## 4. Flow 2: Обновление токена
Происходит автоматически в `AuthInterceptor` при 401 или истечении токена (15 мин TTL).
```
Flutter (AuthInterceptor) Go Backend PostgreSQL
│ │ │
│── POST /auth/refresh ──────►│ │
│ { refresh_token } │── SELECT users WHERE ──►│
│ │ refresh_token=$1 AND │
│ │ token_expires_at>now()│
│ │◄── User ───────────────│
│ │── новый JWT + UUID │
│ │── UPDATE users ────────►│
│◄── { access_token, │ │
│ refresh_token } │ │
│── повторяет исходный запрос│ │
```
**Запросов к Firebase:** 0
**SQL:** 2 (SELECT + UPDATE)
---
## 5. Flow 3: Профиль пользователя
### Просмотр
```
Flutter → GET /profile → SELECT * FROM users WHERE id=$1
```
**Запросов на бэкенд:** 1 | **SQL:** 1
### Обновление (онбординг)
```
Flutter → PUT /profile → Go Backend
{ height_cm, weight_kg, │
age, gender, │── Mifflin-St Jeor (локально):
activity, goal } │ BMR = 10W + 6.25H - 5A + offset
│ TDEE = BMR × activity_factor
│ Calories = TDEE ± goal_delta
│── UPDATE users SET daily_calories=...
```
**Запросов к сторонним API:** 0 (всё считается на Go)
**Запросов на бэкенд:** 1 | **SQL:** 1
---
## 6. Flow 4: Рекомендации рецептов
Центральный flow приложения. Вызывается когда пользователь открывает экран рекомендаций.
### 6.1 Полный flow
```
Пользователь Flutter Go Backend Gemini Pexels PostgreSQL
│ │ │ │ │ │
│── открывает │ │ │ │ │
│ экран ───►│ │ │ │ │
│ │── GET /recommendations ─────────►│ │ │
│ │ ?count=5 │ │ │
│ │ │ │ │
│ │ │── SELECT user ──────────────────────────────── ►│
│ │ │ profile + products (Iter.2) ◄────────────────│
│ │ │ │ │ │
│ │ │── GenerateContent(prompt) ────►│ │
│ │ │ prompt содержит: │ │
│ │ │ - цель пользователя │ │
│ │ │ - дневные калории │ │
│ │ │ - список продуктов (Iter.2) │ │
│ │ │ - N=5 рецептов │ │
│ │ │◄── JSON: [Recipe×5] ───────────│ │
│ │ │ каждый с image_query │ │
│ │ │ │ │ │
│ │ │ для каждого рецепта: │ │
│ │ │── GET /v1/search?query=... ─────────────────── ►│
│ │ │ Authorization: Pexels key │ │
│ │ │◄── { photos[0].src.medium } ───────────────────│
│ │ │ │ │ │
│◄── [Recipe×5 c image_url] ─│ │ │ │
```
### 6.2 Структура промпта для Gemini
```
Ты — диетолог-повар. Предложи {N} рецептов на русском языке.
Профиль пользователя:
- Цель: похудение
- Дневная норма калорий: 1800 ккал
- Ограничения: без глютена (если есть в preferences)
[Итерация 2+] Доступные продукты:
- куриная грудка (500г)
- помидоры (3 шт)
- ...
Требования к каждому рецепту:
- Калорийность на порцию: не более 600 ккал
- Время приготовления: до 40 минут
- Укажи КБЖУ на порцию (приблизительно)
Верни ТОЛЬКО валидный JSON массив без markdown:
[{
"title": "Название рецепта",
"description": "Краткое описание (2-3 предложения)",
"cuisine": "mediterranean",
"difficulty": "easy|medium|hard",
"prep_time_min": 10,
"cook_time_min": 20,
"servings": 2,
"image_query": "grilled chicken breast vegetables mediterranean",
"ingredients": [
{ "name": "Куриная грудка", "amount": 300, "unit": "г" }
],
"steps": [
{ "number": 1, "description": "Нарежьте курицу...", "timer_seconds": null }
],
"tags": ["без глютена", "высокий белок"],
"nutrition_per_serving": {
"calories": 420,
"protein_g": 48,
"fat_g": 12,
"carbs_g": 18
}
}]
```
### 6.3 Кэширование рекомендаций
Рекомендации **не сохраняются** автоматически. При каждом открытии экрана генерируются заново. Это дает:
- Свежесть контента
- Учёт изменившихся продуктов (Итерация 2)
Возможная оптимизация в будущем: кэшировать последний набор рекомендаций в Redis/памяти на 30 минут, инвалидировать при обновлении продуктов.
---
## 7. Flow 5: Сохранённые рецепты
### 7.1 Сохранить рекомендацию
```
Пользователь тапает ❤️ на рецепте
Flutter → POST /saved-recipes → PostgreSQL
{ полный JSON рецепта } └── INSERT INTO saved_recipes
(user_id, title, steps,
ingredients, nutrition,
image_url, ...)
← { id, saved_at }
```
**Запросов на бэкенд:** 1
**Вызовов Gemini/Pexels:** 0 (данные уже есть в клиенте)
**SQL:** 1
### 7.2 Список сохранённых
```
Flutter → GET /saved-recipes → SELECT * FROM saved_recipes
WHERE user_id=$1
ORDER BY saved_at DESC
```
**Запросов на бэкенд:** 1 | **SQL:** 1
### 7.3 Удалить из сохранённых
```
Flutter → DELETE /saved-recipes/{id} → DELETE FROM saved_recipes
WHERE id=$1 AND user_id=$2
```
**Запросов на бэкенд:** 1 | **SQL:** 1
---
## 8. Flow 6: Управление продуктами (Итерация 2)
### 8.1 Открытие списка продуктов
```
Flutter → GET /products → SELECT * FROM products
WHERE user_id=$1
ORDER BY expires_at ASC
```
**Запросов на бэкенд:** 1
### 8.2 Добавление продукта с автодополнением
```
Пользователь вводит "кур" (debounce 300мс)
Flutter → GET /ingredients/search?q=кур → PostgreSQL
├── ILIKE на canonical_name_ru
├── GIN на aliases
└── pg_trgm similarity
Пользователь выбирает "Куриная грудка"
│ поля автозаполняются локально
Flutter → POST /products → INSERT INTO products
{ mapping_id, expires_at GENERATED ALWAYS AS
name, quantity, (added_at + storage_days days)
unit, category,
storage_days }
```
**Запросов на бэкенд:** 35 (поиск, debounce) + 1 (создание)
**Вызовов сторонних API:** 0
### 8.3 Связь продуктов с рекомендациями (Итерация 2+)
После того как у пользователя есть продукты, `GET /recommendations` включает их в промпт для Gemini. Рекомендации становятся персонализированными: "что приготовить из того, что есть".
---
## 9. Flow 7: Распознавание продуктов (Итерация 3)
Пользователь фотографирует чек, холодильник или готовое блюдо — Gemini Vision распознаёт содержимое и заполняет список продуктов.
### 9.1 Распознавание чека
```
Пользователь Flutter Go Backend Gemini PostgreSQL
│ │ │ │ │
│─ фото чека►│ │ │ │
│ │── POST /ai/recognize-receipt ─────────►│ │
│ │ multipart/form-data: image │ │
│ │ │── GenerateContent ►│ │
│ │ │ prompt: OCR чека │ │
│ │ │◄── JSON: [{name, │ │
│ │ │ qty, unit, │ │
│ │ │ category, │ │
│ │ │ confidence}] │ │
│ │ │ │ │
│ │ │ fuzzy match по ingredient_mappings►│
│ │ │◄── mapping_id ─────────────────────│
│ │ │ │ │
│◄── [{name, mapping_id, qty, │ │ │
│ unit, storage_days}] ──────│ │ │
│ │ │ │ │
│─ подтверждает список ─────────►│ │ │
│ POST /products/batch │── INSERT products ►│ │
```
**Запросов на бэкенд:** 2 (recognize + batch insert)
**Gemini:** 1 (vision)
**SQL:** 1 SELECT (fuzzy match) + 1 INSERT batch
### 9.2 Распознавание фото продуктов (холодильник/стол)
Аналогичен чеку, но без цены. Поддерживает несколько фото:
```
Пользователь делает 13 фото холодильника
Flutter → POST /ai/recognize-products → Gemini Vision
(multipart, несколько фото) └── анализирует каждое фото
└── объединяет результаты
(дедупликация по canonical_name)
Backend:
1. Для каждого фото → 1 Gemini-запрос (параллельно)
2. Объединение списков, дедупликация (суммирование количества)
3. Fuzzy match по ingredient_mappings
4. Возврат клиенту для подтверждения
5. POST /products/batch → INSERT
```
**Gemini:** 13 (по числу фото, параллельно)
### 9.3 Распознавание блюда (фото → калории)
```
Пользователь Flutter Go Backend Gemini PostgreSQL
│ │ │ │ │
│─ фото блюда►│ │ │ │
│ │── POST /ai/recognize-dish ────────────►│ │
│ │ │── GenerateContent ►│ │
│ │ │ prompt: распознай│ │
│ │ │ блюдо, КБЖУ │ │
│ │ │◄── {dish_name, │ │
│ │ │ weight_g, │ │
│ │ │ calories, │ │
│ │ │ protein, fat, │ │
│ │ │ carbs, │ │
│ │ │ confidence} │ │
│ │ │ │ │
│ │ │ Опционально: поиск│ │
│ │ │ в saved_recipes │ │
│ │ │ по dish_name │ │
│ │ │ │ │
│◄── {dish, calories, КБЖУ≈, │ │ │
│ matched_recipe?} ──────────│ │ │
│ │ │ │ │
│─ добавить в дневник? ─────────►│ │ │
│ POST /diary │── INSERT meal_diary►│ │
```
**Запросов на бэкенд:** 2 (recognize + diary)
**Gemini:** 1 (vision)
---
## 10. Flow 8: Планирование меню (Итерация 4)
Пользователь запрашивает меню на неделю — Gemini генерирует полный план питания с рецептами на основе продуктов, профиля и целей пользователя.
### 10.1 Генерация меню
```
Пользователь Flutter Go Backend Gemini Pexels PostgreSQL
│ │ │ │ │ │
│─ «Составить │ │ │ │ │
│ меню» ──►│ │ │ │ │
│ │── POST /ai/generate-menu ─────────────►│ │ │
│ │ { period: "week", │ │ │
│ │ meals_per_day: 3 } │ │ │
│ │ │ │ │ │
│ │ SELECT user profile + products ────────────────────────►│
│ │ ◄── {goal, КБЖУ, products list} ───────────────────────│
│ │ │ │ │ │
│ │ │── GenerateContent ►│ │ │
│ │ │ промпт: │ │ │
│ │ │ - профиль юзера │ │ │
│ │ │ - продукты │ │ │
│ │ │ - период 7 дней │ │ │
│ │ │ - 3 приёма/день │ │ │
│ │ │◄── JSON: 21 recipe │ │ │
│ │ │ каждый с │ │ │
│ │ │ image_query │ │ │
│ │ │ │ │ │
│ │ │ для каждого рецепта (параллельно): │
│ │ │── GET /v1/search?query=... ──────────────────────►│
│ │ │◄── photo_url ────────────────────────────────────│
│ │ │ │ │ │
│ │ │── INSERT menu_plans + menu_items ───────────────►│
│ │ │── INSERT saved_recipes (рецепты меню) ──────────►│
│ │ │ │ │ │
│◄── {menu_plan_id, │ │ │ │
│ days: [{day, meals: [ │ │ │ │
│ {meal_type, recipe} │ │ │ │
│ ]}]} ──────────────────────│ │ │ │
```
**Gemini:** 1 (большой промпт, ~21 рецепт)
**Pexels:** до 21 (параллельно; на практике повторяющиеся query кешируются)
**SQL:** 1 SELECT + batch INSERT menu_plans/items + batch INSERT saved_recipes
### 10.2 Просмотр и редактирование меню
```
Flutter → GET /menu?week=2026-W08 → SELECT menu_plans, menu_items WHERE user_id=$1 AND week_start=$2
LEFT JOIN saved_recipes ON menu_items.recipe_id
Flutter → PUT /menu/items/{id} → UPDATE menu_items SET recipe_id=$1
Flutter → DELETE /menu/items/{id} → DELETE menu_items WHERE id=$1 AND user_id=$2
```
**Запросов к бэкенду:** 13 | **Gemini:** 0 | **SQL:** 12
### 10.3 Список покупок из меню
```
Flutter → POST /shopping-list/generate → Go Backend
{ menu_plan_id } ├── SELECT menu_items JOIN saved_recipes
│ WHERE menu_plan_id=$1
├── Агрегация ингредиентов:
│ суммирование по canonical_name
│ вычитание того, что уже есть в products
└── INSERT/UPDATE shopping_lists
Gemini НЕ участвует. Чистая SQL-агрегация.
```
**Gemini:** 0 | **SQL:** 3
---
## 11. Анализ потребления сторонних API
### 9.1 Firebase Auth
| Сценарий | Вызовов |
|----------|---------|
| Логин | 1 (VerifyIDToken) |
| Обычный запрос с JWT | **0** |
| Refresh токена | **0** |
### 9.2 Gemini (Google Gemini 2.0 Flash)
**Тарифы Free tier:**
| Параметр | Значение |
|----------|----------|
| RPM | 15 (Flash) / 30 (Flash-Lite) |
| Запросов/день | 1 500 |
| Токенов/минуту | 1 000 000 |
**Расход на запрос рекомендаций (5 рецептов):**
| Метрика | Значение |
|---------|----------|
| Input токены (промпт + продукты) | ~500800 |
| Output токены (5 рецептов JSON) | ~1 5002 500 |
| Gemini-запросов | **1** |
| Стоимость (Flash, платный) | ~$0.0003 |
**Дневное потребление при 100 активных пользователях (каждый открывает рекомендации 3 раза/день):**
- 100 × 3 = 300 Gemini-запросов/день
- Free tier: 1 500/день → **хватает на ~5× текущую нагрузку**
- Платный Flash: ~$0.09/день = ~$2.7/мес
### 9.3 Pexels API
**Тарифы:**
| Тариф | Запросов/час | Запросов/мес |
|-------|-------------|-------------|
| Free | 200 | 20 000 |
**Расход:**
- 1 рекомендация = 5 рецептов = **5 Pexels-запросов**
- 100 пользователей × 3 рекомендации = 300 запросов/час пик
- ⚠️ **200 req/hour лимит может стать узким местом при пиковой нагрузке**
**Стратегия:** кэшировать image_url в `saved_recipes`, для несохранённых рекомендаций — запрашивать при генерации. При росте нагрузки — кэшировать по `image_query` в Redis (большинство запросов повторяются: "grilled chicken", "pasta carbonara", etc.).
---
## 12. Количество запросов к бэкенду по сценариям
### Первый запуск (новый пользователь)
| Шаг | Endpoint | Сторонний API |
|-----|----------|---------------|
| Вход через Google | POST /auth/login | Firebase (1×) |
| Загрузка профиля | GET /profile | — |
| Онбординг | PUT /profile | — |
| Первые рекомендации | GET /recommendations | Gemini (1×) + Pexels (5×) |
| **Итого** | **4** | **Firebase×1, Gemini×1, Pexels×5** |
### Обычная сессия
| Шаг | Endpoint | Кол-во |
|-----|----------|--------|
| Refresh токена (если истёк) | POST /auth/refresh | 01 |
| Открыть рекомендации | GET /recommendations | 1 |
| Сохранить рецепт | POST /saved-recipes | 1 |
| Открыть сохранённые | GET /saved-recipes | 1 |
| **Итого** | **34** | **Gemini×1, Pexels×5** |
### Сценарий: пользователь не взаимодействует с рекомендациями
```
Открывает приложение → просматривает сохранённые рецепты
Запросов: 1 GET /saved-recipes
Сторонних API: 0
SQL: 1
```
### Детальный breakdown: GET /recommendations
```
1. SELECT users WHERE id=$1 → 1 SQL
2. [Iter.2+] SELECT products WHERE user_id=$1 → 1 SQL
3. Gemini.GenerateContent(prompt) → 1 Gemini req (~13 сек)
4. Pexels.Search(image_query) × 5 (параллельно) → 5 Pexels req (параллельно)
5. Формирование ответа и отдача → 0 SQL
Итого: 12 SQL + 1 Gemini + 5 Pexels
Время ответа: ~24 секунды (доминирует Gemini latency)
```
---
## 13. Сводная таблица
### Сторонние API в рантайме
| API | Trigger | Вызовов на запрос | Free tier (день) |
|-----|---------|------------------|-----------------|
| Firebase Auth | POST /auth/login | 1 | Без ограничений |
| Gemini Flash | GET /recommendations | 1 | 1 500 |
| Gemini Flash | POST /ai/recognize-receipt | 1 | — |
| Gemini Flash | POST /ai/recognize-products | 13 (фото) | — |
| Gemini Flash | POST /ai/recognize-dish | 1 | — |
| Gemini Flash | POST /ai/generate-menu | 1 | — |
| Pexels | GET /recommendations | 5 (параллельно) | ~667 рекомендаций |
| Pexels | POST /ai/generate-menu | до 21 (параллельно) | — |
### Запросы к бэкенду
| Сценарий | Бэкенд | Firebase | Gemini | Pexels |
|----------|--------|----------|--------|--------|
| Первый вход | 1 | 1 | 0 | 0 |
| Просмотр профиля | 1 | 0 | 0 | 0 |
| Обновление профиля | 1 | 0 | 0 | 0 |
| Рекомендации | 1 | 0 | 1 | 5 |
| Сохранить рецепт | 1 | 0 | 0 | 0 |
| Список сохранённых | 1 | 0 | 0 | 0 |
| Удалить из сохранённых | 1 | 0 | 0 | 0 |
| Refresh токена | 1 | 0 | 0 | 0 |
| Распознавание чека | 2 | 0 | 1 | 0 |
| Распознавание фото продуктов | 2 | 0 | 13 | 0 |
| Распознавание блюда | 2 | 0 | 1 | 0 |
| Генерация меню (неделя) | 1 | 0 | 1 | до 21 |
| Просмотр меню | 1 | 0 | 0 | 0 |
| Список покупок из меню | 1 | 0 | 0 | 0 |
### Ключевые выводы
1. **Критические пути требуют Gemini + Pexels:** рекомендации (24 сек), распознавание продуктов (13 сек), генерация меню (510 сек). Во всех случаях нужна skeleton-загрузка в UI.
2. **Pexels — потенциальный bottleneck** при масштабировании (200 req/hour). Особенно при генерации меню (до 21 вызова). Решается кэшированием image_url по query-строке в Redis.
3. **Всё остальное работает без внешних зависимостей** — отказ Gemini/Pexels не роняет авторизацию, профиль, сохранённые рецепты, меню (просмотр/редактирование).
4. **КБЖУ приблизительные** — Gemini генерирует оценочные значения. Для MVP этого достаточно; точные данные требуют интеграции с верифицированной базой (USDA FoodData Central, см. TODO.md).
5. **Gemini Free tier (1 500 req/day):** распознавание продуктов (3 AI-операции) + рекомендации (1) + меню (1) = ~5 Gemini-запросов на активного пользователя. Free tier хватает на 300 DAU.