feat: translate recommendations and menu dishes into user language

- Generate recipes in English (reverted prompt to English-only)
- Add TranslateRecipes to OpenAI client (translate.go) — sends compact
  JSON payload of translatable fields, merges back into original recipes
- recommendation/handler.go: translate recipes in-memory before response
  when lang != "en"; falls back to English on error
- dish/repository.go: Create() now returns (dishID, recipeID, err) so
  callers can upsert dish_translations after saving
- menu/handler.go: saveRecipes returns savedRecipeEntry slice with dishID;
  saveDishTranslations calls TranslateRecipes then UpsertTranslation for
  each dish when the request locale is not English
- savedrecipe/repository.go: updated to ignore dishID from Create()
- init.go: wire openaiClient as RecipeTranslator and dishRepository as
  DishTranslator for menu.NewHandler

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
dbastrikin
2026-03-23 17:40:19 +02:00
parent cba50489be
commit bffeb05a43
7 changed files with 278 additions and 55 deletions

View File

@@ -38,7 +38,7 @@ func initApp(appConfig *config.Config, pool *pgxpool.Pool) (*App, error) {
mainPexelsAPIKey := newPexelsAPIKey(appConfig) mainPexelsAPIKey := newPexelsAPIKey(appConfig)
pexelsClient := newPexelsClient(mainPexelsAPIKey) pexelsClient := newPexelsClient(mainPexelsAPIKey)
userProductRepository := userproduct.NewRepository(pool) userProductRepository := userproduct.NewRepository(pool)
recommendationHandler := recommendation.NewHandler(openaiClient, pexelsClient, userRepository, userProductRepository) recommendationHandler := recommendation.NewHandler(openaiClient, openaiClient, pexelsClient, userRepository, userProductRepository)
dishRepository := dish.NewRepository(pool) dishRepository := dish.NewRepository(pool)
savedrecipeRepository := savedrecipe.NewRepository(pool, dishRepository) savedrecipeRepository := savedrecipe.NewRepository(pool, dishRepository)
savedrecipeHandler := savedrecipe.NewHandler(savedrecipeRepository) 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) recognitionHandler := recognition.NewHandler(openaiClient, productRepository, jobRepository, kafkaProducer, sseBroker)
menuRepository := menu.NewRepository(pool) 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) diaryRepository := diary.NewRepository(pool)
diaryHandler := diary.NewHandler(diaryRepository, dishRepository, dishRepository) diaryHandler := diary.NewHandler(diaryRepository, dishRepository, dishRepository)
homeHandler := home.NewHandler(pool) homeHandler := home.NewHandler(pool)

View File

@@ -16,7 +16,6 @@ var goalNames = map[string]string{
"gain": "muscle gain", "gain": "muscle gain",
} }
// GenerateRecipes generates recipes using the OpenAI API. // GenerateRecipes generates recipes using the OpenAI API.
// Retries up to maxRetries times only when the response is not valid JSON. // Retries up to maxRetries times only when the response is not valid JSON.
// API-level errors (rate limits, auth, etc.) are returned immediately. // API-level errors (rate limits, auth, etc.) are returned immediately.
@@ -62,11 +61,6 @@ func buildRecipePrompt(req ai.RecipeRequest) string {
goal = "weight maintenance" goal = "weight maintenance"
} }
targetLang := langNames[req.Lang]
if targetLang == "" {
targetLang = "English"
}
restrictions := "none" restrictions := "none"
if len(req.Restrictions) > 0 { if len(req.Restrictions) > 0 {
restrictions = strings.Join(req.Restrictions, ", ") restrictions = strings.Join(req.Restrictions, ", ")
@@ -93,7 +87,7 @@ func buildRecipePrompt(req ai.RecipeRequest) string {
strings.Join(req.AvailableProducts, "\n") + "\n" 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: User profile:
- Goal: %s - Goal: %s
@@ -107,7 +101,7 @@ Requirements for each recipe:
- Include approximate macros per serving - Include approximate macros per serving
IMPORTANT: 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). - 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: 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}], "steps": [{"number": 1, "description": "...", "timer_seconds": null}],
"tags": ["..."], "tags": ["..."],
"nutrition_per_serving": {"calories": 420, "protein_g": 48, "fat_g": 12, "carbs_g": 18} "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) { func parseRecipesJSON(text string) ([]ai.Recipe, error) {

View File

@@ -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)
}

View File

@@ -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. // Create creates a new dish + recipe row from a CreateRequest.
// Returns the recipe ID to be used in menu_items or user_saved_recipes. // Returns the dish ID and the recipe ID; the recipe ID is used in menu_items
func (r *Repository) Create(ctx context.Context, req CreateRequest) (recipeID string, err error) { // 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{}) tx, err := r.pool.BeginTx(ctx, pgx.TxOptions{})
if err != nil { if err != nil {
return "", fmt.Errorf("begin tx: %w", err) return "", "", fmt.Errorf("begin tx: %w", err)
} }
defer tx.Rollback(ctx) //nolint:errcheck 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) cuisineSlug := nullableStr(req.CuisineSlug)
// Insert dish. // Insert dish.
var dishID string
err = tx.QueryRow(ctx, ` err = tx.QueryRow(ctx, `
INSERT INTO dishes (cuisine_slug, name, description, image_url) INSERT INTO dishes (cuisine_slug, name, description, image_url)
VALUES ($1, $2, NULLIF($3,''), NULLIF($4,'')) 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, cuisineSlug, req.Name, req.Description, req.ImageURL,
).Scan(&dishID) ).Scan(&dishID)
if err != nil { 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 // 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`, `INSERT INTO tags (slug, name) VALUES ($1, $2) ON CONFLICT (slug) DO NOTHING`,
slug, slug, slug, slug,
); upsertErr != nil { ); 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, if _, insertErr := tx.Exec(ctx,
`INSERT INTO dish_tags (dish_id, tag_slug) VALUES ($1, $2) ON CONFLICT DO NOTHING`, `INSERT INTO dish_tags (dish_id, tag_slug) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
dishID, slug, dishID, slug,
); insertErr != nil { ); 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, calories, protein, fat, carbs,
).Scan(&recipeID) ).Scan(&recipeID)
if err != nil { if err != nil {
return "", fmt.Errorf("insert recipe: %w", err) return "", "", fmt.Errorf("insert recipe: %w", err)
} }
// Insert recipe_ingredients. // 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)`, VALUES ($1, $2, $3, NULLIF($4,''), $5, $6)`,
recipeID, ing.Name, ing.Amount, ing.Unit, ing.IsOptional, i, recipeID, ing.Name, ing.Amount, ing.Unit, ing.IsOptional, i,
); err != nil { ); 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)`, VALUES ($1, $2, $3, $4)`,
recipeID, num, s.TimerSeconds, s.Description, recipeID, num, s.TimerSeconds, s.Description,
); err != nil { ); 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 { 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. // loadTags fills d.Tags with the slugs from dish_tags.

View File

@@ -35,9 +35,19 @@ type ProductLister interface {
ListForPromptByIDs(ctx context.Context, userID string, ids []string) ([]string, error) 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 { 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. // 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. // Handler handles menu and shopping-list endpoints.
type Handler struct { type Handler struct {
repo *Repository repo *Repository
menuGenerator MenuGenerator menuGenerator MenuGenerator
pexels PhotoSearcher translator RecipeTranslator
userLoader UserLoader dishTranslator DishTranslator
productLister ProductLister pexels PhotoSearcher
recipeSaver RecipeSaver userLoader UserLoader
productLister ProductLister
recipeSaver RecipeSaver
} }
// NewHandler creates a new Handler. // NewHandler creates a new Handler.
func NewHandler( func NewHandler(
repo *Repository, repo *Repository,
menuGenerator MenuGenerator, menuGenerator MenuGenerator,
translator RecipeTranslator,
dishTranslator DishTranslator,
pexels PhotoSearcher, pexels PhotoSearcher,
userLoader UserLoader, userLoader UserLoader,
productLister ProductLister, productLister ProductLister,
recipeSaver RecipeSaver, recipeSaver RecipeSaver,
) *Handler { ) *Handler {
return &Handler{ return &Handler{
repo: repo, repo: repo,
menuGenerator: menuGenerator, menuGenerator: menuGenerator,
pexels: pexels, translator: translator,
userLoader: userLoader, dishTranslator: dishTranslator,
productLister: productLister, pexels: pexels,
recipeSaver: recipeSaver, 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) h.fetchImages(r.Context(), days)
planItems, saveError := h.saveRecipes(r.Context(), days) savedEntries, saveError := h.saveRecipes(r.Context(), days)
if saveError != nil { if saveError != nil {
writeError(w, r, http.StatusInternalServerError, "failed to save recipes") writeError(w, r, http.StatusInternalServerError, "failed to save recipes")
return 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 { if txError != nil {
slog.ErrorContext(r.Context(), "save menu plan", "err", txError) slog.ErrorContext(r.Context(), "save menu plan", "err", txError)
writeError(w, r, http.StatusInternalServerError, "failed to save menu plan") 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) h.fetchImages(r.Context(), days)
planItems, saveError := h.saveRecipes(r.Context(), days) savedEntries, saveError := h.saveRecipes(r.Context(), days)
if saveError != nil { if saveError != nil {
writeError(w, r, http.StatusInternalServerError, "failed to save recipes") writeError(w, r, http.StatusInternalServerError, "failed to save recipes")
return 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 { if upsertError != nil {
slog.ErrorContext(r.Context(), "upsert menu items", "week", weekStart, "err", upsertError) slog.ErrorContext(r.Context(), "upsert menu items", "week", weekStart, "err", upsertError)
writeError(w, r, http.StatusInternalServerError, "failed to save menu plan") 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. // savedRecipeEntry pairs a persisted dish ID and plan item with the original English recipe.
func (h *Handler) saveRecipes(ctx context.Context, days []ai.DayPlan) ([]PlanItem, error) { type savedRecipeEntry struct {
planItems := make([]PlanItem, 0, len(days)*6) 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 _, day := range days {
for _, meal := range day.Meals { 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 { if createError != nil {
slog.ErrorContext(ctx, "save recipe for menu", "title", meal.Recipe.Title, "err", createError) slog.ErrorContext(ctx, "save recipe for menu", "title", meal.Recipe.Title, "err", createError)
return nil, createError return nil, createError
} }
planItems = append(planItems, PlanItem{ entries = append(entries, savedRecipeEntry{
DayOfWeek: day.Day, DishID: dishID,
MealType: meal.MealType, Recipe: meal.Recipe,
RecipeID: recipeID, 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. // groupDatesByWeek groups YYYY-MM-DD date strings by their ISO week's Monday date.

View File

@@ -40,18 +40,25 @@ type RecipeGenerator interface {
GenerateRecipes(ctx context.Context, req ai.RecipeRequest) ([]ai.Recipe, error) 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. // Handler handles GET /recommendations.
type Handler struct { type Handler struct {
recipeGenerator RecipeGenerator recipeGenerator RecipeGenerator
translator RecipeTranslator
pexels PhotoSearcher pexels PhotoSearcher
userLoader UserLoader userLoader UserLoader
productLister ProductLister productLister ProductLister
} }
// NewHandler creates a new Handler. // 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{ return &Handler{
recipeGenerator: recipeGenerator, recipeGenerator: recipeGenerator,
translator: translator,
pexels: pexels, pexels: pexels,
userLoader: userLoader, userLoader: userLoader,
productLister: productLister, productLister: productLister,
@@ -111,6 +118,20 @@ func (h *Handler) GetRecommendations(w http.ResponseWriter, r *http.Request) {
} }
wg.Wait() 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) writeJSON(w, http.StatusOK, recipes)
} }

View File

@@ -136,10 +136,10 @@ func (r *Repository) Save(ctx context.Context, userID string, req SaveRequest) (
} }
} }
var err error var saveError error
recipeID, err = r.dishRepo.Create(ctx, cr) _, recipeID, saveError = r.dishRepo.Create(ctx, cr)
if err != nil { if saveError != nil {
return nil, fmt.Errorf("create dish+recipe: %w", err) return nil, fmt.Errorf("create dish+recipe: %w", saveError)
} }
} }