diff --git a/backend/internal/gemini/recognition.go b/backend/internal/gemini/recognition.go index 887ccc8..e8ba6f7 100644 --- a/backend/internal/gemini/recognition.go +++ b/backend/internal/gemini/recognition.go @@ -40,18 +40,25 @@ type DishResult struct { SimilarDishes []string `json:"similar_dishes"` } +// IngredientTranslation holds the localized name and aliases for one language. +type IngredientTranslation struct { + Lang string `json:"lang"` + Name string `json:"name"` + Aliases []string `json:"aliases"` +} + // IngredientClassification is the AI-produced classification of an unknown food item. type IngredientClassification struct { - CanonicalName string `json:"canonical_name"` - CanonicalNameRu string `json:"canonical_name_ru"` - Category string `json:"category"` - DefaultUnit string `json:"default_unit"` - CaloriesPer100g *float64 `json:"calories_per_100g"` - ProteinPer100g *float64 `json:"protein_per_100g"` - FatPer100g *float64 `json:"fat_per_100g"` - CarbsPer100g *float64 `json:"carbs_per_100g"` - StorageDays int `json:"storage_days"` - Aliases []string `json:"aliases"` + CanonicalName string `json:"canonical_name"` + Aliases []string `json:"aliases"` // English aliases + Translations []IngredientTranslation `json:"translations"` // other languages + Category string `json:"category"` + DefaultUnit string `json:"default_unit"` + CaloriesPer100g *float64 `json:"calories_per_100g"` + ProteinPer100g *float64 `json:"protein_per_100g"` + FatPer100g *float64 `json:"fat_per_100g"` + CarbsPer100g *float64 `json:"carbs_per_100g"` + StorageDays int `json:"storage_days"` } // RecognizeReceipt uses the vision model to extract food items from a receipt photo. @@ -177,20 +184,21 @@ func (c *Client) RecognizeDish(ctx context.Context, imageBase64, mimeType string // ClassifyIngredient uses the text model to classify an unknown food item // and build an ingredient_mappings record for it. func (c *Client) ClassifyIngredient(ctx context.Context, name string) (*IngredientClassification, error) { - prompt := fmt.Sprintf(`Классифицируй продукт питания: "%s". - -Ответь ТОЛЬКО валидным JSON без markdown: + prompt := fmt.Sprintf(`Classify the food product: "%s". +Return ONLY valid JSON without markdown: { "canonical_name": "turkey_breast", - "canonical_name_ru": "грудка индейки", + "aliases": ["turkey breast"], + "translations": [ + {"lang": "ru", "name": "грудка индейки", "aliases": ["грудка индейки", "филе индейки"]} + ], "category": "meat", "default_unit": "g", "calories_per_100g": 135, "protein_per_100g": 29, "fat_per_100g": 1, "carbs_per_100g": 0, - "storage_days": 3, - "aliases": ["грудка индейки", "филе индейки", "turkey breast"] + "storage_days": 3 }`, name) messages := []map[string]string{ diff --git a/backend/internal/ingredient/model.go b/backend/internal/ingredient/model.go index c78185c..2f867e2 100644 --- a/backend/internal/ingredient/model.go +++ b/backend/internal/ingredient/model.go @@ -6,15 +6,15 @@ import ( ) // IngredientMapping is the canonical ingredient record used to link -// user products, recipe ingredients, and Spoonacular data. +// user products and recipe ingredients. // CanonicalName holds the content for the language resolved at query time // (English by default, or from ingredient_translations when available). type IngredientMapping struct { ID string `json:"id"` CanonicalName string `json:"canonical_name"` - SpoonacularID *int `json:"spoonacular_id"` - Aliases json.RawMessage `json:"aliases"` // []string + Aliases json.RawMessage `json:"aliases"` // []string, populated by read queries Category *string `json:"category"` + CategoryName *string `json:"category_name"` // localized category display name DefaultUnit *string `json:"default_unit"` CaloriesPer100g *float64 `json:"calories_per_100g"` diff --git a/backend/internal/ingredient/repository.go b/backend/internal/ingredient/repository.go index 828d9cc..9b1b32d 100644 --- a/backend/internal/ingredient/repository.go +++ b/backend/internal/ingredient/repository.go @@ -22,18 +22,16 @@ func NewRepository(pool *pgxpool.Pool) *Repository { } // Upsert inserts or updates an ingredient mapping (English canonical content). -// Conflict is resolved on spoonacular_id when set. +// Conflict is resolved on canonical_name. func (r *Repository) Upsert(ctx context.Context, m *IngredientMapping) (*IngredientMapping, error) { query := ` INSERT INTO ingredient_mappings ( - canonical_name, spoonacular_id, aliases, + canonical_name, category, default_unit, calories_per_100g, protein_per_100g, fat_per_100g, carbs_per_100g, fiber_per_100g, storage_days - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) - ON CONFLICT (spoonacular_id) DO UPDATE SET - canonical_name = EXCLUDED.canonical_name, - aliases = EXCLUDED.aliases, + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + ON CONFLICT (canonical_name) DO UPDATE SET category = EXCLUDED.category, default_unit = EXCLUDED.default_unit, calories_per_100g = EXCLUDED.calories_per_100g, @@ -43,62 +41,47 @@ func (r *Repository) Upsert(ctx context.Context, m *IngredientMapping) (*Ingredi fiber_per_100g = EXCLUDED.fiber_per_100g, storage_days = EXCLUDED.storage_days, updated_at = now() - RETURNING id, canonical_name, spoonacular_id, aliases, - category, default_unit, + RETURNING id, canonical_name, category, default_unit, calories_per_100g, protein_per_100g, fat_per_100g, carbs_per_100g, fiber_per_100g, storage_days, created_at, updated_at` row := r.pool.QueryRow(ctx, query, - m.CanonicalName, m.SpoonacularID, m.Aliases, + m.CanonicalName, m.Category, m.DefaultUnit, m.CaloriesPer100g, m.ProteinPer100g, m.FatPer100g, m.CarbsPer100g, m.FiberPer100g, m.StorageDays, ) - return scanMapping(row) -} - -// GetBySpoonacularID returns an ingredient mapping by Spoonacular ID. -// CanonicalName is resolved for the language stored in ctx. -// Returns nil, nil if not found. -func (r *Repository) GetBySpoonacularID(ctx context.Context, id int) (*IngredientMapping, error) { - lang := locale.FromContext(ctx) - query := ` - SELECT im.id, - COALESCE(it.name, im.canonical_name) AS canonical_name, - im.spoonacular_id, im.aliases, - im.category, im.default_unit, - im.calories_per_100g, im.protein_per_100g, im.fat_per_100g, im.carbs_per_100g, im.fiber_per_100g, - im.storage_days, im.created_at, im.updated_at - FROM ingredient_mappings im - LEFT JOIN ingredient_translations it ON it.ingredient_id = im.id AND it.lang = $2 - WHERE im.spoonacular_id = $1` - - row := r.pool.QueryRow(ctx, query, id, lang) - m, err := scanMapping(row) - if errors.Is(err, pgx.ErrNoRows) { - return nil, nil - } - return m, err + return scanMappingWrite(row) } // GetByID returns an ingredient mapping by UUID. -// CanonicalName is resolved for the language stored in ctx. +// CanonicalName and aliases are resolved for the language stored in ctx. // Returns nil, nil if not found. func (r *Repository) GetByID(ctx context.Context, id string) (*IngredientMapping, error) { lang := locale.FromContext(ctx) query := ` SELECT im.id, COALESCE(it.name, im.canonical_name) AS canonical_name, - im.spoonacular_id, im.aliases, - im.category, im.default_unit, + im.category, + COALESCE(ict.name, im.category) AS category_name, + im.default_unit, im.calories_per_100g, im.protein_per_100g, im.fat_per_100g, im.carbs_per_100g, im.fiber_per_100g, - im.storage_days, im.created_at, im.updated_at + im.storage_days, im.created_at, im.updated_at, + COALESCE(al.aliases, '[]'::json) AS aliases FROM ingredient_mappings im - LEFT JOIN ingredient_translations it ON it.ingredient_id = im.id AND it.lang = $2 + LEFT JOIN ingredient_translations it + ON it.ingredient_id = im.id AND it.lang = $2 + LEFT JOIN ingredient_category_translations ict + ON ict.category_slug = im.category AND ict.lang = $2 + LEFT JOIN LATERAL ( + SELECT json_agg(ia.alias ORDER BY ia.alias) AS aliases + FROM ingredient_aliases ia + WHERE ia.ingredient_id = im.id AND ia.lang = $2 + ) al ON true WHERE im.id = $1` row := r.pool.QueryRow(ctx, query, id, lang) - m, err := scanMapping(row) + m, err := scanMappingRead(row) if errors.Is(err, pgx.ErrNoRows) { return nil, nil } @@ -120,8 +103,7 @@ func (r *Repository) FuzzyMatch(ctx context.Context, name string) (*IngredientMa } // Search finds ingredient mappings matching the query string. -// Searches English aliases/name and the translated name for the language in ctx. -// Uses a three-level strategy: exact aliases match, ILIKE, and pg_trgm similarity. +// Searches aliases table and translated names for the language in ctx. func (r *Repository) Search(ctx context.Context, query string, limit int) ([]*IngredientMapping, error) { if limit <= 0 { limit = 10 @@ -130,17 +112,31 @@ func (r *Repository) Search(ctx context.Context, query string, limit int) ([]*In q := ` SELECT im.id, COALESCE(it.name, im.canonical_name) AS canonical_name, - im.spoonacular_id, im.aliases, - im.category, im.default_unit, + im.category, + COALESCE(ict.name, im.category) AS category_name, + im.default_unit, im.calories_per_100g, im.protein_per_100g, im.fat_per_100g, im.carbs_per_100g, im.fiber_per_100g, - im.storage_days, im.created_at, im.updated_at + im.storage_days, im.created_at, im.updated_at, + COALESCE(al.aliases, '[]'::json) AS aliases FROM ingredient_mappings im - LEFT JOIN ingredient_translations it ON it.ingredient_id = im.id AND it.lang = $3 - WHERE im.aliases @> to_jsonb(lower($1)::text) - OR it.aliases @> to_jsonb(lower($1)::text) - OR im.canonical_name ILIKE '%' || $1 || '%' - OR it.name ILIKE '%' || $1 || '%' - OR similarity(COALESCE(it.name, im.canonical_name), $1) > 0.3 + LEFT JOIN ingredient_translations it + ON it.ingredient_id = im.id AND it.lang = $3 + LEFT JOIN ingredient_category_translations ict + ON ict.category_slug = im.category AND ict.lang = $3 + LEFT JOIN LATERAL ( + SELECT json_agg(ia.alias ORDER BY ia.alias) AS aliases + FROM ingredient_aliases ia + WHERE ia.ingredient_id = im.id AND ia.lang = $3 + ) al ON true + WHERE EXISTS ( + SELECT 1 FROM ingredient_aliases ia + WHERE ia.ingredient_id = im.id + AND (ia.lang = $3 OR ia.lang = 'en') + AND ia.alias ILIKE '%' || $1 || '%' + ) + 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` @@ -149,7 +145,7 @@ func (r *Repository) Search(ctx context.Context, query string, limit int) ([]*In return nil, fmt.Errorf("search ingredient_mappings: %w", err) } defer rows.Close() - return collectMappings(rows) + return collectMappingsRead(rows) } // Count returns the total number of ingredient mappings. @@ -165,7 +161,7 @@ func (r *Repository) Count(ctx context.Context) (int, error) { // given language, ordered by id. func (r *Repository) ListMissingTranslation(ctx context.Context, lang string, limit, offset int) ([]*IngredientMapping, error) { query := ` - SELECT im.id, im.canonical_name, im.spoonacular_id, im.aliases, + SELECT im.id, im.canonical_name, im.category, im.default_unit, im.calories_per_100g, im.protein_per_100g, im.fat_per_100g, im.carbs_per_100g, im.fiber_per_100g, im.storage_days, im.created_at, im.updated_at @@ -182,53 +178,119 @@ func (r *Repository) ListMissingTranslation(ctx context.Context, lang string, li return nil, fmt.Errorf("list missing translation (%s): %w", lang, err) } defer rows.Close() - return collectMappings(rows) + return collectMappingsWrite(rows) } -// UpsertTranslation inserts or replaces a translation for an ingredient mapping. -func (r *Repository) UpsertTranslation(ctx context.Context, id, lang, name string, aliases json.RawMessage) error { +// UpsertTranslation inserts or replaces a name translation for an ingredient. +func (r *Repository) UpsertTranslation(ctx context.Context, id, lang, name string) error { query := ` - INSERT INTO ingredient_translations (ingredient_id, lang, name, aliases) - VALUES ($1, $2, $3, $4) - ON CONFLICT (ingredient_id, lang) DO UPDATE SET - name = EXCLUDED.name, - aliases = EXCLUDED.aliases` + INSERT INTO ingredient_translations (ingredient_id, lang, name) + VALUES ($1, $2, $3) + ON CONFLICT (ingredient_id, lang) DO UPDATE SET name = EXCLUDED.name` - if _, err := r.pool.Exec(ctx, query, id, lang, name, aliases); err != nil { + if _, err := r.pool.Exec(ctx, query, id, lang, name); err != nil { return fmt.Errorf("upsert ingredient translation %s/%s: %w", id, lang, err) } return nil } -// --- helpers --- +// UpsertAliases inserts aliases for a given ingredient and language. +// Each alias is inserted with ON CONFLICT DO NOTHING, so duplicates are skipped. +func (r *Repository) UpsertAliases(ctx context.Context, id, lang string, aliases []string) error { + if len(aliases) == 0 { + return nil + } + batch := &pgx.Batch{} + for _, alias := range aliases { + batch.Queue( + `INSERT INTO ingredient_aliases (ingredient_id, lang, alias) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING`, + id, lang, alias, + ) + } + results := r.pool.SendBatch(ctx, batch) + defer results.Close() + for range aliases { + if _, err := results.Exec(); err != nil { + return fmt.Errorf("upsert ingredient alias %s/%s: %w", id, lang, err) + } + } + return nil +} -func scanMapping(row pgx.Row) (*IngredientMapping, error) { +// UpsertCategoryTranslation inserts or replaces a localized category name. +func (r *Repository) UpsertCategoryTranslation(ctx context.Context, slug, lang, name string) error { + query := ` + INSERT INTO ingredient_category_translations (category_slug, lang, name) + VALUES ($1, $2, $3) + ON CONFLICT (category_slug, lang) DO UPDATE SET name = EXCLUDED.name` + + if _, err := r.pool.Exec(ctx, query, slug, lang, name); err != nil { + return fmt.Errorf("upsert category translation %s/%s: %w", slug, lang, err) + } + return nil +} + +// --- scan helpers --- + +// scanMappingWrite scans rows from Upsert / ListMissingTranslation queries +// (no aliases lateral join, no category_name). +func scanMappingWrite(row pgx.Row) (*IngredientMapping, error) { var m IngredientMapping - var aliases []byte - err := row.Scan( - &m.ID, &m.CanonicalName, &m.SpoonacularID, &aliases, - &m.Category, &m.DefaultUnit, + &m.ID, &m.CanonicalName, &m.Category, &m.DefaultUnit, &m.CaloriesPer100g, &m.ProteinPer100g, &m.FatPer100g, &m.CarbsPer100g, &m.FiberPer100g, &m.StorageDays, &m.CreatedAt, &m.UpdatedAt, ) if err != nil { return nil, err } + m.Aliases = json.RawMessage("[]") + return &m, nil +} + +// scanMappingRead scans rows from GetByID / Search queries +// (includes category_name and aliases lateral join). +func scanMappingRead(row pgx.Row) (*IngredientMapping, error) { + var m IngredientMapping + var aliases []byte + err := row.Scan( + &m.ID, &m.CanonicalName, &m.Category, &m.CategoryName, &m.DefaultUnit, + &m.CaloriesPer100g, &m.ProteinPer100g, &m.FatPer100g, &m.CarbsPer100g, &m.FiberPer100g, + &m.StorageDays, &m.CreatedAt, &m.UpdatedAt, &aliases, + ) + if err != nil { + return nil, err + } m.Aliases = json.RawMessage(aliases) return &m, nil } -func collectMappings(rows pgx.Rows) ([]*IngredientMapping, error) { +func collectMappingsWrite(rows pgx.Rows) ([]*IngredientMapping, error) { + var result []*IngredientMapping + for rows.Next() { + var m IngredientMapping + if err := rows.Scan( + &m.ID, &m.CanonicalName, &m.Category, &m.DefaultUnit, + &m.CaloriesPer100g, &m.ProteinPer100g, &m.FatPer100g, &m.CarbsPer100g, &m.FiberPer100g, + &m.StorageDays, &m.CreatedAt, &m.UpdatedAt, + ); err != nil { + return nil, fmt.Errorf("scan mapping: %w", err) + } + m.Aliases = json.RawMessage("[]") + result = append(result, &m) + } + return result, rows.Err() +} + +func collectMappingsRead(rows pgx.Rows) ([]*IngredientMapping, error) { var result []*IngredientMapping for rows.Next() { var m IngredientMapping var aliases []byte if err := rows.Scan( - &m.ID, &m.CanonicalName, &m.SpoonacularID, &aliases, - &m.Category, &m.DefaultUnit, + &m.ID, &m.CanonicalName, &m.Category, &m.CategoryName, &m.DefaultUnit, &m.CaloriesPer100g, &m.ProteinPer100g, &m.FatPer100g, &m.CarbsPer100g, &m.FiberPer100g, - &m.StorageDays, &m.CreatedAt, &m.UpdatedAt, + &m.StorageDays, &m.CreatedAt, &m.UpdatedAt, &aliases, ); err != nil { return nil, fmt.Errorf("scan mapping: %w", err) } diff --git a/backend/internal/ingredient/repository_integration_test.go b/backend/internal/ingredient/repository_integration_test.go index c940a64..d9f8faa 100644 --- a/backend/internal/ingredient/repository_integration_test.go +++ b/backend/internal/ingredient/repository_integration_test.go @@ -4,7 +4,6 @@ package ingredient import ( "context" - "encoding/json" "testing" "github.com/food-ai/backend/internal/locale" @@ -16,17 +15,14 @@ func TestIngredientRepository_Upsert_Insert(t *testing.T) { repo := NewRepository(pool) ctx := context.Background() - id := 1001 cat := "produce" unit := "g" cal := 52.0 m := &IngredientMapping{ - CanonicalName: "apple", - SpoonacularID: &id, - Aliases: json.RawMessage(`["apple", "apples"]`), - Category: &cat, - DefaultUnit: &unit, + CanonicalName: "apple", + Category: &cat, + DefaultUnit: &unit, CaloriesPer100g: &cal, } @@ -50,14 +46,11 @@ func TestIngredientRepository_Upsert_ConflictUpdates(t *testing.T) { repo := NewRepository(pool) ctx := context.Background() - id := 2001 cat := "produce" unit := "g" first := &IngredientMapping{ CanonicalName: "banana", - SpoonacularID: &id, - Aliases: json.RawMessage(`["banana"]`), Category: &cat, DefaultUnit: &unit, } @@ -68,9 +61,7 @@ func TestIngredientRepository_Upsert_ConflictUpdates(t *testing.T) { cal := 89.0 second := &IngredientMapping{ - CanonicalName: "banana_updated", - SpoonacularID: &id, - Aliases: json.RawMessage(`["banana", "bananas"]`), + CanonicalName: "banana", Category: &cat, DefaultUnit: &unit, CaloriesPer100g: &cal, @@ -83,7 +74,7 @@ func TestIngredientRepository_Upsert_ConflictUpdates(t *testing.T) { if got1.ID != got2.ID { t.Errorf("ID changed on conflict update: %s != %s", got1.ID, got2.ID) } - if got2.CanonicalName != "banana_updated" { + if got2.CanonicalName != "banana" { t.Errorf("canonical_name not updated: got %s", got2.CanonicalName) } if got2.CaloriesPer100g == nil || *got2.CaloriesPer100g != 89.0 { @@ -91,19 +82,16 @@ func TestIngredientRepository_Upsert_ConflictUpdates(t *testing.T) { } } -func TestIngredientRepository_GetBySpoonacularID_Found(t *testing.T) { +func TestIngredientRepository_GetByID_Found(t *testing.T) { pool := testutil.SetupTestDB(t) repo := NewRepository(pool) ctx := context.Background() - id := 3001 cat := "dairy" unit := "g" - _, err := repo.Upsert(ctx, &IngredientMapping{ + saved, err := repo.Upsert(ctx, &IngredientMapping{ CanonicalName: "cheese", - SpoonacularID: &id, - Aliases: json.RawMessage(`["cheese"]`), Category: &cat, DefaultUnit: &unit, }) @@ -111,7 +99,7 @@ func TestIngredientRepository_GetBySpoonacularID_Found(t *testing.T) { t.Fatalf("upsert: %v", err) } - got, err := repo.GetBySpoonacularID(ctx, id) + got, err := repo.GetByID(ctx, saved.ID) if err != nil { t.Fatalf("get: %v", err) } @@ -123,12 +111,12 @@ func TestIngredientRepository_GetBySpoonacularID_Found(t *testing.T) { } } -func TestIngredientRepository_GetBySpoonacularID_NotFound(t *testing.T) { +func TestIngredientRepository_GetByID_NotFound(t *testing.T) { pool := testutil.SetupTestDB(t) repo := NewRepository(pool) ctx := context.Background() - got, err := repo.GetBySpoonacularID(ctx, 99999999) + got, err := repo.GetByID(ctx, "00000000-0000-0000-0000-000000000000") if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -146,12 +134,9 @@ func TestIngredientRepository_ListMissingTranslation(t *testing.T) { unit := "g" // Insert 3 without any translation. - for i, name := range []string{"carrot", "onion", "garlic"} { - id := 4000 + i + for _, name := range []string{"carrot", "onion", "garlic"} { _, err := repo.Upsert(ctx, &IngredientMapping{ CanonicalName: name, - SpoonacularID: &id, - Aliases: json.RawMessage(`[]`), Category: &cat, DefaultUnit: &unit, }) @@ -160,19 +145,16 @@ func TestIngredientRepository_ListMissingTranslation(t *testing.T) { } } - // Insert 1 and add a Russian translation — should not appear in the result. - id := 4100 + // Insert 1 and add a translation — should not appear in the result. saved, err := repo.Upsert(ctx, &IngredientMapping{ CanonicalName: "tomato", - SpoonacularID: &id, - Aliases: json.RawMessage(`[]`), Category: &cat, DefaultUnit: &unit, }) if err != nil { t.Fatalf("upsert tomato: %v", err) } - if err := repo.UpsertTranslation(ctx, saved.ID, "ru", "помидор", json.RawMessage(`["помидор","томат"]`)); err != nil { + if err := repo.UpsertTranslation(ctx, saved.ID, "ru", "помидор"); err != nil { t.Fatalf("upsert translation: %v", err) } @@ -196,14 +178,11 @@ func TestIngredientRepository_UpsertTranslation(t *testing.T) { repo := NewRepository(pool) ctx := context.Background() - id := 5001 cat := "meat" unit := "g" saved, err := repo.Upsert(ctx, &IngredientMapping{ CanonicalName: "chicken_breast", - SpoonacularID: &id, - Aliases: json.RawMessage(`["chicken breast"]`), Category: &cat, DefaultUnit: &unit, }) @@ -211,9 +190,7 @@ func TestIngredientRepository_UpsertTranslation(t *testing.T) { t.Fatalf("upsert: %v", err) } - err = repo.UpsertTranslation(ctx, saved.ID, "ru", "куриная грудка", - json.RawMessage(`["куриная грудка","куриное филе"]`)) - if err != nil { + if err := repo.UpsertTranslation(ctx, saved.ID, "ru", "куриная грудка"); err != nil { t.Fatalf("upsert translation: %v", err) } @@ -237,3 +214,83 @@ func TestIngredientRepository_UpsertTranslation(t *testing.T) { t.Errorf("expected English CanonicalName='chicken_breast', got %q", gotEn.CanonicalName) } } + +func TestIngredientRepository_UpsertAliases(t *testing.T) { + pool := testutil.SetupTestDB(t) + repo := NewRepository(pool) + ctx := context.Background() + + cat := "produce" + unit := "g" + + saved, err := repo.Upsert(ctx, &IngredientMapping{ + CanonicalName: "apple_test_aliases", + Category: &cat, + DefaultUnit: &unit, + }) + if err != nil { + t.Fatalf("upsert: %v", err) + } + + aliases := []string{"apple", "apples", "red apple"} + if err := repo.UpsertAliases(ctx, saved.ID, "en", aliases); err != nil { + t.Fatalf("upsert aliases: %v", err) + } + + // Idempotent — second call should not fail. + if err := repo.UpsertAliases(ctx, saved.ID, "en", aliases); err != nil { + t.Fatalf("second upsert aliases: %v", err) + } + + // Retrieve with English context — aliases should appear. + enCtx := locale.WithLang(ctx, "en") + got, err := repo.GetByID(enCtx, saved.ID) + if err != nil { + t.Fatalf("get by id: %v", err) + } + if string(got.Aliases) == "[]" || string(got.Aliases) == "null" { + t.Errorf("expected non-empty aliases, got %s", got.Aliases) + } +} + +func TestIngredientRepository_UpsertCategoryTranslation(t *testing.T) { + pool := testutil.SetupTestDB(t) + repo := NewRepository(pool) + ctx := context.Background() + + // Upsert an ingredient with a known category. + cat := "dairy" + unit := "g" + saved, err := repo.Upsert(ctx, &IngredientMapping{ + CanonicalName: "milk_test_category", + Category: &cat, + DefaultUnit: &unit, + }) + if err != nil { + t.Fatalf("upsert: %v", err) + } + + // The migration already inserted Russian translations for known categories. + // Retrieve with Russian context — CategoryName should be set. + ruCtx := locale.WithLang(ctx, "ru") + got, err := repo.GetByID(ruCtx, saved.ID) + if err != nil { + t.Fatalf("get by id: %v", err) + } + if got.CategoryName == nil || *got.CategoryName == "" { + t.Error("expected non-empty CategoryName for 'dairy' in Russian") + } + + // Upsert a new translation and verify it is returned. + if err := repo.UpsertCategoryTranslation(ctx, "dairy", "de", "Milchprodukte"); err != nil { + t.Fatalf("upsert category translation: %v", err) + } + deCtx := locale.WithLang(ctx, "de") + gotDe, err := repo.GetByID(deCtx, saved.ID) + if err != nil { + t.Fatalf("get by id (de): %v", err) + } + if gotDe.CategoryName == nil || *gotDe.CategoryName != "Milchprodukte" { + t.Errorf("expected CategoryName='Milchprodukte', got %v", gotDe.CategoryName) + } +} diff --git a/backend/internal/recipe/model.go b/backend/internal/recipe/model.go index ec5f55a..64a44d6 100644 --- a/backend/internal/recipe/model.go +++ b/backend/internal/recipe/model.go @@ -43,12 +43,11 @@ type Recipe struct { // RecipeIngredient is a single ingredient in a recipe's JSONB array. type RecipeIngredient struct { - SpoonacularID *int `json:"spoonacular_id"` - MappingID *string `json:"mapping_id"` - Name string `json:"name"` - Amount float64 `json:"amount"` - Unit string `json:"unit"` - Optional bool `json:"optional"` + MappingID *string `json:"mapping_id"` + Name string `json:"name"` + Amount float64 `json:"amount"` + Unit string `json:"unit"` + Optional bool `json:"optional"` } // RecipeStep is a single step in a recipe's JSONB array. diff --git a/backend/internal/recognition/handler.go b/backend/internal/recognition/handler.go index 02a410f..22bb363 100644 --- a/backend/internal/recognition/handler.go +++ b/backend/internal/recognition/handler.go @@ -17,7 +17,8 @@ import ( type ingredientRepo interface { FuzzyMatch(ctx context.Context, name string) (*ingredient.IngredientMapping, error) Upsert(ctx context.Context, m *ingredient.IngredientMapping) (*ingredient.IngredientMapping, error) - UpsertTranslation(ctx context.Context, id, lang, name string, aliases json.RawMessage) error + UpsertTranslation(ctx context.Context, id, lang, name string) error + UpsertAliases(ctx context.Context, id, lang string, aliases []string) error } // Handler handles POST /ai/* recognition endpoints. @@ -212,11 +213,6 @@ func (h *Handler) saveClassification(ctx context.Context, c *gemini.IngredientCl return nil } - aliasesJSON, err := json.Marshal(c.Aliases) - if err != nil { - return nil - } - m := &ingredient.IngredientMapping{ CanonicalName: c.CanonicalName, Category: strPtr(c.Category), @@ -226,7 +222,6 @@ func (h *Handler) saveClassification(ctx context.Context, c *gemini.IngredientCl FatPer100g: c.FatPer100g, CarbsPer100g: c.CarbsPer100g, StorageDays: intPtr(c.StorageDays), - Aliases: aliasesJSON, } saved, err := h.ingredientRepo.Upsert(ctx, m) @@ -235,12 +230,23 @@ func (h *Handler) saveClassification(ctx context.Context, c *gemini.IngredientCl 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) + if len(c.Aliases) > 0 { + if err := h.ingredientRepo.UpsertAliases(ctx, saved.ID, "en", c.Aliases); err != nil { + slog.Warn("upsert ingredient aliases", "id", saved.ID, "err", err) } } + + for _, t := range c.Translations { + if err := h.ingredientRepo.UpsertTranslation(ctx, saved.ID, t.Lang, t.Name); err != nil { + slog.Warn("upsert ingredient translation", "id", saved.ID, "lang", t.Lang, "err", err) + } + if len(t.Aliases) > 0 { + if err := h.ingredientRepo.UpsertAliases(ctx, saved.ID, t.Lang, t.Aliases); err != nil { + slog.Warn("upsert ingredient translation aliases", "id", saved.ID, "lang", t.Lang, "err", err) + } + } + } + return saved } diff --git a/backend/migrations/012_refactor_ingredients.sql b/backend/migrations/012_refactor_ingredients.sql new file mode 100644 index 0000000..9f27189 --- /dev/null +++ b/backend/migrations/012_refactor_ingredients.sql @@ -0,0 +1,89 @@ +-- +goose Up + +-- 1. ingredient_categories: slug-keyed table (the 7 known slugs) +CREATE TABLE ingredient_categories ( + slug VARCHAR(50) PRIMARY KEY, + sort_order SMALLINT NOT NULL DEFAULT 0 +); +INSERT INTO ingredient_categories (slug, sort_order) VALUES + ('dairy', 1), ('meat', 2), ('produce', 3), + ('bakery', 4), ('frozen', 5), ('beverages', 6), ('other', 7); + +-- 2. ingredient_category_translations +CREATE TABLE ingredient_category_translations ( + category_slug VARCHAR(50) NOT NULL REFERENCES ingredient_categories(slug) ON DELETE CASCADE, + lang VARCHAR(10) NOT NULL, + name TEXT NOT NULL, + PRIMARY KEY (category_slug, lang) +); +INSERT INTO ingredient_category_translations (category_slug, lang, name) VALUES + ('dairy', 'ru', 'Молочные продукты'), + ('meat', 'ru', 'Мясо и птица'), + ('produce', 'ru', 'Овощи и фрукты'), + ('bakery', 'ru', 'Выпечка и хлеб'), + ('frozen', 'ru', 'Замороженные'), + ('beverages', 'ru', 'Напитки'), + ('other', 'ru', 'Прочее'); + +-- 3. Nullify any unknown category values before adding FK +UPDATE ingredient_mappings + SET category = NULL + WHERE category IS NOT NULL + AND category NOT IN (SELECT slug FROM ingredient_categories); + +ALTER TABLE ingredient_mappings + ADD CONSTRAINT fk_ingredient_category + FOREIGN KEY (category) REFERENCES ingredient_categories(slug); + +-- 4. ingredient_aliases table +CREATE TABLE ingredient_aliases ( + ingredient_id UUID NOT NULL REFERENCES ingredient_mappings(id) ON DELETE CASCADE, + lang VARCHAR(10) NOT NULL, + alias TEXT NOT NULL, + PRIMARY KEY (ingredient_id, lang, alias) +); +CREATE INDEX idx_ingredient_aliases_lookup ON ingredient_aliases (ingredient_id, lang); +CREATE INDEX idx_ingredient_aliases_trgm ON ingredient_aliases USING GIN (alias gin_trgm_ops); + +-- 5. Migrate English aliases from ingredient_mappings.aliases +INSERT INTO ingredient_aliases (ingredient_id, lang, alias) +SELECT im.id, 'en', a.val +FROM ingredient_mappings im, + jsonb_array_elements_text(im.aliases) a(val) +ON CONFLICT DO NOTHING; + +-- 6. Migrate per-language aliases from ingredient_translations.aliases +INSERT INTO ingredient_aliases (ingredient_id, lang, alias) +SELECT it.ingredient_id, it.lang, a.val +FROM ingredient_translations it, + jsonb_array_elements_text(it.aliases) a(val) +ON CONFLICT DO NOTHING; + +-- 7. Drop aliases JSONB columns +DROP INDEX IF EXISTS idx_ingredient_mappings_aliases; +ALTER TABLE ingredient_mappings DROP COLUMN aliases; +ALTER TABLE ingredient_translations DROP COLUMN aliases; + +-- 8. Drop spoonacular_id +ALTER TABLE ingredient_mappings DROP COLUMN spoonacular_id; + +-- 9. Unique constraint on canonical_name (replaces spoonacular_id as conflict key) +ALTER TABLE ingredient_mappings + ADD CONSTRAINT uq_ingredient_canonical_name UNIQUE (canonical_name); + +-- +goose Down +ALTER TABLE ingredient_mappings DROP CONSTRAINT IF EXISTS uq_ingredient_canonical_name; +ALTER TABLE ingredient_mappings DROP CONSTRAINT IF EXISTS fk_ingredient_category; +ALTER TABLE ingredient_mappings ADD COLUMN spoonacular_id INTEGER; +ALTER TABLE ingredient_translations ADD COLUMN aliases JSONB NOT NULL DEFAULT '[]'::jsonb; +ALTER TABLE ingredient_mappings ADD COLUMN aliases JSONB NOT NULL DEFAULT '[]'::jsonb; +-- Restore aliases JSONB from ingredient_aliases (best-effort) +UPDATE ingredient_mappings im + SET aliases = COALESCE( + (SELECT json_agg(ia.alias) FROM ingredient_aliases ia + WHERE ia.ingredient_id = im.id AND ia.lang = 'en'), + '[]'::json); +DROP TABLE IF EXISTS ingredient_aliases; +DROP TABLE IF EXISTS ingredient_category_translations; +DROP TABLE IF EXISTS ingredient_categories; +CREATE INDEX idx_ingredient_mappings_aliases ON ingredient_mappings USING GIN (aliases); diff --git a/client/lib/shared/models/ingredient_mapping.dart b/client/lib/shared/models/ingredient_mapping.dart index 4ad41e7..6500f22 100644 --- a/client/lib/shared/models/ingredient_mapping.dart +++ b/client/lib/shared/models/ingredient_mapping.dart @@ -7,8 +7,8 @@ class IngredientMapping { final String id; @JsonKey(name: 'canonical_name') final String canonicalName; - @JsonKey(name: 'canonical_name_ru') - final String? canonicalNameRu; + @JsonKey(name: 'category_name') + final String? categoryName; final String? category; @JsonKey(name: 'default_unit') final String? defaultUnit; @@ -18,14 +18,14 @@ class IngredientMapping { const IngredientMapping({ required this.id, required this.canonicalName, - this.canonicalNameRu, + this.categoryName, this.category, this.defaultUnit, this.storageDays, }); - /// Display name prefers Russian, falls back to canonical English name. - String get displayName => canonicalNameRu ?? canonicalName; + /// Display name is the server-resolved canonical name (language-aware from backend). + String get displayName => canonicalName; factory IngredientMapping.fromJson(Map json) => _$IngredientMappingFromJson(json); diff --git a/client/lib/shared/models/ingredient_mapping.g.dart b/client/lib/shared/models/ingredient_mapping.g.dart index bfe61e2..ddfb161 100644 --- a/client/lib/shared/models/ingredient_mapping.g.dart +++ b/client/lib/shared/models/ingredient_mapping.g.dart @@ -10,7 +10,7 @@ IngredientMapping _$IngredientMappingFromJson(Map json) => IngredientMapping( id: json['id'] as String, canonicalName: json['canonical_name'] as String, - canonicalNameRu: json['canonical_name_ru'] as String?, + categoryName: json['category_name'] as String?, category: json['category'] as String?, defaultUnit: json['default_unit'] as String?, storageDays: (json['storage_days'] as num?)?.toInt(), @@ -20,7 +20,7 @@ Map _$IngredientMappingToJson(IngredientMapping instance) => { 'id': instance.id, 'canonical_name': instance.canonicalName, - 'canonical_name_ru': instance.canonicalNameRu, + 'category_name': instance.categoryName, 'category': instance.category, 'default_unit': instance.defaultUnit, 'storage_days': instance.storageDays,