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 AvailableProducts []string // human-readable list of products in user's pantry } // 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 } productsSection := "" if len(req.AvailableProducts) > 0 { productsSection = "\nДоступные продукты (приоритет — скоро истекают ⚠):\n" + strings.Join(req.AvailableProducts, "\n") + "\nПредпочтительно использовать эти продукты в рецептах.\n" } return fmt.Sprintf(`Ты — диетолог-повар. Предложи %d рецептов на русском языке. Профиль пользователя: - Цель: %s - Дневная норма калорий: %d ккал - Ограничения: %s - Предпочтения: %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, productsSection, 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 }