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:
@@ -9,11 +9,20 @@ import (
|
||||
"sync"
|
||||
|
||||
"github.com/food-ai/backend/internal/adapters/ai"
|
||||
"github.com/food-ai/backend/internal/domain/dish"
|
||||
"github.com/food-ai/backend/internal/domain/ingredient"
|
||||
"github.com/food-ai/backend/internal/infra/locale"
|
||||
"github.com/food-ai/backend/internal/infra/middleware"
|
||||
"github.com/food-ai/backend/internal/domain/ingredient"
|
||||
)
|
||||
|
||||
// DishRepository is the subset of dish.Repository used by this handler.
|
||||
type DishRepository interface {
|
||||
FindOrCreate(ctx context.Context, name string) (string, bool, error)
|
||||
FindOrCreateRecipe(ctx context.Context, dishID string, calories, proteinG, fatG, carbsG float64) (string, bool, error)
|
||||
UpsertTranslation(ctx context.Context, dishID, lang, name string) error
|
||||
AddRecipe(ctx context.Context, dishID string, req dish.CreateRequest) (string, error)
|
||||
}
|
||||
|
||||
// IngredientRepository is the subset of ingredient.Repository used by this handler.
|
||||
type IngredientRepository interface {
|
||||
FuzzyMatch(ctx context.Context, name string) (*ingredient.IngredientMapping, error)
|
||||
@@ -28,17 +37,20 @@ type Recognizer interface {
|
||||
RecognizeProducts(ctx context.Context, imageBase64, mimeType, lang string) ([]ai.RecognizedItem, error)
|
||||
RecognizeDish(ctx context.Context, imageBase64, mimeType, lang string) (*ai.DishResult, error)
|
||||
ClassifyIngredient(ctx context.Context, name string) (*ai.IngredientClassification, error)
|
||||
GenerateRecipeForDish(ctx context.Context, dishName string) (*ai.Recipe, error)
|
||||
TranslateDishName(ctx context.Context, name string) (map[string]string, error)
|
||||
}
|
||||
|
||||
// Handler handles POST /ai/* recognition endpoints.
|
||||
type Handler struct {
|
||||
recognizer Recognizer
|
||||
ingredientRepo IngredientRepository
|
||||
dishRepo DishRepository
|
||||
}
|
||||
|
||||
// NewHandler creates a new Handler.
|
||||
func NewHandler(recognizer Recognizer, repo IngredientRepository) *Handler {
|
||||
return &Handler{recognizer: recognizer, ingredientRepo: repo}
|
||||
func NewHandler(recognizer Recognizer, repo IngredientRepository, dishRepo DishRepository) *Handler {
|
||||
return &Handler{recognizer: recognizer, ingredientRepo: repo, dishRepo: dishRepo}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -159,6 +171,41 @@ func (h *Handler) RecognizeDish(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Resolve dish_id and recipe_id for each candidate in parallel.
|
||||
var mu sync.Mutex
|
||||
var wg sync.WaitGroup
|
||||
for i := range result.Candidates {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
candidate := result.Candidates[i]
|
||||
dishID, created, findError := h.dishRepo.FindOrCreate(r.Context(), candidate.DishName)
|
||||
if findError != nil {
|
||||
slog.Warn("find or create dish", "name", candidate.DishName, "err", findError)
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
result.Candidates[i].DishID = &dishID
|
||||
mu.Unlock()
|
||||
if created {
|
||||
go h.enrichDishInBackground(dishID, candidate.DishName)
|
||||
}
|
||||
|
||||
recipeID, _, recipeError := h.dishRepo.FindOrCreateRecipe(
|
||||
r.Context(), dishID,
|
||||
candidate.Calories, candidate.ProteinG, candidate.FatG, candidate.CarbsG,
|
||||
)
|
||||
if recipeError != nil {
|
||||
slog.Warn("find or create recipe", "dish_id", dishID, "err", recipeError)
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
result.Candidates[i].RecipeID = &recipeID
|
||||
mu.Unlock()
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
writeJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
@@ -262,6 +309,58 @@ func (h *Handler) saveClassification(ctx context.Context, c *ai.IngredientClassi
|
||||
return saved
|
||||
}
|
||||
|
||||
// enrichDishInBackground generates name translations for a newly created dish stub.
|
||||
// Recipe creation is handled synchronously in RecognizeDish.
|
||||
// Runs as a fire-and-forget goroutine so it never blocks the HTTP response.
|
||||
func (h *Handler) enrichDishInBackground(dishID, dishName string) {
|
||||
enrichContext := context.Background()
|
||||
|
||||
translations, translateError := h.recognizer.TranslateDishName(enrichContext, dishName)
|
||||
if translateError != nil {
|
||||
slog.Warn("translate dish name", "name", dishName, "err", translateError)
|
||||
return
|
||||
}
|
||||
for lang, translatedName := range translations {
|
||||
if upsertError := h.dishRepo.UpsertTranslation(enrichContext, dishID, lang, translatedName); upsertError != nil {
|
||||
slog.Warn("upsert dish translation", "dish_id", dishID, "lang", lang, "err", upsertError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// aiRecipeToCreateRequest converts an AI-generated recipe into a dish.CreateRequest.
|
||||
func aiRecipeToCreateRequest(recipe *ai.Recipe) dish.CreateRequest {
|
||||
ingredients := make([]dish.IngredientInput, len(recipe.Ingredients))
|
||||
for i, ingredient := range recipe.Ingredients {
|
||||
ingredients[i] = dish.IngredientInput{
|
||||
Name: ingredient.Name, Amount: ingredient.Amount, Unit: ingredient.Unit,
|
||||
}
|
||||
}
|
||||
steps := make([]dish.StepInput, len(recipe.Steps))
|
||||
for i, step := range recipe.Steps {
|
||||
steps[i] = dish.StepInput{
|
||||
Number: step.Number, Description: step.Description, TimerSeconds: step.TimerSeconds,
|
||||
}
|
||||
}
|
||||
return dish.CreateRequest{
|
||||
Name: recipe.Title,
|
||||
Description: recipe.Description,
|
||||
CuisineSlug: recipe.Cuisine,
|
||||
ImageURL: recipe.ImageURL,
|
||||
Tags: recipe.Tags,
|
||||
Source: "ai",
|
||||
Difficulty: recipe.Difficulty,
|
||||
PrepTimeMin: recipe.PrepTimeMin,
|
||||
CookTimeMin: recipe.CookTimeMin,
|
||||
Servings: recipe.Servings,
|
||||
Calories: recipe.Nutrition.Calories,
|
||||
Protein: recipe.Nutrition.ProteinG,
|
||||
Fat: recipe.Nutrition.FatG,
|
||||
Carbs: recipe.Nutrition.CarbsG,
|
||||
Ingredients: ingredients,
|
||||
Steps: steps,
|
||||
}
|
||||
}
|
||||
|
||||
// MergeAndDeduplicate combines results from multiple images.
|
||||
// Items sharing the same name (case-insensitive) have their quantities summed.
|
||||
func MergeAndDeduplicate(batches [][]ai.RecognizedItem) []ai.RecognizedItem {
|
||||
|
||||
Reference in New Issue
Block a user