diff --git a/backend/cmd/server/init.go b/backend/cmd/server/init.go index 859b693..daf9f85 100644 --- a/backend/cmd/server/init.go +++ b/backend/cmd/server/init.go @@ -38,7 +38,7 @@ func initApp(appConfig *config.Config, pool *pgxpool.Pool) (*App, error) { mainPexelsAPIKey := newPexelsAPIKey(appConfig) pexelsClient := newPexelsClient(mainPexelsAPIKey) userProductRepository := userproduct.NewRepository(pool) - recommendationHandler := recommendation.NewHandler(openaiClient, pexelsClient, userRepository, userProductRepository) + recommendationHandler := recommendation.NewHandler(openaiClient, openaiClient, pexelsClient, userRepository, userProductRepository) dishRepository := dish.NewRepository(pool) savedrecipeRepository := savedrecipe.NewRepository(pool, dishRepository) savedrecipeHandler := savedrecipe.NewHandler(savedrecipeRepository) @@ -59,7 +59,7 @@ func initApp(appConfig *config.Config, pool *pgxpool.Pool) (*App, error) { recognitionHandler := recognition.NewHandler(openaiClient, productRepository, jobRepository, kafkaProducer, sseBroker) menuRepository := menu.NewRepository(pool) - menuHandler := menu.NewHandler(menuRepository, openaiClient, pexelsClient, userRepository, userProductRepository, dishRepository) + menuHandler := menu.NewHandler(menuRepository, openaiClient, openaiClient, dishRepository, pexelsClient, userRepository, userProductRepository, dishRepository) diaryRepository := diary.NewRepository(pool) diaryHandler := diary.NewHandler(diaryRepository, dishRepository, dishRepository) homeHandler := home.NewHandler(pool) diff --git a/backend/internal/adapters/openai/recipe.go b/backend/internal/adapters/openai/recipe.go index 989bbf1..3ac61ab 100644 --- a/backend/internal/adapters/openai/recipe.go +++ b/backend/internal/adapters/openai/recipe.go @@ -16,7 +16,6 @@ var goalNames = map[string]string{ "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. @@ -62,11 +61,6 @@ func buildRecipePrompt(req ai.RecipeRequest) string { goal = "weight maintenance" } - targetLang := langNames[req.Lang] - if targetLang == "" { - targetLang = "English" - } - restrictions := "none" if len(req.Restrictions) > 0 { restrictions = strings.Join(req.Restrictions, ", ") @@ -93,7 +87,7 @@ func buildRecipePrompt(req ai.RecipeRequest) string { strings.Join(req.AvailableProducts, "\n") + "\n" } - return fmt.Sprintf(`You are a chef and nutritionist. Generate %d recipes in %s. + return fmt.Sprintf(`You are a chef and nutritionist. Generate %d recipes in English. User profile: - Goal: %s @@ -107,7 +101,7 @@ Requirements for each recipe: - Include approximate macros per serving IMPORTANT: -- All text fields (title, description, ingredient names, units, step descriptions, tags) MUST be in %s. +- All text fields (title, description, ingredient names, units, step descriptions, tags) MUST be in English. - 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: @@ -124,7 +118,7 @@ Return ONLY a valid JSON array without markdown or extra text: "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) +}]`, count, goal, req.DailyCalories, restrictions, cuisines, productsSection, perMealCalories) } func parseRecipesJSON(text string) ([]ai.Recipe, error) { diff --git a/backend/internal/adapters/openai/translate.go b/backend/internal/adapters/openai/translate.go new file mode 100644 index 0000000..7ddfbd9 --- /dev/null +++ b/backend/internal/adapters/openai/translate.go @@ -0,0 +1,137 @@ +package openai + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/food-ai/backend/internal/adapters/ai" +) + +// recipeTranslationPayload is the compact shape sent to and received from the +// translation prompt. Only human-readable text fields are included; numeric, +// enum, and English-only fields (image_query) are omitted. +type recipeTranslationPayload struct { + Title string `json:"title"` + Description string `json:"description"` + Ingredients []ingredientTranslationEntry `json:"ingredients"` + Steps []stepTranslationEntry `json:"steps"` + Tags []string `json:"tags"` +} + +type ingredientTranslationEntry struct { + Name string `json:"name"` + Unit string `json:"unit"` +} + +type stepTranslationEntry struct { + Description string `json:"description"` +} + +// TranslateRecipes translates the human-readable text fields of the given +// recipes from English into targetLang. Non-text fields (nutrition, timers, +// image_query, difficulty, cuisine, amounts) are copied unchanged. +// On error the caller should fall back to the original English recipes. +func (c *Client) TranslateRecipes(requestContext context.Context, recipes []ai.Recipe, targetLang string) ([]ai.Recipe, error) { + targetLangName := langNames[targetLang] + if targetLangName == "" { + return recipes, nil // unknown language — nothing to do + } + + // Build compact payload with only the fields that need translation. + payloads := make([]recipeTranslationPayload, len(recipes)) + for index, recipe := range recipes { + ingredients := make([]ingredientTranslationEntry, len(recipe.Ingredients)) + for ingredientIndex, ingredient := range recipe.Ingredients { + ingredients[ingredientIndex] = ingredientTranslationEntry{ + Name: ingredient.Name, + Unit: ingredient.Unit, + } + } + steps := make([]stepTranslationEntry, len(recipe.Steps)) + for stepIndex, step := range recipe.Steps { + steps[stepIndex] = stepTranslationEntry{Description: step.Description} + } + payloads[index] = recipeTranslationPayload{ + Title: recipe.Title, + Description: recipe.Description, + Ingredients: ingredients, + Steps: steps, + Tags: recipe.Tags, + } + } + + payloadJSON, marshalError := json.Marshal(payloads) + if marshalError != nil { + return nil, fmt.Errorf("marshal translation payload: %w", marshalError) + } + + prompt := fmt.Sprintf( + `Translate the following recipe text fields from English to %s. +Return ONLY a valid JSON array in the exact same structure. Do not add, remove, or reorder elements. +Do not translate the array structure itself — only the string values. + +%s`, + targetLangName, + string(payloadJSON), + ) + + var lastErr error + for attempt := range maxRetries { + messages := []map[string]string{{"role": "user", "content": prompt}} + if attempt > 0 { + messages = append(messages, map[string]string{ + "role": "user", + "content": "Previous response was not valid JSON. Return ONLY a JSON array with no text before or after.", + }) + } + + responseText, generateError := c.generateContent(requestContext, messages) + if generateError != nil { + return nil, generateError + } + + responseText = strings.TrimSpace(responseText) + if strings.HasPrefix(responseText, "```") { + responseText = strings.TrimPrefix(responseText, "```json") + responseText = strings.TrimPrefix(responseText, "```") + responseText = strings.TrimSuffix(responseText, "```") + responseText = strings.TrimSpace(responseText) + } + + var translated []recipeTranslationPayload + if parseError := json.Unmarshal([]byte(responseText), &translated); parseError != nil { + lastErr = fmt.Errorf("attempt %d parse translation JSON: %w", attempt+1, parseError) + continue + } + if len(translated) != len(recipes) { + lastErr = fmt.Errorf("attempt %d: expected %d translated recipes, got %d", attempt+1, len(recipes), len(translated)) + continue + } + + // Merge translated text fields back into copies of the original recipes. + result := make([]ai.Recipe, len(recipes)) + for recipeIndex, recipe := range recipes { + result[recipeIndex] = recipe // copy all fields (nutrition, timers, image_query, etc.) + translatedPayload := translated[recipeIndex] + result[recipeIndex].Title = translatedPayload.Title + result[recipeIndex].Description = translatedPayload.Description + result[recipeIndex].Tags = translatedPayload.Tags + for ingredientIndex := range result[recipeIndex].Ingredients { + if ingredientIndex < len(translatedPayload.Ingredients) { + result[recipeIndex].Ingredients[ingredientIndex].Name = translatedPayload.Ingredients[ingredientIndex].Name + result[recipeIndex].Ingredients[ingredientIndex].Unit = translatedPayload.Ingredients[ingredientIndex].Unit + } + } + for stepIndex := range result[recipeIndex].Steps { + if stepIndex < len(translatedPayload.Steps) { + result[recipeIndex].Steps[stepIndex].Description = translatedPayload.Steps[stepIndex].Description + } + } + } + return result, nil + } + + return nil, fmt.Errorf("failed to parse valid translation JSON after %d attempts: %w", maxRetries, lastErr) +} diff --git a/backend/internal/domain/dish/repository.go b/backend/internal/domain/dish/repository.go index 2cfc777..a9df201 100644 --- a/backend/internal/domain/dish/repository.go +++ b/backend/internal/domain/dish/repository.go @@ -289,11 +289,12 @@ func (r *Repository) AddRecipe(ctx context.Context, dishID string, req CreateReq } // Create creates a new dish + recipe row from a CreateRequest. -// Returns the recipe ID to be used in menu_items or user_saved_recipes. -func (r *Repository) Create(ctx context.Context, req CreateRequest) (recipeID string, err error) { +// Returns the dish ID and the recipe ID; the recipe ID is used in menu_items +// or user_saved_recipes, and the dish ID is used for upsert of translations. +func (r *Repository) Create(ctx context.Context, req CreateRequest) (dishID, recipeID string, err error) { tx, err := r.pool.BeginTx(ctx, pgx.TxOptions{}) if err != nil { - return "", fmt.Errorf("begin tx: %w", err) + return "", "", fmt.Errorf("begin tx: %w", err) } defer tx.Rollback(ctx) //nolint:errcheck @@ -301,7 +302,6 @@ func (r *Repository) Create(ctx context.Context, req CreateRequest) (recipeID st cuisineSlug := nullableStr(req.CuisineSlug) // Insert dish. - var dishID string err = tx.QueryRow(ctx, ` INSERT INTO dishes (cuisine_slug, name, description, image_url) VALUES ($1, $2, NULLIF($3,''), NULLIF($4,'')) @@ -309,7 +309,7 @@ func (r *Repository) Create(ctx context.Context, req CreateRequest) (recipeID st cuisineSlug, req.Name, req.Description, req.ImageURL, ).Scan(&dishID) if err != nil { - return "", fmt.Errorf("insert dish: %w", err) + return "", "", fmt.Errorf("insert dish: %w", err) } // Insert tags — upsert into tags first so the FK constraint is satisfied @@ -319,13 +319,13 @@ func (r *Repository) Create(ctx context.Context, req CreateRequest) (recipeID st `INSERT INTO tags (slug, name) VALUES ($1, $2) ON CONFLICT (slug) DO NOTHING`, slug, slug, ); upsertErr != nil { - return "", fmt.Errorf("upsert tag %s: %w", slug, upsertErr) + return "", "", fmt.Errorf("upsert tag %s: %w", slug, upsertErr) } if _, insertErr := tx.Exec(ctx, `INSERT INTO dish_tags (dish_id, tag_slug) VALUES ($1, $2) ON CONFLICT DO NOTHING`, dishID, slug, ); insertErr != nil { - return "", fmt.Errorf("insert dish tag %s: %w", slug, insertErr) + return "", "", fmt.Errorf("insert dish tag %s: %w", slug, insertErr) } } @@ -352,7 +352,7 @@ func (r *Repository) Create(ctx context.Context, req CreateRequest) (recipeID st calories, protein, fat, carbs, ).Scan(&recipeID) if err != nil { - return "", fmt.Errorf("insert recipe: %w", err) + return "", "", fmt.Errorf("insert recipe: %w", err) } // Insert recipe_ingredients. @@ -362,7 +362,7 @@ func (r *Repository) Create(ctx context.Context, req CreateRequest) (recipeID st VALUES ($1, $2, $3, NULLIF($4,''), $5, $6)`, recipeID, ing.Name, ing.Amount, ing.Unit, ing.IsOptional, i, ); err != nil { - return "", fmt.Errorf("insert ingredient %d: %w", i, err) + return "", "", fmt.Errorf("insert ingredient %d: %w", i, err) } } @@ -377,14 +377,14 @@ func (r *Repository) Create(ctx context.Context, req CreateRequest) (recipeID st VALUES ($1, $2, $3, $4)`, recipeID, num, s.TimerSeconds, s.Description, ); err != nil { - return "", fmt.Errorf("insert step %d: %w", num, err) + return "", "", fmt.Errorf("insert step %d: %w", num, err) } } if err := tx.Commit(ctx); err != nil { - return "", fmt.Errorf("commit: %w", err) + return "", "", fmt.Errorf("commit: %w", err) } - return recipeID, nil + return dishID, recipeID, nil } // loadTags fills d.Tags with the slugs from dish_tags. diff --git a/backend/internal/domain/menu/handler.go b/backend/internal/domain/menu/handler.go index 8e13fa0..f53816e 100644 --- a/backend/internal/domain/menu/handler.go +++ b/backend/internal/domain/menu/handler.go @@ -35,9 +35,19 @@ type ProductLister interface { ListForPromptByIDs(ctx context.Context, userID string, ids []string) ([]string, error) } -// RecipeSaver creates a dish+recipe and returns the new recipe ID. +// RecipeSaver creates a dish+recipe and returns the dish ID and recipe ID. type RecipeSaver interface { - Create(ctx context.Context, req dish.CreateRequest) (string, error) + Create(ctx context.Context, req dish.CreateRequest) (dishID, recipeID string, err error) +} + +// DishTranslator upserts a translated dish name into the translation table. +type DishTranslator interface { + UpsertTranslation(ctx context.Context, dishID, lang, name string) error +} + +// RecipeTranslator translates English recipe text fields into a target language. +type RecipeTranslator interface { + TranslateRecipes(ctx context.Context, recipes []ai.Recipe, targetLang string) ([]ai.Recipe, error) } // MenuGenerator generates a 7-day meal plan via an AI provider. @@ -47,30 +57,36 @@ type MenuGenerator interface { // Handler handles menu and shopping-list endpoints. type Handler struct { - repo *Repository - menuGenerator MenuGenerator - pexels PhotoSearcher - userLoader UserLoader - productLister ProductLister - recipeSaver RecipeSaver + repo *Repository + menuGenerator MenuGenerator + translator RecipeTranslator + dishTranslator DishTranslator + pexels PhotoSearcher + userLoader UserLoader + productLister ProductLister + recipeSaver RecipeSaver } // NewHandler creates a new Handler. func NewHandler( repo *Repository, menuGenerator MenuGenerator, + translator RecipeTranslator, + dishTranslator DishTranslator, pexels PhotoSearcher, userLoader UserLoader, productLister ProductLister, recipeSaver RecipeSaver, ) *Handler { return &Handler{ - repo: repo, - menuGenerator: menuGenerator, - pexels: pexels, - userLoader: userLoader, - productLister: productLister, - recipeSaver: recipeSaver, + repo: repo, + menuGenerator: menuGenerator, + translator: translator, + dishTranslator: dishTranslator, + pexels: pexels, + userLoader: userLoader, + productLister: productLister, + recipeSaver: recipeSaver, } } @@ -172,13 +188,18 @@ func (h *Handler) GenerateMenu(w http.ResponseWriter, r *http.Request) { h.fetchImages(r.Context(), days) - planItems, saveError := h.saveRecipes(r.Context(), days) + savedEntries, saveError := h.saveRecipes(r.Context(), days) if saveError != nil { writeError(w, r, http.StatusInternalServerError, "failed to save recipes") return } - planID, txError := h.repo.SaveMenuInTx(r.Context(), userID, weekStart, planItems) + lang := locale.FromContext(r.Context()) + if lang != "en" { + h.saveDishTranslations(r.Context(), savedEntries, lang) + } + + planID, txError := h.repo.SaveMenuInTx(r.Context(), userID, weekStart, planItemsFrom(savedEntries)) if txError != nil { slog.ErrorContext(r.Context(), "save menu plan", "err", txError) writeError(w, r, http.StatusInternalServerError, "failed to save menu plan") @@ -246,13 +267,18 @@ func (h *Handler) generateForDates(w http.ResponseWriter, r *http.Request, userI h.fetchImages(r.Context(), days) - planItems, saveError := h.saveRecipes(r.Context(), days) + savedEntries, saveError := h.saveRecipes(r.Context(), days) if saveError != nil { writeError(w, r, http.StatusInternalServerError, "failed to save recipes") return } - planID, upsertError := h.repo.UpsertItemsInTx(r.Context(), userID, weekStart, planItems) + requestLang := locale.FromContext(r.Context()) + if requestLang != "en" { + h.saveDishTranslations(r.Context(), savedEntries, requestLang) + } + + planID, upsertError := h.repo.UpsertItemsInTx(r.Context(), userID, weekStart, planItemsFrom(savedEntries)) if upsertError != nil { slog.ErrorContext(r.Context(), "upsert menu items", "week", weekStart, "err", upsertError) writeError(w, r, http.StatusInternalServerError, "failed to save menu plan") @@ -310,24 +336,69 @@ func (h *Handler) fetchImages(ctx context.Context, days []ai.DayPlan) { } } -// saveRecipes persists all recipes as dish+recipe rows and returns a PlanItem list. -func (h *Handler) saveRecipes(ctx context.Context, days []ai.DayPlan) ([]PlanItem, error) { - planItems := make([]PlanItem, 0, len(days)*6) +// savedRecipeEntry pairs a persisted dish ID and plan item with the original English recipe. +type savedRecipeEntry struct { + DishID string + Recipe ai.Recipe + PlanItem PlanItem +} + +// saveRecipes persists all recipes as dish+recipe rows and returns plan items +// alongside dish IDs needed for translation upserts. +func (h *Handler) saveRecipes(ctx context.Context, days []ai.DayPlan) ([]savedRecipeEntry, error) { + entries := make([]savedRecipeEntry, 0, len(days)*6) for _, day := range days { for _, meal := range day.Meals { - recipeID, createError := h.recipeSaver.Create(ctx, recipeToCreateRequest(meal.Recipe)) + dishID, recipeID, createError := h.recipeSaver.Create(ctx, recipeToCreateRequest(meal.Recipe)) if createError != nil { slog.ErrorContext(ctx, "save recipe for menu", "title", meal.Recipe.Title, "err", createError) return nil, createError } - planItems = append(planItems, PlanItem{ - DayOfWeek: day.Day, - MealType: meal.MealType, - RecipeID: recipeID, + entries = append(entries, savedRecipeEntry{ + DishID: dishID, + Recipe: meal.Recipe, + PlanItem: PlanItem{ + DayOfWeek: day.Day, + MealType: meal.MealType, + RecipeID: recipeID, + }, }) } } - return planItems, nil + return entries, nil +} + +// planItemsFrom extracts the PlanItem slice from savedRecipeEntries. +func planItemsFrom(entries []savedRecipeEntry) []PlanItem { + items := make([]PlanItem, len(entries)) + for i, entry := range entries { + items[i] = entry.PlanItem + } + return items +} + +// saveDishTranslations translates recipe titles and upserts them into dish_translations. +// Errors are logged but do not fail the request (translations are best-effort). +func (h *Handler) saveDishTranslations(ctx context.Context, entries []savedRecipeEntry, lang string) { + recipes := make([]ai.Recipe, len(entries)) + for i, entry := range entries { + recipes[i] = entry.Recipe + } + + translated, translateError := h.translator.TranslateRecipes(ctx, recipes, lang) + if translateError != nil { + slog.WarnContext(ctx, "translate menu dish names", "lang", lang, "err", translateError) + return + } + + for i, entry := range entries { + if i >= len(translated) { + break + } + if upsertError := h.dishTranslator.UpsertTranslation(ctx, entry.DishID, lang, translated[i].Title); upsertError != nil { + slog.WarnContext(ctx, "upsert dish translation", "dish_id", entry.DishID, "lang", lang, "err", upsertError) + } + } } // groupDatesByWeek groups YYYY-MM-DD date strings by their ISO week's Monday date. diff --git a/backend/internal/domain/recommendation/handler.go b/backend/internal/domain/recommendation/handler.go index d305b27..bdb0d03 100644 --- a/backend/internal/domain/recommendation/handler.go +++ b/backend/internal/domain/recommendation/handler.go @@ -40,18 +40,25 @@ type RecipeGenerator interface { GenerateRecipes(ctx context.Context, req ai.RecipeRequest) ([]ai.Recipe, error) } +// RecipeTranslator translates a slice of English recipes into a target language. +type RecipeTranslator interface { + TranslateRecipes(ctx context.Context, recipes []ai.Recipe, targetLang string) ([]ai.Recipe, error) +} + // Handler handles GET /recommendations. type Handler struct { recipeGenerator RecipeGenerator + translator RecipeTranslator pexels PhotoSearcher userLoader UserLoader productLister ProductLister } // NewHandler creates a new Handler. -func NewHandler(recipeGenerator RecipeGenerator, pexels PhotoSearcher, userLoader UserLoader, productLister ProductLister) *Handler { +func NewHandler(recipeGenerator RecipeGenerator, translator RecipeTranslator, pexels PhotoSearcher, userLoader UserLoader, productLister ProductLister) *Handler { return &Handler{ recipeGenerator: recipeGenerator, + translator: translator, pexels: pexels, userLoader: userLoader, productLister: productLister, @@ -111,6 +118,20 @@ func (h *Handler) GetRecommendations(w http.ResponseWriter, r *http.Request) { } wg.Wait() + // Translate text fields into the requested language. + // The generation prompt always produces English; translations are applied + // in-memory here since recommendations are not persisted to the database. + lang := locale.FromContext(r.Context()) + if lang != "en" { + translated, translateError := h.translator.TranslateRecipes(r.Context(), recipes, lang) + if translateError != nil { + slog.WarnContext(r.Context(), "translate recommendations", "lang", lang, "err", translateError) + // Fall back to English rather than failing the request. + } else { + recipes = translated + } + } + writeJSON(w, http.StatusOK, recipes) } diff --git a/backend/internal/domain/savedrecipe/repository.go b/backend/internal/domain/savedrecipe/repository.go index 99953c3..95162a0 100644 --- a/backend/internal/domain/savedrecipe/repository.go +++ b/backend/internal/domain/savedrecipe/repository.go @@ -136,10 +136,10 @@ func (r *Repository) Save(ctx context.Context, userID string, req SaveRequest) ( } } - var err error - recipeID, err = r.dishRepo.Create(ctx, cr) - if err != nil { - return nil, fmt.Errorf("create dish+recipe: %w", err) + var saveError error + _, recipeID, saveError = r.dishRepo.Create(ctx, cr) + if saveError != nil { + return nil, fmt.Errorf("create dish+recipe: %w", saveError) } }