Files
food-ai/docs/plans/Iteration_3.md
dbastrikin e57ff8e06c 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>
2026-02-21 22:43:29 +02:00

329 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Итерация 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: 13 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.01.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.01.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.01.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 сканирование/день:
- 100300 дополнительных Gemini-запросов/день
- Free tier (1 500/день) остаётся в запасе