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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user