Files
dbastrikin ad00998344 feat: slim meal_diary — derive name and nutrition from dish/recipe
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>
2026-03-18 13:28:37 +02:00

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
}