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:
dbastrikin
2026-02-27 23:17:34 +02:00
parent ea4a6301ea
commit c0cf1b38ea
18 changed files with 718 additions and 273 deletions

View File

@@ -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, "```")