- 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>
178 lines
6.2 KiB
Go
178 lines
6.2 KiB
Go
package openai
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"strings"
|
||
|
||
"github.com/food-ai/backend/internal/adapters/ai"
|
||
)
|
||
|
||
// RecognizeReceipt uses the vision model to extract food items from a receipt photo.
|
||
func (c *Client) RecognizeReceipt(ctx context.Context, imageBase64, mimeType string) (*ai.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 ai.ReceiptResult
|
||
if err := parseJSON(text, &result); err != nil {
|
||
return nil, fmt.Errorf("parse receipt result: %w", err)
|
||
}
|
||
if result.Items == nil {
|
||
result.Items = []ai.RecognizedItem{}
|
||
}
|
||
if result.Unrecognized == nil {
|
||
result.Unrecognized = []ai.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) ([]ai.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 []ai.RecognizedItem `json:"items"`
|
||
}
|
||
if err := parseJSON(text, &result); err != nil {
|
||
return nil, fmt.Errorf("parse products result: %w", err)
|
||
}
|
||
if result.Items == nil {
|
||
return []ai.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) (*ai.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 ai.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) (*ai.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 ai.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)
|
||
}
|