diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index 4129a07..31ad8d2 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -54,11 +54,11 @@ func initRouter(appConfig *config.Config, pool *pgxpool.Pool) (http.Handler, err ingredientRepository := ingredient.NewRepository(pool) ingredientHandler := ingredient.NewHandler(ingredientRepository) productHandler := product.NewHandler(productRepository) - recognitionHandler := recognition.NewHandler(client, ingredientRepository) + recognitionHandler := recognition.NewHandler(client, ingredientRepository, dishRepository) menuRepository := menu.NewRepository(pool) menuHandler := menu.NewHandler(menuRepository, client, pexelsClient, repository, productRepository, dishRepository) diaryRepository := diary.NewRepository(pool) - diaryHandler := diary.NewHandler(diaryRepository) + diaryHandler := diary.NewHandler(diaryRepository, dishRepository, dishRepository) homeHandler := home.NewHandler(pool) dishHandler := dish.NewHandler(dishRepository) recipeRepository := recipe.NewRepository(pool) diff --git a/backend/internal/adapters/ai/types.go b/backend/internal/adapters/ai/types.go index bc12d53..0e5fd65 100644 --- a/backend/internal/adapters/ai/types.go +++ b/backend/internal/adapters/ai/types.go @@ -96,6 +96,8 @@ type ReceiptResult struct { // DishCandidate is a single dish recognition candidate with estimated nutrition. type DishCandidate struct { + DishID *string `json:"dish_id,omitempty"` + RecipeID *string `json:"recipe_id,omitempty"` DishName string `json:"dish_name"` WeightGrams int `json:"weight_grams"` Calories float64 `json:"calories"` diff --git a/backend/internal/adapters/openai/dish.go b/backend/internal/adapters/openai/dish.go new file mode 100644 index 0000000..cc0528c --- /dev/null +++ b/backend/internal/adapters/openai/dish.go @@ -0,0 +1,97 @@ +package openai + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/food-ai/backend/internal/adapters/ai" + "github.com/food-ai/backend/internal/infra/locale" +) + +// GenerateRecipeForDish generates a single English recipe for a named dish. +func (c *Client) GenerateRecipeForDish(ctx context.Context, dishName string) (*ai.Recipe, error) { + prompt := fmt.Sprintf(`You are a chef and nutritionist. +Generate exactly 1 recipe for the dish "%s" in English. + +Requirements: +- Include accurate macros per serving +- Total cooking time: max 60 minutes + +IMPORTANT: +- All text fields MUST be in English. +- The "image_query" field MUST be in English. + +Return ONLY a valid JSON array with exactly one element, no markdown: +[{"title":"...","description":"...","cuisine":"russian|asian|european|mediterranean|american|other","difficulty":"easy|medium|hard","prep_time_min":10,"cook_time_min":20,"servings":2,"image_query":"...","ingredients":[{"name":"...","amount":100,"unit":"..."}],"steps":[{"number":1,"description":"...","timer_seconds":null}],"tags":["..."],"nutrition_per_serving":{"calories":400,"protein_g":30,"fat_g":10,"carbs_g":40}}]`, + dishName) + + var lastError error + for attempt := range maxRetries { + messages := []map[string]string{{"role": "user", "content": prompt}} + if attempt > 0 { + messages = append(messages, map[string]string{ + "role": "user", + "content": "Previous response was not valid JSON. Return ONLY a JSON array with no text before or after.", + }) + } + text, generateError := c.generateContent(ctx, messages) + if generateError != nil { + return nil, generateError + } + recipes, parseError := parseRecipesJSON(text) + if parseError != nil { + lastError = parseError + continue + } + if len(recipes) == 0 { + lastError = fmt.Errorf("empty recipes array") + continue + } + recipes[0].Nutrition.Approximate = true + return &recipes[0], nil + } + return nil, fmt.Errorf("generate recipe for dish %q after %d attempts: %w", dishName, maxRetries, lastError) +} + +// TranslateDishName translates a dish name into all supported languages (except English) +// in a single AI call. Returns a map of lang-code → translated name. +func (c *Client) TranslateDishName(ctx context.Context, name string) (map[string]string, error) { + var langLines []string + for _, language := range locale.Languages { + if language.Code != "en" { + langLines = append(langLines, fmt.Sprintf(`"%s": "%s"`, language.Code, language.EnglishName)) + } + } + if len(langLines) == 0 { + return map[string]string{}, nil + } + + prompt := fmt.Sprintf(`Translate the dish name "%s" into the following languages. +Return ONLY a valid JSON object mapping language code to translated name, no markdown: +{%s} +Fill each value with the natural translation of the dish name in that language.`, + name, strings.Join(langLines, ", ")) + + text, generateError := c.generateContent(ctx, []map[string]string{ + {"role": "user", "content": prompt}, + }) + if generateError != nil { + return nil, generateError + } + + text = strings.TrimSpace(text) + if strings.HasPrefix(text, "```") { + text = strings.TrimPrefix(text, "```json") + text = strings.TrimPrefix(text, "```") + text = strings.TrimSuffix(text, "```") + text = strings.TrimSpace(text) + } + + var translations map[string]string + if parseError := json.Unmarshal([]byte(text), &translations); parseError != nil { + return nil, fmt.Errorf("parse translations for %q: %w", name, parseError) + } + return translations, nil +} diff --git a/backend/internal/domain/diary/entity.go b/backend/internal/domain/diary/entity.go index 95db2e2..1d93638 100644 --- a/backend/internal/domain/diary/entity.go +++ b/backend/internal/domain/diary/entity.go @@ -7,14 +7,14 @@ type Entry struct { ID string `json:"id"` Date string `json:"date"` // YYYY-MM-DD MealType string `json:"meal_type"` - Name string `json:"name"` + Name string `json:"name"` // from dishes JOIN Portions float64 `json:"portions"` - Calories *float64 `json:"calories,omitempty"` + Calories *float64 `json:"calories,omitempty"` // recipe.calories_per_serving * portions ProteinG *float64 `json:"protein_g,omitempty"` FatG *float64 `json:"fat_g,omitempty"` CarbsG *float64 `json:"carbs_g,omitempty"` Source string `json:"source"` - DishID *string `json:"dish_id,omitempty"` + DishID string `json:"dish_id"` RecipeID *string `json:"recipe_id,omitempty"` PortionG *float64 `json:"portion_g,omitempty"` CreatedAt time.Time `json:"created_at"` @@ -24,12 +24,8 @@ type Entry struct { type CreateRequest struct { Date string `json:"date"` MealType string `json:"meal_type"` - Name string `json:"name"` + Name string `json:"name"` // input-only; used if DishID is nil Portions float64 `json:"portions"` - Calories *float64 `json:"calories"` - ProteinG *float64 `json:"protein_g"` - FatG *float64 `json:"fat_g"` - CarbsG *float64 `json:"carbs_g"` Source string `json:"source"` DishID *string `json:"dish_id"` RecipeID *string `json:"recipe_id"` diff --git a/backend/internal/domain/diary/handler.go b/backend/internal/domain/diary/handler.go index 52a26c7..e488730 100644 --- a/backend/internal/domain/diary/handler.go +++ b/backend/internal/domain/diary/handler.go @@ -17,14 +17,26 @@ type DiaryRepository interface { Delete(ctx context.Context, id, userID string) error } +// DishRepository is the subset of dish.Repository used by Handler to resolve dish IDs. +type DishRepository interface { + FindOrCreate(ctx context.Context, name string) (string, bool, error) +} + +// RecipeRepository is the subset of dish.Repository used by Handler to resolve recipe IDs. +type RecipeRepository interface { + FindOrCreateRecipe(ctx context.Context, dishID string, calories, proteinG, fatG, carbsG float64) (string, bool, error) +} + // Handler handles diary endpoints. type Handler struct { - repo DiaryRepository + repo DiaryRepository + dishRepo DishRepository + recipeRepo RecipeRepository } // NewHandler creates a new Handler. -func NewHandler(repo DiaryRepository) *Handler { - return &Handler{repo: repo} +func NewHandler(repo DiaryRepository, dishRepo DishRepository, recipeRepo RecipeRepository) *Handler { + return &Handler{repo: repo, dishRepo: dishRepo, recipeRepo: recipeRepo} } // GetByDate handles GET /diary?date=YYYY-MM-DD @@ -41,9 +53,9 @@ func (h *Handler) GetByDate(w http.ResponseWriter, r *http.Request) { return } - entries, err := h.repo.ListByDate(r.Context(), userID, date) - if err != nil { - slog.Error("list diary by date", "err", err) + entries, listError := h.repo.ListByDate(r.Context(), userID, date) + if listError != nil { + slog.Error("list diary by date", "err", listError) writeError(w, http.StatusInternalServerError, "failed to load diary") return } @@ -62,18 +74,42 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) { } var req CreateRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + if decodeError := json.NewDecoder(r.Body).Decode(&req); decodeError != nil { writeError(w, http.StatusBadRequest, "invalid request body") return } - if req.Date == "" || req.Name == "" || req.MealType == "" { - writeError(w, http.StatusBadRequest, "date, meal_type and name are required") + if req.Date == "" || req.MealType == "" { + writeError(w, http.StatusBadRequest, "date and meal_type are required") + return + } + if req.DishID == nil && req.Name == "" { + writeError(w, http.StatusBadRequest, "dish_id or name is required") return } - entry, err := h.repo.Create(r.Context(), userID, req) - if err != nil { - slog.Error("create diary entry", "err", err) + if req.DishID == nil { + dishID, _, resolveError := h.dishRepo.FindOrCreate(r.Context(), req.Name) + if resolveError != nil { + slog.Error("resolve dish for diary entry", "name", req.Name, "err", resolveError) + writeError(w, http.StatusInternalServerError, "failed to resolve dish") + return + } + req.DishID = &dishID + } + + if req.RecipeID == nil { + recipeID, _, recipeError := h.recipeRepo.FindOrCreateRecipe(r.Context(), *req.DishID, 0, 0, 0, 0) + if recipeError != nil { + slog.Error("find or create recipe for diary entry", "dish_id", *req.DishID, "err", recipeError) + writeError(w, http.StatusInternalServerError, "failed to resolve recipe") + return + } + req.RecipeID = &recipeID + } + + entry, createError := h.repo.Create(r.Context(), userID, req) + if createError != nil { + slog.Error("create diary entry", "err", createError) writeError(w, http.StatusInternalServerError, "failed to create diary entry") return } @@ -89,12 +125,12 @@ func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) { } id := chi.URLParam(r, "id") - if err := h.repo.Delete(r.Context(), id, userID); err != nil { - if err == ErrNotFound { + if deleteError := h.repo.Delete(r.Context(), id, userID); deleteError != nil { + if deleteError == ErrNotFound { writeError(w, http.StatusNotFound, "diary entry not found") return } - slog.Error("delete diary entry", "err", err) + slog.Error("delete diary entry", "err", deleteError) writeError(w, http.StatusInternalServerError, "failed to delete diary entry") return } diff --git a/backend/internal/domain/diary/mocks/repository.go b/backend/internal/domain/diary/mocks/repository.go index bbffc54..19c4867 100644 --- a/backend/internal/domain/diary/mocks/repository.go +++ b/backend/internal/domain/diary/mocks/repository.go @@ -6,6 +6,30 @@ import ( "github.com/food-ai/backend/internal/domain/diary" ) +// MockDishRepository is a test double implementing diary.DishRepository. +type MockDishRepository struct { + FindOrCreateFn func(ctx context.Context, name string) (string, bool, error) +} + +func (m *MockDishRepository) FindOrCreate(ctx context.Context, name string) (string, bool, error) { + if m.FindOrCreateFn != nil { + return m.FindOrCreateFn(ctx, name) + } + return "", false, nil +} + +// MockRecipeRepository is a test double implementing diary.RecipeRepository. +type MockRecipeRepository struct { + FindOrCreateRecipeFn func(ctx context.Context, dishID string, calories, proteinG, fatG, carbsG float64) (string, bool, error) +} + +func (m *MockRecipeRepository) FindOrCreateRecipe(ctx context.Context, dishID string, calories, proteinG, fatG, carbsG float64) (string, bool, error) { + if m.FindOrCreateRecipeFn != nil { + return m.FindOrCreateRecipeFn(ctx, dishID, calories, proteinG, fatG, carbsG) + } + return "", false, nil +} + // MockDiaryRepository is a test double implementing diary.DiaryRepository. type MockDiaryRepository struct { ListByDateFn func(ctx context.Context, userID, date string) ([]*diary.Entry, error) diff --git a/backend/internal/domain/diary/repository.go b/backend/internal/domain/diary/repository.go index f7a3195..35df2bf 100644 --- a/backend/internal/domain/diary/repository.go +++ b/backend/internal/domain/diary/repository.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" + "github.com/food-ai/backend/internal/infra/locale" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) @@ -23,14 +24,24 @@ func NewRepository(pool *pgxpool.Pool) *Repository { } // ListByDate returns all diary entries for a user on a given date (YYYY-MM-DD). +// Dish name and macros are computed via JOIN with dishes and recipes. func (r *Repository) ListByDate(ctx context.Context, userID, date string) ([]*Entry, error) { + lang := locale.FromContext(ctx) rows, err := r.pool.Query(ctx, ` - SELECT id, date::text, meal_type, name, portions, - calories, protein_g, fat_g, carbs_g, - source, dish_id, recipe_id, portion_g, created_at - FROM meal_diary - WHERE user_id = $1 AND date = $2::date - ORDER BY created_at ASC`, userID, date) + SELECT + md.id, md.date::text, md.meal_type, md.portions, + md.source, md.dish_id::text, md.recipe_id::text, md.portion_g, md.created_at, + COALESCE(dt.name, d.name) AS dish_name, + r.calories_per_serving * md.portions, + r.protein_per_serving * md.portions, + r.fat_per_serving * md.portions, + r.carbs_per_serving * md.portions + FROM meal_diary md + JOIN dishes d ON d.id = md.dish_id + LEFT JOIN dish_translations dt ON dt.dish_id = d.id AND dt.lang = $3 + LEFT JOIN recipes r ON r.id = md.recipe_id + WHERE md.user_id = $1 AND md.date = $2::date + ORDER BY md.created_at ASC`, userID, date, lang) if err != nil { return nil, fmt.Errorf("list diary: %w", err) } @@ -38,17 +49,18 @@ func (r *Repository) ListByDate(ctx context.Context, userID, date string) ([]*En var result []*Entry for rows.Next() { - e, err := scanEntry(rows) - if err != nil { - return nil, fmt.Errorf("scan diary entry: %w", err) + entry, scanError := scanEntry(rows) + if scanError != nil { + return nil, fmt.Errorf("scan diary entry: %w", scanError) } - result = append(result, e) + result = append(result, entry) } return result, rows.Err() } -// Create inserts a new diary entry and returns the stored record. +// Create inserts a new diary entry and returns the stored record (with computed macros). func (r *Repository) Create(ctx context.Context, userID string, req CreateRequest) (*Entry, error) { + lang := locale.FromContext(ctx) portions := req.Portions if portions <= 0 { portions = 1 @@ -58,25 +70,40 @@ func (r *Repository) Create(ctx context.Context, userID string, req CreateReques source = "manual" } + var entryID string + insertError := r.pool.QueryRow(ctx, ` + INSERT INTO meal_diary (user_id, date, meal_type, portions, source, dish_id, recipe_id, portion_g) + VALUES ($1, $2::date, $3, $4, $5, $6, $7, $8) + RETURNING id`, + userID, req.Date, req.MealType, portions, source, req.DishID, req.RecipeID, req.PortionG, + ).Scan(&entryID) + if insertError != nil { + return nil, fmt.Errorf("insert diary entry: %w", insertError) + } + row := r.pool.QueryRow(ctx, ` - INSERT INTO meal_diary (user_id, date, meal_type, name, portions, - calories, protein_g, fat_g, carbs_g, source, dish_id, recipe_id, portion_g) - VALUES ($1, $2::date, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) - RETURNING id, date::text, meal_type, name, portions, - calories, protein_g, fat_g, carbs_g, source, dish_id, recipe_id, portion_g, created_at`, - userID, req.Date, req.MealType, req.Name, portions, - req.Calories, req.ProteinG, req.FatG, req.CarbsG, - source, req.DishID, req.RecipeID, req.PortionG, - ) + SELECT + md.id, md.date::text, md.meal_type, md.portions, + md.source, md.dish_id::text, md.recipe_id::text, md.portion_g, md.created_at, + COALESCE(dt.name, d.name) AS dish_name, + r.calories_per_serving * md.portions, + r.protein_per_serving * md.portions, + r.fat_per_serving * md.portions, + r.carbs_per_serving * md.portions + FROM meal_diary md + JOIN dishes d ON d.id = md.dish_id + LEFT JOIN dish_translations dt ON dt.dish_id = d.id AND dt.lang = $2 + LEFT JOIN recipes r ON r.id = md.recipe_id + WHERE md.id = $1`, entryID, lang) return scanEntry(row) } // Delete removes a diary entry for the given user. func (r *Repository) Delete(ctx context.Context, id, userID string) error { - tag, err := r.pool.Exec(ctx, + tag, deleteError := r.pool.Exec(ctx, `DELETE FROM meal_diary WHERE id = $1 AND user_id = $2`, id, userID) - if err != nil { - return fmt.Errorf("delete diary entry: %w", err) + if deleteError != nil { + return fmt.Errorf("delete diary entry: %w", deleteError) } if tag.RowsAffected() == 0 { return ErrNotFound @@ -91,14 +118,15 @@ type scannable interface { } func scanEntry(s scannable) (*Entry, error) { - var e Entry - err := s.Scan( - &e.ID, &e.Date, &e.MealType, &e.Name, &e.Portions, - &e.Calories, &e.ProteinG, &e.FatG, &e.CarbsG, - &e.Source, &e.DishID, &e.RecipeID, &e.PortionG, &e.CreatedAt, + var entry Entry + scanError := s.Scan( + &entry.ID, &entry.Date, &entry.MealType, &entry.Portions, + &entry.Source, &entry.DishID, &entry.RecipeID, &entry.PortionG, &entry.CreatedAt, + &entry.Name, + &entry.Calories, &entry.ProteinG, &entry.FatG, &entry.CarbsG, ) - if errors.Is(err, pgx.ErrNoRows) { + if errors.Is(scanError, pgx.ErrNoRows) { return nil, ErrNotFound } - return &e, err + return &entry, scanError } diff --git a/backend/internal/domain/dish/repository.go b/backend/internal/domain/dish/repository.go index 3e24324..e7d049a 100644 --- a/backend/internal/domain/dish/repository.go +++ b/backend/internal/domain/dish/repository.go @@ -98,6 +98,125 @@ func (r *Repository) List(ctx context.Context) ([]*Dish, error) { return dishes, nil } +// FindOrCreate returns the dish ID and whether it was newly created. +// Looks up by case-insensitive name match; creates a minimal dish row if not found. +func (r *Repository) FindOrCreate(ctx context.Context, name string) (id string, created bool, err error) { + queryError := r.pool.QueryRow(ctx, + `SELECT id FROM dishes WHERE LOWER(name) = LOWER($1) LIMIT 1`, name, + ).Scan(&id) + if queryError == nil { + return id, false, nil + } + if !errors.Is(queryError, pgx.ErrNoRows) { + return "", false, fmt.Errorf("find dish %q: %w", name, queryError) + } + insertError := r.pool.QueryRow(ctx, + `INSERT INTO dishes (name) VALUES ($1) RETURNING id`, name, + ).Scan(&id) + if insertError != nil { + return "", false, fmt.Errorf("create dish %q: %w", name, insertError) + } + return id, true, nil +} + +// FindOrCreateRecipe returns the recipe ID for the given dish, creating a minimal stub if none exists. +// Pass zeros for all nutrition params when no estimates are available. +func (r *Repository) FindOrCreateRecipe(ctx context.Context, dishID string, calories, proteinG, fatG, carbsG float64) (string, bool, error) { + var recipeID string + findError := r.pool.QueryRow(ctx, + `SELECT id FROM recipes WHERE dish_id = $1 ORDER BY created_at ASC LIMIT 1`, + dishID, + ).Scan(&recipeID) + if findError == nil { + return recipeID, false, nil + } + if !errors.Is(findError, pgx.ErrNoRows) { + return "", false, fmt.Errorf("find recipe for dish: %w", findError) + } + + insertError := r.pool.QueryRow(ctx, ` + INSERT INTO recipes (dish_id, source, servings, calories_per_serving, protein_per_serving, fat_per_serving, carbs_per_serving) + VALUES ($1, 'ai', 1, $2, $3, $4, $5) + RETURNING id`, + dishID, nullableFloat(calories), nullableFloat(proteinG), nullableFloat(fatG), nullableFloat(carbsG), + ).Scan(&recipeID) + if insertError != nil { + return "", false, fmt.Errorf("create recipe stub for dish: %w", insertError) + } + return recipeID, true, nil +} + +// UpsertTranslation inserts or updates the name translation for a dish in a given language. +func (r *Repository) UpsertTranslation(ctx context.Context, dishID, lang, name string) error { + _, upsertError := r.pool.Exec(ctx, + `INSERT INTO dish_translations (dish_id, lang, name) + VALUES ($1, $2, $3) + ON CONFLICT (dish_id, lang) DO UPDATE SET name = EXCLUDED.name`, + dishID, lang, name, + ) + if upsertError != nil { + return fmt.Errorf("upsert dish translation %s/%s: %w", dishID, lang, upsertError) + } + return nil +} + +// AddRecipe inserts a recipe with its ingredients and steps for an existing dish. +func (r *Repository) AddRecipe(ctx context.Context, dishID string, req CreateRequest) (string, error) { + transaction, beginError := r.pool.BeginTx(ctx, pgx.TxOptions{}) + if beginError != nil { + return "", fmt.Errorf("begin tx: %w", beginError) + } + defer transaction.Rollback(ctx) //nolint:errcheck + + source := req.Source + if source == "" { + source = "ai" + } + + var recipeID string + insertError := transaction.QueryRow(ctx, ` + INSERT INTO recipes (dish_id, source, difficulty, prep_time_min, cook_time_min, servings, + calories_per_serving, protein_per_serving, fat_per_serving, carbs_per_serving) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + RETURNING id`, + dishID, source, + nullableStr(req.Difficulty), nullableInt(req.PrepTimeMin), nullableInt(req.CookTimeMin), nullableInt(req.Servings), + nullableFloat(req.Calories), nullableFloat(req.Protein), nullableFloat(req.Fat), nullableFloat(req.Carbs), + ).Scan(&recipeID) + if insertError != nil { + return "", fmt.Errorf("insert recipe for dish %s: %w", dishID, insertError) + } + + for i, ingredient := range req.Ingredients { + if _, ingredientError := transaction.Exec(ctx, ` + INSERT INTO recipe_ingredients (recipe_id, name, amount, unit_code, is_optional, sort_order) + VALUES ($1, $2, $3, NULLIF($4,''), $5, $6)`, + recipeID, ingredient.Name, ingredient.Amount, ingredient.Unit, ingredient.IsOptional, i, + ); ingredientError != nil { + return "", fmt.Errorf("insert ingredient %d: %w", i, ingredientError) + } + } + + for _, step := range req.Steps { + stepNumber := step.Number + if stepNumber <= 0 { + stepNumber = 1 + } + if _, stepError := transaction.Exec(ctx, ` + INSERT INTO recipe_steps (recipe_id, step_number, timer_seconds, description) + VALUES ($1, $2, $3, $4)`, + recipeID, stepNumber, step.TimerSeconds, step.Description, + ); stepError != nil { + return "", fmt.Errorf("insert step %d: %w", stepNumber, stepError) + } + } + + if commitError := transaction.Commit(ctx); commitError != nil { + return "", fmt.Errorf("commit: %w", commitError) + } + return recipeID, nil +} + // Create creates a new dish + recipe row from a CreateRequest. // Returns the recipe ID to be used in menu_items or user_saved_recipes. func (r *Repository) Create(ctx context.Context, req CreateRequest) (recipeID string, err error) { diff --git a/backend/internal/domain/recognition/handler.go b/backend/internal/domain/recognition/handler.go index 8040e47..57d0e52 100644 --- a/backend/internal/domain/recognition/handler.go +++ b/backend/internal/domain/recognition/handler.go @@ -9,11 +9,20 @@ import ( "sync" "github.com/food-ai/backend/internal/adapters/ai" + "github.com/food-ai/backend/internal/domain/dish" + "github.com/food-ai/backend/internal/domain/ingredient" "github.com/food-ai/backend/internal/infra/locale" "github.com/food-ai/backend/internal/infra/middleware" - "github.com/food-ai/backend/internal/domain/ingredient" ) +// DishRepository is the subset of dish.Repository used by this handler. +type DishRepository interface { + FindOrCreate(ctx context.Context, name string) (string, bool, error) + FindOrCreateRecipe(ctx context.Context, dishID string, calories, proteinG, fatG, carbsG float64) (string, bool, error) + UpsertTranslation(ctx context.Context, dishID, lang, name string) error + AddRecipe(ctx context.Context, dishID string, req dish.CreateRequest) (string, error) +} + // IngredientRepository is the subset of ingredient.Repository used by this handler. type IngredientRepository interface { FuzzyMatch(ctx context.Context, name string) (*ingredient.IngredientMapping, error) @@ -28,17 +37,20 @@ type Recognizer interface { RecognizeProducts(ctx context.Context, imageBase64, mimeType, lang string) ([]ai.RecognizedItem, error) RecognizeDish(ctx context.Context, imageBase64, mimeType, lang string) (*ai.DishResult, error) ClassifyIngredient(ctx context.Context, name string) (*ai.IngredientClassification, error) + GenerateRecipeForDish(ctx context.Context, dishName string) (*ai.Recipe, error) + TranslateDishName(ctx context.Context, name string) (map[string]string, error) } // Handler handles POST /ai/* recognition endpoints. type Handler struct { recognizer Recognizer ingredientRepo IngredientRepository + dishRepo DishRepository } // NewHandler creates a new Handler. -func NewHandler(recognizer Recognizer, repo IngredientRepository) *Handler { - return &Handler{recognizer: recognizer, ingredientRepo: repo} +func NewHandler(recognizer Recognizer, repo IngredientRepository, dishRepo DishRepository) *Handler { + return &Handler{recognizer: recognizer, ingredientRepo: repo, dishRepo: dishRepo} } // --------------------------------------------------------------------------- @@ -159,6 +171,41 @@ func (h *Handler) RecognizeDish(w http.ResponseWriter, r *http.Request) { return } + // Resolve dish_id and recipe_id for each candidate in parallel. + var mu sync.Mutex + var wg sync.WaitGroup + for i := range result.Candidates { + wg.Add(1) + go func(i int) { + defer wg.Done() + candidate := result.Candidates[i] + dishID, created, findError := h.dishRepo.FindOrCreate(r.Context(), candidate.DishName) + if findError != nil { + slog.Warn("find or create dish", "name", candidate.DishName, "err", findError) + return + } + mu.Lock() + result.Candidates[i].DishID = &dishID + mu.Unlock() + if created { + go h.enrichDishInBackground(dishID, candidate.DishName) + } + + recipeID, _, recipeError := h.dishRepo.FindOrCreateRecipe( + r.Context(), dishID, + candidate.Calories, candidate.ProteinG, candidate.FatG, candidate.CarbsG, + ) + if recipeError != nil { + slog.Warn("find or create recipe", "dish_id", dishID, "err", recipeError) + return + } + mu.Lock() + result.Candidates[i].RecipeID = &recipeID + mu.Unlock() + }(i) + } + wg.Wait() + writeJSON(w, http.StatusOK, result) } @@ -262,6 +309,58 @@ func (h *Handler) saveClassification(ctx context.Context, c *ai.IngredientClassi return saved } +// enrichDishInBackground generates name translations for a newly created dish stub. +// Recipe creation is handled synchronously in RecognizeDish. +// Runs as a fire-and-forget goroutine so it never blocks the HTTP response. +func (h *Handler) enrichDishInBackground(dishID, dishName string) { + enrichContext := context.Background() + + translations, translateError := h.recognizer.TranslateDishName(enrichContext, dishName) + if translateError != nil { + slog.Warn("translate dish name", "name", dishName, "err", translateError) + return + } + for lang, translatedName := range translations { + if upsertError := h.dishRepo.UpsertTranslation(enrichContext, dishID, lang, translatedName); upsertError != nil { + slog.Warn("upsert dish translation", "dish_id", dishID, "lang", lang, "err", upsertError) + } + } +} + +// aiRecipeToCreateRequest converts an AI-generated recipe into a dish.CreateRequest. +func aiRecipeToCreateRequest(recipe *ai.Recipe) dish.CreateRequest { + ingredients := make([]dish.IngredientInput, len(recipe.Ingredients)) + for i, ingredient := range recipe.Ingredients { + ingredients[i] = dish.IngredientInput{ + Name: ingredient.Name, Amount: ingredient.Amount, Unit: ingredient.Unit, + } + } + steps := make([]dish.StepInput, len(recipe.Steps)) + for i, step := range recipe.Steps { + steps[i] = dish.StepInput{ + Number: step.Number, Description: step.Description, TimerSeconds: step.TimerSeconds, + } + } + return dish.CreateRequest{ + Name: recipe.Title, + Description: recipe.Description, + CuisineSlug: recipe.Cuisine, + ImageURL: recipe.ImageURL, + Tags: recipe.Tags, + Source: "ai", + Difficulty: recipe.Difficulty, + PrepTimeMin: recipe.PrepTimeMin, + CookTimeMin: recipe.CookTimeMin, + Servings: recipe.Servings, + Calories: recipe.Nutrition.Calories, + Protein: recipe.Nutrition.ProteinG, + Fat: recipe.Nutrition.FatG, + Carbs: recipe.Nutrition.CarbsG, + Ingredients: ingredients, + Steps: steps, + } +} + // MergeAndDeduplicate combines results from multiple images. // Items sharing the same name (case-insensitive) have their quantities summed. func MergeAndDeduplicate(batches [][]ai.RecognizedItem) []ai.RecognizedItem { diff --git a/backend/migrations/001_initial_schema.sql b/backend/migrations/001_initial_schema.sql index 94ef651..4aaf6b8 100644 --- a/backend/migrations/001_initial_schema.sql +++ b/backend/migrations/001_initial_schema.sql @@ -398,14 +398,9 @@ CREATE TABLE meal_diary ( user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, date DATE NOT NULL, meal_type TEXT NOT NULL, - name TEXT NOT NULL, portions DECIMAL(5,2) NOT NULL DEFAULT 1, - calories DECIMAL(8,2), - protein_g DECIMAL(8,2), - fat_g DECIMAL(8,2), - carbs_g DECIMAL(8,2), source TEXT NOT NULL DEFAULT 'manual', - dish_id UUID REFERENCES dishes(id) ON DELETE SET NULL, + dish_id UUID NOT NULL REFERENCES dishes(id) ON DELETE RESTRICT, recipe_id UUID REFERENCES recipes(id) ON DELETE SET NULL, portion_g DECIMAL(10,2), created_at TIMESTAMPTZ NOT NULL DEFAULT now() diff --git a/backend/tests/diary/handler_test.go b/backend/tests/diary/handler_test.go index dd04c90..021a683 100644 --- a/backend/tests/diary/handler_test.go +++ b/backend/tests/diary/handler_test.go @@ -39,8 +39,17 @@ func authorizedRequest(method, target string, body []byte) *http.Request { return request } +func newHandler(diaryRepo diary.DiaryRepository, dishRepo diary.DishRepository, recipeRepo diary.RecipeRepository) *diary.Handler { + return diary.NewHandler(diaryRepo, dishRepo, recipeRepo) +} + +func defaultMocks() (*diarymocks.MockDiaryRepository, *diarymocks.MockDishRepository, *diarymocks.MockRecipeRepository) { + return &diarymocks.MockDiaryRepository{}, &diarymocks.MockDishRepository{}, &diarymocks.MockRecipeRepository{} +} + func TestGetByDate_MissingQueryParam(t *testing.T) { - handler := diary.NewHandler(&diarymocks.MockDiaryRepository{}) + diaryRepo, dishRepo, recipeRepo := defaultMocks() + handler := newHandler(diaryRepo, dishRepo, recipeRepo) router := buildRouter(handler, "user-1") recorder := httptest.NewRecorder() @@ -59,7 +68,8 @@ func TestGetByDate_Success(t *testing.T) { }, nil }, } - handler := diary.NewHandler(mockRepo) + _, dishRepo, recipeRepo := defaultMocks() + handler := newHandler(mockRepo, dishRepo, recipeRepo) router := buildRouter(handler, "user-1") recorder := httptest.NewRecorder() @@ -79,7 +89,8 @@ func TestGetByDate_Success(t *testing.T) { } func TestCreate_MissingDate(t *testing.T) { - handler := diary.NewHandler(&diarymocks.MockDiaryRepository{}) + diaryRepo, dishRepo, recipeRepo := defaultMocks() + handler := newHandler(diaryRepo, dishRepo, recipeRepo) router := buildRouter(handler, "user-1") body, _ := json.Marshal(map[string]string{"name": "Oatmeal", "meal_type": "breakfast"}) @@ -91,8 +102,9 @@ func TestCreate_MissingDate(t *testing.T) { } } -func TestCreate_MissingName(t *testing.T) { - handler := diary.NewHandler(&diarymocks.MockDiaryRepository{}) +func TestCreate_MissingNameAndDishID(t *testing.T) { + diaryRepo, dishRepo, recipeRepo := defaultMocks() + handler := newHandler(diaryRepo, dishRepo, recipeRepo) router := buildRouter(handler, "user-1") body, _ := json.Marshal(map[string]string{"date": "2026-03-15", "meal_type": "breakfast"}) @@ -105,7 +117,8 @@ func TestCreate_MissingName(t *testing.T) { } func TestCreate_MissingMealType(t *testing.T) { - handler := diary.NewHandler(&diarymocks.MockDiaryRepository{}) + diaryRepo, dishRepo, recipeRepo := defaultMocks() + handler := newHandler(diaryRepo, dishRepo, recipeRepo) router := buildRouter(handler, "user-1") body, _ := json.Marshal(map[string]string{"date": "2026-03-15", "name": "Oatmeal"}) @@ -118,20 +131,31 @@ func TestCreate_MissingMealType(t *testing.T) { } func TestCreate_Success(t *testing.T) { - mockRepo := &diarymocks.MockDiaryRepository{ + mockDiaryRepo := &diarymocks.MockDiaryRepository{ CreateFn: func(ctx context.Context, userID string, req diary.CreateRequest) (*diary.Entry, error) { return &diary.Entry{ ID: "entry-1", Date: req.Date, MealType: req.MealType, - Name: req.Name, + Name: "Oatmeal", Portions: 1, Source: "manual", + DishID: "dish-1", CreatedAt: time.Now(), }, nil }, } - handler := diary.NewHandler(mockRepo) + mockDishRepo := &diarymocks.MockDishRepository{ + FindOrCreateFn: func(ctx context.Context, name string) (string, bool, error) { + return "dish-1", false, nil + }, + } + mockRecipeRepo := &diarymocks.MockRecipeRepository{ + FindOrCreateRecipeFn: func(ctx context.Context, dishID string, calories, proteinG, fatG, carbsG float64) (string, bool, error) { + return "recipe-1", false, nil + }, + } + handler := newHandler(mockDiaryRepo, mockDishRepo, mockRecipeRepo) router := buildRouter(handler, "user-1") body, _ := json.Marshal(diary.CreateRequest{ @@ -154,7 +178,8 @@ func TestDelete_NotFound(t *testing.T) { return diary.ErrNotFound }, } - handler := diary.NewHandler(mockRepo) + _, dishRepo, recipeRepo := defaultMocks() + handler := newHandler(mockRepo, dishRepo, recipeRepo) router := buildRouter(handler, "user-1") recorder := httptest.NewRecorder() @@ -171,7 +196,8 @@ func TestDelete_Success(t *testing.T) { return nil }, } - handler := diary.NewHandler(mockRepo) + _, dishRepo, recipeRepo := defaultMocks() + handler := newHandler(mockRepo, dishRepo, recipeRepo) router := buildRouter(handler, "user-1") recorder := httptest.NewRecorder() diff --git a/client/lib/core/router/app_router.dart b/client/lib/core/router/app_router.dart index 1475f94..94e8cf4 100644 --- a/client/lib/core/router/app_router.dart +++ b/client/lib/core/router/app_router.dart @@ -14,7 +14,6 @@ import '../../features/products/add_product_screen.dart'; import '../../features/scan/scan_screen.dart'; import '../../features/scan/recognition_confirm_screen.dart'; import '../../features/scan/recognition_service.dart'; -import '../../features/menu/diary_screen.dart'; import '../../features/menu/menu_screen.dart'; import '../../features/menu/shopping_list_screen.dart'; import '../../features/recipes/recipe_detail_screen.dart'; @@ -129,14 +128,6 @@ final routerProvider = Provider((ref) { return ShoppingListScreen(week: week); }, ), - // Diary — full-screen, no bottom nav. - GoRoute( - path: '/menu/diary', - builder: (context, state) { - final date = state.extra as String? ?? ''; - return DiaryScreen(date: date); - }, - ), // Scan / recognition flow — all without bottom nav. GoRoute( path: '/scan', diff --git a/client/lib/features/home/home_screen.dart b/client/lib/features/home/home_screen.dart index 0368458..5b03603 100644 --- a/client/lib/features/home/home_screen.dart +++ b/client/lib/features/home/home_screen.dart @@ -89,7 +89,7 @@ class HomeScreen extends ConsumerWidget { _ExpiringBanner(items: expiringSoon), ], const SizedBox(height: 16), - _QuickActionsRow(date: dateString), + _QuickActionsRow(), if (recommendations.isNotEmpty) ...[ const SizedBox(height: 20), _SectionTitle('Рекомендуем приготовить'), @@ -988,8 +988,7 @@ class _ExpiringBanner extends StatelessWidget { // ── Quick actions ───────────────────────────────────────────── class _QuickActionsRow extends StatelessWidget { - final String date; - const _QuickActionsRow({required this.date}); + const _QuickActionsRow(); @override Widget build(BuildContext context) { @@ -1010,14 +1009,6 @@ class _QuickActionsRow extends StatelessWidget { onTap: () => context.push('/menu'), ), ), - const SizedBox(width: 8), - Expanded( - child: _ActionButton( - icon: Icons.book_outlined, - label: 'Дневник', - onTap: () => context.push('/menu/diary', extra: date), - ), - ), ], ); } diff --git a/client/lib/features/menu/menu_screen.dart b/client/lib/features/menu/menu_screen.dart index f6f44a3..152787f 100644 --- a/client/lib/features/menu/menu_screen.dart +++ b/client/lib/features/menu/menu_screen.dart @@ -150,20 +150,6 @@ class _MenuContent extends StatelessWidget { label: const Text('Список покупок'), ), ), - const SizedBox(height: 8), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: OutlinedButton.icon( - onPressed: () { - final today = DateTime.now(); - final dateStr = - '${today.year}-${today.month.toString().padLeft(2, '0')}-${today.day.toString().padLeft(2, '0')}'; - context.push('/menu/diary', extra: dateStr); - }, - icon: const Icon(Icons.book_outlined), - label: const Text('Дневник питания'), - ), - ), ], ); } diff --git a/client/lib/features/scan/dish_result_screen.dart b/client/lib/features/scan/dish_result_screen.dart index d6f1ac1..a33f906 100644 --- a/client/lib/features/scan/dish_result_screen.dart +++ b/client/lib/features/scan/dish_result_screen.dart @@ -106,6 +106,7 @@ class _DishResultSheetState extends ConsumerState { 'carbs_g': scaledCarbs, 'portion_g': _portionGrams, 'source': 'recognition', + if (_selected.dishId != null) 'dish_id': _selected.dishId, }); if (mounted) widget.onAdded(); } catch (addError) { diff --git a/client/lib/features/scan/recognition_service.dart b/client/lib/features/scan/recognition_service.dart index dba2698..7051381 100644 --- a/client/lib/features/scan/recognition_service.dart +++ b/client/lib/features/scan/recognition_service.dart @@ -65,6 +65,7 @@ class ReceiptResult { /// A single dish recognition candidate with estimated nutrition for the portion in the photo. class DishCandidate { + final String? dishId; final String dishName; final int weightGrams; final double calories; @@ -74,6 +75,7 @@ class DishCandidate { final double confidence; const DishCandidate({ + this.dishId, required this.dishName, required this.weightGrams, required this.calories, @@ -85,6 +87,7 @@ class DishCandidate { factory DishCandidate.fromJson(Map json) { return DishCandidate( + dishId: json['dish_id'] as String?, dishName: json['dish_name'] as String? ?? '', weightGrams: json['weight_grams'] as int? ?? 0, calories: (json['calories'] as num?)?.toDouble() ?? 0,