From c0cf1b38ea2ef1781cfac1a5c5106c6a30cfdbed Mon Sep 17 00:00:00 2001 From: dbastrikin Date: Fri, 27 Feb 2026 23:17:34 +0200 Subject: [PATCH] feat: implement backend localization infrastructure - Add internal/locale package: Parse(Accept-Language), FromContext/WithLang helpers, 12 supported languages - Add Language middleware that reads Accept-Language header and stores lang in context - Register Language middleware globally in server router (after CORS) Database migrations: - 009: create recipe_translations, saved_recipe_translations, ingredient_translations tables; migrate existing _ru data - 010: drop legacy _ru columns (title_ru, description_ru, canonical_name_ru); update FTS index Models: remove all _ru fields (TitleRu, DescriptionRu, NameRu, UnitRu, CanonicalNameRu) Repositories: - recipe: Upsert drops _ru params; GetByID does LEFT JOIN COALESCE on recipe_translations; ListMissingTranslation(lang); UpsertTranslation - ingredient: same pattern with ingredient_translations; Search now queries translated names/aliases - savedrecipe: List/GetByID LEFT JOIN COALESCE on saved_recipe_translations; UpsertTranslation Gemini: - RecipeRequest/MenuRequest gain Lang field - buildRecipePrompt rewritten in English with target-language content instruction; image_query always in English - GenerateMenu propagates Lang to GenerateRecipes Handlers: - recommendation/menu: pass locale.FromContext(ctx) as Lang - recognition: saveClassification stores Russian translation via UpsertTranslation instead of _ru column Co-Authored-By: Claude Sonnet 4.6 --- backend/internal/gemini/menu.go | 2 + backend/internal/gemini/recipe.go | 103 ++++++++----- backend/internal/ingredient/model.go | 15 +- backend/internal/ingredient/repository.go | 137 ++++++++++-------- .../ingredient/repository_integration_test.go | 83 +++++------ backend/internal/locale/locale.go | 69 +++++++++ backend/internal/locale/locale_test.go | 94 ++++++++++++ backend/internal/menu/handler.go | 7 +- backend/internal/middleware/language.go | 18 +++ backend/internal/recipe/model.go | 38 +++-- backend/internal/recipe/repository.go | 108 ++++++++------ .../recipe/repository_integration_test.go | 67 +++++---- backend/internal/recognition/handler.go | 9 +- backend/internal/recommendation/handler.go | 6 +- backend/internal/savedrecipe/repository.go | 80 +++++++--- backend/internal/server/server.go | 1 + .../009_create_translation_tables.sql | 118 +++++++++++++++ backend/migrations/010_drop_ru_columns.sql | 36 +++++ 18 files changed, 718 insertions(+), 273 deletions(-) create mode 100644 backend/internal/locale/locale.go create mode 100644 backend/internal/locale/locale_test.go create mode 100644 backend/internal/middleware/language.go create mode 100644 backend/migrations/009_create_translation_tables.sql create mode 100644 backend/migrations/010_drop_ru_columns.sql diff --git a/backend/internal/gemini/menu.go b/backend/internal/gemini/menu.go index bc3ad1d..ac7c2b7 100644 --- a/backend/internal/gemini/menu.go +++ b/backend/internal/gemini/menu.go @@ -13,6 +13,7 @@ type MenuRequest struct { Restrictions []string CuisinePrefs []string AvailableProducts []string + Lang string // ISO 639-1 target language code, e.g. "en", "ru" } // DayPlan is the AI-generated plan for a single day. @@ -63,6 +64,7 @@ func (c *Client) GenerateMenu(ctx context.Context, req MenuRequest) ([]DayPlan, CuisinePrefs: req.CuisinePrefs, Count: 7, AvailableProducts: req.AvailableProducts, + Lang: req.Lang, }) results[idx] = mealResult{r, err} }(i, slot.mealType, slot.fraction) diff --git a/backend/internal/gemini/recipe.go b/backend/internal/gemini/recipe.go index ef6be73..f8a0baf 100644 --- a/backend/internal/gemini/recipe.go +++ b/backend/internal/gemini/recipe.go @@ -14,12 +14,13 @@ type RecipeGenerator interface { // RecipeRequest contains parameters for recipe generation. type RecipeRequest struct { - UserGoal string // "weight_loss" | "maintain" | "gain" + UserGoal string // "lose" | "maintain" | "gain" DailyCalories int Restrictions []string // e.g. ["gluten_free", "vegetarian"] CuisinePrefs []string // e.g. ["russian", "asian"] Count int AvailableProducts []string // human-readable list of products in user's pantry + Lang string // ISO 639-1 target language code, e.g. "en", "ru" } // Recipe is a recipe returned by Gemini. @@ -62,35 +63,55 @@ type NutritionInfo struct { Approximate bool `json:"approximate"` } +// langNames maps ISO 639-1 codes to English language names used in the prompt. +var langNames = map[string]string{ + "en": "English", + "ru": "Russian", + "es": "Spanish", + "de": "German", + "fr": "French", + "it": "Italian", + "pt": "Portuguese", + "zh": "Chinese (Simplified)", + "ja": "Japanese", + "ko": "Korean", + "ar": "Arabic", + "hi": "Hindi", +} + +// 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 Gemini AI. // 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 RecipeRequest) ([]Recipe, error) { prompt := buildRecipePrompt(req) - // OpenAI messages format. messages := []map[string]string{ {"role": "user", "content": prompt}, } var lastErr error - for attempt := 0; attempt < maxRetries; attempt++ { + for attempt := range maxRetries { if attempt > 0 { messages = []map[string]string{ {"role": "user", "content": prompt}, - {"role": "user", "content": "Предыдущий ответ не был валидным JSON. Верни ТОЛЬКО массив JSON без какого-либо текста до или после."}, + {"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 { - // API-level error (4xx/5xx): no point retrying immediately. return nil, err } recipes, err := parseRecipesJSON(text) if err != nil { - // Malformed JSON from the model — retry with a clarifying message. lastErr = fmt.Errorf("attempt %d parse JSON: %w", attempt+1, err) continue } @@ -105,22 +126,26 @@ func (c *Client) GenerateRecipes(ctx context.Context, req RecipeRequest) ([]Reci } func buildRecipePrompt(req RecipeRequest) string { - goalRu := map[string]string{ - "weight_loss": "похудение", - "maintain": "поддержание веса", - "gain": "набор массы", + lang := req.Lang + if lang == "" { + lang = "en" } - goal := goalRu[req.UserGoal] - if goal == "" { - goal = "поддержание веса" + langName, ok := langNames[lang] + if !ok { + langName = "English" } - restrictions := "нет" + goal := goalNames[req.UserGoal] + if goal == "" { + goal = "weight maintenance" + } + + restrictions := "none" if len(req.Restrictions) > 0 { restrictions = strings.Join(req.Restrictions, ", ") } - cuisines := "любые" + cuisines := "any" if len(req.CuisinePrefs) > 0 { cuisines = strings.Join(req.CuisinePrefs, ", ") } @@ -137,48 +162,46 @@ func buildRecipePrompt(req RecipeRequest) string { productsSection := "" if len(req.AvailableProducts) > 0 { - productsSection = "\nДоступные продукты (приоритет — скоро истекают ⚠):\n" + - strings.Join(req.AvailableProducts, "\n") + - "\nПредпочтительно использовать эти продукты в рецептах.\n" + productsSection = "\nAvailable products (⚠ = expiring soon, prioritise these):\n" + + strings.Join(req.AvailableProducts, "\n") + "\n" } - return fmt.Sprintf(`Ты — диетолог-повар. Предложи %d рецептов на русском языке. + return fmt.Sprintf(`You are a chef and nutritionist. Generate %d recipes in %s. -Профиль пользователя: -- Цель: %s -- Дневная норма калорий: %d ккал -- Ограничения: %s -- Предпочтения: %s +User profile: +- Goal: %s +- Daily calories: %d kcal +- Dietary restrictions: %s +- Cuisine preferences: %s %s -Требования к каждому рецепту: -- Калорийность на порцию: не более %d ккал -- Время приготовления: до 60 минут -- Укажи КБЖУ на порцию (приблизительно) +Requirements for each recipe: +- Max %d kcal per serving +- Total cooking time: max 60 minutes +- Include approximate macros per serving -ВАЖНО: поле image_query заполняй ТОЛЬКО на английском языке — оно используется для поиска фото. +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). -Верни ТОЛЬКО валидный JSON-массив без markdown-обёртки: +Return ONLY a valid JSON array without markdown or extra text: [{ - "title": "Название", - "description": "2-3 предложения", + "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": "chicken breast vegetables healthy (ENGLISH ONLY, used for photo search)", - "ingredients": [{"name": "Куриная грудка", "amount": 300, "unit": "г"}], + "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, goal, req.DailyCalories, restrictions, cuisines, productsSection, perMealCalories) + "tags": ["..."], + "nutrition_per_serving": {"calories": 420, "protein_g": 48, "fat_g": 12, "carbs_g": 18} +}]`, count, langName, goal, req.DailyCalories, restrictions, cuisines, productsSection, perMealCalories, langName) } func parseRecipesJSON(text string) ([]Recipe, error) { text = strings.TrimSpace(text) - // Strip potential markdown code fences if strings.HasPrefix(text, "```") { text = strings.TrimPrefix(text, "```json") text = strings.TrimPrefix(text, "```") diff --git a/backend/internal/ingredient/model.go b/backend/internal/ingredient/model.go index d468e88..c78185c 100644 --- a/backend/internal/ingredient/model.go +++ b/backend/internal/ingredient/model.go @@ -7,14 +7,15 @@ import ( // IngredientMapping is the canonical ingredient record used to link // user products, recipe ingredients, and Spoonacular data. +// CanonicalName holds the content for the language resolved at query time +// (English by default, or from ingredient_translations when available). type IngredientMapping struct { - ID string `json:"id"` - CanonicalName string `json:"canonical_name"` - CanonicalNameRu *string `json:"canonical_name_ru"` - SpoonacularID *int `json:"spoonacular_id"` - Aliases json.RawMessage `json:"aliases"` // []string - Category *string `json:"category"` - DefaultUnit *string `json:"default_unit"` + ID string `json:"id"` + CanonicalName string `json:"canonical_name"` + SpoonacularID *int `json:"spoonacular_id"` + Aliases json.RawMessage `json:"aliases"` // []string + Category *string `json:"category"` + DefaultUnit *string `json:"default_unit"` CaloriesPer100g *float64 `json:"calories_per_100g"` ProteinPer100g *float64 `json:"protein_per_100g"` diff --git a/backend/internal/ingredient/repository.go b/backend/internal/ingredient/repository.go index 3ed768d..828d9cc 100644 --- a/backend/internal/ingredient/repository.go +++ b/backend/internal/ingredient/repository.go @@ -6,11 +6,12 @@ import ( "errors" "fmt" + "github.com/food-ai/backend/internal/locale" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) -// Repository handles persistence for ingredient_mappings. +// Repository handles persistence for ingredient_mappings and their translations. type Repository struct { pool *pgxpool.Pool } @@ -20,16 +21,16 @@ func NewRepository(pool *pgxpool.Pool) *Repository { return &Repository{pool: pool} } -// Upsert inserts or updates an ingredient mapping. -// Conflict is resolved on spoonacular_id when set; otherwise a simple insert is done. +// Upsert inserts or updates an ingredient mapping (English canonical content). +// Conflict is resolved on spoonacular_id when set. func (r *Repository) Upsert(ctx context.Context, m *IngredientMapping) (*IngredientMapping, error) { query := ` INSERT INTO ingredient_mappings ( - canonical_name, canonical_name_ru, spoonacular_id, aliases, + canonical_name, spoonacular_id, aliases, category, default_unit, calories_per_100g, protein_per_100g, fat_per_100g, carbs_per_100g, fiber_per_100g, storage_days - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) ON CONFLICT (spoonacular_id) DO UPDATE SET canonical_name = EXCLUDED.canonical_name, aliases = EXCLUDED.aliases, @@ -42,13 +43,13 @@ func (r *Repository) Upsert(ctx context.Context, m *IngredientMapping) (*Ingredi fiber_per_100g = EXCLUDED.fiber_per_100g, storage_days = EXCLUDED.storage_days, updated_at = now() - RETURNING id, canonical_name, canonical_name_ru, spoonacular_id, aliases, + RETURNING id, canonical_name, spoonacular_id, aliases, category, default_unit, calories_per_100g, protein_per_100g, fat_per_100g, carbs_per_100g, fiber_per_100g, storage_days, created_at, updated_at` row := r.pool.QueryRow(ctx, query, - m.CanonicalName, m.CanonicalNameRu, m.SpoonacularID, m.Aliases, + m.CanonicalName, m.SpoonacularID, m.Aliases, m.Category, m.DefaultUnit, m.CaloriesPer100g, m.ProteinPer100g, m.FatPer100g, m.CarbsPer100g, m.FiberPer100g, m.StorageDays, @@ -57,17 +58,22 @@ func (r *Repository) Upsert(ctx context.Context, m *IngredientMapping) (*Ingredi } // GetBySpoonacularID returns an ingredient mapping by Spoonacular ID. +// CanonicalName is resolved for the language stored in ctx. // Returns nil, nil if not found. func (r *Repository) GetBySpoonacularID(ctx context.Context, id int) (*IngredientMapping, error) { + lang := locale.FromContext(ctx) query := ` - SELECT id, canonical_name, canonical_name_ru, spoonacular_id, aliases, - category, default_unit, - calories_per_100g, protein_per_100g, fat_per_100g, carbs_per_100g, fiber_per_100g, - storage_days, created_at, updated_at - FROM ingredient_mappings - WHERE spoonacular_id = $1` + SELECT im.id, + COALESCE(it.name, im.canonical_name) AS canonical_name, + im.spoonacular_id, im.aliases, + im.category, im.default_unit, + im.calories_per_100g, im.protein_per_100g, im.fat_per_100g, im.carbs_per_100g, im.fiber_per_100g, + im.storage_days, im.created_at, im.updated_at + FROM ingredient_mappings im + LEFT JOIN ingredient_translations it ON it.ingredient_id = im.id AND it.lang = $2 + WHERE im.spoonacular_id = $1` - row := r.pool.QueryRow(ctx, query, id) + row := r.pool.QueryRow(ctx, query, id, lang) m, err := scanMapping(row) if errors.Is(err, pgx.ErrNoRows) { return nil, nil @@ -76,17 +82,22 @@ func (r *Repository) GetBySpoonacularID(ctx context.Context, id int) (*Ingredien } // GetByID returns an ingredient mapping by UUID. +// CanonicalName is resolved for the language stored in ctx. // Returns nil, nil if not found. func (r *Repository) GetByID(ctx context.Context, id string) (*IngredientMapping, error) { + lang := locale.FromContext(ctx) query := ` - SELECT id, canonical_name, canonical_name_ru, spoonacular_id, aliases, - category, default_unit, - calories_per_100g, protein_per_100g, fat_per_100g, carbs_per_100g, fiber_per_100g, - storage_days, created_at, updated_at - FROM ingredient_mappings - WHERE id = $1` + SELECT im.id, + COALESCE(it.name, im.canonical_name) AS canonical_name, + im.spoonacular_id, im.aliases, + im.category, im.default_unit, + im.calories_per_100g, im.protein_per_100g, im.fat_per_100g, im.carbs_per_100g, im.fiber_per_100g, + im.storage_days, im.created_at, im.updated_at + FROM ingredient_mappings im + LEFT JOIN ingredient_translations it ON it.ingredient_id = im.id AND it.lang = $2 + WHERE im.id = $1` - row := r.pool.QueryRow(ctx, query, id) + row := r.pool.QueryRow(ctx, query, id, lang) m, err := scanMapping(row) if errors.Is(err, pgx.ErrNoRows) { return nil, nil @@ -95,6 +106,7 @@ func (r *Repository) GetByID(ctx context.Context, id string) (*IngredientMapping } // FuzzyMatch finds the single best matching ingredient mapping for a given name. +// Searches both English and translated names for the language in ctx. // Returns nil, nil when no match is found. func (r *Repository) FuzzyMatch(ctx context.Context, name string) (*IngredientMapping, error) { results, err := r.Search(ctx, name, 1) @@ -108,24 +120,31 @@ func (r *Repository) FuzzyMatch(ctx context.Context, name string) (*IngredientMa } // Search finds ingredient mappings matching the query string. +// Searches English aliases/name and the translated name for the language in ctx. // Uses a three-level strategy: exact aliases match, ILIKE, and pg_trgm similarity. func (r *Repository) Search(ctx context.Context, query string, limit int) ([]*IngredientMapping, error) { if limit <= 0 { limit = 10 } + lang := locale.FromContext(ctx) q := ` - SELECT id, canonical_name, canonical_name_ru, spoonacular_id, aliases, - category, default_unit, - calories_per_100g, protein_per_100g, fat_per_100g, carbs_per_100g, fiber_per_100g, - storage_days, created_at, updated_at - FROM ingredient_mappings - WHERE aliases @> to_jsonb(lower($1)::text) - OR canonical_name_ru ILIKE '%' || $1 || '%' - OR similarity(canonical_name_ru, $1) > 0.3 - ORDER BY similarity(canonical_name_ru, $1) DESC + SELECT im.id, + COALESCE(it.name, im.canonical_name) AS canonical_name, + im.spoonacular_id, im.aliases, + im.category, im.default_unit, + im.calories_per_100g, im.protein_per_100g, im.fat_per_100g, im.carbs_per_100g, im.fiber_per_100g, + im.storage_days, im.created_at, im.updated_at + FROM ingredient_mappings im + LEFT JOIN ingredient_translations it ON it.ingredient_id = im.id AND it.lang = $3 + WHERE im.aliases @> to_jsonb(lower($1)::text) + OR it.aliases @> to_jsonb(lower($1)::text) + OR im.canonical_name ILIKE '%' || $1 || '%' + OR it.name ILIKE '%' || $1 || '%' + OR similarity(COALESCE(it.name, im.canonical_name), $1) > 0.3 + ORDER BY similarity(COALESCE(it.name, im.canonical_name), $1) DESC LIMIT $2` - rows, err := r.pool.Query(ctx, q, query, limit) + rows, err := r.pool.Query(ctx, q, query, limit, lang) if err != nil { return nil, fmt.Errorf("search ingredient_mappings: %w", err) } @@ -142,45 +161,41 @@ func (r *Repository) Count(ctx context.Context) (int, error) { return n, nil } -// ListUntranslated returns ingredients without a Russian name, ordered by id. -func (r *Repository) ListUntranslated(ctx context.Context, limit, offset int) ([]*IngredientMapping, error) { +// ListMissingTranslation returns ingredients that have no translation for the +// given language, ordered by id. +func (r *Repository) ListMissingTranslation(ctx context.Context, lang string, limit, offset int) ([]*IngredientMapping, error) { query := ` - SELECT id, canonical_name, canonical_name_ru, spoonacular_id, aliases, - category, default_unit, - calories_per_100g, protein_per_100g, fat_per_100g, carbs_per_100g, fiber_per_100g, - storage_days, created_at, updated_at - FROM ingredient_mappings - WHERE canonical_name_ru IS NULL - ORDER BY id + SELECT im.id, im.canonical_name, im.spoonacular_id, im.aliases, + im.category, im.default_unit, + im.calories_per_100g, im.protein_per_100g, im.fat_per_100g, im.carbs_per_100g, im.fiber_per_100g, + im.storage_days, im.created_at, im.updated_at + FROM ingredient_mappings im + WHERE NOT EXISTS ( + SELECT 1 FROM ingredient_translations it + WHERE it.ingredient_id = im.id AND it.lang = $3 + ) + ORDER BY im.id LIMIT $1 OFFSET $2` - rows, err := r.pool.Query(ctx, query, limit, offset) + rows, err := r.pool.Query(ctx, query, limit, offset, lang) if err != nil { - return nil, fmt.Errorf("list untranslated: %w", err) + return nil, fmt.Errorf("list missing translation (%s): %w", lang, err) } defer rows.Close() return collectMappings(rows) } -// UpdateTranslation saves the Russian name and adds Russian aliases. -func (r *Repository) UpdateTranslation(ctx context.Context, id, canonicalNameRu string, aliasesRu []string) error { - // Merge new aliases into existing JSONB array without duplicates +// UpsertTranslation inserts or replaces a translation for an ingredient mapping. +func (r *Repository) UpsertTranslation(ctx context.Context, id, lang, name string, aliases json.RawMessage) error { query := ` - UPDATE ingredient_mappings SET - canonical_name_ru = $2, - aliases = ( - SELECT jsonb_agg(DISTINCT elem) - FROM ( - SELECT jsonb_array_elements(aliases) AS elem - UNION - SELECT to_jsonb(unnest) FROM unnest($3::text[]) AS unnest - ) sub - ), - updated_at = now() - WHERE id = $1` + INSERT INTO ingredient_translations (ingredient_id, lang, name, aliases) + VALUES ($1, $2, $3, $4) + ON CONFLICT (ingredient_id, lang) DO UPDATE SET + name = EXCLUDED.name, + aliases = EXCLUDED.aliases` - if _, err := r.pool.Exec(ctx, query, id, canonicalNameRu, aliasesRu); err != nil { - return fmt.Errorf("update translation %s: %w", id, err) + if _, err := r.pool.Exec(ctx, query, id, lang, name, aliases); err != nil { + return fmt.Errorf("upsert ingredient translation %s/%s: %w", id, lang, err) } return nil } @@ -192,7 +207,7 @@ func scanMapping(row pgx.Row) (*IngredientMapping, error) { var aliases []byte err := row.Scan( - &m.ID, &m.CanonicalName, &m.CanonicalNameRu, &m.SpoonacularID, &aliases, + &m.ID, &m.CanonicalName, &m.SpoonacularID, &aliases, &m.Category, &m.DefaultUnit, &m.CaloriesPer100g, &m.ProteinPer100g, &m.FatPer100g, &m.CarbsPer100g, &m.FiberPer100g, &m.StorageDays, &m.CreatedAt, &m.UpdatedAt, @@ -210,7 +225,7 @@ func collectMappings(rows pgx.Rows) ([]*IngredientMapping, error) { var m IngredientMapping var aliases []byte if err := rows.Scan( - &m.ID, &m.CanonicalName, &m.CanonicalNameRu, &m.SpoonacularID, &aliases, + &m.ID, &m.CanonicalName, &m.SpoonacularID, &aliases, &m.Category, &m.DefaultUnit, &m.CaloriesPer100g, &m.ProteinPer100g, &m.FatPer100g, &m.CarbsPer100g, &m.FiberPer100g, &m.StorageDays, &m.CreatedAt, &m.UpdatedAt, diff --git a/backend/internal/ingredient/repository_integration_test.go b/backend/internal/ingredient/repository_integration_test.go index e756f84..c940a64 100644 --- a/backend/internal/ingredient/repository_integration_test.go +++ b/backend/internal/ingredient/repository_integration_test.go @@ -7,6 +7,7 @@ import ( "encoding/json" "testing" + "github.com/food-ai/backend/internal/locale" "github.com/food-ai/backend/internal/testutil" ) @@ -65,7 +66,6 @@ func TestIngredientRepository_Upsert_ConflictUpdates(t *testing.T) { t.Fatalf("first upsert: %v", err) } - // Update with same spoonacular_id cal := 89.0 second := &IngredientMapping{ CanonicalName: "banana_updated", @@ -137,7 +137,7 @@ func TestIngredientRepository_GetBySpoonacularID_NotFound(t *testing.T) { } } -func TestIngredientRepository_ListUntranslated(t *testing.T) { +func TestIngredientRepository_ListMissingTranslation(t *testing.T) { pool := testutil.SetupTestDB(t) repo := NewRepository(pool) ctx := context.Background() @@ -145,7 +145,7 @@ func TestIngredientRepository_ListUntranslated(t *testing.T) { cat := "produce" unit := "g" - // Insert 3 without translation + // Insert 3 without any translation. for i, name := range []string{"carrot", "onion", "garlic"} { id := 4000 + i _, err := repo.Upsert(ctx, &IngredientMapping{ @@ -160,45 +160,38 @@ func TestIngredientRepository_ListUntranslated(t *testing.T) { } } - // Insert 1 with translation (shouldn't appear in untranslated list) + // Insert 1 and add a Russian translation — should not appear in the result. id := 4100 - ruName := "помидор" - withTranslation := &IngredientMapping{ - CanonicalName: "tomato", - CanonicalNameRu: &ruName, - SpoonacularID: &id, - Aliases: json.RawMessage(`[]`), - Category: &cat, - DefaultUnit: &unit, - } - saved, err := repo.Upsert(ctx, withTranslation) + saved, err := repo.Upsert(ctx, &IngredientMapping{ + CanonicalName: "tomato", + SpoonacularID: &id, + Aliases: json.RawMessage(`[]`), + Category: &cat, + DefaultUnit: &unit, + }) if err != nil { - t.Fatalf("upsert with translation: %v", err) + t.Fatalf("upsert tomato: %v", err) } - // The upsert doesn't set canonical_name_ru because the UPDATE clause doesn't include it - // We need to manually set it after - if err := repo.UpdateTranslation(ctx, saved.ID, "помидор", []string{"помидор", "томат"}); err != nil { - t.Fatalf("update translation: %v", err) + if err := repo.UpsertTranslation(ctx, saved.ID, "ru", "помидор", json.RawMessage(`["помидор","томат"]`)); err != nil { + t.Fatalf("upsert translation: %v", err) } - untranslated, err := repo.ListUntranslated(ctx, 10, 0) + missing, err := repo.ListMissingTranslation(ctx, "ru", 10, 0) if err != nil { - t.Fatalf("list untranslated: %v", err) + t.Fatalf("list missing translation: %v", err) } - // Should return the 3 without translation (carrot, onion, garlic) - // The translated tomato should not appear - for _, m := range untranslated { + for _, m := range missing { if m.CanonicalName == "tomato" { - t.Error("translated ingredient should not appear in ListUntranslated") + t.Error("translated ingredient should not appear in ListMissingTranslation") } } - if len(untranslated) < 3 { - t.Errorf("expected at least 3 untranslated, got %d", len(untranslated)) + if len(missing) < 3 { + t.Errorf("expected at least 3 untranslated, got %d", len(missing)) } } -func TestIngredientRepository_UpdateTranslation(t *testing.T) { +func TestIngredientRepository_UpsertTranslation(t *testing.T) { pool := testutil.SetupTestDB(t) repo := NewRepository(pool) ctx := context.Background() @@ -218,33 +211,29 @@ func TestIngredientRepository_UpdateTranslation(t *testing.T) { t.Fatalf("upsert: %v", err) } - err = repo.UpdateTranslation(ctx, saved.ID, "куриная грудка", - []string{"куриная грудка", "куриное филе"}) + err = repo.UpsertTranslation(ctx, saved.ID, "ru", "куриная грудка", + json.RawMessage(`["куриная грудка","куриное филе"]`)) if err != nil { - t.Fatalf("update translation: %v", err) + t.Fatalf("upsert translation: %v", err) } - got, err := repo.GetByID(ctx, saved.ID) + // Retrieve with Russian context — CanonicalName should be the Russian name. + ruCtx := locale.WithLang(ctx, "ru") + got, err := repo.GetByID(ruCtx, saved.ID) if err != nil { t.Fatalf("get by id: %v", err) } - if got.CanonicalNameRu == nil || *got.CanonicalNameRu != "куриная грудка" { - t.Errorf("expected canonical_name_ru='куриная грудка', got %v", got.CanonicalNameRu) + if got.CanonicalName != "куриная грудка" { + t.Errorf("expected CanonicalName='куриная грудка', got %q", got.CanonicalName) } - var aliases []string - if err := json.Unmarshal(got.Aliases, &aliases); err != nil { - t.Fatalf("unmarshal aliases: %v", err) + // Retrieve with English context (default) — CanonicalName should be the English name. + enCtx := locale.WithLang(ctx, "en") + gotEn, err := repo.GetByID(enCtx, saved.ID) + if err != nil { + t.Fatalf("get by id (en): %v", err) } - - hasRu := false - for _, a := range aliases { - if a == "куриное филе" { - hasRu = true - break - } - } - if !hasRu { - t.Errorf("Russian alias not found in aliases: %v", aliases) + if gotEn.CanonicalName != "chicken_breast" { + t.Errorf("expected English CanonicalName='chicken_breast', got %q", gotEn.CanonicalName) } } diff --git a/backend/internal/locale/locale.go b/backend/internal/locale/locale.go new file mode 100644 index 0000000..69f43fc --- /dev/null +++ b/backend/internal/locale/locale.go @@ -0,0 +1,69 @@ +package locale + +import ( + "context" + "net/http" + "strings" +) + +// Default is the fallback language when no supported language is detected. +const Default = "en" + +// Supported is the set of language codes the application currently handles. +// Keys are ISO 639-1 two-letter codes (lower-case). +var Supported = map[string]bool{ + "en": true, + "ru": true, + "es": true, + "de": true, + "fr": true, + "it": true, + "pt": true, + "zh": true, + "ja": true, + "ko": true, + "ar": true, + "hi": true, +} + +type contextKey struct{} + +// Parse returns the best-matching supported language from an Accept-Language +// header value. It iterates through the comma-separated list in preference +// order and returns the first entry whose primary subtag is in Supported. +// Returns Default when the header is empty or no match is found. +func Parse(acceptLang string) string { + if acceptLang == "" { + return Default + } + for part := range strings.SplitSeq(acceptLang, ",") { + // Strip quality value (e.g. ";q=0.9"). + tag := strings.TrimSpace(strings.SplitN(part, ";", 2)[0]) + // Use only the primary subtag (e.g. "ru" from "ru-RU"). + lang := strings.ToLower(strings.SplitN(tag, "-", 2)[0]) + if Supported[lang] { + return lang + } + } + return Default +} + +// WithLang returns a copy of ctx carrying the given language code. +func WithLang(ctx context.Context, lang string) context.Context { + return context.WithValue(ctx, contextKey{}, lang) +} + +// FromContext returns the language stored in ctx. +// Returns Default when no language has been set. +func FromContext(ctx context.Context) string { + if lang, ok := ctx.Value(contextKey{}).(string); ok && lang != "" { + return lang + } + return Default +} + +// FromRequest extracts the preferred language from the request's +// Accept-Language header. +func FromRequest(r *http.Request) string { + return Parse(r.Header.Get("Accept-Language")) +} diff --git a/backend/internal/locale/locale_test.go b/backend/internal/locale/locale_test.go new file mode 100644 index 0000000..9012b30 --- /dev/null +++ b/backend/internal/locale/locale_test.go @@ -0,0 +1,94 @@ +package locale_test + +import ( + "context" + "net/http" + "testing" + + "github.com/food-ai/backend/internal/locale" +) + +func TestParse(t *testing.T) { + tests := []struct { + name string + acceptLang string + want string + }{ + { + name: "empty header returns default", + acceptLang: "", + want: locale.Default, + }, + { + name: "exact match", + acceptLang: "ru", + want: "ru", + }, + { + name: "region subtag stripped", + acceptLang: "ru-RU", + want: "ru", + }, + { + name: "full browser header picks first supported", + acceptLang: "ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7", + want: "ru", + }, + { + name: "unsupported first falls through to supported", + acceptLang: "xx-XX,ru;q=0.8", + want: "ru", + }, + { + name: "completely unsupported returns default", + acceptLang: "xx-XX,yy-YY", + want: locale.Default, + }, + { + name: "chinese region subtag", + acceptLang: "zh-CN,zh;q=0.9", + want: "zh", + }, + { + name: "case insensitive", + acceptLang: "RU-RU", + want: "ru", + }, + { + name: "whitespace around tag", + acceptLang: " ru ", + want: "ru", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := locale.Parse(tc.acceptLang) + if got != tc.want { + t.Errorf("Parse(%q) = %q, want %q", tc.acceptLang, got, tc.want) + } + }) + } +} + +func TestWithLangAndFromContext(t *testing.T) { + ctx := context.Background() + + if got := locale.FromContext(ctx); got != locale.Default { + t.Errorf("FromContext on empty ctx = %q, want %q", got, locale.Default) + } + + ctx = locale.WithLang(ctx, "ru") + if got := locale.FromContext(ctx); got != "ru" { + t.Errorf("FromContext after WithLang(ru) = %q, want %q", got, "ru") + } +} + +func TestFromRequest(t *testing.T) { + req, _ := http.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("Accept-Language", "es-ES,es;q=0.9") + + if got := locale.FromRequest(req); got != "es" { + t.Errorf("FromRequest = %q, want %q", got, "es") + } +} diff --git a/backend/internal/menu/handler.go b/backend/internal/menu/handler.go index 1c9bea0..1a907f9 100644 --- a/backend/internal/menu/handler.go +++ b/backend/internal/menu/handler.go @@ -11,6 +11,7 @@ import ( "time" "github.com/food-ai/backend/internal/gemini" + "github.com/food-ai/backend/internal/locale" "github.com/food-ai/backend/internal/middleware" "github.com/food-ai/backend/internal/savedrecipe" "github.com/food-ai/backend/internal/user" @@ -128,7 +129,7 @@ func (h *Handler) GenerateMenu(w http.ResponseWriter, r *http.Request) { return } - menuReq := buildMenuRequest(u) + menuReq := buildMenuRequest(u, locale.FromContext(r.Context())) // Attach pantry products. if products, err := h.productLister.ListForPrompt(r.Context(), userID); err == nil { @@ -460,8 +461,8 @@ type userPreferences struct { Restrictions []string `json:"restrictions"` } -func buildMenuRequest(u *user.User) gemini.MenuRequest { - req := gemini.MenuRequest{DailyCalories: 2000} +func buildMenuRequest(u *user.User, lang string) gemini.MenuRequest { + req := gemini.MenuRequest{DailyCalories: 2000, Lang: lang} if u.Goal != nil { req.UserGoal = *u.Goal } diff --git a/backend/internal/middleware/language.go b/backend/internal/middleware/language.go new file mode 100644 index 0000000..0f51228 --- /dev/null +++ b/backend/internal/middleware/language.go @@ -0,0 +1,18 @@ +package middleware + +import ( + "net/http" + + "github.com/food-ai/backend/internal/locale" +) + +// Language reads the Accept-Language request header, resolves the best +// supported language via locale.Parse, and stores it in the request context. +// Downstream handlers retrieve it with locale.FromContext. +func Language(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + lang := locale.FromRequest(r) + ctx := locale.WithLang(r.Context(), lang) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} diff --git a/backend/internal/recipe/model.go b/backend/internal/recipe/model.go index aeb2e80..ec5f55a 100644 --- a/backend/internal/recipe/model.go +++ b/backend/internal/recipe/model.go @@ -6,22 +6,23 @@ import ( ) // Recipe is a recipe record in the database. +// Title, Description, Ingredients, and Steps hold the content for the language +// resolved at query time (English by default, or from recipe_translations when +// a matching row exists for the requested language). type Recipe struct { - ID string `json:"id"` - Source string `json:"source"` // spoonacular | ai | user - SpoonacularID *int `json:"spoonacular_id"` + ID string `json:"id"` + Source string `json:"source"` // spoonacular | ai | user + SpoonacularID *int `json:"spoonacular_id"` - Title string `json:"title"` - TitleRu *string `json:"title_ru"` - Description *string `json:"description"` - DescriptionRu *string `json:"description_ru"` + Title string `json:"title"` + Description *string `json:"description"` - Cuisine *string `json:"cuisine"` - Difficulty *string `json:"difficulty"` // easy | medium | hard - PrepTimeMin *int `json:"prep_time_min"` - CookTimeMin *int `json:"cook_time_min"` - Servings *int `json:"servings"` - ImageURL *string `json:"image_url"` + Cuisine *string `json:"cuisine"` + Difficulty *string `json:"difficulty"` // easy | medium | hard + PrepTimeMin *int `json:"prep_time_min"` + CookTimeMin *int `json:"cook_time_min"` + Servings *int `json:"servings"` + ImageURL *string `json:"image_url"` CaloriesPerServing *float64 `json:"calories_per_serving"` ProteinPerServing *float64 `json:"protein_per_serving"` @@ -45,18 +46,15 @@ type RecipeIngredient struct { SpoonacularID *int `json:"spoonacular_id"` MappingID *string `json:"mapping_id"` Name string `json:"name"` - NameRu *string `json:"name_ru"` Amount float64 `json:"amount"` Unit string `json:"unit"` - UnitRu *string `json:"unit_ru"` Optional bool `json:"optional"` } // RecipeStep is a single step in a recipe's JSONB array. type RecipeStep struct { - Number int `json:"number"` - Description string `json:"description"` - DescriptionRu *string `json:"description_ru"` - TimerSeconds *int `json:"timer_seconds"` - ImageURL *string `json:"image_url"` + Number int `json:"number"` + Description string `json:"description"` + TimerSeconds *int `json:"timer_seconds"` + ImageURL *string `json:"image_url"` } diff --git a/backend/internal/recipe/repository.go b/backend/internal/recipe/repository.go index a9e687e..4dd662c 100644 --- a/backend/internal/recipe/repository.go +++ b/backend/internal/recipe/repository.go @@ -6,11 +6,12 @@ import ( "errors" "fmt" + "github.com/food-ai/backend/internal/locale" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) -// Repository handles persistence for recipes. +// Repository handles persistence for recipes and their translations. type Repository struct { pool *pgxpool.Pool } @@ -20,17 +21,17 @@ func NewRepository(pool *pgxpool.Pool) *Repository { return &Repository{pool: pool} } -// Upsert inserts or updates a recipe. +// Upsert inserts or updates a recipe (English canonical content only). // Conflict is resolved on spoonacular_id. func (r *Repository) Upsert(ctx context.Context, recipe *Recipe) (*Recipe, error) { query := ` INSERT INTO recipes ( source, spoonacular_id, - title, description, title_ru, description_ru, + title, description, cuisine, difficulty, prep_time_min, cook_time_min, servings, image_url, calories_per_serving, protein_per_serving, fat_per_serving, carbs_per_serving, fiber_per_serving, ingredients, steps, tags - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20) + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18) ON CONFLICT (spoonacular_id) DO UPDATE SET title = EXCLUDED.title, description = EXCLUDED.description, @@ -50,7 +51,7 @@ func (r *Repository) Upsert(ctx context.Context, recipe *Recipe) (*Recipe, error tags = EXCLUDED.tags, updated_at = now() RETURNING id, source, spoonacular_id, - title, description, title_ru, description_ru, + title, description, cuisine, difficulty, prep_time_min, cook_time_min, servings, image_url, calories_per_serving, protein_per_serving, fat_per_serving, carbs_per_serving, fiber_per_serving, ingredients, steps, tags, @@ -58,7 +59,7 @@ func (r *Repository) Upsert(ctx context.Context, recipe *Recipe) (*Recipe, error row := r.pool.QueryRow(ctx, query, recipe.Source, recipe.SpoonacularID, - recipe.Title, recipe.Description, recipe.TitleRu, recipe.DescriptionRu, + recipe.Title, recipe.Description, recipe.Cuisine, recipe.Difficulty, recipe.PrepTimeMin, recipe.CookTimeMin, recipe.Servings, recipe.ImageURL, recipe.CaloriesPerServing, recipe.ProteinPerServing, recipe.FatPerServing, recipe.CarbsPerServing, recipe.FiberPerServing, recipe.Ingredients, recipe.Steps, recipe.Tags, @@ -66,6 +67,33 @@ func (r *Repository) Upsert(ctx context.Context, recipe *Recipe) (*Recipe, error return scanRecipe(row) } +// GetByID returns a recipe by UUID, with content resolved for the language +// stored in ctx (falls back to English when no translation exists). +// Returns nil, nil if not found. +func (r *Repository) GetByID(ctx context.Context, id string) (*Recipe, error) { + lang := locale.FromContext(ctx) + query := ` + SELECT r.id, r.source, r.spoonacular_id, + COALESCE(rt.title, r.title) AS title, + COALESCE(rt.description, r.description) AS description, + r.cuisine, r.difficulty, r.prep_time_min, r.cook_time_min, r.servings, r.image_url, + r.calories_per_serving, r.protein_per_serving, r.fat_per_serving, r.carbs_per_serving, r.fiber_per_serving, + COALESCE(rt.ingredients, r.ingredients) AS ingredients, + COALESCE(rt.steps, r.steps) AS steps, + r.tags, + r.avg_rating, r.review_count, r.created_by, r.created_at, r.updated_at + FROM recipes r + LEFT JOIN recipe_translations rt ON rt.recipe_id = r.id AND rt.lang = $2 + WHERE r.id = $1` + + row := r.pool.QueryRow(ctx, query, id, lang) + rec, err := scanRecipe(row) + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + return rec, err +} + // Count returns the total number of recipes. func (r *Repository) Count(ctx context.Context) (int, error) { var n int @@ -75,40 +103,51 @@ func (r *Repository) Count(ctx context.Context) (int, error) { return n, nil } -// ListUntranslated returns recipes without a Russian title, ordered by review_count DESC. -func (r *Repository) ListUntranslated(ctx context.Context, limit, offset int) ([]*Recipe, error) { +// ListMissingTranslation returns Spoonacular recipes that have no translation +// for the given language, ordered by review_count DESC. +func (r *Repository) ListMissingTranslation(ctx context.Context, lang string, limit, offset int) ([]*Recipe, error) { query := ` SELECT id, source, spoonacular_id, - title, description, title_ru, description_ru, + title, description, cuisine, difficulty, prep_time_min, cook_time_min, servings, image_url, calories_per_serving, protein_per_serving, fat_per_serving, carbs_per_serving, fiber_per_serving, ingredients, steps, tags, avg_rating, review_count, created_by, created_at, updated_at FROM recipes - WHERE title_ru IS NULL AND source = 'spoonacular' + WHERE source = 'spoonacular' + AND NOT EXISTS ( + SELECT 1 FROM recipe_translations rt + WHERE rt.recipe_id = recipes.id AND rt.lang = $3 + ) ORDER BY review_count DESC LIMIT $1 OFFSET $2` - rows, err := r.pool.Query(ctx, query, limit, offset) + rows, err := r.pool.Query(ctx, query, limit, offset, lang) if err != nil { - return nil, fmt.Errorf("list untranslated recipes: %w", err) + return nil, fmt.Errorf("list missing translation (%s): %w", lang, err) } defer rows.Close() return collectRecipes(rows) } -// UpdateTranslation saves the Russian title, description, and step translations. -func (r *Repository) UpdateTranslation(ctx context.Context, id string, titleRu, descriptionRu *string, steps json.RawMessage) error { +// UpsertTranslation inserts or replaces a recipe translation for a specific language. +func (r *Repository) UpsertTranslation( + ctx context.Context, + id, lang string, + title, description *string, + ingredients, steps json.RawMessage, +) error { query := ` - UPDATE recipes SET - title_ru = $2, - description_ru = $3, - steps = $4, - updated_at = now() - WHERE id = $1` + INSERT INTO recipe_translations (recipe_id, lang, title, description, ingredients, steps) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (recipe_id, lang) DO UPDATE SET + title = EXCLUDED.title, + description = EXCLUDED.description, + ingredients = EXCLUDED.ingredients, + steps = EXCLUDED.steps` - if _, err := r.pool.Exec(ctx, query, id, titleRu, descriptionRu, steps); err != nil { - return fmt.Errorf("update recipe translation %s: %w", id, err) + if _, err := r.pool.Exec(ctx, query, id, lang, title, description, ingredients, steps); err != nil { + return fmt.Errorf("upsert recipe translation %s/%s: %w", id, lang, err) } return nil } @@ -121,7 +160,7 @@ func scanRecipe(row pgx.Row) (*Recipe, error) { err := row.Scan( &rec.ID, &rec.Source, &rec.SpoonacularID, - &rec.Title, &rec.Description, &rec.TitleRu, &rec.DescriptionRu, + &rec.Title, &rec.Description, &rec.Cuisine, &rec.Difficulty, &rec.PrepTimeMin, &rec.CookTimeMin, &rec.Servings, &rec.ImageURL, &rec.CaloriesPerServing, &rec.ProteinPerServing, &rec.FatPerServing, &rec.CarbsPerServing, &rec.FiberPerServing, &ingredients, &steps, &tags, @@ -143,7 +182,7 @@ func collectRecipes(rows pgx.Rows) ([]*Recipe, error) { var ingredients, steps, tags []byte if err := rows.Scan( &rec.ID, &rec.Source, &rec.SpoonacularID, - &rec.Title, &rec.Description, &rec.TitleRu, &rec.DescriptionRu, + &rec.Title, &rec.Description, &rec.Cuisine, &rec.Difficulty, &rec.PrepTimeMin, &rec.CookTimeMin, &rec.Servings, &rec.ImageURL, &rec.CaloriesPerServing, &rec.ProteinPerServing, &rec.FatPerServing, &rec.CarbsPerServing, &rec.FiberPerServing, &ingredients, &steps, &tags, @@ -158,24 +197,3 @@ func collectRecipes(rows pgx.Rows) ([]*Recipe, error) { } return result, rows.Err() } - -// GetByID returns a recipe by UUID. -// Returns nil, nil if not found. -func (r *Repository) GetByID(ctx context.Context, id string) (*Recipe, error) { - query := ` - SELECT id, source, spoonacular_id, - title, description, title_ru, description_ru, - cuisine, difficulty, prep_time_min, cook_time_min, servings, image_url, - calories_per_serving, protein_per_serving, fat_per_serving, carbs_per_serving, fiber_per_serving, - ingredients, steps, tags, - avg_rating, review_count, created_by, created_at, updated_at - FROM recipes - WHERE id = $1` - - row := r.pool.QueryRow(ctx, query, id) - rec, err := scanRecipe(row) - if errors.Is(err, pgx.ErrNoRows) { - return nil, nil - } - return rec, err -} diff --git a/backend/internal/recipe/repository_integration_test.go b/backend/internal/recipe/repository_integration_test.go index 3e4b4c7..3cbc8fa 100644 --- a/backend/internal/recipe/repository_integration_test.go +++ b/backend/internal/recipe/repository_integration_test.go @@ -7,6 +7,7 @@ import ( "encoding/json" "testing" + "github.com/food-ai/backend/internal/locale" "github.com/food-ai/backend/internal/testutil" ) @@ -143,7 +144,7 @@ func TestRecipeRepository_GetByID_NotFound(t *testing.T) { } } -func TestRecipeRepository_ListUntranslated_Pagination(t *testing.T) { +func TestRecipeRepository_ListMissingTranslation_Pagination(t *testing.T) { pool := testutil.SetupTestDB(t) repo := NewRepository(pool) ctx := context.Background() @@ -165,16 +166,16 @@ func TestRecipeRepository_ListUntranslated_Pagination(t *testing.T) { } } - untranslated, err := repo.ListUntranslated(ctx, 3, 0) + missing, err := repo.ListMissingTranslation(ctx, "ru", 3, 0) if err != nil { - t.Fatalf("list untranslated: %v", err) + t.Fatalf("list missing translation: %v", err) } - if len(untranslated) != 3 { - t.Errorf("expected 3 results with limit=3, got %d", len(untranslated)) + if len(missing) != 3 { + t.Errorf("expected 3 results with limit=3, got %d", len(missing)) } } -func TestRecipeRepository_UpdateTranslation(t *testing.T) { +func TestRecipeRepository_UpsertTranslation(t *testing.T) { pool := testutil.SetupTestDB(t) repo := NewRepository(pool) ctx := context.Background() @@ -196,40 +197,52 @@ func TestRecipeRepository_UpdateTranslation(t *testing.T) { titleRu := "Курица Тикка Масала" descRu := "Классическое индийское блюдо" - stepsRu := json.RawMessage(`[{"number":1,"description":"Heat oil","description_ru":"Разогрейте масло"}]`) + stepsRu := json.RawMessage(`[{"number":1,"description":"Разогрейте масло"}]`) - if err := repo.UpdateTranslation(ctx, saved.ID, &titleRu, &descRu, stepsRu); err != nil { - t.Fatalf("update translation: %v", err) + if err := repo.UpsertTranslation(ctx, saved.ID, "ru", &titleRu, &descRu, nil, stepsRu); err != nil { + t.Fatalf("upsert translation: %v", err) } - got, err := repo.GetByID(ctx, saved.ID) + // Retrieve with Russian context — title and steps should be translated. + ruCtx := locale.WithLang(ctx, "ru") + got, err := repo.GetByID(ruCtx, saved.ID) if err != nil { t.Fatalf("get by id: %v", err) } - if got.TitleRu == nil || *got.TitleRu != titleRu { - t.Errorf("expected title_ru=%q, got %v", titleRu, got.TitleRu) + if got.Title != titleRu { + t.Errorf("expected title=%q, got %q", titleRu, got.Title) } - if got.DescriptionRu == nil || *got.DescriptionRu != descRu { - t.Errorf("expected description_ru=%q, got %v", descRu, got.DescriptionRu) + if got.Description == nil || *got.Description != descRu { + t.Errorf("expected description=%q, got %v", descRu, got.Description) } var steps []RecipeStep if err := json.Unmarshal(got.Steps, &steps); err != nil { t.Fatalf("unmarshal steps: %v", err) } - if len(steps) == 0 || steps[0].DescriptionRu == nil || *steps[0].DescriptionRu != "Разогрейте масло" { - t.Errorf("expected description_ru in steps, got %v", steps) + if len(steps) == 0 || steps[0].Description != "Разогрейте масло" { + t.Errorf("expected Russian step description, got %v", steps) + } + + // Retrieve with English context — should return original English content. + enCtx := locale.WithLang(ctx, "en") + gotEn, err := repo.GetByID(enCtx, saved.ID) + if err != nil { + t.Fatalf("get by id (en): %v", err) + } + if gotEn.Title != "Chicken Tikka Masala" { + t.Errorf("expected English title, got %q", gotEn.Title) } } -func TestRecipeRepository_ListUntranslated_ExcludesTranslated(t *testing.T) { +func TestRecipeRepository_ListMissingTranslation_ExcludesTranslated(t *testing.T) { pool := testutil.SetupTestDB(t) repo := NewRepository(pool) ctx := context.Background() diff := "easy" - // Insert untranslated + // Insert untranslated recipes. for i := 0; i < 3; i++ { spID := 60000 + i _, err := repo.Upsert(ctx, &Recipe{ @@ -246,7 +259,7 @@ func TestRecipeRepository_ListUntranslated_ExcludesTranslated(t *testing.T) { } } - // Insert translated + // Insert one recipe and add a Russian translation. spID := 60100 translated, err := repo.Upsert(ctx, &Recipe{ Source: "spoonacular", @@ -261,21 +274,21 @@ func TestRecipeRepository_ListUntranslated_ExcludesTranslated(t *testing.T) { t.Fatalf("upsert translated: %v", err) } titleRu := "Переведённый рецепт" - if err := repo.UpdateTranslation(ctx, translated.ID, &titleRu, nil, translated.Steps); err != nil { - t.Fatalf("update translation: %v", err) + if err := repo.UpsertTranslation(ctx, translated.ID, "ru", &titleRu, nil, nil, nil); err != nil { + t.Fatalf("upsert translation: %v", err) } - untranslated, err := repo.ListUntranslated(ctx, 10, 0) + missing, err := repo.ListMissingTranslation(ctx, "ru", 10, 0) if err != nil { - t.Fatalf("list untranslated: %v", err) + t.Fatalf("list missing translation: %v", err) } - for _, r := range untranslated { + for _, r := range missing { if r.Title == "Translated Recipe" { - t.Error("translated recipe should not appear in ListUntranslated") + t.Error("translated recipe should not appear in ListMissingTranslation") } } - if len(untranslated) < 3 { - t.Errorf("expected at least 3 untranslated, got %d", len(untranslated)) + if len(missing) < 3 { + t.Errorf("expected at least 3 missing, got %d", len(missing)) } } diff --git a/backend/internal/recognition/handler.go b/backend/internal/recognition/handler.go index 0349f02..02a410f 100644 --- a/backend/internal/recognition/handler.go +++ b/backend/internal/recognition/handler.go @@ -17,6 +17,7 @@ import ( type ingredientRepo interface { FuzzyMatch(ctx context.Context, name string) (*ingredient.IngredientMapping, error) Upsert(ctx context.Context, m *ingredient.IngredientMapping) (*ingredient.IngredientMapping, error) + UpsertTranslation(ctx context.Context, id, lang, name string, aliases json.RawMessage) error } // Handler handles POST /ai/* recognition endpoints. @@ -218,7 +219,6 @@ func (h *Handler) saveClassification(ctx context.Context, c *gemini.IngredientCl m := &ingredient.IngredientMapping{ CanonicalName: c.CanonicalName, - CanonicalNameRu: &c.CanonicalNameRu, Category: strPtr(c.Category), DefaultUnit: strPtr(c.DefaultUnit), CaloriesPer100g: c.CaloriesPer100g, @@ -234,6 +234,13 @@ func (h *Handler) saveClassification(ctx context.Context, c *gemini.IngredientCl slog.Warn("upsert classified ingredient", "name", c.CanonicalName, "err", err) return nil } + + // Persist the Russian translation when Gemini provided one. + if c.CanonicalNameRu != "" { + if err := h.ingredientRepo.UpsertTranslation(ctx, saved.ID, "ru", c.CanonicalNameRu, json.RawMessage("[]")); err != nil { + slog.Warn("upsert ingredient translation", "id", saved.ID, "err", err) + } + } return saved } diff --git a/backend/internal/recommendation/handler.go b/backend/internal/recommendation/handler.go index 1eb2cde..816307d 100644 --- a/backend/internal/recommendation/handler.go +++ b/backend/internal/recommendation/handler.go @@ -9,6 +9,7 @@ import ( "sync" "github.com/food-ai/backend/internal/gemini" + "github.com/food-ai/backend/internal/locale" "github.com/food-ai/backend/internal/middleware" "github.com/food-ai/backend/internal/user" ) @@ -74,7 +75,7 @@ func (h *Handler) GetRecommendations(w http.ResponseWriter, r *http.Request) { return } - req := buildRecipeRequest(u, count) + req := buildRecipeRequest(u, count, locale.FromContext(r.Context())) // Attach available products to personalise the prompt. if products, err := h.productLister.ListForPrompt(r.Context(), userID); err == nil { @@ -108,10 +109,11 @@ func (h *Handler) GetRecommendations(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, recipes) } -func buildRecipeRequest(u *user.User, count int) gemini.RecipeRequest { +func buildRecipeRequest(u *user.User, count int, lang string) gemini.RecipeRequest { req := gemini.RecipeRequest{ Count: count, DailyCalories: 2000, // sensible default + Lang: lang, } if u.Goal != nil { diff --git a/backend/internal/savedrecipe/repository.go b/backend/internal/savedrecipe/repository.go index 4a86d67..ef704db 100644 --- a/backend/internal/savedrecipe/repository.go +++ b/backend/internal/savedrecipe/repository.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" + "github.com/food-ai/backend/internal/locale" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) @@ -13,7 +14,7 @@ import ( // ErrNotFound is returned when a saved recipe does not exist for the given user. var ErrNotFound = errors.New("saved recipe not found") -// Repository handles persistence for saved recipes. +// Repository handles persistence for saved recipes and their translations. type Repository struct { pool *pgxpool.Pool } @@ -24,6 +25,7 @@ func NewRepository(pool *pgxpool.Pool) *Repository { } // Save persists a recipe for userID and returns the stored record. +// The canonical content (any language) is stored directly in saved_recipes. func (r *Repository) Save(ctx context.Context, userID string, req SaveRequest) (*SavedRecipe, error) { const query = ` INSERT INTO saved_recipes ( @@ -61,16 +63,27 @@ func (r *Repository) Save(ctx context.Context, userID string, req SaveRequest) ( } // List returns all saved recipes for userID ordered by saved_at DESC. +// Text content (title, description, ingredients, steps) is resolved for the +// language stored in ctx, falling back to the canonical content when no +// translation exists. func (r *Repository) List(ctx context.Context, userID string) ([]*SavedRecipe, error) { + lang := locale.FromContext(ctx) const query = ` - SELECT id, user_id, title, description, cuisine, difficulty, - prep_time_min, cook_time_min, servings, image_url, - ingredients, steps, tags, nutrition, source, saved_at - FROM saved_recipes - WHERE user_id = $1 - ORDER BY saved_at DESC` + SELECT sr.id, sr.user_id, + COALESCE(srt.title, sr.title) AS title, + COALESCE(srt.description, sr.description) AS description, + sr.cuisine, sr.difficulty, + sr.prep_time_min, sr.cook_time_min, sr.servings, sr.image_url, + COALESCE(srt.ingredients, sr.ingredients) AS ingredients, + COALESCE(srt.steps, sr.steps) AS steps, + sr.tags, sr.nutrition, sr.source, sr.saved_at + FROM saved_recipes sr + LEFT JOIN saved_recipe_translations srt + ON srt.saved_recipe_id = sr.id AND srt.lang = $2 + WHERE sr.user_id = $1 + ORDER BY sr.saved_at DESC` - rows, err := r.pool.Query(ctx, query, userID) + rows, err := r.pool.Query(ctx, query, userID, lang) if err != nil { return nil, fmt.Errorf("list saved recipes: %w", err) } @@ -78,7 +91,7 @@ func (r *Repository) List(ctx context.Context, userID string) ([]*SavedRecipe, e var result []*SavedRecipe for rows.Next() { - rec, err := scanRows(rows) + rec, err := scanRow(rows) if err != nil { return nil, fmt.Errorf("scan saved recipe: %w", err) } @@ -88,15 +101,24 @@ func (r *Repository) List(ctx context.Context, userID string) ([]*SavedRecipe, e } // GetByID returns the saved recipe with id for userID, or nil if not found. +// Text content is resolved for the language stored in ctx. func (r *Repository) GetByID(ctx context.Context, userID, id string) (*SavedRecipe, error) { + lang := locale.FromContext(ctx) const query = ` - SELECT id, user_id, title, description, cuisine, difficulty, - prep_time_min, cook_time_min, servings, image_url, - ingredients, steps, tags, nutrition, source, saved_at - FROM saved_recipes - WHERE id = $1 AND user_id = $2` + SELECT sr.id, sr.user_id, + COALESCE(srt.title, sr.title) AS title, + COALESCE(srt.description, sr.description) AS description, + sr.cuisine, sr.difficulty, + sr.prep_time_min, sr.cook_time_min, sr.servings, sr.image_url, + COALESCE(srt.ingredients, sr.ingredients) AS ingredients, + COALESCE(srt.steps, sr.steps) AS steps, + sr.tags, sr.nutrition, sr.source, sr.saved_at + FROM saved_recipes sr + LEFT JOIN saved_recipe_translations srt + ON srt.saved_recipe_id = sr.id AND srt.lang = $3 + WHERE sr.id = $1 AND sr.user_id = $2` - rec, err := scanRow(r.pool.QueryRow(ctx, query, id, userID)) + rec, err := scanRow(r.pool.QueryRow(ctx, query, id, userID, lang)) if errors.Is(err, pgx.ErrNoRows) { return nil, nil } @@ -119,6 +141,29 @@ func (r *Repository) Delete(ctx context.Context, userID, id string) error { return nil } +// UpsertTranslation inserts or replaces a translation for a saved recipe. +func (r *Repository) UpsertTranslation( + ctx context.Context, + id, lang string, + title, description *string, + ingredients, steps json.RawMessage, +) error { + const query = ` + INSERT INTO saved_recipe_translations (saved_recipe_id, lang, title, description, ingredients, steps) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (saved_recipe_id, lang) DO UPDATE SET + title = EXCLUDED.title, + description = EXCLUDED.description, + ingredients = EXCLUDED.ingredients, + steps = EXCLUDED.steps, + generated_at = now()` + + if _, err := r.pool.Exec(ctx, query, id, lang, title, description, ingredients, steps); err != nil { + return fmt.Errorf("upsert saved recipe translation %s/%s: %w", id, lang, err) + } + return nil +} + // --- helpers --- type scannable interface { @@ -146,11 +191,6 @@ func scanRow(s scannable) (*SavedRecipe, error) { return &rec, nil } -// scanRows wraps pgx.Rows to satisfy the scannable interface. -func scanRows(rows pgx.Rows) (*SavedRecipe, error) { - return scanRow(rows) -} - func nullableStr(s string) *string { if s == "" { return nil diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go index e7a2720..eb0f443 100644 --- a/backend/internal/server/server.go +++ b/backend/internal/server/server.go @@ -41,6 +41,7 @@ func NewRouter( r.Use(middleware.Logging) r.Use(middleware.Recovery) r.Use(middleware.CORS(allowedOrigins)) + r.Use(middleware.Language) // Public r.Get("/health", healthCheck(pool)) diff --git a/backend/migrations/009_create_translation_tables.sql b/backend/migrations/009_create_translation_tables.sql new file mode 100644 index 0000000..e9a6893 --- /dev/null +++ b/backend/migrations/009_create_translation_tables.sql @@ -0,0 +1,118 @@ +-- +goose Up + +-- --------------------------------------------------------------------------- +-- recipe_translations +-- Stores per-language overrides for the catalog recipe fields that contain +-- human-readable text (title, description, ingredients list, step descriptions). +-- The base `recipes` row always holds the English (canonical) content. +-- --------------------------------------------------------------------------- +CREATE TABLE recipe_translations ( + recipe_id UUID NOT NULL REFERENCES recipes(id) ON DELETE CASCADE, + lang VARCHAR(10) NOT NULL, + title VARCHAR(500), + description TEXT, + ingredients JSONB, + steps JSONB, + PRIMARY KEY (recipe_id, lang) +); + +CREATE INDEX idx_recipe_translations_recipe_id ON recipe_translations (recipe_id); + +-- --------------------------------------------------------------------------- +-- saved_recipe_translations +-- Stores per-language translations for user-saved (AI-generated) recipes. +-- The base `saved_recipes` row always holds the English canonical content. +-- Translations are generated on demand by the AI layer and recorded here. +-- --------------------------------------------------------------------------- +CREATE TABLE saved_recipe_translations ( + saved_recipe_id UUID NOT NULL REFERENCES saved_recipes(id) ON DELETE CASCADE, + lang VARCHAR(10) NOT NULL, + title VARCHAR(500), + description TEXT, + ingredients JSONB, + steps JSONB, + generated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (saved_recipe_id, lang) +); + +CREATE INDEX idx_saved_recipe_translations_recipe_id ON saved_recipe_translations (saved_recipe_id); + +-- --------------------------------------------------------------------------- +-- ingredient_translations +-- Stores per-language names (and optional aliases) for ingredient mappings. +-- The base `ingredient_mappings` row holds the English canonical name. +-- --------------------------------------------------------------------------- +CREATE TABLE ingredient_translations ( + ingredient_id UUID NOT NULL REFERENCES ingredient_mappings(id) ON DELETE CASCADE, + lang VARCHAR(10) NOT NULL, + name VARCHAR(255) NOT NULL, + aliases JSONB NOT NULL DEFAULT '[]'::jsonb, + PRIMARY KEY (ingredient_id, lang) +); + +CREATE INDEX idx_ingredient_translations_ingredient_id ON ingredient_translations (ingredient_id); + +-- --------------------------------------------------------------------------- +-- Migrate existing Russian data from _ru columns into the translation tables. +-- --------------------------------------------------------------------------- + +-- Recipe translations: title_ru / description_ru at the row level, plus the +-- embedded name_ru / unit_ru fields inside the ingredients JSONB array, and +-- description_ru inside the steps JSONB array. +INSERT INTO recipe_translations (recipe_id, lang, title, description, ingredients, steps) +SELECT + id, + 'ru', + title_ru, + description_ru, + -- Rebuild ingredients array with Russian name/unit substituted in. + CASE + WHEN jsonb_array_length(ingredients) > 0 THEN ( + SELECT COALESCE( + jsonb_agg( + jsonb_build_object( + 'spoonacular_id', elem->>'spoonacular_id', + 'mapping_id', elem->>'mapping_id', + 'name', COALESCE(NULLIF(elem->>'name_ru', ''), elem->>'name'), + 'amount', (elem->>'amount')::numeric, + 'unit', COALESCE(NULLIF(elem->>'unit_ru', ''), elem->>'unit'), + 'optional', (elem->>'optional')::boolean + ) + ), + '[]'::jsonb + ) + FROM jsonb_array_elements(ingredients) AS elem + ) + ELSE NULL + END, + -- Rebuild steps array with Russian description substituted in. + CASE + WHEN jsonb_array_length(steps) > 0 THEN ( + SELECT COALESCE( + jsonb_agg( + jsonb_build_object( + 'number', (elem->>'number')::int, + 'description', COALESCE(NULLIF(elem->>'description_ru', ''), elem->>'description'), + 'timer_seconds', elem->'timer_seconds', + 'image_url', elem->>'image_url' + ) + ), + '[]'::jsonb + ) + FROM jsonb_array_elements(steps) AS elem + ) + ELSE NULL + END +FROM recipes +WHERE title_ru IS NOT NULL; + +-- Ingredient translations: canonical_name_ru. +INSERT INTO ingredient_translations (ingredient_id, lang, name) +SELECT id, 'ru', canonical_name_ru +FROM ingredient_mappings +WHERE canonical_name_ru IS NOT NULL; + +-- +goose Down +DROP TABLE IF EXISTS ingredient_translations; +DROP TABLE IF EXISTS saved_recipe_translations; +DROP TABLE IF EXISTS recipe_translations; diff --git a/backend/migrations/010_drop_ru_columns.sql b/backend/migrations/010_drop_ru_columns.sql new file mode 100644 index 0000000..057f1ff --- /dev/null +++ b/backend/migrations/010_drop_ru_columns.sql @@ -0,0 +1,36 @@ +-- +goose Up + +-- Drop the full-text search index that references the soon-to-be-removed +-- title_ru column. +DROP INDEX IF EXISTS idx_recipes_title_fts; + +-- Remove legacy _ru columns from recipes now that the data lives in +-- recipe_translations (migration 009). +ALTER TABLE recipes + DROP COLUMN IF EXISTS title_ru, + DROP COLUMN IF EXISTS description_ru; + +-- Remove the legacy Russian name column from ingredient_mappings. +ALTER TABLE ingredient_mappings + DROP COLUMN IF EXISTS canonical_name_ru; + +-- Recreate the FTS index on the English title only. +-- Cross-language search is now handled at the application level by querying +-- the appropriate translation row. +CREATE INDEX idx_recipes_title_fts ON recipes + USING GIN (to_tsvector('simple', coalesce(title, ''))); + +-- +goose Down +DROP INDEX IF EXISTS idx_recipes_title_fts; + +ALTER TABLE recipes + ADD COLUMN title_ru VARCHAR(500), + ADD COLUMN description_ru TEXT; + +ALTER TABLE ingredient_mappings + ADD COLUMN canonical_name_ru VARCHAR(255); + +-- Restore the bilingual FTS index. +CREATE INDEX idx_recipes_title_fts ON recipes + USING GIN (to_tsvector('simple', + coalesce(title_ru, '') || ' ' || coalesce(title, '')));