refactor: restructure internal/ into adapters/, infra/, and app layers

- internal/gemini/ → internal/adapters/openai/ (renamed package to openai)
- internal/pexels/ → internal/adapters/pexels/
- internal/config/   → internal/infra/config/
- internal/database/ → internal/infra/database/
- internal/locale/   → internal/infra/locale/
- internal/middleware/ → internal/infra/middleware/
- internal/server/   → internal/infra/server/

All import paths and call sites updated accordingly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
dbastrikin
2026-03-15 21:10:37 +02:00
parent b427576629
commit 19a985ad49
44 changed files with 87 additions and 87 deletions

View File

@@ -0,0 +1,206 @@
package openai
import (
"context"
"encoding/json"
"fmt"
"strings"
"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",
"maintain": "weight maintenance",
"gain": "muscle gain",
}
// GenerateRecipes generates recipes using the Gemini AI.
// 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) {
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 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) ([]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 []Recipe
if err := json.Unmarshal([]byte(text), &recipes); err != nil {
return nil, err
}
return recipes, nil
}