From fee240da7dfaacfc99b275e1693a392f76bbe98e Mon Sep 17 00:00:00 2001 From: dbastrikin Date: Sun, 15 Mar 2026 21:27:04 +0200 Subject: [PATCH] refactor: introduce adapter pattern for AI provider (OpenAI) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add internal/adapters/ai/types.go with neutral shared types (Recipe, DayPlan, RecognizedItem, IngredientClassification, etc.) - Remove types from internal/adapters/openai/ — adapter now uses ai.* - Define Recognizer interface in recognition package - Define MenuGenerator interface in menu package - Define RecipeGenerator interface in recommendation package - Handler structs now hold interfaces, not *openai.Client - Add wire.Bind entries for the three new interface bindings To swap OpenAI for another provider: implement the three interfaces using ai.* types and change the wire.Bind lines in cmd/server/wire.go. Co-Authored-By: Claude Sonnet 4.6 --- backend/cmd/server/wire.go | 8 +- backend/internal/adapters/ai/types.go | 128 ++++++++++++++++++ backend/internal/adapters/openai/menu.go | 36 ++--- backend/internal/adapters/openai/recipe.go | 67 +-------- .../internal/adapters/openai/recognition.go | 80 ++--------- backend/internal/menu/handler.go | 21 +-- backend/internal/recognition/handler.go | 40 +++--- backend/internal/recommendation/handler.go | 31 +++-- 8 files changed, 217 insertions(+), 194 deletions(-) create mode 100644 backend/internal/adapters/ai/types.go diff --git a/backend/cmd/server/wire.go b/backend/cmd/server/wire.go index b823562..facc344 100644 --- a/backend/cmd/server/wire.go +++ b/backend/cmd/server/wire.go @@ -13,6 +13,7 @@ import ( "github.com/food-ai/backend/internal/ingredient" "github.com/food-ai/backend/internal/menu" "github.com/food-ai/backend/internal/infra/middleware" + "github.com/food-ai/backend/internal/adapters/openai" "github.com/food-ai/backend/internal/adapters/pexels" "github.com/food-ai/backend/internal/product" "github.com/food-ai/backend/internal/recipe" @@ -104,8 +105,11 @@ func initRouter(appConfig *config.Config, pool *pgxpool.Pool) (http.Handler, err wire.Bind(new(recommendation.PhotoSearcher), new(*pexels.Client)), wire.Bind(new(recommendation.UserLoader), new(*user.Repository)), wire.Bind(new(recommendation.ProductLister), new(*product.Repository)), - wire.Bind(new(recognition.IngredientRepository), new(*ingredient.Repository)), - wire.Bind(new(middleware.AccessTokenValidator), new(*jwtAdapter)), + wire.Bind(new(recognition.IngredientRepository), new(*ingredient.Repository)), + wire.Bind(new(recognition.Recognizer), new(*openai.Client)), + wire.Bind(new(menu.MenuGenerator), new(*openai.Client)), + wire.Bind(new(recommendation.RecipeGenerator), new(*openai.Client)), + wire.Bind(new(middleware.AccessTokenValidator), new(*jwtAdapter)), ) return nil, nil } diff --git a/backend/internal/adapters/ai/types.go b/backend/internal/adapters/ai/types.go new file mode 100644 index 0000000..1e88be2 --- /dev/null +++ b/backend/internal/adapters/ai/types.go @@ -0,0 +1,128 @@ +package ai + +// RecipeRequest contains parameters for recipe generation. +type RecipeRequest struct { + 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 an AI-generated recipe. +type Recipe struct { + Title string `json:"title"` + Description string `json:"description"` + Cuisine string `json:"cuisine"` + Difficulty string `json:"difficulty"` + PrepTimeMin int `json:"prep_time_min"` + CookTimeMin int `json:"cook_time_min"` + Servings int `json:"servings"` + ImageQuery string `json:"image_query"` + ImageURL string `json:"image_url"` + Ingredients []Ingredient `json:"ingredients"` + Steps []Step `json:"steps"` + Tags []string `json:"tags"` + Nutrition NutritionInfo `json:"nutrition_per_serving"` +} + +// Ingredient is a single ingredient in a recipe. +type Ingredient struct { + Name string `json:"name"` + Amount float64 `json:"amount"` + Unit string `json:"unit"` +} + +// Step is a single preparation step. +type Step struct { + Number int `json:"number"` + Description string `json:"description"` + TimerSeconds *int `json:"timer_seconds"` +} + +// NutritionInfo contains approximate nutritional information per serving. +type NutritionInfo struct { + Calories float64 `json:"calories"` + ProteinG float64 `json:"protein_g"` + FatG float64 `json:"fat_g"` + CarbsG float64 `json:"carbs_g"` + Approximate bool `json:"approximate"` +} + +// MenuRequest contains parameters for weekly menu generation. +type MenuRequest struct { + UserGoal string + DailyCalories int + Restrictions []string + CuisinePrefs []string + AvailableProducts []string + Lang string // ISO 639-1 target language code, e.g. "en", "ru" +} + +// DayPlan is the AI-generated plan for a single day. +type DayPlan struct { + Day int `json:"day"` + Meals []MealEntry `json:"meals"` +} + +// MealEntry is a single meal within a day plan. +type MealEntry struct { + MealType string `json:"meal_type"` // breakfast | lunch | dinner + Recipe Recipe `json:"recipe"` +} + +// RecognizedItem is a food item identified in an image. +type RecognizedItem struct { + Name string `json:"name"` + Quantity float64 `json:"quantity"` + Unit string `json:"unit"` + Category string `json:"category"` + Confidence float64 `json:"confidence"` +} + +// UnrecognizedItem is text from a receipt that could not be identified as food. +type UnrecognizedItem struct { + RawText string `json:"raw_text"` + Price float64 `json:"price,omitempty"` +} + +// ReceiptResult is the full result of receipt OCR. +type ReceiptResult struct { + Items []RecognizedItem `json:"items"` + Unrecognized []UnrecognizedItem `json:"unrecognized"` +} + +// DishResult is the result of dish recognition. +type DishResult struct { + DishName string `json:"dish_name"` + WeightGrams int `json:"weight_grams"` + Calories float64 `json:"calories"` + ProteinG float64 `json:"protein_g"` + FatG float64 `json:"fat_g"` + CarbsG float64 `json:"carbs_g"` + Confidence float64 `json:"confidence"` + 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"` + 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"` +} diff --git a/backend/internal/adapters/openai/menu.go b/backend/internal/adapters/openai/menu.go index 6bb6629..635d882 100644 --- a/backend/internal/adapters/openai/menu.go +++ b/backend/internal/adapters/openai/menu.go @@ -4,34 +4,14 @@ import ( "context" "fmt" "sync" + + "github.com/food-ai/backend/internal/adapters/ai" ) -// MenuRequest contains parameters for weekly menu generation. -type MenuRequest struct { - UserGoal string - DailyCalories int - Restrictions []string - CuisinePrefs []string - AvailableProducts []string - Lang string // ISO 639-1 target language code, e.g. "en", "ru" -} - -// DayPlan is the AI-generated plan for a single day. -type DayPlan struct { - Day int `json:"day"` - Meals []MealEntry `json:"meals"` -} - -// MealEntry is a single meal within a day plan. -type MealEntry struct { - MealType string `json:"meal_type"` // breakfast | lunch | dinner - Recipe Recipe `json:"recipe"` -} - // GenerateMenu produces a 7-day × 3-meal plan by issuing three parallel // GenerateRecipes calls (one per meal type). This avoids token-limit errors // that arise from requesting 21 full recipes in a single prompt. -func (c *Client) GenerateMenu(ctx context.Context, req MenuRequest) ([]DayPlan, error) { +func (c *Client) GenerateMenu(ctx context.Context, req ai.MenuRequest) ([]ai.DayPlan, error) { type mealSlot struct { mealType string fraction float64 // share of daily calories @@ -44,7 +24,7 @@ func (c *Client) GenerateMenu(ctx context.Context, req MenuRequest) ([]DayPlan, } type mealResult struct { - recipes []Recipe + recipes []ai.Recipe err error } @@ -57,7 +37,7 @@ func (c *Client) GenerateMenu(ctx context.Context, req MenuRequest) ([]DayPlan, defer wg.Done() // Scale daily calories to what this meal should contribute. mealCal := int(float64(req.DailyCalories) * fraction) - r, err := c.GenerateRecipes(ctx, RecipeRequest{ + r, err := c.GenerateRecipes(ctx, ai.RecipeRequest{ UserGoal: req.UserGoal, DailyCalories: mealCal * 3, // prompt divides by 3 internally Restrictions: req.Restrictions, @@ -84,11 +64,11 @@ func (c *Client) GenerateMenu(ctx context.Context, req MenuRequest) ([]DayPlan, } } - days := make([]DayPlan, 7) + days := make([]ai.DayPlan, 7) for day := range 7 { - days[day] = DayPlan{ + days[day] = ai.DayPlan{ Day: day + 1, - Meals: []MealEntry{ + Meals: []ai.MealEntry{ {MealType: slots[0].mealType, Recipe: results[0].recipes[day]}, {MealType: slots[1].mealType, Recipe: results[1].recipes[day]}, {MealType: slots[2].mealType, Recipe: results[2].recipes[day]}, diff --git a/backend/internal/adapters/openai/recipe.go b/backend/internal/adapters/openai/recipe.go index 53e478b..df7e073 100644 --- a/backend/internal/adapters/openai/recipe.go +++ b/backend/internal/adapters/openai/recipe.go @@ -6,65 +6,10 @@ import ( "fmt" "strings" + "github.com/food-ai/backend/internal/adapters/ai" "github.com/food-ai/backend/internal/infra/locale" ) -// RecipeGenerator generates recipes using the Gemini AI. -type RecipeGenerator interface { - GenerateRecipes(ctx context.Context, req RecipeRequest) ([]Recipe, error) -} - -// RecipeRequest contains parameters for recipe generation. -type RecipeRequest struct { - 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. -type Recipe struct { - Title string `json:"title"` - Description string `json:"description"` - Cuisine string `json:"cuisine"` - Difficulty string `json:"difficulty"` - PrepTimeMin int `json:"prep_time_min"` - CookTimeMin int `json:"cook_time_min"` - Servings int `json:"servings"` - ImageQuery string `json:"image_query"` - ImageURL string `json:"image_url"` - Ingredients []Ingredient `json:"ingredients"` - Steps []Step `json:"steps"` - Tags []string `json:"tags"` - Nutrition NutritionInfo `json:"nutrition_per_serving"` -} - -// Ingredient is a single ingredient in a recipe. -type Ingredient struct { - Name string `json:"name"` - Amount float64 `json:"amount"` - Unit string `json:"unit"` -} - -// Step is a single preparation step. -type Step struct { - Number int `json:"number"` - Description string `json:"description"` - TimerSeconds *int `json:"timer_seconds"` -} - -// NutritionInfo contains approximate nutritional information per serving. -type NutritionInfo struct { - Calories float64 `json:"calories"` - ProteinG float64 `json:"protein_g"` - FatG float64 `json:"fat_g"` - CarbsG float64 `json:"carbs_g"` - Approximate bool `json:"approximate"` -} - // goalNames maps internal goal codes to English descriptions used in the prompt. var goalNames = map[string]string{ "lose": "weight loss", @@ -72,10 +17,10 @@ var goalNames = map[string]string{ "gain": "muscle gain", } -// GenerateRecipes generates recipes using the Gemini AI. +// GenerateRecipes generates recipes using the OpenAI API. // 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) { +func (c *Client) GenerateRecipes(ctx context.Context, req ai.RecipeRequest) ([]ai.Recipe, error) { prompt := buildRecipePrompt(req) messages := []map[string]string{ @@ -111,7 +56,7 @@ func (c *Client) GenerateRecipes(ctx context.Context, req RecipeRequest) ([]Reci return nil, fmt.Errorf("failed to parse valid JSON after %d attempts: %w", maxRetries, lastErr) } -func buildRecipePrompt(req RecipeRequest) string { +func buildRecipePrompt(req ai.RecipeRequest) string { lang := req.Lang if lang == "" { lang = "en" @@ -189,7 +134,7 @@ Return ONLY a valid JSON array without markdown or extra text: }]`, count, langName, goal, req.DailyCalories, restrictions, cuisines, productsSection, perMealCalories, langName) } -func parseRecipesJSON(text string) ([]Recipe, error) { +func parseRecipesJSON(text string) ([]ai.Recipe, error) { text = strings.TrimSpace(text) if strings.HasPrefix(text, "```") { text = strings.TrimPrefix(text, "```json") @@ -198,7 +143,7 @@ func parseRecipesJSON(text string) ([]Recipe, error) { text = strings.TrimSpace(text) } - var recipes []Recipe + var recipes []ai.Recipe if err := json.Unmarshal([]byte(text), &recipes); err != nil { return nil, err } diff --git a/backend/internal/adapters/openai/recognition.go b/backend/internal/adapters/openai/recognition.go index 65c25ee..4335ba9 100644 --- a/backend/internal/adapters/openai/recognition.go +++ b/backend/internal/adapters/openai/recognition.go @@ -1,68 +1,16 @@ package openai import ( + "context" "encoding/json" "fmt" "strings" - "context" + + "github.com/food-ai/backend/internal/adapters/ai" ) -// RecognizedItem is a food item identified in an image. -type RecognizedItem struct { - Name string `json:"name"` - Quantity float64 `json:"quantity"` - Unit string `json:"unit"` - Category string `json:"category"` - Confidence float64 `json:"confidence"` -} - -// UnrecognizedItem is text from a receipt that could not be identified as food. -type UnrecognizedItem struct { - RawText string `json:"raw_text"` - Price float64 `json:"price,omitempty"` -} - -// ReceiptResult is the full result of receipt OCR. -type ReceiptResult struct { - Items []RecognizedItem `json:"items"` - Unrecognized []UnrecognizedItem `json:"unrecognized"` -} - -// DishResult is the result of dish recognition. -type DishResult struct { - DishName string `json:"dish_name"` - WeightGrams int `json:"weight_grams"` - Calories float64 `json:"calories"` - ProteinG float64 `json:"protein_g"` - FatG float64 `json:"fat_g"` - CarbsG float64 `json:"carbs_g"` - Confidence float64 `json:"confidence"` - 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"` - 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. -func (c *Client) RecognizeReceipt(ctx context.Context, imageBase64, mimeType string) (*ReceiptResult, error) { +func (c *Client) RecognizeReceipt(ctx context.Context, imageBase64, mimeType string) (*ai.ReceiptResult, error) { prompt := `Ты — OCR-система для чеков из продуктовых магазинов. Проанализируй фото чека и извлеки список продуктов питания. @@ -91,21 +39,21 @@ func (c *Client) RecognizeReceipt(ctx context.Context, imageBase64, mimeType str return nil, fmt.Errorf("recognize receipt: %w", err) } - var result ReceiptResult + var result ai.ReceiptResult if err := parseJSON(text, &result); err != nil { return nil, fmt.Errorf("parse receipt result: %w", err) } if result.Items == nil { - result.Items = []RecognizedItem{} + result.Items = []ai.RecognizedItem{} } if result.Unrecognized == nil { - result.Unrecognized = []UnrecognizedItem{} + result.Unrecognized = []ai.UnrecognizedItem{} } return &result, nil } // RecognizeProducts uses the vision model to identify food items in a photo (fridge, shelf, etc.). -func (c *Client) RecognizeProducts(ctx context.Context, imageBase64, mimeType string) ([]RecognizedItem, error) { +func (c *Client) RecognizeProducts(ctx context.Context, imageBase64, mimeType string) ([]ai.RecognizedItem, error) { prompt := `Ты — система распознавания продуктов питания. Посмотри на фото и определи все видимые продукты питания. @@ -131,19 +79,19 @@ func (c *Client) RecognizeProducts(ctx context.Context, imageBase64, mimeType st } var result struct { - Items []RecognizedItem `json:"items"` + Items []ai.RecognizedItem `json:"items"` } if err := parseJSON(text, &result); err != nil { return nil, fmt.Errorf("parse products result: %w", err) } if result.Items == nil { - return []RecognizedItem{}, nil + return []ai.RecognizedItem{}, nil } return result.Items, nil } // RecognizeDish uses the vision model to identify a dish and estimate its nutritional content. -func (c *Client) RecognizeDish(ctx context.Context, imageBase64, mimeType string) (*DishResult, error) { +func (c *Client) RecognizeDish(ctx context.Context, imageBase64, mimeType string) (*ai.DishResult, error) { prompt := `Ты — диетолог и кулинарный эксперт. Посмотри на фото блюда и определи: @@ -171,7 +119,7 @@ func (c *Client) RecognizeDish(ctx context.Context, imageBase64, mimeType string return nil, fmt.Errorf("recognize dish: %w", err) } - var result DishResult + var result ai.DishResult if err := parseJSON(text, &result); err != nil { return nil, fmt.Errorf("parse dish result: %w", err) } @@ -183,7 +131,7 @@ 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) { +func (c *Client) ClassifyIngredient(ctx context.Context, name string) (*ai.IngredientClassification, error) { prompt := fmt.Sprintf(`Classify the food product: "%s". Return ONLY valid JSON without markdown: { @@ -209,7 +157,7 @@ Return ONLY valid JSON without markdown: return nil, fmt.Errorf("classify ingredient: %w", err) } - var result IngredientClassification + var result ai.IngredientClassification if err := parseJSON(text, &result); err != nil { return nil, fmt.Errorf("parse classification: %w", err) } diff --git a/backend/internal/menu/handler.go b/backend/internal/menu/handler.go index 4a94deb..a2b6d55 100644 --- a/backend/internal/menu/handler.go +++ b/backend/internal/menu/handler.go @@ -10,8 +10,8 @@ import ( "sync" "time" + "github.com/food-ai/backend/internal/adapters/ai" "github.com/food-ai/backend/internal/dish" - "github.com/food-ai/backend/internal/adapters/openai" "github.com/food-ai/backend/internal/infra/locale" "github.com/food-ai/backend/internal/infra/middleware" "github.com/food-ai/backend/internal/user" @@ -38,10 +38,15 @@ type RecipeSaver interface { Create(ctx context.Context, req dish.CreateRequest) (string, error) } +// MenuGenerator generates a 7-day meal plan via an AI provider. +type MenuGenerator interface { + GenerateMenu(ctx context.Context, req ai.MenuRequest) ([]ai.DayPlan, error) +} + // Handler handles menu and shopping-list endpoints. type Handler struct { repo *Repository - openaiClient *openai.Client + menuGenerator MenuGenerator pexels PhotoSearcher userLoader UserLoader productLister ProductLister @@ -51,7 +56,7 @@ type Handler struct { // NewHandler creates a new Handler. func NewHandler( repo *Repository, - openaiClient *openai.Client, + menuGenerator MenuGenerator, pexels PhotoSearcher, userLoader UserLoader, productLister ProductLister, @@ -59,7 +64,7 @@ func NewHandler( ) *Handler { return &Handler{ repo: repo, - openaiClient: openaiClient, + menuGenerator: menuGenerator, pexels: pexels, userLoader: userLoader, productLister: productLister, @@ -137,7 +142,7 @@ func (h *Handler) GenerateMenu(w http.ResponseWriter, r *http.Request) { } // Generate 7-day plan via Gemini. - days, err := h.openaiClient.GenerateMenu(r.Context(), menuReq) + days, err := h.menuGenerator.GenerateMenu(r.Context(), menuReq) if err != nil { slog.Error("generate menu", "user_id", userID, "err", err) writeError(w, http.StatusServiceUnavailable, "menu generation failed, please try again") @@ -449,8 +454,8 @@ type userPreferences struct { Restrictions []string `json:"restrictions"` } -func buildMenuRequest(u *user.User, lang string) openai.MenuRequest { - req := openai.MenuRequest{DailyCalories: 2000, Lang: lang} +func buildMenuRequest(u *user.User, lang string) ai.MenuRequest { + req := ai.MenuRequest{DailyCalories: 2000, Lang: lang} if u.Goal != nil { req.UserGoal = *u.Goal } @@ -468,7 +473,7 @@ func buildMenuRequest(u *user.User, lang string) openai.MenuRequest { } // recipeToCreateRequest converts a Gemini recipe to a dish.CreateRequest. -func recipeToCreateRequest(r openai.Recipe) dish.CreateRequest { +func recipeToCreateRequest(r ai.Recipe) dish.CreateRequest { cr := dish.CreateRequest{ Name: r.Title, Description: r.Description, diff --git a/backend/internal/recognition/handler.go b/backend/internal/recognition/handler.go index b3db114..377eb1c 100644 --- a/backend/internal/recognition/handler.go +++ b/backend/internal/recognition/handler.go @@ -8,7 +8,7 @@ import ( "strings" "sync" - "github.com/food-ai/backend/internal/adapters/openai" + "github.com/food-ai/backend/internal/adapters/ai" "github.com/food-ai/backend/internal/infra/middleware" "github.com/food-ai/backend/internal/ingredient" ) @@ -21,15 +21,23 @@ type IngredientRepository interface { UpsertAliases(ctx context.Context, id, lang string, aliases []string) error } +// Recognizer is the AI provider interface for image-based food recognition. +type Recognizer interface { + RecognizeReceipt(ctx context.Context, imageBase64, mimeType string) (*ai.ReceiptResult, error) + RecognizeProducts(ctx context.Context, imageBase64, mimeType string) ([]ai.RecognizedItem, error) + RecognizeDish(ctx context.Context, imageBase64, mimeType string) (*ai.DishResult, error) + ClassifyIngredient(ctx context.Context, name string) (*ai.IngredientClassification, error) +} + // Handler handles POST /ai/* recognition endpoints. type Handler struct { - openaiClient *openai.Client + recognizer Recognizer ingredientRepo IngredientRepository } // NewHandler creates a new Handler. -func NewHandler(openaiClient *openai.Client, repo IngredientRepository) *Handler { - return &Handler{openaiClient: openaiClient, ingredientRepo: repo} +func NewHandler(recognizer Recognizer, repo IngredientRepository) *Handler { + return &Handler{recognizer: recognizer, ingredientRepo: repo} } // --------------------------------------------------------------------------- @@ -61,11 +69,11 @@ type EnrichedItem struct { // ReceiptResponse is the response for POST /ai/recognize-receipt. type ReceiptResponse struct { Items []EnrichedItem `json:"items"` - Unrecognized []openai.UnrecognizedItem `json:"unrecognized"` + Unrecognized []ai.UnrecognizedItem `json:"unrecognized"` } // DishResponse is the response for POST /ai/recognize-dish. -type DishResponse = openai.DishResult +type DishResponse = ai.DishResult // --------------------------------------------------------------------------- // Handlers @@ -83,7 +91,7 @@ func (h *Handler) RecognizeReceipt(w http.ResponseWriter, r *http.Request) { return } - result, err := h.openaiClient.RecognizeReceipt(r.Context(), req.ImageBase64, req.MimeType) + result, err := h.recognizer.RecognizeReceipt(r.Context(), req.ImageBase64, req.MimeType) if err != nil { slog.Error("recognize receipt", "err", err) writeErrorJSON(w, http.StatusServiceUnavailable, "recognition failed, please try again") @@ -110,13 +118,13 @@ func (h *Handler) RecognizeProducts(w http.ResponseWriter, r *http.Request) { } // Process each image in parallel. - allItems := make([][]openai.RecognizedItem, len(req.Images)) + allItems := make([][]ai.RecognizedItem, len(req.Images)) var wg sync.WaitGroup for i, img := range req.Images { wg.Add(1) go func(i int, img imageRequest) { defer wg.Done() - items, err := h.openaiClient.RecognizeProducts(r.Context(), img.ImageBase64, img.MimeType) + items, err := h.recognizer.RecognizeProducts(r.Context(), img.ImageBase64, img.MimeType) if err != nil { slog.Warn("recognize products from image", "index", i, "err", err) return @@ -140,7 +148,7 @@ func (h *Handler) RecognizeDish(w http.ResponseWriter, r *http.Request) { return } - result, err := h.openaiClient.RecognizeDish(r.Context(), req.ImageBase64, req.MimeType) + result, err := h.recognizer.RecognizeDish(r.Context(), req.ImageBase64, req.MimeType) if err != nil { slog.Error("recognize dish", "err", err) writeErrorJSON(w, http.StatusServiceUnavailable, "recognition failed, please try again") @@ -156,7 +164,7 @@ func (h *Handler) RecognizeDish(w http.ResponseWriter, r *http.Request) { // enrichItems matches each recognized item against ingredient_mappings. // Items without a match trigger a Gemini classification call and upsert into the DB. -func (h *Handler) enrichItems(ctx context.Context, items []openai.RecognizedItem) []EnrichedItem { +func (h *Handler) enrichItems(ctx context.Context, items []ai.RecognizedItem) []EnrichedItem { result := make([]EnrichedItem, 0, len(items)) for _, item := range items { enriched := EnrichedItem{ @@ -188,7 +196,7 @@ func (h *Handler) enrichItems(ctx context.Context, items []openai.RecognizedItem } } else { // No mapping — ask AI to classify and save for future reuse. - classification, err := h.openaiClient.ClassifyIngredient(ctx, item.Name) + classification, err := h.recognizer.ClassifyIngredient(ctx, item.Name) if err != nil { slog.Warn("classify unknown ingredient", "name", item.Name, "err", err) } else { @@ -208,7 +216,7 @@ func (h *Handler) enrichItems(ctx context.Context, items []openai.RecognizedItem } // saveClassification upserts an AI-produced ingredient classification into the DB. -func (h *Handler) saveClassification(ctx context.Context, c *openai.IngredientClassification) *ingredient.IngredientMapping { +func (h *Handler) saveClassification(ctx context.Context, c *ai.IngredientClassification) *ingredient.IngredientMapping { if c == nil || c.CanonicalName == "" { return nil } @@ -252,8 +260,8 @@ func (h *Handler) saveClassification(ctx context.Context, c *openai.IngredientCl // mergeAndDeduplicate combines results from multiple images. // Items sharing the same name (case-insensitive) have their quantities summed. -func mergeAndDeduplicate(batches [][]openai.RecognizedItem) []openai.RecognizedItem { - seen := make(map[string]*openai.RecognizedItem) +func mergeAndDeduplicate(batches [][]ai.RecognizedItem) []ai.RecognizedItem { + seen := make(map[string]*ai.RecognizedItem) var order []string for _, batch := range batches { @@ -273,7 +281,7 @@ func mergeAndDeduplicate(batches [][]openai.RecognizedItem) []openai.RecognizedI } } - result := make([]openai.RecognizedItem, 0, len(order)) + result := make([]ai.RecognizedItem, 0, len(order)) for _, key := range order { result = append(result, *seen[key]) } diff --git a/backend/internal/recommendation/handler.go b/backend/internal/recommendation/handler.go index 7c7ae63..dd60efc 100644 --- a/backend/internal/recommendation/handler.go +++ b/backend/internal/recommendation/handler.go @@ -8,7 +8,7 @@ import ( "strconv" "sync" - "github.com/food-ai/backend/internal/adapters/openai" + "github.com/food-ai/backend/internal/adapters/ai" "github.com/food-ai/backend/internal/infra/locale" "github.com/food-ai/backend/internal/infra/middleware" "github.com/food-ai/backend/internal/user" @@ -35,21 +35,26 @@ type userPreferences struct { Restrictions []string `json:"restrictions"` } +// RecipeGenerator generates recipe recommendations via an AI provider. +type RecipeGenerator interface { + GenerateRecipes(ctx context.Context, req ai.RecipeRequest) ([]ai.Recipe, error) +} + // Handler handles GET /recommendations. type Handler struct { - openaiClient *openai.Client - pexels PhotoSearcher - userLoader UserLoader - productLister ProductLister + recipeGenerator RecipeGenerator + pexels PhotoSearcher + userLoader UserLoader + productLister ProductLister } // NewHandler creates a new Handler. -func NewHandler(openaiClient *openai.Client, pexels PhotoSearcher, userLoader UserLoader, productLister ProductLister) *Handler { +func NewHandler(recipeGenerator RecipeGenerator, pexels PhotoSearcher, userLoader UserLoader, productLister ProductLister) *Handler { return &Handler{ - openaiClient: openaiClient, - pexels: pexels, - userLoader: userLoader, - productLister: productLister, + recipeGenerator: recipeGenerator, + pexels: pexels, + userLoader: userLoader, + productLister: productLister, } } @@ -84,7 +89,7 @@ func (h *Handler) GetRecommendations(w http.ResponseWriter, r *http.Request) { slog.Warn("load products for recommendations", "user_id", userID, "err", err) } - recipes, err := h.openaiClient.GenerateRecipes(r.Context(), req) + recipes, err := h.recipeGenerator.GenerateRecipes(r.Context(), req) if err != nil { slog.Error("generate recipes", "user_id", userID, "err", err) writeErrorJSON(w, http.StatusServiceUnavailable, "recipe generation failed, please try again") @@ -109,8 +114,8 @@ func (h *Handler) GetRecommendations(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, recipes) } -func buildRecipeRequest(u *user.User, count int, lang string) openai.RecipeRequest { - req := openai.RecipeRequest{ +func buildRecipeRequest(u *user.User, count int, lang string) ai.RecipeRequest { + req := ai.RecipeRequest{ Count: count, DailyCalories: 2000, // sensible default Lang: lang,