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:
dbastrikin
2026-03-18 13:28:37 +02:00
parent a32d2960c4
commit ad00998344
16 changed files with 503 additions and 109 deletions

View File

@@ -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 {