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 // "lose" | "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 Lang string // ISO 639-1 target language code, e.g. "en", "ru" } // 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"` } // langNames maps ISO 639-1 codes to English language names used in the prompt. var langNames = map[string]string{ "en": "English", "ru": "Russian", "es": "Spanish", "de": "German", "fr": "French", "it": "Italian", "pt": "Portuguese", "zh": "Chinese (Simplified)", "ja": "Japanese", "ko": "Korean", "ar": "Arabic", "hi": "Hindi", } // goalNames maps internal goal codes to English descriptions used in the prompt. var goalNames = map[string]string{ "lose": "weight loss", "maintain": "weight maintenance", "gain": "muscle gain", } // 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) messages := []map[string]string{ {"role": "user", "content": prompt}, } var lastErr error for attempt := range maxRetries { if attempt > 0 { messages = []map[string]string{ {"role": "user", "content": prompt}, {"role": "user", "content": "Previous response was not valid JSON. Return ONLY a JSON array with no text before or after."}, } } text, err := c.generateContent(ctx, messages) if err != nil { return nil, err } recipes, err := parseRecipesJSON(text) if err != nil { 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 { lang := req.Lang if lang == "" { lang = "en" } langName, ok := langNames[lang] if !ok { langName = "English" } goal := goalNames[req.UserGoal] if goal == "" { goal = "weight maintenance" } restrictions := "none" if len(req.Restrictions) > 0 { restrictions = strings.Join(req.Restrictions, ", ") } cuisines := "any" 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 = "\nAvailable products (⚠ = expiring soon, prioritise these):\n" + strings.Join(req.AvailableProducts, "\n") + "\n" } return fmt.Sprintf(`You are a chef and nutritionist. Generate %d recipes in %s. User profile: - Goal: %s - Daily calories: %d kcal - Dietary restrictions: %s - Cuisine preferences: %s %s Requirements for each recipe: - Max %d kcal per serving - Total cooking time: max 60 minutes - Include approximate macros per serving IMPORTANT: - All text fields (title, description, ingredient names, units, step descriptions, tags) MUST be in %s. - The "image_query" field MUST always be in English (it is used for stock-photo search). Return ONLY a valid JSON array without markdown or extra text: [{ "title": "...", "description": "2-3 sentences", "cuisine": "russian|asian|european|mediterranean|american|other", "difficulty": "easy|medium|hard", "prep_time_min": 10, "cook_time_min": 20, "servings": 2, "image_query": "short English photo-search query", "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, langName, goal, req.DailyCalories, restrictions, cuisines, productsSection, perMealCalories, langName) } func parseRecipesJSON(text string) ([]Recipe, error) { text = strings.TrimSpace(text) 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 }