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 <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,7 @@ type MenuRequest struct {
|
|||||||
Restrictions []string
|
Restrictions []string
|
||||||
CuisinePrefs []string
|
CuisinePrefs []string
|
||||||
AvailableProducts []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.
|
// 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,
|
CuisinePrefs: req.CuisinePrefs,
|
||||||
Count: 7,
|
Count: 7,
|
||||||
AvailableProducts: req.AvailableProducts,
|
AvailableProducts: req.AvailableProducts,
|
||||||
|
Lang: req.Lang,
|
||||||
})
|
})
|
||||||
results[idx] = mealResult{r, err}
|
results[idx] = mealResult{r, err}
|
||||||
}(i, slot.mealType, slot.fraction)
|
}(i, slot.mealType, slot.fraction)
|
||||||
|
|||||||
@@ -14,12 +14,13 @@ type RecipeGenerator interface {
|
|||||||
|
|
||||||
// RecipeRequest contains parameters for recipe generation.
|
// RecipeRequest contains parameters for recipe generation.
|
||||||
type RecipeRequest struct {
|
type RecipeRequest struct {
|
||||||
UserGoal string // "weight_loss" | "maintain" | "gain"
|
UserGoal string // "lose" | "maintain" | "gain"
|
||||||
DailyCalories int
|
DailyCalories int
|
||||||
Restrictions []string // e.g. ["gluten_free", "vegetarian"]
|
Restrictions []string // e.g. ["gluten_free", "vegetarian"]
|
||||||
CuisinePrefs []string // e.g. ["russian", "asian"]
|
CuisinePrefs []string // e.g. ["russian", "asian"]
|
||||||
Count int
|
Count int
|
||||||
AvailableProducts []string // human-readable list of products in user's pantry
|
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.
|
// Recipe is a recipe returned by Gemini.
|
||||||
@@ -62,35 +63,55 @@ type NutritionInfo struct {
|
|||||||
Approximate bool `json:"approximate"`
|
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.
|
// GenerateRecipes generates recipes using the Gemini AI.
|
||||||
// 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.
|
||||||
func (c *Client) GenerateRecipes(ctx context.Context, req RecipeRequest) ([]Recipe, error) {
|
func (c *Client) GenerateRecipes(ctx context.Context, req RecipeRequest) ([]Recipe, error) {
|
||||||
prompt := buildRecipePrompt(req)
|
prompt := buildRecipePrompt(req)
|
||||||
|
|
||||||
// OpenAI messages format.
|
|
||||||
messages := []map[string]string{
|
messages := []map[string]string{
|
||||||
{"role": "user", "content": prompt},
|
{"role": "user", "content": prompt},
|
||||||
}
|
}
|
||||||
|
|
||||||
var lastErr error
|
var lastErr error
|
||||||
for attempt := 0; attempt < maxRetries; attempt++ {
|
for attempt := range maxRetries {
|
||||||
if attempt > 0 {
|
if attempt > 0 {
|
||||||
messages = []map[string]string{
|
messages = []map[string]string{
|
||||||
{"role": "user", "content": prompt},
|
{"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)
|
text, err := c.generateContent(ctx, messages)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// API-level error (4xx/5xx): no point retrying immediately.
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
recipes, err := parseRecipesJSON(text)
|
recipes, err := parseRecipesJSON(text)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Malformed JSON from the model — retry with a clarifying message.
|
|
||||||
lastErr = fmt.Errorf("attempt %d parse JSON: %w", attempt+1, err)
|
lastErr = fmt.Errorf("attempt %d parse JSON: %w", attempt+1, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -105,22 +126,26 @@ func (c *Client) GenerateRecipes(ctx context.Context, req RecipeRequest) ([]Reci
|
|||||||
}
|
}
|
||||||
|
|
||||||
func buildRecipePrompt(req RecipeRequest) string {
|
func buildRecipePrompt(req RecipeRequest) string {
|
||||||
goalRu := map[string]string{
|
lang := req.Lang
|
||||||
"weight_loss": "похудение",
|
if lang == "" {
|
||||||
"maintain": "поддержание веса",
|
lang = "en"
|
||||||
"gain": "набор массы",
|
|
||||||
}
|
}
|
||||||
goal := goalRu[req.UserGoal]
|
langName, ok := langNames[lang]
|
||||||
if goal == "" {
|
if !ok {
|
||||||
goal = "поддержание веса"
|
langName = "English"
|
||||||
}
|
}
|
||||||
|
|
||||||
restrictions := "нет"
|
goal := goalNames[req.UserGoal]
|
||||||
|
if goal == "" {
|
||||||
|
goal = "weight maintenance"
|
||||||
|
}
|
||||||
|
|
||||||
|
restrictions := "none"
|
||||||
if len(req.Restrictions) > 0 {
|
if len(req.Restrictions) > 0 {
|
||||||
restrictions = strings.Join(req.Restrictions, ", ")
|
restrictions = strings.Join(req.Restrictions, ", ")
|
||||||
}
|
}
|
||||||
|
|
||||||
cuisines := "любые"
|
cuisines := "any"
|
||||||
if len(req.CuisinePrefs) > 0 {
|
if len(req.CuisinePrefs) > 0 {
|
||||||
cuisines = strings.Join(req.CuisinePrefs, ", ")
|
cuisines = strings.Join(req.CuisinePrefs, ", ")
|
||||||
}
|
}
|
||||||
@@ -137,48 +162,46 @@ func buildRecipePrompt(req RecipeRequest) string {
|
|||||||
|
|
||||||
productsSection := ""
|
productsSection := ""
|
||||||
if len(req.AvailableProducts) > 0 {
|
if len(req.AvailableProducts) > 0 {
|
||||||
productsSection = "\nДоступные продукты (приоритет — скоро истекают ⚠):\n" +
|
productsSection = "\nAvailable products (⚠ = expiring soon, prioritise these):\n" +
|
||||||
strings.Join(req.AvailableProducts, "\n") +
|
strings.Join(req.AvailableProducts, "\n") + "\n"
|
||||||
"\nПредпочтительно использовать эти продукты в рецептах.\n"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return fmt.Sprintf(`Ты — диетолог-повар. Предложи %d рецептов на русском языке.
|
return fmt.Sprintf(`You are a chef and nutritionist. Generate %d recipes in %s.
|
||||||
|
|
||||||
Профиль пользователя:
|
User profile:
|
||||||
- Цель: %s
|
- Goal: %s
|
||||||
- Дневная норма калорий: %d ккал
|
- Daily calories: %d kcal
|
||||||
- Ограничения: %s
|
- Dietary restrictions: %s
|
||||||
- Предпочтения: %s
|
- Cuisine preferences: %s
|
||||||
%s
|
%s
|
||||||
Требования к каждому рецепту:
|
Requirements for each recipe:
|
||||||
- Калорийность на порцию: не более %d ккал
|
- Max %d kcal per serving
|
||||||
- Время приготовления: до 60 минут
|
- 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": "Название",
|
"title": "...",
|
||||||
"description": "2-3 предложения",
|
"description": "2-3 sentences",
|
||||||
"cuisine": "russian|asian|european|mediterranean|american|other",
|
"cuisine": "russian|asian|european|mediterranean|american|other",
|
||||||
"difficulty": "easy|medium|hard",
|
"difficulty": "easy|medium|hard",
|
||||||
"prep_time_min": 10,
|
"prep_time_min": 10,
|
||||||
"cook_time_min": 20,
|
"cook_time_min": 20,
|
||||||
"servings": 2,
|
"servings": 2,
|
||||||
"image_query": "chicken breast vegetables healthy (ENGLISH ONLY, used for photo search)",
|
"image_query": "short English photo-search query",
|
||||||
"ingredients": [{"name": "Куриная грудка", "amount": 300, "unit": "г"}],
|
"ingredients": [{"name": "...", "amount": 300, "unit": "..."}],
|
||||||
"steps": [{"number": 1, "description": "...", "timer_seconds": null}],
|
"steps": [{"number": 1, "description": "...", "timer_seconds": null}],
|
||||||
"tags": ["высокий белок"],
|
"tags": ["..."],
|
||||||
"nutrition_per_serving": {
|
"nutrition_per_serving": {"calories": 420, "protein_g": 48, "fat_g": 12, "carbs_g": 18}
|
||||||
"calories": 420, "protein_g": 48, "fat_g": 12, "carbs_g": 18
|
}]`, count, langName, goal, req.DailyCalories, restrictions, cuisines, productsSection, perMealCalories, langName)
|
||||||
}
|
|
||||||
}]`, count, goal, req.DailyCalories, restrictions, cuisines, productsSection, perMealCalories)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseRecipesJSON(text string) ([]Recipe, error) {
|
func parseRecipesJSON(text string) ([]Recipe, error) {
|
||||||
text = strings.TrimSpace(text)
|
text = strings.TrimSpace(text)
|
||||||
// Strip potential markdown code fences
|
|
||||||
if strings.HasPrefix(text, "```") {
|
if strings.HasPrefix(text, "```") {
|
||||||
text = strings.TrimPrefix(text, "```json")
|
text = strings.TrimPrefix(text, "```json")
|
||||||
text = strings.TrimPrefix(text, "```")
|
text = strings.TrimPrefix(text, "```")
|
||||||
|
|||||||
@@ -7,14 +7,15 @@ import (
|
|||||||
|
|
||||||
// IngredientMapping is the canonical ingredient record used to link
|
// IngredientMapping is the canonical ingredient record used to link
|
||||||
// user products, recipe ingredients, and Spoonacular data.
|
// 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 {
|
type IngredientMapping struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
CanonicalName string `json:"canonical_name"`
|
CanonicalName string `json:"canonical_name"`
|
||||||
CanonicalNameRu *string `json:"canonical_name_ru"`
|
SpoonacularID *int `json:"spoonacular_id"`
|
||||||
SpoonacularID *int `json:"spoonacular_id"`
|
Aliases json.RawMessage `json:"aliases"` // []string
|
||||||
Aliases json.RawMessage `json:"aliases"` // []string
|
Category *string `json:"category"`
|
||||||
Category *string `json:"category"`
|
DefaultUnit *string `json:"default_unit"`
|
||||||
DefaultUnit *string `json:"default_unit"`
|
|
||||||
|
|
||||||
CaloriesPer100g *float64 `json:"calories_per_100g"`
|
CaloriesPer100g *float64 `json:"calories_per_100g"`
|
||||||
ProteinPer100g *float64 `json:"protein_per_100g"`
|
ProteinPer100g *float64 `json:"protein_per_100g"`
|
||||||
|
|||||||
@@ -6,11 +6,12 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/food-ai/backend/internal/locale"
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Repository handles persistence for ingredient_mappings.
|
// Repository handles persistence for ingredient_mappings and their translations.
|
||||||
type Repository struct {
|
type Repository struct {
|
||||||
pool *pgxpool.Pool
|
pool *pgxpool.Pool
|
||||||
}
|
}
|
||||||
@@ -20,16 +21,16 @@ func NewRepository(pool *pgxpool.Pool) *Repository {
|
|||||||
return &Repository{pool: pool}
|
return &Repository{pool: pool}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upsert inserts or updates an ingredient mapping.
|
// Upsert inserts or updates an ingredient mapping (English canonical content).
|
||||||
// Conflict is resolved on spoonacular_id when set; otherwise a simple insert is done.
|
// Conflict is resolved on spoonacular_id when set.
|
||||||
func (r *Repository) Upsert(ctx context.Context, m *IngredientMapping) (*IngredientMapping, error) {
|
func (r *Repository) Upsert(ctx context.Context, m *IngredientMapping) (*IngredientMapping, error) {
|
||||||
query := `
|
query := `
|
||||||
INSERT INTO ingredient_mappings (
|
INSERT INTO ingredient_mappings (
|
||||||
canonical_name, canonical_name_ru, spoonacular_id, aliases,
|
canonical_name, spoonacular_id, aliases,
|
||||||
category, default_unit,
|
category, default_unit,
|
||||||
calories_per_100g, protein_per_100g, fat_per_100g, carbs_per_100g, fiber_per_100g,
|
calories_per_100g, protein_per_100g, fat_per_100g, carbs_per_100g, fiber_per_100g,
|
||||||
storage_days
|
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
|
ON CONFLICT (spoonacular_id) DO UPDATE SET
|
||||||
canonical_name = EXCLUDED.canonical_name,
|
canonical_name = EXCLUDED.canonical_name,
|
||||||
aliases = EXCLUDED.aliases,
|
aliases = EXCLUDED.aliases,
|
||||||
@@ -42,13 +43,13 @@ func (r *Repository) Upsert(ctx context.Context, m *IngredientMapping) (*Ingredi
|
|||||||
fiber_per_100g = EXCLUDED.fiber_per_100g,
|
fiber_per_100g = EXCLUDED.fiber_per_100g,
|
||||||
storage_days = EXCLUDED.storage_days,
|
storage_days = EXCLUDED.storage_days,
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
RETURNING id, canonical_name, canonical_name_ru, spoonacular_id, aliases,
|
RETURNING id, canonical_name, spoonacular_id, aliases,
|
||||||
category, default_unit,
|
category, default_unit,
|
||||||
calories_per_100g, protein_per_100g, fat_per_100g, carbs_per_100g, fiber_per_100g,
|
calories_per_100g, protein_per_100g, fat_per_100g, carbs_per_100g, fiber_per_100g,
|
||||||
storage_days, created_at, updated_at`
|
storage_days, created_at, updated_at`
|
||||||
|
|
||||||
row := r.pool.QueryRow(ctx, query,
|
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.Category, m.DefaultUnit,
|
||||||
m.CaloriesPer100g, m.ProteinPer100g, m.FatPer100g, m.CarbsPer100g, m.FiberPer100g,
|
m.CaloriesPer100g, m.ProteinPer100g, m.FatPer100g, m.CarbsPer100g, m.FiberPer100g,
|
||||||
m.StorageDays,
|
m.StorageDays,
|
||||||
@@ -57,17 +58,22 @@ func (r *Repository) Upsert(ctx context.Context, m *IngredientMapping) (*Ingredi
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetBySpoonacularID returns an ingredient mapping by Spoonacular ID.
|
// GetBySpoonacularID returns an ingredient mapping by Spoonacular ID.
|
||||||
|
// CanonicalName is resolved for the language stored in ctx.
|
||||||
// Returns nil, nil if not found.
|
// Returns nil, nil if not found.
|
||||||
func (r *Repository) GetBySpoonacularID(ctx context.Context, id int) (*IngredientMapping, error) {
|
func (r *Repository) GetBySpoonacularID(ctx context.Context, id int) (*IngredientMapping, error) {
|
||||||
|
lang := locale.FromContext(ctx)
|
||||||
query := `
|
query := `
|
||||||
SELECT id, canonical_name, canonical_name_ru, spoonacular_id, aliases,
|
SELECT im.id,
|
||||||
category, default_unit,
|
COALESCE(it.name, im.canonical_name) AS canonical_name,
|
||||||
calories_per_100g, protein_per_100g, fat_per_100g, carbs_per_100g, fiber_per_100g,
|
im.spoonacular_id, im.aliases,
|
||||||
storage_days, created_at, updated_at
|
im.category, im.default_unit,
|
||||||
FROM ingredient_mappings
|
im.calories_per_100g, im.protein_per_100g, im.fat_per_100g, im.carbs_per_100g, im.fiber_per_100g,
|
||||||
WHERE spoonacular_id = $1`
|
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)
|
m, err := scanMapping(row)
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
@@ -76,17 +82,22 @@ func (r *Repository) GetBySpoonacularID(ctx context.Context, id int) (*Ingredien
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetByID returns an ingredient mapping by UUID.
|
// GetByID returns an ingredient mapping by UUID.
|
||||||
|
// CanonicalName is resolved for the language stored in ctx.
|
||||||
// Returns nil, nil if not found.
|
// Returns nil, nil if not found.
|
||||||
func (r *Repository) GetByID(ctx context.Context, id string) (*IngredientMapping, error) {
|
func (r *Repository) GetByID(ctx context.Context, id string) (*IngredientMapping, error) {
|
||||||
|
lang := locale.FromContext(ctx)
|
||||||
query := `
|
query := `
|
||||||
SELECT id, canonical_name, canonical_name_ru, spoonacular_id, aliases,
|
SELECT im.id,
|
||||||
category, default_unit,
|
COALESCE(it.name, im.canonical_name) AS canonical_name,
|
||||||
calories_per_100g, protein_per_100g, fat_per_100g, carbs_per_100g, fiber_per_100g,
|
im.spoonacular_id, im.aliases,
|
||||||
storage_days, created_at, updated_at
|
im.category, im.default_unit,
|
||||||
FROM ingredient_mappings
|
im.calories_per_100g, im.protein_per_100g, im.fat_per_100g, im.carbs_per_100g, im.fiber_per_100g,
|
||||||
WHERE id = $1`
|
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)
|
m, err := scanMapping(row)
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return nil, nil
|
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.
|
// 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.
|
// Returns nil, nil when no match is found.
|
||||||
func (r *Repository) FuzzyMatch(ctx context.Context, name string) (*IngredientMapping, error) {
|
func (r *Repository) FuzzyMatch(ctx context.Context, name string) (*IngredientMapping, error) {
|
||||||
results, err := r.Search(ctx, name, 1)
|
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.
|
// 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.
|
// 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) {
|
func (r *Repository) Search(ctx context.Context, query string, limit int) ([]*IngredientMapping, error) {
|
||||||
if limit <= 0 {
|
if limit <= 0 {
|
||||||
limit = 10
|
limit = 10
|
||||||
}
|
}
|
||||||
|
lang := locale.FromContext(ctx)
|
||||||
q := `
|
q := `
|
||||||
SELECT id, canonical_name, canonical_name_ru, spoonacular_id, aliases,
|
SELECT im.id,
|
||||||
category, default_unit,
|
COALESCE(it.name, im.canonical_name) AS canonical_name,
|
||||||
calories_per_100g, protein_per_100g, fat_per_100g, carbs_per_100g, fiber_per_100g,
|
im.spoonacular_id, im.aliases,
|
||||||
storage_days, created_at, updated_at
|
im.category, im.default_unit,
|
||||||
FROM ingredient_mappings
|
im.calories_per_100g, im.protein_per_100g, im.fat_per_100g, im.carbs_per_100g, im.fiber_per_100g,
|
||||||
WHERE aliases @> to_jsonb(lower($1)::text)
|
im.storage_days, im.created_at, im.updated_at
|
||||||
OR canonical_name_ru ILIKE '%' || $1 || '%'
|
FROM ingredient_mappings im
|
||||||
OR similarity(canonical_name_ru, $1) > 0.3
|
LEFT JOIN ingredient_translations it ON it.ingredient_id = im.id AND it.lang = $3
|
||||||
ORDER BY similarity(canonical_name_ru, $1) DESC
|
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`
|
LIMIT $2`
|
||||||
|
|
||||||
rows, err := r.pool.Query(ctx, q, query, limit)
|
rows, err := r.pool.Query(ctx, q, query, limit, lang)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("search ingredient_mappings: %w", err)
|
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
|
return n, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListUntranslated returns ingredients without a Russian name, ordered by id.
|
// ListMissingTranslation returns ingredients that have no translation for the
|
||||||
func (r *Repository) ListUntranslated(ctx context.Context, limit, offset int) ([]*IngredientMapping, error) {
|
// given language, ordered by id.
|
||||||
|
func (r *Repository) ListMissingTranslation(ctx context.Context, lang string, limit, offset int) ([]*IngredientMapping, error) {
|
||||||
query := `
|
query := `
|
||||||
SELECT id, canonical_name, canonical_name_ru, spoonacular_id, aliases,
|
SELECT im.id, im.canonical_name, im.spoonacular_id, im.aliases,
|
||||||
category, default_unit,
|
im.category, im.default_unit,
|
||||||
calories_per_100g, protein_per_100g, fat_per_100g, carbs_per_100g, fiber_per_100g,
|
im.calories_per_100g, im.protein_per_100g, im.fat_per_100g, im.carbs_per_100g, im.fiber_per_100g,
|
||||||
storage_days, created_at, updated_at
|
im.storage_days, im.created_at, im.updated_at
|
||||||
FROM ingredient_mappings
|
FROM ingredient_mappings im
|
||||||
WHERE canonical_name_ru IS NULL
|
WHERE NOT EXISTS (
|
||||||
ORDER BY id
|
SELECT 1 FROM ingredient_translations it
|
||||||
|
WHERE it.ingredient_id = im.id AND it.lang = $3
|
||||||
|
)
|
||||||
|
ORDER BY im.id
|
||||||
LIMIT $1 OFFSET $2`
|
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 {
|
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()
|
defer rows.Close()
|
||||||
return collectMappings(rows)
|
return collectMappings(rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateTranslation saves the Russian name and adds Russian aliases.
|
// UpsertTranslation inserts or replaces a translation for an ingredient mapping.
|
||||||
func (r *Repository) UpdateTranslation(ctx context.Context, id, canonicalNameRu string, aliasesRu []string) error {
|
func (r *Repository) UpsertTranslation(ctx context.Context, id, lang, name string, aliases json.RawMessage) error {
|
||||||
// Merge new aliases into existing JSONB array without duplicates
|
|
||||||
query := `
|
query := `
|
||||||
UPDATE ingredient_mappings SET
|
INSERT INTO ingredient_translations (ingredient_id, lang, name, aliases)
|
||||||
canonical_name_ru = $2,
|
VALUES ($1, $2, $3, $4)
|
||||||
aliases = (
|
ON CONFLICT (ingredient_id, lang) DO UPDATE SET
|
||||||
SELECT jsonb_agg(DISTINCT elem)
|
name = EXCLUDED.name,
|
||||||
FROM (
|
aliases = EXCLUDED.aliases`
|
||||||
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`
|
|
||||||
|
|
||||||
if _, err := r.pool.Exec(ctx, query, id, canonicalNameRu, aliasesRu); err != nil {
|
if _, err := r.pool.Exec(ctx, query, id, lang, name, aliases); err != nil {
|
||||||
return fmt.Errorf("update translation %s: %w", id, err)
|
return fmt.Errorf("upsert ingredient translation %s/%s: %w", id, lang, err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -192,7 +207,7 @@ func scanMapping(row pgx.Row) (*IngredientMapping, error) {
|
|||||||
var aliases []byte
|
var aliases []byte
|
||||||
|
|
||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
&m.ID, &m.CanonicalName, &m.CanonicalNameRu, &m.SpoonacularID, &aliases,
|
&m.ID, &m.CanonicalName, &m.SpoonacularID, &aliases,
|
||||||
&m.Category, &m.DefaultUnit,
|
&m.Category, &m.DefaultUnit,
|
||||||
&m.CaloriesPer100g, &m.ProteinPer100g, &m.FatPer100g, &m.CarbsPer100g, &m.FiberPer100g,
|
&m.CaloriesPer100g, &m.ProteinPer100g, &m.FatPer100g, &m.CarbsPer100g, &m.FiberPer100g,
|
||||||
&m.StorageDays, &m.CreatedAt, &m.UpdatedAt,
|
&m.StorageDays, &m.CreatedAt, &m.UpdatedAt,
|
||||||
@@ -210,7 +225,7 @@ func collectMappings(rows pgx.Rows) ([]*IngredientMapping, error) {
|
|||||||
var m IngredientMapping
|
var m IngredientMapping
|
||||||
var aliases []byte
|
var aliases []byte
|
||||||
if err := rows.Scan(
|
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.Category, &m.DefaultUnit,
|
||||||
&m.CaloriesPer100g, &m.ProteinPer100g, &m.FatPer100g, &m.CarbsPer100g, &m.FiberPer100g,
|
&m.CaloriesPer100g, &m.ProteinPer100g, &m.FatPer100g, &m.CarbsPer100g, &m.FiberPer100g,
|
||||||
&m.StorageDays, &m.CreatedAt, &m.UpdatedAt,
|
&m.StorageDays, &m.CreatedAt, &m.UpdatedAt,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/food-ai/backend/internal/locale"
|
||||||
"github.com/food-ai/backend/internal/testutil"
|
"github.com/food-ai/backend/internal/testutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -65,7 +66,6 @@ func TestIngredientRepository_Upsert_ConflictUpdates(t *testing.T) {
|
|||||||
t.Fatalf("first upsert: %v", err)
|
t.Fatalf("first upsert: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update with same spoonacular_id
|
|
||||||
cal := 89.0
|
cal := 89.0
|
||||||
second := &IngredientMapping{
|
second := &IngredientMapping{
|
||||||
CanonicalName: "banana_updated",
|
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)
|
pool := testutil.SetupTestDB(t)
|
||||||
repo := NewRepository(pool)
|
repo := NewRepository(pool)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
@@ -145,7 +145,7 @@ func TestIngredientRepository_ListUntranslated(t *testing.T) {
|
|||||||
cat := "produce"
|
cat := "produce"
|
||||||
unit := "g"
|
unit := "g"
|
||||||
|
|
||||||
// Insert 3 without translation
|
// Insert 3 without any translation.
|
||||||
for i, name := range []string{"carrot", "onion", "garlic"} {
|
for i, name := range []string{"carrot", "onion", "garlic"} {
|
||||||
id := 4000 + i
|
id := 4000 + i
|
||||||
_, err := repo.Upsert(ctx, &IngredientMapping{
|
_, 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
|
id := 4100
|
||||||
ruName := "помидор"
|
saved, err := repo.Upsert(ctx, &IngredientMapping{
|
||||||
withTranslation := &IngredientMapping{
|
CanonicalName: "tomato",
|
||||||
CanonicalName: "tomato",
|
SpoonacularID: &id,
|
||||||
CanonicalNameRu: &ruName,
|
Aliases: json.RawMessage(`[]`),
|
||||||
SpoonacularID: &id,
|
Category: &cat,
|
||||||
Aliases: json.RawMessage(`[]`),
|
DefaultUnit: &unit,
|
||||||
Category: &cat,
|
})
|
||||||
DefaultUnit: &unit,
|
|
||||||
}
|
|
||||||
saved, err := repo.Upsert(ctx, withTranslation)
|
|
||||||
if err != nil {
|
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
|
if err := repo.UpsertTranslation(ctx, saved.ID, "ru", "помидор", json.RawMessage(`["помидор","томат"]`)); err != nil {
|
||||||
// We need to manually set it after
|
t.Fatalf("upsert translation: %v", err)
|
||||||
if err := repo.UpdateTranslation(ctx, saved.ID, "помидор", []string{"помидор", "томат"}); err != nil {
|
|
||||||
t.Fatalf("update translation: %v", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
untranslated, err := repo.ListUntranslated(ctx, 10, 0)
|
missing, err := repo.ListMissingTranslation(ctx, "ru", 10, 0)
|
||||||
if err != nil {
|
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)
|
for _, m := range missing {
|
||||||
// The translated tomato should not appear
|
|
||||||
for _, m := range untranslated {
|
|
||||||
if m.CanonicalName == "tomato" {
|
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 {
|
if len(missing) < 3 {
|
||||||
t.Errorf("expected at least 3 untranslated, got %d", len(untranslated))
|
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)
|
pool := testutil.SetupTestDB(t)
|
||||||
repo := NewRepository(pool)
|
repo := NewRepository(pool)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
@@ -218,33 +211,29 @@ func TestIngredientRepository_UpdateTranslation(t *testing.T) {
|
|||||||
t.Fatalf("upsert: %v", err)
|
t.Fatalf("upsert: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = repo.UpdateTranslation(ctx, saved.ID, "куриная грудка",
|
err = repo.UpsertTranslation(ctx, saved.ID, "ru", "куриная грудка",
|
||||||
[]string{"куриная грудка", "куриное филе"})
|
json.RawMessage(`["куриная грудка","куриное филе"]`))
|
||||||
if err != nil {
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("get by id: %v", err)
|
t.Fatalf("get by id: %v", err)
|
||||||
}
|
}
|
||||||
if got.CanonicalNameRu == nil || *got.CanonicalNameRu != "куриная грудка" {
|
if got.CanonicalName != "куриная грудка" {
|
||||||
t.Errorf("expected canonical_name_ru='куриная грудка', got %v", got.CanonicalNameRu)
|
t.Errorf("expected CanonicalName='куриная грудка', got %q", got.CanonicalName)
|
||||||
}
|
}
|
||||||
|
|
||||||
var aliases []string
|
// Retrieve with English context (default) — CanonicalName should be the English name.
|
||||||
if err := json.Unmarshal(got.Aliases, &aliases); err != nil {
|
enCtx := locale.WithLang(ctx, "en")
|
||||||
t.Fatalf("unmarshal aliases: %v", err)
|
gotEn, err := repo.GetByID(enCtx, saved.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get by id (en): %v", err)
|
||||||
}
|
}
|
||||||
|
if gotEn.CanonicalName != "chicken_breast" {
|
||||||
hasRu := false
|
t.Errorf("expected English CanonicalName='chicken_breast', got %q", gotEn.CanonicalName)
|
||||||
for _, a := range aliases {
|
|
||||||
if a == "куриное филе" {
|
|
||||||
hasRu = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !hasRu {
|
|
||||||
t.Errorf("Russian alias not found in aliases: %v", aliases)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
69
backend/internal/locale/locale.go
Normal file
69
backend/internal/locale/locale.go
Normal file
@@ -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"))
|
||||||
|
}
|
||||||
94
backend/internal/locale/locale_test.go
Normal file
94
backend/internal/locale/locale_test.go
Normal file
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/food-ai/backend/internal/gemini"
|
"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/middleware"
|
||||||
"github.com/food-ai/backend/internal/savedrecipe"
|
"github.com/food-ai/backend/internal/savedrecipe"
|
||||||
"github.com/food-ai/backend/internal/user"
|
"github.com/food-ai/backend/internal/user"
|
||||||
@@ -128,7 +129,7 @@ func (h *Handler) GenerateMenu(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
menuReq := buildMenuRequest(u)
|
menuReq := buildMenuRequest(u, locale.FromContext(r.Context()))
|
||||||
|
|
||||||
// Attach pantry products.
|
// Attach pantry products.
|
||||||
if products, err := h.productLister.ListForPrompt(r.Context(), userID); err == nil {
|
if products, err := h.productLister.ListForPrompt(r.Context(), userID); err == nil {
|
||||||
@@ -460,8 +461,8 @@ type userPreferences struct {
|
|||||||
Restrictions []string `json:"restrictions"`
|
Restrictions []string `json:"restrictions"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildMenuRequest(u *user.User) gemini.MenuRequest {
|
func buildMenuRequest(u *user.User, lang string) gemini.MenuRequest {
|
||||||
req := gemini.MenuRequest{DailyCalories: 2000}
|
req := gemini.MenuRequest{DailyCalories: 2000, Lang: lang}
|
||||||
if u.Goal != nil {
|
if u.Goal != nil {
|
||||||
req.UserGoal = *u.Goal
|
req.UserGoal = *u.Goal
|
||||||
}
|
}
|
||||||
|
|||||||
18
backend/internal/middleware/language.go
Normal file
18
backend/internal/middleware/language.go
Normal file
@@ -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))
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -6,22 +6,23 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Recipe is a recipe record in the database.
|
// 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 {
|
type Recipe struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Source string `json:"source"` // spoonacular | ai | user
|
Source string `json:"source"` // spoonacular | ai | user
|
||||||
SpoonacularID *int `json:"spoonacular_id"`
|
SpoonacularID *int `json:"spoonacular_id"`
|
||||||
|
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
TitleRu *string `json:"title_ru"`
|
Description *string `json:"description"`
|
||||||
Description *string `json:"description"`
|
|
||||||
DescriptionRu *string `json:"description_ru"`
|
|
||||||
|
|
||||||
Cuisine *string `json:"cuisine"`
|
Cuisine *string `json:"cuisine"`
|
||||||
Difficulty *string `json:"difficulty"` // easy | medium | hard
|
Difficulty *string `json:"difficulty"` // easy | medium | hard
|
||||||
PrepTimeMin *int `json:"prep_time_min"`
|
PrepTimeMin *int `json:"prep_time_min"`
|
||||||
CookTimeMin *int `json:"cook_time_min"`
|
CookTimeMin *int `json:"cook_time_min"`
|
||||||
Servings *int `json:"servings"`
|
Servings *int `json:"servings"`
|
||||||
ImageURL *string `json:"image_url"`
|
ImageURL *string `json:"image_url"`
|
||||||
|
|
||||||
CaloriesPerServing *float64 `json:"calories_per_serving"`
|
CaloriesPerServing *float64 `json:"calories_per_serving"`
|
||||||
ProteinPerServing *float64 `json:"protein_per_serving"`
|
ProteinPerServing *float64 `json:"protein_per_serving"`
|
||||||
@@ -45,18 +46,15 @@ type RecipeIngredient struct {
|
|||||||
SpoonacularID *int `json:"spoonacular_id"`
|
SpoonacularID *int `json:"spoonacular_id"`
|
||||||
MappingID *string `json:"mapping_id"`
|
MappingID *string `json:"mapping_id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
NameRu *string `json:"name_ru"`
|
|
||||||
Amount float64 `json:"amount"`
|
Amount float64 `json:"amount"`
|
||||||
Unit string `json:"unit"`
|
Unit string `json:"unit"`
|
||||||
UnitRu *string `json:"unit_ru"`
|
|
||||||
Optional bool `json:"optional"`
|
Optional bool `json:"optional"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// RecipeStep is a single step in a recipe's JSONB array.
|
// RecipeStep is a single step in a recipe's JSONB array.
|
||||||
type RecipeStep struct {
|
type RecipeStep struct {
|
||||||
Number int `json:"number"`
|
Number int `json:"number"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
DescriptionRu *string `json:"description_ru"`
|
TimerSeconds *int `json:"timer_seconds"`
|
||||||
TimerSeconds *int `json:"timer_seconds"`
|
ImageURL *string `json:"image_url"`
|
||||||
ImageURL *string `json:"image_url"`
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,11 +6,12 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/food-ai/backend/internal/locale"
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Repository handles persistence for recipes.
|
// Repository handles persistence for recipes and their translations.
|
||||||
type Repository struct {
|
type Repository struct {
|
||||||
pool *pgxpool.Pool
|
pool *pgxpool.Pool
|
||||||
}
|
}
|
||||||
@@ -20,17 +21,17 @@ func NewRepository(pool *pgxpool.Pool) *Repository {
|
|||||||
return &Repository{pool: pool}
|
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.
|
// Conflict is resolved on spoonacular_id.
|
||||||
func (r *Repository) Upsert(ctx context.Context, recipe *Recipe) (*Recipe, error) {
|
func (r *Repository) Upsert(ctx context.Context, recipe *Recipe) (*Recipe, error) {
|
||||||
query := `
|
query := `
|
||||||
INSERT INTO recipes (
|
INSERT INTO recipes (
|
||||||
source, spoonacular_id,
|
source, spoonacular_id,
|
||||||
title, description, title_ru, description_ru,
|
title, description,
|
||||||
cuisine, difficulty, prep_time_min, cook_time_min, servings, image_url,
|
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,
|
calories_per_serving, protein_per_serving, fat_per_serving, carbs_per_serving, fiber_per_serving,
|
||||||
ingredients, steps, tags
|
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
|
ON CONFLICT (spoonacular_id) DO UPDATE SET
|
||||||
title = EXCLUDED.title,
|
title = EXCLUDED.title,
|
||||||
description = EXCLUDED.description,
|
description = EXCLUDED.description,
|
||||||
@@ -50,7 +51,7 @@ func (r *Repository) Upsert(ctx context.Context, recipe *Recipe) (*Recipe, error
|
|||||||
tags = EXCLUDED.tags,
|
tags = EXCLUDED.tags,
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
RETURNING id, source, spoonacular_id,
|
RETURNING id, source, spoonacular_id,
|
||||||
title, description, title_ru, description_ru,
|
title, description,
|
||||||
cuisine, difficulty, prep_time_min, cook_time_min, servings, image_url,
|
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,
|
calories_per_serving, protein_per_serving, fat_per_serving, carbs_per_serving, fiber_per_serving,
|
||||||
ingredients, steps, tags,
|
ingredients, steps, tags,
|
||||||
@@ -58,7 +59,7 @@ func (r *Repository) Upsert(ctx context.Context, recipe *Recipe) (*Recipe, error
|
|||||||
|
|
||||||
row := r.pool.QueryRow(ctx, query,
|
row := r.pool.QueryRow(ctx, query,
|
||||||
recipe.Source, recipe.SpoonacularID,
|
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.Cuisine, recipe.Difficulty, recipe.PrepTimeMin, recipe.CookTimeMin, recipe.Servings, recipe.ImageURL,
|
||||||
recipe.CaloriesPerServing, recipe.ProteinPerServing, recipe.FatPerServing, recipe.CarbsPerServing, recipe.FiberPerServing,
|
recipe.CaloriesPerServing, recipe.ProteinPerServing, recipe.FatPerServing, recipe.CarbsPerServing, recipe.FiberPerServing,
|
||||||
recipe.Ingredients, recipe.Steps, recipe.Tags,
|
recipe.Ingredients, recipe.Steps, recipe.Tags,
|
||||||
@@ -66,6 +67,33 @@ func (r *Repository) Upsert(ctx context.Context, recipe *Recipe) (*Recipe, error
|
|||||||
return scanRecipe(row)
|
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.
|
// Count returns the total number of recipes.
|
||||||
func (r *Repository) Count(ctx context.Context) (int, error) {
|
func (r *Repository) Count(ctx context.Context) (int, error) {
|
||||||
var n int
|
var n int
|
||||||
@@ -75,40 +103,51 @@ func (r *Repository) Count(ctx context.Context) (int, error) {
|
|||||||
return n, nil
|
return n, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListUntranslated returns recipes without a Russian title, ordered by review_count DESC.
|
// ListMissingTranslation returns Spoonacular recipes that have no translation
|
||||||
func (r *Repository) ListUntranslated(ctx context.Context, limit, offset int) ([]*Recipe, error) {
|
// for the given language, ordered by review_count DESC.
|
||||||
|
func (r *Repository) ListMissingTranslation(ctx context.Context, lang string, limit, offset int) ([]*Recipe, error) {
|
||||||
query := `
|
query := `
|
||||||
SELECT id, source, spoonacular_id,
|
SELECT id, source, spoonacular_id,
|
||||||
title, description, title_ru, description_ru,
|
title, description,
|
||||||
cuisine, difficulty, prep_time_min, cook_time_min, servings, image_url,
|
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,
|
calories_per_serving, protein_per_serving, fat_per_serving, carbs_per_serving, fiber_per_serving,
|
||||||
ingredients, steps, tags,
|
ingredients, steps, tags,
|
||||||
avg_rating, review_count, created_by, created_at, updated_at
|
avg_rating, review_count, created_by, created_at, updated_at
|
||||||
FROM recipes
|
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
|
ORDER BY review_count DESC
|
||||||
LIMIT $1 OFFSET $2`
|
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 {
|
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()
|
defer rows.Close()
|
||||||
return collectRecipes(rows)
|
return collectRecipes(rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateTranslation saves the Russian title, description, and step translations.
|
// UpsertTranslation inserts or replaces a recipe translation for a specific language.
|
||||||
func (r *Repository) UpdateTranslation(ctx context.Context, id string, titleRu, descriptionRu *string, steps json.RawMessage) error {
|
func (r *Repository) UpsertTranslation(
|
||||||
|
ctx context.Context,
|
||||||
|
id, lang string,
|
||||||
|
title, description *string,
|
||||||
|
ingredients, steps json.RawMessage,
|
||||||
|
) error {
|
||||||
query := `
|
query := `
|
||||||
UPDATE recipes SET
|
INSERT INTO recipe_translations (recipe_id, lang, title, description, ingredients, steps)
|
||||||
title_ru = $2,
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
description_ru = $3,
|
ON CONFLICT (recipe_id, lang) DO UPDATE SET
|
||||||
steps = $4,
|
title = EXCLUDED.title,
|
||||||
updated_at = now()
|
description = EXCLUDED.description,
|
||||||
WHERE id = $1`
|
ingredients = EXCLUDED.ingredients,
|
||||||
|
steps = EXCLUDED.steps`
|
||||||
|
|
||||||
if _, err := r.pool.Exec(ctx, query, id, titleRu, descriptionRu, steps); err != nil {
|
if _, err := r.pool.Exec(ctx, query, id, lang, title, description, ingredients, steps); err != nil {
|
||||||
return fmt.Errorf("update recipe translation %s: %w", id, err)
|
return fmt.Errorf("upsert recipe translation %s/%s: %w", id, lang, err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -121,7 +160,7 @@ func scanRecipe(row pgx.Row) (*Recipe, error) {
|
|||||||
|
|
||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
&rec.ID, &rec.Source, &rec.SpoonacularID,
|
&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.Cuisine, &rec.Difficulty, &rec.PrepTimeMin, &rec.CookTimeMin, &rec.Servings, &rec.ImageURL,
|
||||||
&rec.CaloriesPerServing, &rec.ProteinPerServing, &rec.FatPerServing, &rec.CarbsPerServing, &rec.FiberPerServing,
|
&rec.CaloriesPerServing, &rec.ProteinPerServing, &rec.FatPerServing, &rec.CarbsPerServing, &rec.FiberPerServing,
|
||||||
&ingredients, &steps, &tags,
|
&ingredients, &steps, &tags,
|
||||||
@@ -143,7 +182,7 @@ func collectRecipes(rows pgx.Rows) ([]*Recipe, error) {
|
|||||||
var ingredients, steps, tags []byte
|
var ingredients, steps, tags []byte
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&rec.ID, &rec.Source, &rec.SpoonacularID,
|
&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.Cuisine, &rec.Difficulty, &rec.PrepTimeMin, &rec.CookTimeMin, &rec.Servings, &rec.ImageURL,
|
||||||
&rec.CaloriesPerServing, &rec.ProteinPerServing, &rec.FatPerServing, &rec.CarbsPerServing, &rec.FiberPerServing,
|
&rec.CaloriesPerServing, &rec.ProteinPerServing, &rec.FatPerServing, &rec.CarbsPerServing, &rec.FiberPerServing,
|
||||||
&ingredients, &steps, &tags,
|
&ingredients, &steps, &tags,
|
||||||
@@ -158,24 +197,3 @@ func collectRecipes(rows pgx.Rows) ([]*Recipe, error) {
|
|||||||
}
|
}
|
||||||
return result, rows.Err()
|
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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/food-ai/backend/internal/locale"
|
||||||
"github.com/food-ai/backend/internal/testutil"
|
"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)
|
pool := testutil.SetupTestDB(t)
|
||||||
repo := NewRepository(pool)
|
repo := NewRepository(pool)
|
||||||
ctx := context.Background()
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("list untranslated: %v", err)
|
t.Fatalf("list missing translation: %v", err)
|
||||||
}
|
}
|
||||||
if len(untranslated) != 3 {
|
if len(missing) != 3 {
|
||||||
t.Errorf("expected 3 results with limit=3, got %d", len(untranslated))
|
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)
|
pool := testutil.SetupTestDB(t)
|
||||||
repo := NewRepository(pool)
|
repo := NewRepository(pool)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
@@ -196,40 +197,52 @@ func TestRecipeRepository_UpdateTranslation(t *testing.T) {
|
|||||||
|
|
||||||
titleRu := "Курица Тикка Масала"
|
titleRu := "Курица Тикка Масала"
|
||||||
descRu := "Классическое индийское блюдо"
|
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 {
|
if err := repo.UpsertTranslation(ctx, saved.ID, "ru", &titleRu, &descRu, nil, stepsRu); 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 — title and steps should be translated.
|
||||||
|
ruCtx := locale.WithLang(ctx, "ru")
|
||||||
|
got, err := repo.GetByID(ruCtx, saved.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("get by id: %v", err)
|
t.Fatalf("get by id: %v", err)
|
||||||
}
|
}
|
||||||
if got.TitleRu == nil || *got.TitleRu != titleRu {
|
if got.Title != titleRu {
|
||||||
t.Errorf("expected title_ru=%q, got %v", titleRu, got.TitleRu)
|
t.Errorf("expected title=%q, got %q", titleRu, got.Title)
|
||||||
}
|
}
|
||||||
if got.DescriptionRu == nil || *got.DescriptionRu != descRu {
|
if got.Description == nil || *got.Description != descRu {
|
||||||
t.Errorf("expected description_ru=%q, got %v", descRu, got.DescriptionRu)
|
t.Errorf("expected description=%q, got %v", descRu, got.Description)
|
||||||
}
|
}
|
||||||
|
|
||||||
var steps []RecipeStep
|
var steps []RecipeStep
|
||||||
if err := json.Unmarshal(got.Steps, &steps); err != nil {
|
if err := json.Unmarshal(got.Steps, &steps); err != nil {
|
||||||
t.Fatalf("unmarshal steps: %v", err)
|
t.Fatalf("unmarshal steps: %v", err)
|
||||||
}
|
}
|
||||||
if len(steps) == 0 || steps[0].DescriptionRu == nil || *steps[0].DescriptionRu != "Разогрейте масло" {
|
if len(steps) == 0 || steps[0].Description != "Разогрейте масло" {
|
||||||
t.Errorf("expected description_ru in steps, got %v", steps)
|
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)
|
pool := testutil.SetupTestDB(t)
|
||||||
repo := NewRepository(pool)
|
repo := NewRepository(pool)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
diff := "easy"
|
diff := "easy"
|
||||||
|
|
||||||
// Insert untranslated
|
// Insert untranslated recipes.
|
||||||
for i := 0; i < 3; i++ {
|
for i := 0; i < 3; i++ {
|
||||||
spID := 60000 + i
|
spID := 60000 + i
|
||||||
_, err := repo.Upsert(ctx, &Recipe{
|
_, 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
|
spID := 60100
|
||||||
translated, err := repo.Upsert(ctx, &Recipe{
|
translated, err := repo.Upsert(ctx, &Recipe{
|
||||||
Source: "spoonacular",
|
Source: "spoonacular",
|
||||||
@@ -261,21 +274,21 @@ func TestRecipeRepository_ListUntranslated_ExcludesTranslated(t *testing.T) {
|
|||||||
t.Fatalf("upsert translated: %v", err)
|
t.Fatalf("upsert translated: %v", err)
|
||||||
}
|
}
|
||||||
titleRu := "Переведённый рецепт"
|
titleRu := "Переведённый рецепт"
|
||||||
if err := repo.UpdateTranslation(ctx, translated.ID, &titleRu, nil, translated.Steps); err != nil {
|
if err := repo.UpsertTranslation(ctx, translated.ID, "ru", &titleRu, nil, nil, nil); err != nil {
|
||||||
t.Fatalf("update translation: %v", err)
|
t.Fatalf("upsert translation: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
untranslated, err := repo.ListUntranslated(ctx, 10, 0)
|
missing, err := repo.ListMissingTranslation(ctx, "ru", 10, 0)
|
||||||
if err != nil {
|
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" {
|
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 {
|
if len(missing) < 3 {
|
||||||
t.Errorf("expected at least 3 untranslated, got %d", len(untranslated))
|
t.Errorf("expected at least 3 missing, got %d", len(missing))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import (
|
|||||||
type ingredientRepo interface {
|
type ingredientRepo interface {
|
||||||
FuzzyMatch(ctx context.Context, name string) (*ingredient.IngredientMapping, error)
|
FuzzyMatch(ctx context.Context, name string) (*ingredient.IngredientMapping, error)
|
||||||
Upsert(ctx context.Context, m *ingredient.IngredientMapping) (*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.
|
// Handler handles POST /ai/* recognition endpoints.
|
||||||
@@ -218,7 +219,6 @@ func (h *Handler) saveClassification(ctx context.Context, c *gemini.IngredientCl
|
|||||||
|
|
||||||
m := &ingredient.IngredientMapping{
|
m := &ingredient.IngredientMapping{
|
||||||
CanonicalName: c.CanonicalName,
|
CanonicalName: c.CanonicalName,
|
||||||
CanonicalNameRu: &c.CanonicalNameRu,
|
|
||||||
Category: strPtr(c.Category),
|
Category: strPtr(c.Category),
|
||||||
DefaultUnit: strPtr(c.DefaultUnit),
|
DefaultUnit: strPtr(c.DefaultUnit),
|
||||||
CaloriesPer100g: c.CaloriesPer100g,
|
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)
|
slog.Warn("upsert classified ingredient", "name", c.CanonicalName, "err", err)
|
||||||
return nil
|
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
|
return saved
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/food-ai/backend/internal/gemini"
|
"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/middleware"
|
||||||
"github.com/food-ai/backend/internal/user"
|
"github.com/food-ai/backend/internal/user"
|
||||||
)
|
)
|
||||||
@@ -74,7 +75,7 @@ func (h *Handler) GetRecommendations(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
req := buildRecipeRequest(u, count)
|
req := buildRecipeRequest(u, count, locale.FromContext(r.Context()))
|
||||||
|
|
||||||
// Attach available products to personalise the prompt.
|
// Attach available products to personalise the prompt.
|
||||||
if products, err := h.productLister.ListForPrompt(r.Context(), userID); err == nil {
|
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)
|
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{
|
req := gemini.RecipeRequest{
|
||||||
Count: count,
|
Count: count,
|
||||||
DailyCalories: 2000, // sensible default
|
DailyCalories: 2000, // sensible default
|
||||||
|
Lang: lang,
|
||||||
}
|
}
|
||||||
|
|
||||||
if u.Goal != nil {
|
if u.Goal != nil {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/food-ai/backend/internal/locale"
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"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.
|
// ErrNotFound is returned when a saved recipe does not exist for the given user.
|
||||||
var ErrNotFound = errors.New("saved recipe not found")
|
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 {
|
type Repository struct {
|
||||||
pool *pgxpool.Pool
|
pool *pgxpool.Pool
|
||||||
}
|
}
|
||||||
@@ -24,6 +25,7 @@ func NewRepository(pool *pgxpool.Pool) *Repository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Save persists a recipe for userID and returns the stored record.
|
// 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) {
|
func (r *Repository) Save(ctx context.Context, userID string, req SaveRequest) (*SavedRecipe, error) {
|
||||||
const query = `
|
const query = `
|
||||||
INSERT INTO saved_recipes (
|
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.
|
// 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) {
|
func (r *Repository) List(ctx context.Context, userID string) ([]*SavedRecipe, error) {
|
||||||
|
lang := locale.FromContext(ctx)
|
||||||
const query = `
|
const query = `
|
||||||
SELECT id, user_id, title, description, cuisine, difficulty,
|
SELECT sr.id, sr.user_id,
|
||||||
prep_time_min, cook_time_min, servings, image_url,
|
COALESCE(srt.title, sr.title) AS title,
|
||||||
ingredients, steps, tags, nutrition, source, saved_at
|
COALESCE(srt.description, sr.description) AS description,
|
||||||
FROM saved_recipes
|
sr.cuisine, sr.difficulty,
|
||||||
WHERE user_id = $1
|
sr.prep_time_min, sr.cook_time_min, sr.servings, sr.image_url,
|
||||||
ORDER BY saved_at DESC`
|
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("list saved recipes: %w", err)
|
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
|
var result []*SavedRecipe
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
rec, err := scanRows(rows)
|
rec, err := scanRow(rows)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("scan saved recipe: %w", err)
|
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.
|
// 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) {
|
func (r *Repository) GetByID(ctx context.Context, userID, id string) (*SavedRecipe, error) {
|
||||||
|
lang := locale.FromContext(ctx)
|
||||||
const query = `
|
const query = `
|
||||||
SELECT id, user_id, title, description, cuisine, difficulty,
|
SELECT sr.id, sr.user_id,
|
||||||
prep_time_min, cook_time_min, servings, image_url,
|
COALESCE(srt.title, sr.title) AS title,
|
||||||
ingredients, steps, tags, nutrition, source, saved_at
|
COALESCE(srt.description, sr.description) AS description,
|
||||||
FROM saved_recipes
|
sr.cuisine, sr.difficulty,
|
||||||
WHERE id = $1 AND user_id = $2`
|
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) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
@@ -119,6 +141,29 @@ func (r *Repository) Delete(ctx context.Context, userID, id string) error {
|
|||||||
return nil
|
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 ---
|
// --- helpers ---
|
||||||
|
|
||||||
type scannable interface {
|
type scannable interface {
|
||||||
@@ -146,11 +191,6 @@ func scanRow(s scannable) (*SavedRecipe, error) {
|
|||||||
return &rec, nil
|
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 {
|
func nullableStr(s string) *string {
|
||||||
if s == "" {
|
if s == "" {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ func NewRouter(
|
|||||||
r.Use(middleware.Logging)
|
r.Use(middleware.Logging)
|
||||||
r.Use(middleware.Recovery)
|
r.Use(middleware.Recovery)
|
||||||
r.Use(middleware.CORS(allowedOrigins))
|
r.Use(middleware.CORS(allowedOrigins))
|
||||||
|
r.Use(middleware.Language)
|
||||||
|
|
||||||
// Public
|
// Public
|
||||||
r.Get("/health", healthCheck(pool))
|
r.Get("/health", healthCheck(pool))
|
||||||
|
|||||||
118
backend/migrations/009_create_translation_tables.sql
Normal file
118
backend/migrations/009_create_translation_tables.sql
Normal file
@@ -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;
|
||||||
36
backend/migrations/010_drop_ru_columns.sql
Normal file
36
backend/migrations/010_drop_ru_columns.sql
Normal file
@@ -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, '')));
|
||||||
Reference in New Issue
Block a user