Remove denormalized columns (name, calories, protein_g, fat_g, carbs_g) from meal_diary. Name is now resolved via JOIN with dishes/dish_translations; macros are computed as recipe.*_per_serving * portions at query time. - Add dish.Repository.FindOrCreateRecipe: finds or creates a minimal recipe stub seeded with AI-estimated macros - recognition/handler: resolve recipe_id synchronously per candidate; simplify enrichDishInBackground to translations-only - diary/handler: accept dish_id OR name; always resolve recipe_id via FindOrCreateRecipe before INSERT - diary/entity: DishID is now non-nullable string; CreateRequest drops macros - diary/repository: ListByDate and Create use JOIN to return computed macros - ai/types: add RecipeID field to DishCandidate - Update tests and wire_gen accordingly Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
98 lines
3.4 KiB
Go
98 lines
3.4 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"
|
|
)
|
|
|
|
// GenerateRecipeForDish generates a single English recipe for a named dish.
|
|
func (c *Client) GenerateRecipeForDish(ctx context.Context, dishName string) (*ai.Recipe, error) {
|
|
prompt := fmt.Sprintf(`You are a chef and nutritionist.
|
|
Generate exactly 1 recipe for the dish "%s" in English.
|
|
|
|
Requirements:
|
|
- Include accurate macros per serving
|
|
- Total cooking time: max 60 minutes
|
|
|
|
IMPORTANT:
|
|
- All text fields MUST be in English.
|
|
- The "image_query" field MUST be in English.
|
|
|
|
Return ONLY a valid JSON array with exactly one element, no markdown:
|
|
[{"title":"...","description":"...","cuisine":"russian|asian|european|mediterranean|american|other","difficulty":"easy|medium|hard","prep_time_min":10,"cook_time_min":20,"servings":2,"image_query":"...","ingredients":[{"name":"...","amount":100,"unit":"..."}],"steps":[{"number":1,"description":"...","timer_seconds":null}],"tags":["..."],"nutrition_per_serving":{"calories":400,"protein_g":30,"fat_g":10,"carbs_g":40}}]`,
|
|
dishName)
|
|
|
|
var lastError error
|
|
for attempt := range maxRetries {
|
|
messages := []map[string]string{{"role": "user", "content": prompt}}
|
|
if attempt > 0 {
|
|
messages = append(messages, map[string]string{
|
|
"role": "user",
|
|
"content": "Previous response was not valid JSON. Return ONLY a JSON array with no text before or after.",
|
|
})
|
|
}
|
|
text, generateError := c.generateContent(ctx, messages)
|
|
if generateError != nil {
|
|
return nil, generateError
|
|
}
|
|
recipes, parseError := parseRecipesJSON(text)
|
|
if parseError != nil {
|
|
lastError = parseError
|
|
continue
|
|
}
|
|
if len(recipes) == 0 {
|
|
lastError = fmt.Errorf("empty recipes array")
|
|
continue
|
|
}
|
|
recipes[0].Nutrition.Approximate = true
|
|
return &recipes[0], nil
|
|
}
|
|
return nil, fmt.Errorf("generate recipe for dish %q after %d attempts: %w", dishName, maxRetries, lastError)
|
|
}
|
|
|
|
// TranslateDishName translates a dish name into all supported languages (except English)
|
|
// in a single AI call. Returns a map of lang-code → translated name.
|
|
func (c *Client) TranslateDishName(ctx context.Context, name string) (map[string]string, error) {
|
|
var langLines []string
|
|
for _, language := range locale.Languages {
|
|
if language.Code != "en" {
|
|
langLines = append(langLines, fmt.Sprintf(`"%s": "%s"`, language.Code, language.EnglishName))
|
|
}
|
|
}
|
|
if len(langLines) == 0 {
|
|
return map[string]string{}, nil
|
|
}
|
|
|
|
prompt := fmt.Sprintf(`Translate the dish name "%s" into the following languages.
|
|
Return ONLY a valid JSON object mapping language code to translated name, no markdown:
|
|
{%s}
|
|
Fill each value with the natural translation of the dish name in that language.`,
|
|
name, strings.Join(langLines, ", "))
|
|
|
|
text, generateError := c.generateContent(ctx, []map[string]string{
|
|
{"role": "user", "content": prompt},
|
|
})
|
|
if generateError != nil {
|
|
return nil, generateError
|
|
}
|
|
|
|
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 translations map[string]string
|
|
if parseError := json.Unmarshal([]byte(text), &translations); parseError != nil {
|
|
return nil, fmt.Errorf("parse translations for %q: %w", name, parseError)
|
|
}
|
|
return translations, nil
|
|
}
|