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>
187 lines
5.9 KiB
Go
187 lines
5.9 KiB
Go
package gemini
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"strings"
|
||
)
|
||
|
||
// RecipeGenerator generates recipes using the Gemini AI.
|
||
type RecipeGenerator interface {
|
||
GenerateRecipes(ctx context.Context, req RecipeRequest) ([]Recipe, error)
|
||
}
|
||
|
||
// RecipeRequest contains parameters for recipe generation.
|
||
type RecipeRequest struct {
|
||
UserGoal string // "weight_loss" | "maintain" | "gain"
|
||
DailyCalories int
|
||
Restrictions []string // e.g. ["gluten_free", "vegetarian"]
|
||
CuisinePrefs []string // e.g. ["russian", "asian"]
|
||
Count int
|
||
}
|
||
|
||
// Recipe is a recipe returned by Gemini.
|
||
type Recipe struct {
|
||
Title string `json:"title"`
|
||
Description string `json:"description"`
|
||
Cuisine string `json:"cuisine"`
|
||
Difficulty string `json:"difficulty"`
|
||
PrepTimeMin int `json:"prep_time_min"`
|
||
CookTimeMin int `json:"cook_time_min"`
|
||
Servings int `json:"servings"`
|
||
ImageQuery string `json:"image_query"`
|
||
ImageURL string `json:"image_url"`
|
||
Ingredients []Ingredient `json:"ingredients"`
|
||
Steps []Step `json:"steps"`
|
||
Tags []string `json:"tags"`
|
||
Nutrition NutritionInfo `json:"nutrition_per_serving"`
|
||
}
|
||
|
||
// Ingredient is a single ingredient in a recipe.
|
||
type Ingredient struct {
|
||
Name string `json:"name"`
|
||
Amount float64 `json:"amount"`
|
||
Unit string `json:"unit"`
|
||
}
|
||
|
||
// Step is a single preparation step.
|
||
type Step struct {
|
||
Number int `json:"number"`
|
||
Description string `json:"description"`
|
||
TimerSeconds *int `json:"timer_seconds"`
|
||
}
|
||
|
||
// NutritionInfo contains approximate nutritional information per serving.
|
||
type NutritionInfo struct {
|
||
Calories float64 `json:"calories"`
|
||
ProteinG float64 `json:"protein_g"`
|
||
FatG float64 `json:"fat_g"`
|
||
CarbsG float64 `json:"carbs_g"`
|
||
Approximate bool `json:"approximate"`
|
||
}
|
||
|
||
// GenerateRecipes generates recipes using the Gemini AI.
|
||
// Retries up to maxRetries times only when the response is not valid JSON.
|
||
// API-level errors (rate limits, auth, etc.) are returned immediately.
|
||
func (c *Client) GenerateRecipes(ctx context.Context, req RecipeRequest) ([]Recipe, error) {
|
||
prompt := buildRecipePrompt(req)
|
||
|
||
// OpenAI-compatible messages format used by Groq.
|
||
messages := []map[string]string{
|
||
{"role": "user", "content": prompt},
|
||
}
|
||
|
||
var lastErr error
|
||
for attempt := 0; attempt < maxRetries; attempt++ {
|
||
if attempt > 0 {
|
||
messages = []map[string]string{
|
||
{"role": "user", "content": prompt},
|
||
{"role": "user", "content": "Предыдущий ответ не был валидным JSON. Верни ТОЛЬКО массив JSON без какого-либо текста до или после."},
|
||
}
|
||
}
|
||
|
||
text, err := c.generateContent(ctx, messages)
|
||
if err != nil {
|
||
// API-level error (4xx/5xx): no point retrying immediately.
|
||
return nil, err
|
||
}
|
||
|
||
recipes, err := parseRecipesJSON(text)
|
||
if err != nil {
|
||
// Malformed JSON from the model — retry with a clarifying message.
|
||
lastErr = fmt.Errorf("attempt %d parse JSON: %w", attempt+1, err)
|
||
continue
|
||
}
|
||
|
||
for i := range recipes {
|
||
recipes[i].Nutrition.Approximate = true
|
||
}
|
||
return recipes, nil
|
||
}
|
||
|
||
return nil, fmt.Errorf("failed to parse valid JSON after %d attempts: %w", maxRetries, lastErr)
|
||
}
|
||
|
||
func buildRecipePrompt(req RecipeRequest) string {
|
||
goalRu := map[string]string{
|
||
"weight_loss": "похудение",
|
||
"maintain": "поддержание веса",
|
||
"gain": "набор массы",
|
||
}
|
||
goal := goalRu[req.UserGoal]
|
||
if goal == "" {
|
||
goal = "поддержание веса"
|
||
}
|
||
|
||
restrictions := "нет"
|
||
if len(req.Restrictions) > 0 {
|
||
restrictions = strings.Join(req.Restrictions, ", ")
|
||
}
|
||
|
||
cuisines := "любые"
|
||
if len(req.CuisinePrefs) > 0 {
|
||
cuisines = strings.Join(req.CuisinePrefs, ", ")
|
||
}
|
||
|
||
count := req.Count
|
||
if count <= 0 {
|
||
count = 5
|
||
}
|
||
|
||
perMealCalories := req.DailyCalories / 3
|
||
if perMealCalories <= 0 {
|
||
perMealCalories = 600
|
||
}
|
||
|
||
return fmt.Sprintf(`Ты — диетолог-повар. Предложи %d рецептов на русском языке.
|
||
|
||
Профиль пользователя:
|
||
- Цель: %s
|
||
- Дневная норма калорий: %d ккал
|
||
- Ограничения: %s
|
||
- Предпочтения: %s
|
||
|
||
Требования к каждому рецепту:
|
||
- Калорийность на порцию: не более %d ккал
|
||
- Время приготовления: до 60 минут
|
||
- Укажи КБЖУ на порцию (приблизительно)
|
||
|
||
ВАЖНО: поле image_query заполняй ТОЛЬКО на английском языке — оно используется для поиска фото.
|
||
|
||
Верни ТОЛЬКО валидный JSON-массив без markdown-обёртки:
|
||
[{
|
||
"title": "Название",
|
||
"description": "2-3 предложения",
|
||
"cuisine": "russian|asian|european|mediterranean|american|other",
|
||
"difficulty": "easy|medium|hard",
|
||
"prep_time_min": 10,
|
||
"cook_time_min": 20,
|
||
"servings": 2,
|
||
"image_query": "chicken breast vegetables healthy (ENGLISH ONLY, used for photo search)",
|
||
"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
|
||
}
|
||
}]`, count, goal, req.DailyCalories, restrictions, cuisines, perMealCalories)
|
||
}
|
||
|
||
func parseRecipesJSON(text string) ([]Recipe, error) {
|
||
text = strings.TrimSpace(text)
|
||
// Strip potential markdown code fences
|
||
if strings.HasPrefix(text, "```") {
|
||
text = strings.TrimPrefix(text, "```json")
|
||
text = strings.TrimPrefix(text, "```")
|
||
text = strings.TrimSuffix(text, "```")
|
||
text = strings.TrimSpace(text)
|
||
}
|
||
|
||
var recipes []Recipe
|
||
if err := json.Unmarshal([]byte(text), &recipes); err != nil {
|
||
return nil, err
|
||
}
|
||
return recipes, nil
|
||
}
|