refactor: introduce adapter pattern for AI provider (OpenAI)

- 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 <noreply@anthropic.com>
This commit is contained in:
dbastrikin
2026-03-15 21:27:04 +02:00
parent 19a985ad49
commit fee240da7d
8 changed files with 217 additions and 194 deletions

View File

@@ -13,6 +13,7 @@ import (
"github.com/food-ai/backend/internal/ingredient" "github.com/food-ai/backend/internal/ingredient"
"github.com/food-ai/backend/internal/menu" "github.com/food-ai/backend/internal/menu"
"github.com/food-ai/backend/internal/infra/middleware" "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/adapters/pexels"
"github.com/food-ai/backend/internal/product" "github.com/food-ai/backend/internal/product"
"github.com/food-ai/backend/internal/recipe" "github.com/food-ai/backend/internal/recipe"
@@ -105,6 +106,9 @@ func initRouter(appConfig *config.Config, pool *pgxpool.Pool) (http.Handler, err
wire.Bind(new(recommendation.UserLoader), new(*user.Repository)), wire.Bind(new(recommendation.UserLoader), new(*user.Repository)),
wire.Bind(new(recommendation.ProductLister), new(*product.Repository)), wire.Bind(new(recommendation.ProductLister), new(*product.Repository)),
wire.Bind(new(recognition.IngredientRepository), new(*ingredient.Repository)), 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)), wire.Bind(new(middleware.AccessTokenValidator), new(*jwtAdapter)),
) )
return nil, nil return nil, nil

View File

@@ -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"`
}

View File

@@ -4,34 +4,14 @@ import (
"context" "context"
"fmt" "fmt"
"sync" "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 // GenerateMenu produces a 7-day × 3-meal plan by issuing three parallel
// GenerateRecipes calls (one per meal type). This avoids token-limit errors // GenerateRecipes calls (one per meal type). This avoids token-limit errors
// that arise from requesting 21 full recipes in a single prompt. // 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 { type mealSlot struct {
mealType string mealType string
fraction float64 // share of daily calories fraction float64 // share of daily calories
@@ -44,7 +24,7 @@ func (c *Client) GenerateMenu(ctx context.Context, req MenuRequest) ([]DayPlan,
} }
type mealResult struct { type mealResult struct {
recipes []Recipe recipes []ai.Recipe
err error err error
} }
@@ -57,7 +37,7 @@ func (c *Client) GenerateMenu(ctx context.Context, req MenuRequest) ([]DayPlan,
defer wg.Done() defer wg.Done()
// Scale daily calories to what this meal should contribute. // Scale daily calories to what this meal should contribute.
mealCal := int(float64(req.DailyCalories) * fraction) mealCal := int(float64(req.DailyCalories) * fraction)
r, err := c.GenerateRecipes(ctx, RecipeRequest{ r, err := c.GenerateRecipes(ctx, ai.RecipeRequest{
UserGoal: req.UserGoal, UserGoal: req.UserGoal,
DailyCalories: mealCal * 3, // prompt divides by 3 internally DailyCalories: mealCal * 3, // prompt divides by 3 internally
Restrictions: req.Restrictions, 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 { for day := range 7 {
days[day] = DayPlan{ days[day] = ai.DayPlan{
Day: day + 1, Day: day + 1,
Meals: []MealEntry{ Meals: []ai.MealEntry{
{MealType: slots[0].mealType, Recipe: results[0].recipes[day]}, {MealType: slots[0].mealType, Recipe: results[0].recipes[day]},
{MealType: slots[1].mealType, Recipe: results[1].recipes[day]}, {MealType: slots[1].mealType, Recipe: results[1].recipes[day]},
{MealType: slots[2].mealType, Recipe: results[2].recipes[day]}, {MealType: slots[2].mealType, Recipe: results[2].recipes[day]},

View File

@@ -6,65 +6,10 @@ import (
"fmt" "fmt"
"strings" "strings"
"github.com/food-ai/backend/internal/adapters/ai"
"github.com/food-ai/backend/internal/infra/locale" "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. // goalNames maps internal goal codes to English descriptions used in the prompt.
var goalNames = map[string]string{ var goalNames = map[string]string{
"lose": "weight loss", "lose": "weight loss",
@@ -72,10 +17,10 @@ var goalNames = map[string]string{
"gain": "muscle gain", "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. // Retries up to maxRetries times only when the response is not valid JSON.
// API-level errors (rate limits, auth, etc.) are returned immediately. // API-level errors (rate limits, auth, etc.) are returned immediately.
func (c *Client) GenerateRecipes(ctx context.Context, req RecipeRequest) ([]Recipe, error) { func (c *Client) GenerateRecipes(ctx context.Context, req ai.RecipeRequest) ([]ai.Recipe, error) {
prompt := buildRecipePrompt(req) prompt := buildRecipePrompt(req)
messages := []map[string]string{ 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) 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 lang := req.Lang
if lang == "" { if lang == "" {
lang = "en" 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) }]`, 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) text = strings.TrimSpace(text)
if strings.HasPrefix(text, "```") { if strings.HasPrefix(text, "```") {
text = strings.TrimPrefix(text, "```json") text = strings.TrimPrefix(text, "```json")
@@ -198,7 +143,7 @@ func parseRecipesJSON(text string) ([]Recipe, error) {
text = strings.TrimSpace(text) text = strings.TrimSpace(text)
} }
var recipes []Recipe var recipes []ai.Recipe
if err := json.Unmarshal([]byte(text), &recipes); err != nil { if err := json.Unmarshal([]byte(text), &recipes); err != nil {
return nil, err return nil, err
} }

View File

@@ -1,68 +1,16 @@
package openai package openai
import ( import (
"context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"strings" "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. // 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-система для чеков из продуктовых магазинов. prompt := `Ты — OCR-система для чеков из продуктовых магазинов.
Проанализируй фото чека и извлеки список продуктов питания. Проанализируй фото чека и извлеки список продуктов питания.
@@ -91,21 +39,21 @@ func (c *Client) RecognizeReceipt(ctx context.Context, imageBase64, mimeType str
return nil, fmt.Errorf("recognize receipt: %w", err) return nil, fmt.Errorf("recognize receipt: %w", err)
} }
var result ReceiptResult var result ai.ReceiptResult
if err := parseJSON(text, &result); err != nil { if err := parseJSON(text, &result); err != nil {
return nil, fmt.Errorf("parse receipt result: %w", err) return nil, fmt.Errorf("parse receipt result: %w", err)
} }
if result.Items == nil { if result.Items == nil {
result.Items = []RecognizedItem{} result.Items = []ai.RecognizedItem{}
} }
if result.Unrecognized == nil { if result.Unrecognized == nil {
result.Unrecognized = []UnrecognizedItem{} result.Unrecognized = []ai.UnrecognizedItem{}
} }
return &result, nil return &result, nil
} }
// RecognizeProducts uses the vision model to identify food items in a photo (fridge, shelf, etc.). // 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 := `Ты — система распознавания продуктов питания. prompt := `Ты — система распознавания продуктов питания.
Посмотри на фото и определи все видимые продукты питания. Посмотри на фото и определи все видимые продукты питания.
@@ -131,19 +79,19 @@ func (c *Client) RecognizeProducts(ctx context.Context, imageBase64, mimeType st
} }
var result struct { var result struct {
Items []RecognizedItem `json:"items"` Items []ai.RecognizedItem `json:"items"`
} }
if err := parseJSON(text, &result); err != nil { if err := parseJSON(text, &result); err != nil {
return nil, fmt.Errorf("parse products result: %w", err) return nil, fmt.Errorf("parse products result: %w", err)
} }
if result.Items == nil { if result.Items == nil {
return []RecognizedItem{}, nil return []ai.RecognizedItem{}, nil
} }
return result.Items, nil return result.Items, nil
} }
// RecognizeDish uses the vision model to identify a dish and estimate its nutritional content. // 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 := `Ты — диетолог и кулинарный эксперт. prompt := `Ты — диетолог и кулинарный эксперт.
Посмотри на фото блюда и определи: Посмотри на фото блюда и определи:
@@ -171,7 +119,7 @@ func (c *Client) RecognizeDish(ctx context.Context, imageBase64, mimeType string
return nil, fmt.Errorf("recognize dish: %w", err) return nil, fmt.Errorf("recognize dish: %w", err)
} }
var result DishResult var result ai.DishResult
if err := parseJSON(text, &result); err != nil { if err := parseJSON(text, &result); err != nil {
return nil, fmt.Errorf("parse dish result: %w", err) 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 // ClassifyIngredient uses the text model to classify an unknown food item
// and build an ingredient_mappings record for it. // 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". prompt := fmt.Sprintf(`Classify the food product: "%s".
Return ONLY valid JSON without markdown: Return ONLY valid JSON without markdown:
{ {
@@ -209,7 +157,7 @@ Return ONLY valid JSON without markdown:
return nil, fmt.Errorf("classify ingredient: %w", err) return nil, fmt.Errorf("classify ingredient: %w", err)
} }
var result IngredientClassification var result ai.IngredientClassification
if err := parseJSON(text, &result); err != nil { if err := parseJSON(text, &result); err != nil {
return nil, fmt.Errorf("parse classification: %w", err) return nil, fmt.Errorf("parse classification: %w", err)
} }

View File

@@ -10,8 +10,8 @@ import (
"sync" "sync"
"time" "time"
"github.com/food-ai/backend/internal/adapters/ai"
"github.com/food-ai/backend/internal/dish" "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/locale"
"github.com/food-ai/backend/internal/infra/middleware" "github.com/food-ai/backend/internal/infra/middleware"
"github.com/food-ai/backend/internal/user" "github.com/food-ai/backend/internal/user"
@@ -38,10 +38,15 @@ type RecipeSaver interface {
Create(ctx context.Context, req dish.CreateRequest) (string, error) 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. // Handler handles menu and shopping-list endpoints.
type Handler struct { type Handler struct {
repo *Repository repo *Repository
openaiClient *openai.Client menuGenerator MenuGenerator
pexels PhotoSearcher pexels PhotoSearcher
userLoader UserLoader userLoader UserLoader
productLister ProductLister productLister ProductLister
@@ -51,7 +56,7 @@ type Handler struct {
// NewHandler creates a new Handler. // NewHandler creates a new Handler.
func NewHandler( func NewHandler(
repo *Repository, repo *Repository,
openaiClient *openai.Client, menuGenerator MenuGenerator,
pexels PhotoSearcher, pexels PhotoSearcher,
userLoader UserLoader, userLoader UserLoader,
productLister ProductLister, productLister ProductLister,
@@ -59,7 +64,7 @@ func NewHandler(
) *Handler { ) *Handler {
return &Handler{ return &Handler{
repo: repo, repo: repo,
openaiClient: openaiClient, menuGenerator: menuGenerator,
pexels: pexels, pexels: pexels,
userLoader: userLoader, userLoader: userLoader,
productLister: productLister, productLister: productLister,
@@ -137,7 +142,7 @@ func (h *Handler) GenerateMenu(w http.ResponseWriter, r *http.Request) {
} }
// Generate 7-day plan via Gemini. // 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 { if err != nil {
slog.Error("generate menu", "user_id", userID, "err", err) slog.Error("generate menu", "user_id", userID, "err", err)
writeError(w, http.StatusServiceUnavailable, "menu generation failed, please try again") writeError(w, http.StatusServiceUnavailable, "menu generation failed, please try again")
@@ -449,8 +454,8 @@ type userPreferences struct {
Restrictions []string `json:"restrictions"` Restrictions []string `json:"restrictions"`
} }
func buildMenuRequest(u *user.User, lang string) openai.MenuRequest { func buildMenuRequest(u *user.User, lang string) ai.MenuRequest {
req := openai.MenuRequest{DailyCalories: 2000, Lang: lang} req := ai.MenuRequest{DailyCalories: 2000, Lang: lang}
if u.Goal != nil { if u.Goal != nil {
req.UserGoal = *u.Goal 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. // 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{ cr := dish.CreateRequest{
Name: r.Title, Name: r.Title,
Description: r.Description, Description: r.Description,

View File

@@ -8,7 +8,7 @@ import (
"strings" "strings"
"sync" "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/infra/middleware"
"github.com/food-ai/backend/internal/ingredient" "github.com/food-ai/backend/internal/ingredient"
) )
@@ -21,15 +21,23 @@ type IngredientRepository interface {
UpsertAliases(ctx context.Context, id, lang string, aliases []string) error 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. // Handler handles POST /ai/* recognition endpoints.
type Handler struct { type Handler struct {
openaiClient *openai.Client recognizer Recognizer
ingredientRepo IngredientRepository ingredientRepo IngredientRepository
} }
// NewHandler creates a new Handler. // NewHandler creates a new Handler.
func NewHandler(openaiClient *openai.Client, repo IngredientRepository) *Handler { func NewHandler(recognizer Recognizer, repo IngredientRepository) *Handler {
return &Handler{openaiClient: openaiClient, ingredientRepo: repo} return &Handler{recognizer: recognizer, ingredientRepo: repo}
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -61,11 +69,11 @@ type EnrichedItem struct {
// ReceiptResponse is the response for POST /ai/recognize-receipt. // ReceiptResponse is the response for POST /ai/recognize-receipt.
type ReceiptResponse struct { type ReceiptResponse struct {
Items []EnrichedItem `json:"items"` Items []EnrichedItem `json:"items"`
Unrecognized []openai.UnrecognizedItem `json:"unrecognized"` Unrecognized []ai.UnrecognizedItem `json:"unrecognized"`
} }
// DishResponse is the response for POST /ai/recognize-dish. // DishResponse is the response for POST /ai/recognize-dish.
type DishResponse = openai.DishResult type DishResponse = ai.DishResult
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Handlers // Handlers
@@ -83,7 +91,7 @@ func (h *Handler) RecognizeReceipt(w http.ResponseWriter, r *http.Request) {
return 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 { if err != nil {
slog.Error("recognize receipt", "err", err) slog.Error("recognize receipt", "err", err)
writeErrorJSON(w, http.StatusServiceUnavailable, "recognition failed, please try again") 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. // Process each image in parallel.
allItems := make([][]openai.RecognizedItem, len(req.Images)) allItems := make([][]ai.RecognizedItem, len(req.Images))
var wg sync.WaitGroup var wg sync.WaitGroup
for i, img := range req.Images { for i, img := range req.Images {
wg.Add(1) wg.Add(1)
go func(i int, img imageRequest) { go func(i int, img imageRequest) {
defer wg.Done() 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 { if err != nil {
slog.Warn("recognize products from image", "index", i, "err", err) slog.Warn("recognize products from image", "index", i, "err", err)
return return
@@ -140,7 +148,7 @@ func (h *Handler) RecognizeDish(w http.ResponseWriter, r *http.Request) {
return 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 { if err != nil {
slog.Error("recognize dish", "err", err) slog.Error("recognize dish", "err", err)
writeErrorJSON(w, http.StatusServiceUnavailable, "recognition failed, please try again") 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. // enrichItems matches each recognized item against ingredient_mappings.
// Items without a match trigger a Gemini classification call and upsert into the DB. // 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)) result := make([]EnrichedItem, 0, len(items))
for _, item := range items { for _, item := range items {
enriched := EnrichedItem{ enriched := EnrichedItem{
@@ -188,7 +196,7 @@ func (h *Handler) enrichItems(ctx context.Context, items []openai.RecognizedItem
} }
} else { } else {
// No mapping — ask AI to classify and save for future reuse. // 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 { if err != nil {
slog.Warn("classify unknown ingredient", "name", item.Name, "err", err) slog.Warn("classify unknown ingredient", "name", item.Name, "err", err)
} else { } 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. // 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 == "" { if c == nil || c.CanonicalName == "" {
return nil return nil
} }
@@ -252,8 +260,8 @@ func (h *Handler) saveClassification(ctx context.Context, c *openai.IngredientCl
// mergeAndDeduplicate combines results from multiple images. // mergeAndDeduplicate combines results from multiple images.
// Items sharing the same name (case-insensitive) have their quantities summed. // Items sharing the same name (case-insensitive) have their quantities summed.
func mergeAndDeduplicate(batches [][]openai.RecognizedItem) []openai.RecognizedItem { func mergeAndDeduplicate(batches [][]ai.RecognizedItem) []ai.RecognizedItem {
seen := make(map[string]*openai.RecognizedItem) seen := make(map[string]*ai.RecognizedItem)
var order []string var order []string
for _, batch := range batches { 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 { for _, key := range order {
result = append(result, *seen[key]) result = append(result, *seen[key])
} }

View File

@@ -8,7 +8,7 @@ import (
"strconv" "strconv"
"sync" "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/locale"
"github.com/food-ai/backend/internal/infra/middleware" "github.com/food-ai/backend/internal/infra/middleware"
"github.com/food-ai/backend/internal/user" "github.com/food-ai/backend/internal/user"
@@ -35,18 +35,23 @@ type userPreferences struct {
Restrictions []string `json:"restrictions"` 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. // Handler handles GET /recommendations.
type Handler struct { type Handler struct {
openaiClient *openai.Client recipeGenerator RecipeGenerator
pexels PhotoSearcher pexels PhotoSearcher
userLoader UserLoader userLoader UserLoader
productLister ProductLister productLister ProductLister
} }
// NewHandler creates a new Handler. // 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{ return &Handler{
openaiClient: openaiClient, recipeGenerator: recipeGenerator,
pexels: pexels, pexels: pexels,
userLoader: userLoader, userLoader: userLoader,
productLister: productLister, 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) 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 { if err != nil {
slog.Error("generate recipes", "user_id", userID, "err", err) slog.Error("generate recipes", "user_id", userID, "err", err)
writeErrorJSON(w, http.StatusServiceUnavailable, "recipe generation failed, please try again") 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) writeJSON(w, http.StatusOK, recipes)
} }
func buildRecipeRequest(u *user.User, count int, lang string) openai.RecipeRequest { func buildRecipeRequest(u *user.User, count int, lang string) ai.RecipeRequest {
req := openai.RecipeRequest{ req := ai.RecipeRequest{
Count: count, Count: count,
DailyCalories: 2000, // sensible default DailyCalories: 2000, // sensible default
Lang: lang, Lang: lang,