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:
@@ -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
|
||||||
|
|||||||
128
backend/internal/adapters/ai/types.go
Normal file
128
backend/internal/adapters/ai/types.go
Normal 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"`
|
||||||
|
}
|
||||||
@@ -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]},
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user