- 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>
152 lines
4.0 KiB
Go
152 lines
4.0 KiB
Go
package openai
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/food-ai/backend/internal/adapters/ai"
|
|
"github.com/food-ai/backend/internal/infra/locale"
|
|
)
|
|
|
|
// goalNames maps internal goal codes to English descriptions used in the prompt.
|
|
var goalNames = map[string]string{
|
|
"lose": "weight loss",
|
|
"maintain": "weight maintenance",
|
|
"gain": "muscle gain",
|
|
}
|
|
|
|
// 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 ai.RecipeRequest) ([]ai.Recipe, error) {
|
|
prompt := buildRecipePrompt(req)
|
|
|
|
messages := []map[string]string{
|
|
{"role": "user", "content": prompt},
|
|
}
|
|
|
|
var lastErr error
|
|
for attempt := range maxRetries {
|
|
if attempt > 0 {
|
|
messages = []map[string]string{
|
|
{"role": "user", "content": prompt},
|
|
{"role": "user", "content": "Previous response was not valid JSON. Return ONLY a JSON array with no text before or after."},
|
|
}
|
|
}
|
|
|
|
text, err := c.generateContent(ctx, messages)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
recipes, err := parseRecipesJSON(text)
|
|
if err != nil {
|
|
lastErr = fmt.Errorf("attempt %d parse JSON: %w", attempt+1, err)
|
|
continue
|
|
}
|
|
|
|
for i := range recipes {
|
|
recipes[i].Nutrition.Approximate = true
|
|
}
|
|
return recipes, nil
|
|
}
|
|
|
|
return nil, fmt.Errorf("failed to parse valid JSON after %d attempts: %w", maxRetries, lastErr)
|
|
}
|
|
|
|
func buildRecipePrompt(req ai.RecipeRequest) string {
|
|
lang := req.Lang
|
|
if lang == "" {
|
|
lang = "en"
|
|
}
|
|
langName := "English"
|
|
for _, l := range locale.Languages {
|
|
if l.Code == lang {
|
|
langName = l.EnglishName
|
|
break
|
|
}
|
|
}
|
|
|
|
goal := goalNames[req.UserGoal]
|
|
if goal == "" {
|
|
goal = "weight maintenance"
|
|
}
|
|
|
|
restrictions := "none"
|
|
if len(req.Restrictions) > 0 {
|
|
restrictions = strings.Join(req.Restrictions, ", ")
|
|
}
|
|
|
|
cuisines := "any"
|
|
if len(req.CuisinePrefs) > 0 {
|
|
cuisines = strings.Join(req.CuisinePrefs, ", ")
|
|
}
|
|
|
|
count := req.Count
|
|
if count <= 0 {
|
|
count = 5
|
|
}
|
|
|
|
perMealCalories := req.DailyCalories / 3
|
|
if perMealCalories <= 0 {
|
|
perMealCalories = 600
|
|
}
|
|
|
|
productsSection := ""
|
|
if len(req.AvailableProducts) > 0 {
|
|
productsSection = "\nAvailable products (⚠ = expiring soon, prioritise these):\n" +
|
|
strings.Join(req.AvailableProducts, "\n") + "\n"
|
|
}
|
|
|
|
return fmt.Sprintf(`You are a chef and nutritionist. Generate %d recipes in %s.
|
|
|
|
User profile:
|
|
- Goal: %s
|
|
- Daily calories: %d kcal
|
|
- Dietary restrictions: %s
|
|
- Cuisine preferences: %s
|
|
%s
|
|
Requirements for each recipe:
|
|
- Max %d kcal per serving
|
|
- Total cooking time: max 60 minutes
|
|
- Include approximate macros per serving
|
|
|
|
IMPORTANT:
|
|
- All text fields (title, description, ingredient names, units, step descriptions, tags) MUST be in %s.
|
|
- The "image_query" field MUST always be in English (it is used for stock-photo search).
|
|
|
|
Return ONLY a valid JSON array without markdown or extra text:
|
|
[{
|
|
"title": "...",
|
|
"description": "2-3 sentences",
|
|
"cuisine": "russian|asian|european|mediterranean|american|other",
|
|
"difficulty": "easy|medium|hard",
|
|
"prep_time_min": 10,
|
|
"cook_time_min": 20,
|
|
"servings": 2,
|
|
"image_query": "short English photo-search query",
|
|
"ingredients": [{"name": "...", "amount": 300, "unit": "..."}],
|
|
"steps": [{"number": 1, "description": "...", "timer_seconds": null}],
|
|
"tags": ["..."],
|
|
"nutrition_per_serving": {"calories": 420, "protein_g": 48, "fat_g": 12, "carbs_g": 18}
|
|
}]`, count, langName, goal, req.DailyCalories, restrictions, cuisines, productsSection, perMealCalories, langName)
|
|
}
|
|
|
|
func parseRecipesJSON(text string) ([]ai.Recipe, error) {
|
|
text = strings.TrimSpace(text)
|
|
if strings.HasPrefix(text, "```") {
|
|
text = strings.TrimPrefix(text, "```json")
|
|
text = strings.TrimPrefix(text, "```")
|
|
text = strings.TrimSuffix(text, "```")
|
|
text = strings.TrimSpace(text)
|
|
}
|
|
|
|
var recipes []ai.Recipe
|
|
if err := json.Unmarshal([]byte(text), &recipes); err != nil {
|
|
return nil, err
|
|
}
|
|
return recipes, nil
|
|
}
|