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,120 @@
package openai
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
const (
// openaiAPIURL is the OpenAI chat completions endpoint.
openaiAPIURL = "https://api.openai.com/v1/chat/completions"
// openaiModel is the default text generation model.
openaiModel = "gpt-4o-mini"
// openaiVisionModel supports image inputs.
openaiVisionModel = "gpt-4o"
maxRetries = 3
)
// Client is an HTTP client for the OpenAI API.
type Client struct {
apiKey string
httpClient *http.Client
}
// NewClient creates a new Client.
func NewClient(apiKey string) *Client {
return &Client{
apiKey: apiKey,
httpClient: &http.Client{
Timeout: 90 * time.Second,
},
}
}
// generateContent sends text messages to the text model.
func (c *Client) generateContent(ctx context.Context, messages []map[string]string) (string, error) {
return c.callOpenAI(ctx, openaiModel, 0.7, messages)
}
// generateVisionContent sends an image + text prompt to the vision model.
// imageBase64 must be the raw base64-encoded image data (no data URI prefix).
// mimeType defaults to "image/jpeg" if empty.
func (c *Client) generateVisionContent(ctx context.Context, prompt, imageBase64, mimeType string) (string, error) {
if mimeType == "" {
mimeType = "image/jpeg"
}
dataURL := fmt.Sprintf("data:%s;base64,%s", mimeType, imageBase64)
messages := []any{
map[string]any{
"role": "user",
"content": []any{
map[string]any{
"type": "image_url",
"image_url": map[string]string{"url": dataURL},
},
map[string]any{
"type": "text",
"text": prompt,
},
},
},
}
return c.callOpenAI(ctx, openaiVisionModel, 0.1, messages)
}
// callOpenAI is the shared HTTP transport for all OpenAI requests.
// messages can be []map[string]string (text) or []any (vision with image content).
func (c *Client) callOpenAI(ctx context.Context, model string, temperature float64, messages any) (string, error) {
body := map[string]any{
"model": model,
"temperature": temperature,
"messages": messages,
}
bodyBytes, err := json.Marshal(body)
if err != nil {
return "", fmt.Errorf("marshal request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, openaiAPIURL, bytes.NewReader(bodyBytes))
if err != nil {
return "", fmt.Errorf("create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+c.apiKey)
resp, err := c.httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("send request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
raw, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("openai API error %d: %s", resp.StatusCode, string(raw))
}
var result struct {
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
} `json:"choices"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", fmt.Errorf("decode response: %w", err)
}
if len(result.Choices) == 0 {
return "", fmt.Errorf("empty response from OpenAI")
}
return result.Choices[0].Message.Content, nil
}

View File

@@ -0,0 +1,99 @@
package openai
import (
"context"
"fmt"
"sync"
)
// 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
// GenerateRecipes calls (one per meal type). This avoids token-limit errors
// that arise from requesting 21 full recipes in a single prompt.
func (c *Client) GenerateMenu(ctx context.Context, req MenuRequest) ([]DayPlan, error) {
type mealSlot struct {
mealType string
fraction float64 // share of daily calories
}
slots := []mealSlot{
{"breakfast", 0.25},
{"lunch", 0.40},
{"dinner", 0.35},
}
type mealResult struct {
recipes []Recipe
err error
}
results := make([]mealResult, len(slots))
var wg sync.WaitGroup
for i, slot := range slots {
wg.Add(1)
go func(idx int, mealType string, fraction float64) {
defer wg.Done()
// Scale daily calories to what this meal should contribute.
mealCal := int(float64(req.DailyCalories) * fraction)
r, err := c.GenerateRecipes(ctx, RecipeRequest{
UserGoal: req.UserGoal,
DailyCalories: mealCal * 3, // prompt divides by 3 internally
Restrictions: req.Restrictions,
CuisinePrefs: req.CuisinePrefs,
Count: 7,
AvailableProducts: req.AvailableProducts,
Lang: req.Lang,
})
results[idx] = mealResult{r, err}
}(i, slot.mealType, slot.fraction)
}
wg.Wait()
for i, res := range results {
if res.err != nil {
return nil, fmt.Errorf("generate %s: %w", slots[i].mealType, res.err)
}
if len(res.recipes) == 0 {
return nil, fmt.Errorf("no %s recipes returned", slots[i].mealType)
}
// Pad to exactly 7 by repeating the last recipe.
for len(results[i].recipes) < 7 {
results[i].recipes = append(results[i].recipes, results[i].recipes[len(results[i].recipes)-1])
}
}
days := make([]DayPlan, 7)
for day := range 7 {
days[day] = DayPlan{
Day: day + 1,
Meals: []MealEntry{
{MealType: slots[0].mealType, Recipe: results[0].recipes[day]},
{MealType: slots[1].mealType, Recipe: results[1].recipes[day]},
{MealType: slots[2].mealType, Recipe: results[2].recipes[day]},
},
}
}
return days, nil
}

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
}

View File

@@ -0,0 +1,229 @@
package openai
import (
"encoding/json"
"fmt"
"strings"
"context"
)
// 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.
func (c *Client) RecognizeReceipt(ctx context.Context, imageBase64, mimeType string) (*ReceiptResult, error) {
prompt := `Ты — OCR-система для чеков из продуктовых магазинов.
Проанализируй фото чека и извлеки список продуктов питания.
Для каждого продукта определи:
- name: название на русском языке (убери артикулы, коды, лишние символы)
- quantity: количество (число)
- unit: единица (г, кг, мл, л, шт, уп)
- category: dairy | meat | produce | bakery | frozen | beverages | other
- confidence: 0.01.0
Позиции, которые не являются едой (бытовая химия, табак, алкоголь) — пропусти.
Позиции с нечитаемым текстом — добавь в unrecognized.
Верни ТОЛЬКО валидный JSON без markdown:
{
"items": [
{"name": "Молоко 2.5%", "quantity": 1, "unit": "л", "category": "dairy", "confidence": 0.95}
],
"unrecognized": [
{"raw_text": "ТОВ АРТИК 1ШТ", "price": 89.0}
]
}`
text, err := c.generateVisionContent(ctx, prompt, imageBase64, mimeType)
if err != nil {
return nil, fmt.Errorf("recognize receipt: %w", err)
}
var result ReceiptResult
if err := parseJSON(text, &result); err != nil {
return nil, fmt.Errorf("parse receipt result: %w", err)
}
if result.Items == nil {
result.Items = []RecognizedItem{}
}
if result.Unrecognized == nil {
result.Unrecognized = []UnrecognizedItem{}
}
return &result, nil
}
// 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) {
prompt := `Ты — система распознавания продуктов питания.
Посмотри на фото и определи все видимые продукты питания.
Для каждого продукта оцени:
- name: название на русском языке
- quantity: приблизительное количество (число)
- unit: единица (г, кг, мл, л, шт)
- category: dairy | meat | produce | bakery | frozen | beverages | other
- confidence: 0.01.0
Только продукты питания. Пустые упаковки и несъедобные предметы — пропусти.
Верни ТОЛЬКО валидный JSON без markdown:
{
"items": [
{"name": "Яйца", "quantity": 10, "unit": "шт", "category": "dairy", "confidence": 0.9}
]
}`
text, err := c.generateVisionContent(ctx, prompt, imageBase64, mimeType)
if err != nil {
return nil, fmt.Errorf("recognize products: %w", err)
}
var result struct {
Items []RecognizedItem `json:"items"`
}
if err := parseJSON(text, &result); err != nil {
return nil, fmt.Errorf("parse products result: %w", err)
}
if result.Items == nil {
return []RecognizedItem{}, nil
}
return result.Items, nil
}
// 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) {
prompt := `Ты — диетолог и кулинарный эксперт.
Посмотри на фото блюда и определи:
- dish_name: название блюда на русском языке
- weight_grams: приблизительный вес порции в граммах
- calories: калорийность порции (приблизительно)
- protein_g, fat_g, carbs_g: БЖУ на порцию
- confidence: 0.01.0
- similar_dishes: до 3 похожих блюд (для поиска рецептов)
Верни ТОЛЬКО валидный JSON без markdown:
{
"dish_name": "Паста Карбонара",
"weight_grams": 350,
"calories": 520,
"protein_g": 22,
"fat_g": 26,
"carbs_g": 48,
"confidence": 0.85,
"similar_dishes": ["Паста с беконом", "Спагетти"]
}`
text, err := c.generateVisionContent(ctx, prompt, imageBase64, mimeType)
if err != nil {
return nil, fmt.Errorf("recognize dish: %w", err)
}
var result DishResult
if err := parseJSON(text, &result); err != nil {
return nil, fmt.Errorf("parse dish result: %w", err)
}
if result.SimilarDishes == nil {
result.SimilarDishes = []string{}
}
return &result, nil
}
// ClassifyIngredient uses the text model to classify an unknown food item
// and build an ingredient_mappings record for it.
func (c *Client) ClassifyIngredient(ctx context.Context, name string) (*IngredientClassification, error) {
prompt := fmt.Sprintf(`Classify the food product: "%s".
Return ONLY valid JSON without markdown:
{
"canonical_name": "turkey_breast",
"aliases": ["turkey breast"],
"translations": [
{"lang": "ru", "name": "грудка индейки", "aliases": ["грудка индейки", "филе индейки"]}
],
"category": "meat",
"default_unit": "g",
"calories_per_100g": 135,
"protein_per_100g": 29,
"fat_per_100g": 1,
"carbs_per_100g": 0,
"storage_days": 3
}`, name)
messages := []map[string]string{
{"role": "user", "content": prompt},
}
text, err := c.generateContent(ctx, messages)
if err != nil {
return nil, fmt.Errorf("classify ingredient: %w", err)
}
var result IngredientClassification
if err := parseJSON(text, &result); err != nil {
return nil, fmt.Errorf("parse classification: %w", err)
}
return &result, nil
}
// parseJSON strips optional markdown fences and unmarshals JSON.
func parseJSON(text string, dst any) 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)
}
return json.Unmarshal([]byte(text), dst)
}

View File

@@ -0,0 +1,77 @@
package pexels
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"time"
)
const (
pexelsSearchURL = "https://api.pexels.com/v1/search"
defaultPlaceholder = "https://images.pexels.com/photos/1640777/pexels-photo-1640777.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750"
)
// PhotoSearcher can search for a photo by text query.
type PhotoSearcher interface {
SearchPhoto(ctx context.Context, query string) (string, error)
}
// Client is an HTTP client for the Pexels Photos API.
type Client struct {
apiKey string
httpClient *http.Client
}
// NewClient creates a new Pexels client.
func NewClient(apiKey string) *Client {
return &Client{
apiKey: apiKey,
httpClient: &http.Client{
Timeout: 10 * time.Second,
},
}
}
// SearchPhoto searches for a landscape photo matching query.
// Returns a default placeholder URL if no photo is found or on error.
func (c *Client) SearchPhoto(ctx context.Context, query string) (string, error) {
params := url.Values{}
params.Set("query", query)
params.Set("per_page", "1")
params.Set("orientation", "landscape")
req, err := http.NewRequestWithContext(ctx, http.MethodGet, pexelsSearchURL+"?"+params.Encode(), nil)
if err != nil {
return defaultPlaceholder, fmt.Errorf("create request: %w", err)
}
req.Header.Set("Authorization", c.apiKey)
resp, err := c.httpClient.Do(req)
if err != nil {
return defaultPlaceholder, fmt.Errorf("send request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return defaultPlaceholder, fmt.Errorf("pexels API error: status %d", resp.StatusCode)
}
var result struct {
Photos []struct {
Src struct {
Medium string `json:"medium"`
} `json:"src"`
} `json:"photos"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return defaultPlaceholder, fmt.Errorf("decode response: %w", err)
}
if len(result.Photos) == 0 || result.Photos[0].Src.Medium == "" {
return defaultPlaceholder, nil
}
return result.Photos[0].Src.Medium, nil
}