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:
241
docs/plans/Iteration_2.md
Normal file
241
docs/plans/Iteration_2.md
Normal file
@@ -0,0 +1,241 @@
|
||||
# Итерация 2: Управление продуктами
|
||||
|
||||
**Цель:** дать пользователю возможность вести список продуктов — вручную или через автодополнение. Рекомендации становятся персонализированными: Gemini учитывает имеющиеся продукты.
|
||||
|
||||
**Зависимости:** Итерация 1 (рекомендации должны принимать список продуктов).
|
||||
|
||||
**Ориентир:** [Summary.md](./Summary.md)
|
||||
|
||||
---
|
||||
|
||||
## Структура задач
|
||||
|
||||
```
|
||||
2.1 Backend: ingredient_mappings
|
||||
├── 2.1.1 Миграция: таблица ingredient_mappings
|
||||
├── 2.1.2 Seed: топ-200 базовых ингредиентов (JSON-файл)
|
||||
├── 2.1.3 Repository (поиск по aliases)
|
||||
└── 2.1.4 GET /ingredients/search?q=
|
||||
|
||||
2.2 Backend: products
|
||||
├── 2.2.1 Миграция: таблица products
|
||||
├── 2.2.2 Repository (CRUD)
|
||||
├── 2.2.3 Service layer
|
||||
├── 2.2.4 GET /products
|
||||
├── 2.2.5 POST /products
|
||||
├── 2.2.6 POST /products/batch
|
||||
├── 2.2.7 PUT /products/{id}
|
||||
├── 2.2.8 DELETE /products/{id}
|
||||
└── 2.2.9 GET /products/expiring (скоро истекают)
|
||||
|
||||
2.3 Backend: интеграция с рекомендациями
|
||||
└── 2.3.1 GET /recommendations — добавить продукты в промпт
|
||||
|
||||
2.4 Flutter: экран продуктов
|
||||
├── 2.4.1 ProductsScreen (список с истекающими)
|
||||
├── 2.4.2 Форма добавления с автодополнением (debounce 300мс)
|
||||
├── 2.4.3 Редактирование (количество, единица, срок)
|
||||
└── 2.4.4 Удаление (свайп или кнопка)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2.1 Ingredient Mappings
|
||||
|
||||
### 2.1.1 Миграция
|
||||
|
||||
```sql
|
||||
-- migrations/003_create_ingredient_mappings.sql
|
||||
|
||||
-- +goose Up
|
||||
CREATE TABLE ingredient_mappings (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
canonical_name TEXT NOT NULL UNIQUE,
|
||||
canonical_name_ru TEXT NOT NULL,
|
||||
aliases JSONB NOT NULL DEFAULT '[]',
|
||||
category TEXT NOT NULL,
|
||||
default_unit TEXT NOT NULL DEFAULT 'g',
|
||||
calories_per_100g DECIMAL(8,2),
|
||||
protein_per_100g DECIMAL(8,2),
|
||||
fat_per_100g DECIMAL(8,2),
|
||||
carbs_per_100g DECIMAL(8,2),
|
||||
storage_days INT NOT NULL DEFAULT 7,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_ingredient_mappings_aliases ON ingredient_mappings USING GIN(aliases);
|
||||
CREATE INDEX idx_ingredient_mappings_canonical_ru ON ingredient_mappings
|
||||
USING GIN(to_tsvector('russian', canonical_name_ru));
|
||||
CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
||||
CREATE INDEX idx_ingredient_mappings_trgm ON ingredient_mappings
|
||||
USING GIN(canonical_name_ru gin_trgm_ops);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE ingredient_mappings;
|
||||
```
|
||||
|
||||
### 2.1.2 Seed-данные
|
||||
|
||||
Файл `migrations/seed_ingredient_mappings.json` с топ-200 ингредиентами:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"canonical_name": "chicken_breast",
|
||||
"canonical_name_ru": "куриная грудка",
|
||||
"aliases": ["куриное филе", "куриная грудка", "грудка курицы", "chicken breast"],
|
||||
"category": "meat",
|
||||
"default_unit": "g",
|
||||
"calories_per_100g": 165,
|
||||
"protein_per_100g": 31,
|
||||
"fat_per_100g": 3.6,
|
||||
"carbs_per_100g": 0,
|
||||
"storage_days": 3
|
||||
},
|
||||
...
|
||||
]
|
||||
```
|
||||
|
||||
Seed применяется отдельным скриптом/Makefile-таргетом `make seed`.
|
||||
|
||||
### 2.1.3 Поиск
|
||||
|
||||
```go
|
||||
// GET /ingredients/search?q=кур&limit=10
|
||||
// Трёхуровневый поиск:
|
||||
// 1. Точное совпадение в aliases (@>)
|
||||
// 2. ILIKE на canonical_name_ru
|
||||
// 3. pg_trgm similarity > 0.3
|
||||
SELECT *
|
||||
FROM ingredient_mappings
|
||||
WHERE aliases @> to_jsonb(lower($1)::text)
|
||||
OR canonical_name_ru ILIKE '%' || $1 || '%'
|
||||
OR similarity(canonical_name_ru, $1) > 0.3
|
||||
ORDER BY similarity(canonical_name_ru, $1) DESC
|
||||
LIMIT $2
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2.2 Products
|
||||
|
||||
### 2.2.1 Миграция
|
||||
|
||||
```sql
|
||||
-- migrations/004_create_products.sql
|
||||
|
||||
-- +goose Up
|
||||
CREATE TABLE products (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
mapping_id UUID REFERENCES ingredient_mappings(id),
|
||||
name TEXT NOT NULL,
|
||||
quantity DECIMAL(10,2) NOT NULL DEFAULT 1,
|
||||
unit TEXT NOT NULL DEFAULT 'pcs',
|
||||
category TEXT,
|
||||
storage_days INT NOT NULL DEFAULT 7,
|
||||
added_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
expires_at TIMESTAMPTZ GENERATED ALWAYS AS
|
||||
(added_at + (storage_days || ' days')::INTERVAL) STORED
|
||||
);
|
||||
|
||||
CREATE INDEX idx_products_user_id ON products(user_id);
|
||||
CREATE INDEX idx_products_expires_at ON products(user_id, expires_at);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE products;
|
||||
```
|
||||
|
||||
### 2.2.2 Эндпоинты
|
||||
|
||||
**GET /products**
|
||||
|
||||
```json
|
||||
[{
|
||||
"id": "uuid",
|
||||
"name": "Куриная грудка",
|
||||
"quantity": 500,
|
||||
"unit": "g",
|
||||
"category": "meat",
|
||||
"expires_at": "2026-02-24T00:00:00Z",
|
||||
"days_left": 3,
|
||||
"expiring_soon": true
|
||||
}]
|
||||
```
|
||||
|
||||
Сортировка: `expires_at ASC` (сначала истекающие).
|
||||
`expiring_soon = true` если `days_left <= 3`.
|
||||
|
||||
**POST /products/batch**
|
||||
|
||||
Массовое добавление после распознавания (Итерация 3):
|
||||
|
||||
```json
|
||||
[{
|
||||
"name": "Куриная грудка",
|
||||
"quantity": 500,
|
||||
"unit": "g",
|
||||
"mapping_id": "uuid или null"
|
||||
}]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2.3 Интеграция с рекомендациями
|
||||
|
||||
Обновить `GET /recommendations`: если у пользователя есть продукты — включать их в промпт.
|
||||
|
||||
```go
|
||||
// Если продуктов нет — промпт без них (базовые рекомендации)
|
||||
// Если продукты есть — добавить секцию в промпт:
|
||||
|
||||
doступные продукты (приоритет — скоро истекают ⚠):
|
||||
- Куриная грудка 500г (истекает завтра ⚠)
|
||||
- Морковь 3 шт
|
||||
- Рис 400г
|
||||
- Яйца 4 шт
|
||||
|
||||
Предпочтительно использовать эти продукты в рецептах.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2.4 Flutter: экран продуктов
|
||||
|
||||
### ProductsScreen
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ Мои продукты [+ Добавить] │
|
||||
├─────────────────────────────────────┤
|
||||
│ ⚠ Истекает скоро │
|
||||
│ ┌──────────────────────────────┐ │
|
||||
│ │ 🥩 Куриная грудка 500 г │ │
|
||||
│ │ Осталось 1 день │ │
|
||||
│ └──────────────────────────────┘ │
|
||||
│ │
|
||||
│ Всё остальное │
|
||||
│ ┌──────────────────────────────┐ │
|
||||
│ │ 🥕 Морковь 3 шт │ │
|
||||
│ │ Осталось 5 дней │ │
|
||||
│ └──────────────────────────────┘ │
|
||||
│ ┌──────────────────────────────┐ │
|
||||
│ │ 🍚 Рис 400 г │ │
|
||||
│ │ Осталось 30 дней │ │
|
||||
│ └──────────────────────────────┘ │
|
||||
├─────────────────────────────────────┤
|
||||
│ [Главная] [Продукты●] [Меню] [Рецепты] [Профиль] │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Форма добавления
|
||||
|
||||
- Поле ввода с debounce 300мс → `GET /ingredients/search?q=`
|
||||
- Dropdown с результатами (canonical_name_ru + category)
|
||||
- При выборе: автозаполнение unit, storage_days из mapping
|
||||
- Поля: Количество, Единица (select: г/кг/мл/л/шт)
|
||||
- Кнопка «Добавить»
|
||||
|
||||
### Badge на вкладке «Продукты»
|
||||
|
||||
Количество продуктов с `days_left <= 3` отображается как badge над иконкой.
|
||||
Reference in New Issue
Block a user