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>
329 lines
13 KiB
Markdown
329 lines
13 KiB
Markdown
# Итерация 3: Распознавание продуктов (Gemini Vision)
|
||
|
||
**Цель:** пользователь фотографирует чек, холодильник или готовое блюдо — Gemini Vision автоматически определяет продукты и их количество, после чего пользователь подтверждает список и добавляет в запасы.
|
||
|
||
**Зависимости:** Итерация 2 (управление продуктами, ingredient_mappings).
|
||
|
||
---
|
||
|
||
## Структура задач
|
||
|
||
```
|
||
3.1 Backend: AI-распознавание чека
|
||
├── 3.1.1 POST /ai/recognize-receipt (multipart: image)
|
||
├── 3.1.2 Промпт для OCR чека
|
||
└── 3.1.3 Fuzzy match результатов по ingredient_mappings
|
||
|
||
3.2 Backend: AI-распознавание фото продуктов
|
||
├── 3.2.1 POST /ai/recognize-products (multipart: 1–3 images)
|
||
├── 3.2.2 Параллельные Gemini-запросы для нескольких фото
|
||
└── 3.2.3 Дедупликация и объединение результатов
|
||
|
||
3.3 Backend: AI-распознавание блюда
|
||
├── 3.3.1 POST /ai/recognize-dish (multipart: image)
|
||
└── 3.3.2 Промпт для определения блюда и КБЖУ
|
||
|
||
3.4 Backend: нераспознанные продукты → Gemini
|
||
└── 3.4.1 Если fuzzy match не найден → запрос к Gemini для классификации
|
||
→ сохранение нового маппинга в ingredient_mappings
|
||
|
||
3.5 S3: загрузка фото
|
||
├── 3.5.1 Конфигурация S3-совместимого хранилища (MinIO/Cloud Storage)
|
||
└── 3.5.2 Сохранение фото на период обработки (TTL 24h)
|
||
|
||
3.6 Flutter: экран сканирования
|
||
├── 3.6.1 ScanScreen (камера или галерея)
|
||
├── 3.6.2 Выбор режима: чек / продукты / блюдо
|
||
├── 3.6.3 Экран подтверждения списка продуктов
|
||
└── 3.6.4 Экран результата распознавания блюда
|
||
```
|
||
|
||
---
|
||
|
||
## 3.1 Распознавание чека
|
||
|
||
### Промпт
|
||
|
||
```
|
||
Ты — OCR-система для чеков из продуктовых магазинов.
|
||
|
||
Проанализируй фото чека и извлеки список продуктов питания.
|
||
Для каждого продукта определи:
|
||
- name: название на русском языке (очисти от артикулов и кодов)
|
||
- quantity: количество (число)
|
||
- unit: единица (г, кг, мл, л, шт, уп)
|
||
- category: dairy | meat | produce | bakery | frozen | beverages | other
|
||
- confidence: 0.0–1.0
|
||
|
||
Позиции, которые не являются едой (бытовая химия, табак), пропусти.
|
||
Позиции с непонятным текстом добавь в unrecognized.
|
||
|
||
Верни ТОЛЬКО валидный JSON без markdown:
|
||
{
|
||
"items": [
|
||
{"name": "Молоко 2.5%", "quantity": 1, "unit": "л",
|
||
"category": "dairy", "confidence": 0.95}
|
||
],
|
||
"unrecognized": [
|
||
{"raw_text": "ТОВ АРТИК 1ШТ", "price": 89.0}
|
||
]
|
||
}
|
||
```
|
||
|
||
### Fuzzy match
|
||
|
||
После получения списка от Gemini — для каждого item:
|
||
|
||
```go
|
||
// 1. Поиск в ingredient_mappings
|
||
mapping, found := ingredientRepo.FuzzyMatch(ctx, item.Name)
|
||
|
||
// 2. Нашли — используем mapping_id, подставляем дефолты (unit, storage_days)
|
||
// 3. Не нашли → разовый запрос к Gemini для классификации:
|
||
if !found {
|
||
mapping = gemini.ClassifyIngredient(ctx, item.Name)
|
||
ingredientRepo.Save(ctx, mapping) // новая строка в ingredient_mappings
|
||
}
|
||
```
|
||
|
||
### Response
|
||
|
||
```json
|
||
{
|
||
"items": [
|
||
{
|
||
"name": "Куриная грудка",
|
||
"quantity": 500,
|
||
"unit": "г",
|
||
"category": "meat",
|
||
"mapping_id": "uuid",
|
||
"storage_days": 3,
|
||
"confidence": 0.95
|
||
}
|
||
],
|
||
"unrecognized": [
|
||
{ "raw_text": "ТОВ АРТИК 1ШТ" }
|
||
]
|
||
}
|
||
```
|
||
|
||
Клиент показывает список для подтверждения, затем вызывает `POST /products/batch`.
|
||
|
||
---
|
||
|
||
## 3.2 Распознавание фото продуктов
|
||
|
||
### Промпт (на каждое фото)
|
||
|
||
```
|
||
Ты — система распознавания продуктов питания.
|
||
|
||
Посмотри на фото и определи все видимые продукты питания.
|
||
Для каждого продукта оцени:
|
||
- name: название на русском языке
|
||
- quantity: приблизительное количество (число)
|
||
- unit: единица (г, кг, мл, л, шт)
|
||
- category: dairy | meat | produce | bakery | frozen | beverages | other
|
||
- confidence: 0.0–1.0
|
||
|
||
Только продукты питания. Упаковки без содержимого — пропусти.
|
||
|
||
Верни ТОЛЬКО валидный JSON:
|
||
{
|
||
"items": [
|
||
{"name": "Яйца", "quantity": 10, "unit": "шт",
|
||
"category": "dairy", "confidence": 0.9}
|
||
]
|
||
}
|
||
```
|
||
|
||
### Обработка нескольких фото
|
||
|
||
```go
|
||
// Параллельные запросы к Gemini (по одному на фото)
|
||
results := make([][]RecognizedItem, len(images))
|
||
var wg sync.WaitGroup
|
||
for i, img := range images {
|
||
wg.Add(1)
|
||
go func(i int, img []byte) {
|
||
defer wg.Done()
|
||
results[i], _ = gemini.RecognizeProducts(ctx, img)
|
||
}(i, img)
|
||
}
|
||
wg.Wait()
|
||
|
||
// Дедупликация: если canonical_name совпадает → суммируем quantity
|
||
merged := mergeAndDeduplicate(results)
|
||
```
|
||
|
||
---
|
||
|
||
## 3.3 Распознавание блюда
|
||
|
||
### Промпт
|
||
|
||
```
|
||
Ты — диетолог и кулинарный эксперт.
|
||
|
||
Посмотри на фото блюда и определи:
|
||
- dish_name: название блюда на русском языке
|
||
- weight_grams: приблизительный вес порции в граммах
|
||
- calories: калорийность порции (приблизительно)
|
||
- protein_g, fat_g, carbs_g: БЖУ на порцию
|
||
- confidence: 0.0–1.0
|
||
- similar_dishes: до 3 похожих блюд (для поиска рецептов)
|
||
|
||
Верни ТОЛЬКО валидный JSON:
|
||
{
|
||
"dish_name": "Паста Карбонара",
|
||
"weight_grams": 350,
|
||
"calories": 520,
|
||
"protein_g": 22,
|
||
"fat_g": 26,
|
||
"carbs_g": 48,
|
||
"confidence": 0.85,
|
||
"similar_dishes": ["Паста с беконом", "Спагетти"]
|
||
}
|
||
```
|
||
|
||
### Response-flow
|
||
|
||
```
|
||
POST /ai/recognize-dish → Gemini → {dish_name, calories≈, КБЖУ≈}
|
||
↓
|
||
Поиск в saved_recipes по dish_name (optional)
|
||
↓
|
||
Response: {dish_name, nutrition≈, matched_recipe?: {id, title}}
|
||
```
|
||
|
||
Клиент показывает результат с возможностью добавить в дневник питания.
|
||
|
||
---
|
||
|
||
## 3.4 Классификация нераспознанного ингредиента
|
||
|
||
Когда fuzzy match не нашёл маппинг:
|
||
|
||
```
|
||
Промпт:
|
||
"Классифицируй продукт питания: '{name}'.
|
||
Ответь JSON:
|
||
{
|
||
'canonical_name': 'turkey_breast',
|
||
'canonical_name_ru': 'грудка индейки',
|
||
'category': 'meat',
|
||
'default_unit': 'g',
|
||
'calories_per_100g': 135,
|
||
'protein_per_100g': 29,
|
||
'fat_per_100g': 1,
|
||
'carbs_per_100g': 0,
|
||
'storage_days': 3,
|
||
'aliases': ['грудка индейки', 'филе индейки', 'turkey breast']
|
||
}"
|
||
```
|
||
|
||
Результат сохраняется в `ingredient_mappings`. Следующий пользователь с тем же продуктом — AI не вызывается.
|
||
|
||
---
|
||
|
||
## 3.5 S3-хранилище для фото
|
||
|
||
Фотографии загружаются на S3 (MinIO локально / Cloud Storage в проде):
|
||
|
||
```
|
||
1. Клиент: PUT /upload → presigned URL
|
||
2. Клиент загружает фото напрямую на S3
|
||
3. Клиент: POST /ai/recognize-receipt { s3_key: "..." }
|
||
4. Backend: скачивает фото с S3, отправляет в Gemini, удаляет (TTL 24h)
|
||
```
|
||
|
||
Альтернатива для MVP: принимать base64 в теле запроса (для начала проще).
|
||
|
||
---
|
||
|
||
## 3.6 Flutter: экран сканирования
|
||
|
||
### ScanScreen (точка входа)
|
||
|
||
```
|
||
┌─────────────────────────────────────┐
|
||
│ [←] Добавить продукты │
|
||
├─────────────────────────────────────┤
|
||
│ │
|
||
│ Выберите способ │
|
||
│ │
|
||
│ ┌───────────────────────────────┐ │
|
||
│ │ 🧾 Сфотографировать чек │ │
|
||
│ │ Распознаем все продукты │ │
|
||
│ └───────────────────────────────┘ │
|
||
│ │
|
||
│ ┌───────────────────────────────┐ │
|
||
│ │ 🥦 Сфотографировать продукты │ │
|
||
│ │ Холодильник, стол, полка │ │
|
||
│ └───────────────────────────────┘ │
|
||
│ │
|
||
│ ┌───────────────────────────────┐ │
|
||
│ │ ✏️ Добавить вручную │ │
|
||
│ └───────────────────────────────┘ │
|
||
│ │
|
||
└─────────────────────────────────────┘
|
||
```
|
||
|
||
### Экран подтверждения
|
||
|
||
После распознавания — список с возможностью:
|
||
- Редактировать количество/единицу каждого пункта
|
||
- Удалить нераспознанные или ошибочные
|
||
- Добавить вручную
|
||
- Кнопка «Добавить всё» → POST /products/batch
|
||
|
||
### Экран результата блюда
|
||
|
||
```
|
||
┌─────────────────────────────────────┐
|
||
│ [←] Распознано блюдо │
|
||
├─────────────────────────────────────┤
|
||
│ [фото блюда] │
|
||
│ │
|
||
│ Паста Карбонара │
|
||
│ Уверенность: 85% │
|
||
│ │
|
||
│ ≈ 520 ккал (приблизительно) │
|
||
│ Б: 22г · Ж: 26г · У: 48г │
|
||
│ Вес порции: ~350г │
|
||
│ │
|
||
│ ┌───────────────────────────────┐ │
|
||
│ │ 📓 Добавить в дневник │ │
|
||
│ └───────────────────────────────┘ │
|
||
│ │
|
||
│ ┌───────────────────────────────┐ │
|
||
│ │ 📖 Открыть рецепт │ │
|
||
│ └───────────────────────────────┘ │
|
||
│ │
|
||
└─────────────────────────────────────┘
|
||
```
|
||
|
||
---
|
||
|
||
## Новые конфигурационные переменные
|
||
|
||
```
|
||
S3_ENDPOINT=http://localhost:9000
|
||
S3_BUCKET=food-ai-uploads
|
||
S3_ACCESS_KEY=minioadmin
|
||
S3_SECRET_KEY=minioadmin
|
||
```
|
||
|
||
## Оценка нагрузки на Gemini
|
||
|
||
| Действие | Gemini-вызовов |
|
||
|----------|---------------|
|
||
| Чек (1 фото) | 1 vision |
|
||
| Фото продуктов (3 фото) | 3 vision (параллельно) |
|
||
| Блюдо | 1 vision |
|
||
| Нераспознанный ингредиент | 1 текст (разово) |
|
||
|
||
При 100 активных пользователях × 1 сканирование/день:
|
||
- 100–300 дополнительных Gemini-запросов/день
|
||
- Free tier (1 500/день) остаётся в запасе
|