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