package gemini import ( "context" "fmt" "strings" ) // MenuRequest contains parameters for weekly menu generation. type MenuRequest struct { UserGoal string DailyCalories int Restrictions []string CuisinePrefs []string AvailableProducts []string } // DayPlan is the AI-generated plan for a single day. type DayPlan struct { Day int `json:"day"` Meals []MealEntry `json:"meals"` } // MealEntry is a single meal within a day plan. type MealEntry struct { MealType string `json:"meal_type"` // breakfast | lunch | dinner Recipe Recipe `json:"recipe"` } // GenerateMenu asks the model to plan 7 days × 3 meals. // Returns exactly 7 DayPlan items on success. func (c *Client) GenerateMenu(ctx context.Context, req MenuRequest) ([]DayPlan, error) { prompt := buildMenuPrompt(req) messages := []map[string]string{ {"role": "user", "content": prompt}, } var lastErr error for attempt := 0; attempt < maxRetries; attempt++ { if attempt > 0 { messages = append(messages, map[string]string{ "role": "user", "content": "Предыдущий ответ не был валидным JSON. Верни ТОЛЬКО объект JSON без какого-либо текста до или после.", }) } text, err := c.generateContent(ctx, messages) if err != nil { return nil, err } days, err := parseMenuJSON(text) if err != nil { lastErr = fmt.Errorf("attempt %d parse JSON: %w", attempt+1, err) continue } for i := range days { for j := range days[i].Meals { days[i].Meals[j].Recipe.Nutrition.Approximate = true } } return days, nil } return nil, fmt.Errorf("failed to parse valid JSON after %d attempts: %w", maxRetries, lastErr) } func buildMenuPrompt(req MenuRequest) 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, ", ") } calories := req.DailyCalories if calories <= 0 { calories = 2000 } productsSection := "" if len(req.AvailableProducts) > 0 { productsSection = "\nПродукты в наличии (приоритет — скоро истекают ⚠):\n" + strings.Join(req.AvailableProducts, "\n") + "\nПо возможности используй эти продукты.\n" } return fmt.Sprintf(`Ты — диетолог-повар. Составь меню на 7 дней на русском языке. Профиль пользователя: - Цель: %s - Дневная норма калорий: %d ккал (завтрак 25%%, обед 40%%, ужин 35%%) - Ограничения: %s - Предпочтения кухни: %s %s Требования: - 3 приёма пищи в день: breakfast, lunch, dinner - Не повторять рецепты - КБЖУ на 1 порцию (приблизительно) - Поле image_query — ТОЛЬКО на английском языке (для поиска фото) Верни ТОЛЬКО валидный JSON без markdown: { "days": [ { "day": 1, "meals": [ { "meal_type": "breakfast", "recipe": { "title": "Название", "description": "2-3 предложения", "cuisine": "russian|asian|european|mediterranean|american|other", "difficulty": "easy|medium|hard", "prep_time_min": 5, "cook_time_min": 10, "servings": 1, "image_query": "oatmeal apple breakfast bowl", "ingredients": [{"name": "Овсянка", "amount": 80, "unit": "г"}], "steps": [{"number": 1, "description": "...", "timer_seconds": null}], "tags": ["быстрый завтрак"], "nutrition_per_serving": { "calories": 320, "protein_g": 8, "fat_g": 6, "carbs_g": 58 } } }, {"meal_type": "lunch", "recipe": {...}}, {"meal_type": "dinner", "recipe": {...}} ] } ] }`, goal, calories, restrictions, cuisines, productsSection) } func parseMenuJSON(text string) ([]DayPlan, 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 wrapper struct { Days []DayPlan `json:"days"` } if err := parseJSON(text, &wrapper); err != nil { return nil, err } if len(wrapper.Days) == 0 { return nil, fmt.Errorf("empty days array in response") } return wrapper.Days, nil }