diff --git a/backend/internal/gemini/menu.go b/backend/internal/gemini/menu.go index 01400ef..bc3ad1d 100644 --- a/backend/internal/gemini/menu.go +++ b/backend/internal/gemini/menu.go @@ -3,7 +3,7 @@ package gemini import ( "context" "fmt" - "strings" + "sync" ) // MenuRequest contains parameters for weekly menu generation. @@ -27,142 +27,71 @@ type MealEntry struct { Recipe Recipe `json:"recipe"` } -// GenerateMenu asks the model to plan 7 days × 3 meals. -// Returns exactly 7 DayPlan items on success. +// GenerateMenu produces a 7-day × 3-meal plan by issuing three parallel +// GenerateRecipes calls (one per meal type). This avoids token-limit errors +// that arise from requesting 21 full recipes in a single prompt. func (c *Client) GenerateMenu(ctx context.Context, req MenuRequest) ([]DayPlan, error) { - prompt := buildMenuPrompt(req) - messages := []map[string]string{ - {"role": "user", "content": prompt}, + type mealSlot struct { + mealType string + fraction float64 // share of daily calories } - var lastErr error - for attempt := 0; attempt < maxRetries; attempt++ { - if attempt > 0 { - messages = append(messages, map[string]string{ - "role": "user", - "content": "Предыдущий ответ не был валидным JSON. Верни ТОЛЬКО объект JSON без какого-либо текста до или после.", + slots := []mealSlot{ + {"breakfast", 0.25}, + {"lunch", 0.40}, + {"dinner", 0.35}, + } + + type mealResult struct { + recipes []Recipe + err error + } + + results := make([]mealResult, len(slots)) + var wg sync.WaitGroup + + for i, slot := range slots { + wg.Add(1) + go func(idx int, mealType string, fraction float64) { + defer wg.Done() + // Scale daily calories to what this meal should contribute. + mealCal := int(float64(req.DailyCalories) * fraction) + r, err := c.GenerateRecipes(ctx, RecipeRequest{ + UserGoal: req.UserGoal, + DailyCalories: mealCal * 3, // prompt divides by 3 internally + Restrictions: req.Restrictions, + CuisinePrefs: req.CuisinePrefs, + Count: 7, + AvailableProducts: req.AvailableProducts, }) - } + results[idx] = mealResult{r, err} + }(i, slot.mealType, slot.fraction) + } + wg.Wait() - text, err := c.generateContent(ctx, messages) - if err != nil { - return nil, err + for i, res := range results { + if res.err != nil { + return nil, fmt.Errorf("generate %s: %w", slots[i].mealType, res.err) } - - days, err := parseMenuJSON(text) - if err != nil { - lastErr = fmt.Errorf("attempt %d parse JSON: %w", attempt+1, err) - continue + if len(res.recipes) == 0 { + return nil, fmt.Errorf("no %s recipes returned", slots[i].mealType) } - - for i := range days { - for j := range days[i].Meals { - days[i].Meals[j].Recipe.Nutrition.Approximate = true - } + // Pad to exactly 7 by repeating the last recipe. + for len(results[i].recipes) < 7 { + results[i].recipes = append(results[i].recipes, results[i].recipes[len(results[i].recipes)-1]) } - 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 + days := make([]DayPlan, 7) + for day := range 7 { + days[day] = DayPlan{ + Day: day + 1, + Meals: []MealEntry{ + {MealType: slots[0].mealType, Recipe: results[0].recipes[day]}, + {MealType: slots[1].mealType, Recipe: results[1].recipes[day]}, + {MealType: slots[2].mealType, Recipe: results[2].recipes[day]}, + }, + } + } + return days, nil }