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>
This commit is contained in:
@@ -96,6 +96,8 @@ type ReceiptResult struct {
|
||||
|
||||
// DishCandidate is a single dish recognition candidate with estimated nutrition.
|
||||
type DishCandidate struct {
|
||||
DishID *string `json:"dish_id,omitempty"`
|
||||
RecipeID *string `json:"recipe_id,omitempty"`
|
||||
DishName string `json:"dish_name"`
|
||||
WeightGrams int `json:"weight_grams"`
|
||||
Calories float64 `json:"calories"`
|
||||
|
||||
97
backend/internal/adapters/openai/dish.go
Normal file
97
backend/internal/adapters/openai/dish.go
Normal file
@@ -0,0 +1,97 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user