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:
120
backend/internal/adapters/openai/client.go
Normal file
120
backend/internal/adapters/openai/client.go
Normal 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
|
||||
}
|
||||
99
backend/internal/adapters/openai/menu.go
Normal file
99
backend/internal/adapters/openai/menu.go
Normal 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
|
||||
}
|
||||
206
backend/internal/adapters/openai/recipe.go
Normal file
206
backend/internal/adapters/openai/recipe.go
Normal 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
|
||||
}
|
||||
229
backend/internal/adapters/openai/recognition.go
Normal file
229
backend/internal/adapters/openai/recognition.go
Normal 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.0–1.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.0–1.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.0–1.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)
|
||||
}
|
||||
77
backend/internal/adapters/pexels/client.go
Normal file
77
backend/internal/adapters/pexels/client.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user