package openai import ( "context" "encoding/json" "fmt" "strings" "github.com/food-ai/backend/internal/adapters/ai" ) // 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 OpenAI API. // 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 ai.RecipeRequest) ([]ai.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 ai.RecipeRequest) string { goal := goalNames[req.UserGoal] if goal == "" { goal = "weight maintenance" } targetLang := langNames[req.Lang] if targetLang == "" { targetLang = "English" } 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, targetLang, goal, req.DailyCalories, restrictions, cuisines, productsSection, perMealCalories, targetLang) } func parseRecipesJSON(text string) ([]ai.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 []ai.Recipe if err := json.Unmarshal([]byte(text), &recipes); err != nil { return nil, err } return recipes, nil }