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

@@ -54,11 +54,11 @@ func initRouter(appConfig *config.Config, pool *pgxpool.Pool) (http.Handler, err
ingredientRepository := ingredient.NewRepository(pool)
ingredientHandler := ingredient.NewHandler(ingredientRepository)
productHandler := product.NewHandler(productRepository)
recognitionHandler := recognition.NewHandler(client, ingredientRepository)
recognitionHandler := recognition.NewHandler(client, ingredientRepository, dishRepository)
menuRepository := menu.NewRepository(pool)
menuHandler := menu.NewHandler(menuRepository, client, pexelsClient, repository, productRepository, dishRepository)
diaryRepository := diary.NewRepository(pool)
diaryHandler := diary.NewHandler(diaryRepository)
diaryHandler := diary.NewHandler(diaryRepository, dishRepository, dishRepository)
homeHandler := home.NewHandler(pool)
dishHandler := dish.NewHandler(dishRepository)
recipeRepository := recipe.NewRepository(pool)

View File

@@ -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"`

View 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
}

View File

@@ -7,14 +7,14 @@ type Entry struct {
ID string `json:"id"`
Date string `json:"date"` // YYYY-MM-DD
MealType string `json:"meal_type"`
Name string `json:"name"`
Name string `json:"name"` // from dishes JOIN
Portions float64 `json:"portions"`
Calories *float64 `json:"calories,omitempty"`
Calories *float64 `json:"calories,omitempty"` // recipe.calories_per_serving * portions
ProteinG *float64 `json:"protein_g,omitempty"`
FatG *float64 `json:"fat_g,omitempty"`
CarbsG *float64 `json:"carbs_g,omitempty"`
Source string `json:"source"`
DishID *string `json:"dish_id,omitempty"`
DishID string `json:"dish_id"`
RecipeID *string `json:"recipe_id,omitempty"`
PortionG *float64 `json:"portion_g,omitempty"`
CreatedAt time.Time `json:"created_at"`
@@ -24,12 +24,8 @@ type Entry struct {
type CreateRequest struct {
Date string `json:"date"`
MealType string `json:"meal_type"`
Name string `json:"name"`
Name string `json:"name"` // input-only; used if DishID is nil
Portions float64 `json:"portions"`
Calories *float64 `json:"calories"`
ProteinG *float64 `json:"protein_g"`
FatG *float64 `json:"fat_g"`
CarbsG *float64 `json:"carbs_g"`
Source string `json:"source"`
DishID *string `json:"dish_id"`
RecipeID *string `json:"recipe_id"`

View File

@@ -17,14 +17,26 @@ type DiaryRepository interface {
Delete(ctx context.Context, id, userID string) error
}
// DishRepository is the subset of dish.Repository used by Handler to resolve dish IDs.
type DishRepository interface {
FindOrCreate(ctx context.Context, name string) (string, bool, error)
}
// RecipeRepository is the subset of dish.Repository used by Handler to resolve recipe IDs.
type RecipeRepository interface {
FindOrCreateRecipe(ctx context.Context, dishID string, calories, proteinG, fatG, carbsG float64) (string, bool, error)
}
// Handler handles diary endpoints.
type Handler struct {
repo DiaryRepository
repo DiaryRepository
dishRepo DishRepository
recipeRepo RecipeRepository
}
// NewHandler creates a new Handler.
func NewHandler(repo DiaryRepository) *Handler {
return &Handler{repo: repo}
func NewHandler(repo DiaryRepository, dishRepo DishRepository, recipeRepo RecipeRepository) *Handler {
return &Handler{repo: repo, dishRepo: dishRepo, recipeRepo: recipeRepo}
}
// GetByDate handles GET /diary?date=YYYY-MM-DD
@@ -41,9 +53,9 @@ func (h *Handler) GetByDate(w http.ResponseWriter, r *http.Request) {
return
}
entries, err := h.repo.ListByDate(r.Context(), userID, date)
if err != nil {
slog.Error("list diary by date", "err", err)
entries, listError := h.repo.ListByDate(r.Context(), userID, date)
if listError != nil {
slog.Error("list diary by date", "err", listError)
writeError(w, http.StatusInternalServerError, "failed to load diary")
return
}
@@ -62,18 +74,42 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
}
var req CreateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
if decodeError := json.NewDecoder(r.Body).Decode(&req); decodeError != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Date == "" || req.Name == "" || req.MealType == "" {
writeError(w, http.StatusBadRequest, "date, meal_type and name are required")
if req.Date == "" || req.MealType == "" {
writeError(w, http.StatusBadRequest, "date and meal_type are required")
return
}
if req.DishID == nil && req.Name == "" {
writeError(w, http.StatusBadRequest, "dish_id or name is required")
return
}
entry, err := h.repo.Create(r.Context(), userID, req)
if err != nil {
slog.Error("create diary entry", "err", err)
if req.DishID == nil {
dishID, _, resolveError := h.dishRepo.FindOrCreate(r.Context(), req.Name)
if resolveError != nil {
slog.Error("resolve dish for diary entry", "name", req.Name, "err", resolveError)
writeError(w, http.StatusInternalServerError, "failed to resolve dish")
return
}
req.DishID = &dishID
}
if req.RecipeID == nil {
recipeID, _, recipeError := h.recipeRepo.FindOrCreateRecipe(r.Context(), *req.DishID, 0, 0, 0, 0)
if recipeError != nil {
slog.Error("find or create recipe for diary entry", "dish_id", *req.DishID, "err", recipeError)
writeError(w, http.StatusInternalServerError, "failed to resolve recipe")
return
}
req.RecipeID = &recipeID
}
entry, createError := h.repo.Create(r.Context(), userID, req)
if createError != nil {
slog.Error("create diary entry", "err", createError)
writeError(w, http.StatusInternalServerError, "failed to create diary entry")
return
}
@@ -89,12 +125,12 @@ func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
}
id := chi.URLParam(r, "id")
if err := h.repo.Delete(r.Context(), id, userID); err != nil {
if err == ErrNotFound {
if deleteError := h.repo.Delete(r.Context(), id, userID); deleteError != nil {
if deleteError == ErrNotFound {
writeError(w, http.StatusNotFound, "diary entry not found")
return
}
slog.Error("delete diary entry", "err", err)
slog.Error("delete diary entry", "err", deleteError)
writeError(w, http.StatusInternalServerError, "failed to delete diary entry")
return
}

View File

@@ -6,6 +6,30 @@ import (
"github.com/food-ai/backend/internal/domain/diary"
)
// MockDishRepository is a test double implementing diary.DishRepository.
type MockDishRepository struct {
FindOrCreateFn func(ctx context.Context, name string) (string, bool, error)
}
func (m *MockDishRepository) FindOrCreate(ctx context.Context, name string) (string, bool, error) {
if m.FindOrCreateFn != nil {
return m.FindOrCreateFn(ctx, name)
}
return "", false, nil
}
// MockRecipeRepository is a test double implementing diary.RecipeRepository.
type MockRecipeRepository struct {
FindOrCreateRecipeFn func(ctx context.Context, dishID string, calories, proteinG, fatG, carbsG float64) (string, bool, error)
}
func (m *MockRecipeRepository) FindOrCreateRecipe(ctx context.Context, dishID string, calories, proteinG, fatG, carbsG float64) (string, bool, error) {
if m.FindOrCreateRecipeFn != nil {
return m.FindOrCreateRecipeFn(ctx, dishID, calories, proteinG, fatG, carbsG)
}
return "", false, nil
}
// MockDiaryRepository is a test double implementing diary.DiaryRepository.
type MockDiaryRepository struct {
ListByDateFn func(ctx context.Context, userID, date string) ([]*diary.Entry, error)

View File

@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"github.com/food-ai/backend/internal/infra/locale"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
@@ -23,14 +24,24 @@ func NewRepository(pool *pgxpool.Pool) *Repository {
}
// ListByDate returns all diary entries for a user on a given date (YYYY-MM-DD).
// Dish name and macros are computed via JOIN with dishes and recipes.
func (r *Repository) ListByDate(ctx context.Context, userID, date string) ([]*Entry, error) {
lang := locale.FromContext(ctx)
rows, err := r.pool.Query(ctx, `
SELECT id, date::text, meal_type, name, portions,
calories, protein_g, fat_g, carbs_g,
source, dish_id, recipe_id, portion_g, created_at
FROM meal_diary
WHERE user_id = $1 AND date = $2::date
ORDER BY created_at ASC`, userID, date)
SELECT
md.id, md.date::text, md.meal_type, md.portions,
md.source, md.dish_id::text, md.recipe_id::text, md.portion_g, md.created_at,
COALESCE(dt.name, d.name) AS dish_name,
r.calories_per_serving * md.portions,
r.protein_per_serving * md.portions,
r.fat_per_serving * md.portions,
r.carbs_per_serving * md.portions
FROM meal_diary md
JOIN dishes d ON d.id = md.dish_id
LEFT JOIN dish_translations dt ON dt.dish_id = d.id AND dt.lang = $3
LEFT JOIN recipes r ON r.id = md.recipe_id
WHERE md.user_id = $1 AND md.date = $2::date
ORDER BY md.created_at ASC`, userID, date, lang)
if err != nil {
return nil, fmt.Errorf("list diary: %w", err)
}
@@ -38,17 +49,18 @@ func (r *Repository) ListByDate(ctx context.Context, userID, date string) ([]*En
var result []*Entry
for rows.Next() {
e, err := scanEntry(rows)
if err != nil {
return nil, fmt.Errorf("scan diary entry: %w", err)
entry, scanError := scanEntry(rows)
if scanError != nil {
return nil, fmt.Errorf("scan diary entry: %w", scanError)
}
result = append(result, e)
result = append(result, entry)
}
return result, rows.Err()
}
// Create inserts a new diary entry and returns the stored record.
// Create inserts a new diary entry and returns the stored record (with computed macros).
func (r *Repository) Create(ctx context.Context, userID string, req CreateRequest) (*Entry, error) {
lang := locale.FromContext(ctx)
portions := req.Portions
if portions <= 0 {
portions = 1
@@ -58,25 +70,40 @@ func (r *Repository) Create(ctx context.Context, userID string, req CreateReques
source = "manual"
}
var entryID string
insertError := r.pool.QueryRow(ctx, `
INSERT INTO meal_diary (user_id, date, meal_type, portions, source, dish_id, recipe_id, portion_g)
VALUES ($1, $2::date, $3, $4, $5, $6, $7, $8)
RETURNING id`,
userID, req.Date, req.MealType, portions, source, req.DishID, req.RecipeID, req.PortionG,
).Scan(&entryID)
if insertError != nil {
return nil, fmt.Errorf("insert diary entry: %w", insertError)
}
row := r.pool.QueryRow(ctx, `
INSERT INTO meal_diary (user_id, date, meal_type, name, portions,
calories, protein_g, fat_g, carbs_g, source, dish_id, recipe_id, portion_g)
VALUES ($1, $2::date, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
RETURNING id, date::text, meal_type, name, portions,
calories, protein_g, fat_g, carbs_g, source, dish_id, recipe_id, portion_g, created_at`,
userID, req.Date, req.MealType, req.Name, portions,
req.Calories, req.ProteinG, req.FatG, req.CarbsG,
source, req.DishID, req.RecipeID, req.PortionG,
)
SELECT
md.id, md.date::text, md.meal_type, md.portions,
md.source, md.dish_id::text, md.recipe_id::text, md.portion_g, md.created_at,
COALESCE(dt.name, d.name) AS dish_name,
r.calories_per_serving * md.portions,
r.protein_per_serving * md.portions,
r.fat_per_serving * md.portions,
r.carbs_per_serving * md.portions
FROM meal_diary md
JOIN dishes d ON d.id = md.dish_id
LEFT JOIN dish_translations dt ON dt.dish_id = d.id AND dt.lang = $2
LEFT JOIN recipes r ON r.id = md.recipe_id
WHERE md.id = $1`, entryID, lang)
return scanEntry(row)
}
// Delete removes a diary entry for the given user.
func (r *Repository) Delete(ctx context.Context, id, userID string) error {
tag, err := r.pool.Exec(ctx,
tag, deleteError := r.pool.Exec(ctx,
`DELETE FROM meal_diary WHERE id = $1 AND user_id = $2`, id, userID)
if err != nil {
return fmt.Errorf("delete diary entry: %w", err)
if deleteError != nil {
return fmt.Errorf("delete diary entry: %w", deleteError)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
@@ -91,14 +118,15 @@ type scannable interface {
}
func scanEntry(s scannable) (*Entry, error) {
var e Entry
err := s.Scan(
&e.ID, &e.Date, &e.MealType, &e.Name, &e.Portions,
&e.Calories, &e.ProteinG, &e.FatG, &e.CarbsG,
&e.Source, &e.DishID, &e.RecipeID, &e.PortionG, &e.CreatedAt,
var entry Entry
scanError := s.Scan(
&entry.ID, &entry.Date, &entry.MealType, &entry.Portions,
&entry.Source, &entry.DishID, &entry.RecipeID, &entry.PortionG, &entry.CreatedAt,
&entry.Name,
&entry.Calories, &entry.ProteinG, &entry.FatG, &entry.CarbsG,
)
if errors.Is(err, pgx.ErrNoRows) {
if errors.Is(scanError, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return &e, err
return &entry, scanError
}

View File

@@ -98,6 +98,125 @@ func (r *Repository) List(ctx context.Context) ([]*Dish, error) {
return dishes, nil
}
// FindOrCreate returns the dish ID and whether it was newly created.
// Looks up by case-insensitive name match; creates a minimal dish row if not found.
func (r *Repository) FindOrCreate(ctx context.Context, name string) (id string, created bool, err error) {
queryError := r.pool.QueryRow(ctx,
`SELECT id FROM dishes WHERE LOWER(name) = LOWER($1) LIMIT 1`, name,
).Scan(&id)
if queryError == nil {
return id, false, nil
}
if !errors.Is(queryError, pgx.ErrNoRows) {
return "", false, fmt.Errorf("find dish %q: %w", name, queryError)
}
insertError := r.pool.QueryRow(ctx,
`INSERT INTO dishes (name) VALUES ($1) RETURNING id`, name,
).Scan(&id)
if insertError != nil {
return "", false, fmt.Errorf("create dish %q: %w", name, insertError)
}
return id, true, nil
}
// FindOrCreateRecipe returns the recipe ID for the given dish, creating a minimal stub if none exists.
// Pass zeros for all nutrition params when no estimates are available.
func (r *Repository) FindOrCreateRecipe(ctx context.Context, dishID string, calories, proteinG, fatG, carbsG float64) (string, bool, error) {
var recipeID string
findError := r.pool.QueryRow(ctx,
`SELECT id FROM recipes WHERE dish_id = $1 ORDER BY created_at ASC LIMIT 1`,
dishID,
).Scan(&recipeID)
if findError == nil {
return recipeID, false, nil
}
if !errors.Is(findError, pgx.ErrNoRows) {
return "", false, fmt.Errorf("find recipe for dish: %w", findError)
}
insertError := r.pool.QueryRow(ctx, `
INSERT INTO recipes (dish_id, source, servings, calories_per_serving, protein_per_serving, fat_per_serving, carbs_per_serving)
VALUES ($1, 'ai', 1, $2, $3, $4, $5)
RETURNING id`,
dishID, nullableFloat(calories), nullableFloat(proteinG), nullableFloat(fatG), nullableFloat(carbsG),
).Scan(&recipeID)
if insertError != nil {
return "", false, fmt.Errorf("create recipe stub for dish: %w", insertError)
}
return recipeID, true, nil
}
// UpsertTranslation inserts or updates the name translation for a dish in a given language.
func (r *Repository) UpsertTranslation(ctx context.Context, dishID, lang, name string) error {
_, upsertError := r.pool.Exec(ctx,
`INSERT INTO dish_translations (dish_id, lang, name)
VALUES ($1, $2, $3)
ON CONFLICT (dish_id, lang) DO UPDATE SET name = EXCLUDED.name`,
dishID, lang, name,
)
if upsertError != nil {
return fmt.Errorf("upsert dish translation %s/%s: %w", dishID, lang, upsertError)
}
return nil
}
// AddRecipe inserts a recipe with its ingredients and steps for an existing dish.
func (r *Repository) AddRecipe(ctx context.Context, dishID string, req CreateRequest) (string, error) {
transaction, beginError := r.pool.BeginTx(ctx, pgx.TxOptions{})
if beginError != nil {
return "", fmt.Errorf("begin tx: %w", beginError)
}
defer transaction.Rollback(ctx) //nolint:errcheck
source := req.Source
if source == "" {
source = "ai"
}
var recipeID string
insertError := transaction.QueryRow(ctx, `
INSERT INTO recipes (dish_id, source, difficulty, prep_time_min, cook_time_min, servings,
calories_per_serving, protein_per_serving, fat_per_serving, carbs_per_serving)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING id`,
dishID, source,
nullableStr(req.Difficulty), nullableInt(req.PrepTimeMin), nullableInt(req.CookTimeMin), nullableInt(req.Servings),
nullableFloat(req.Calories), nullableFloat(req.Protein), nullableFloat(req.Fat), nullableFloat(req.Carbs),
).Scan(&recipeID)
if insertError != nil {
return "", fmt.Errorf("insert recipe for dish %s: %w", dishID, insertError)
}
for i, ingredient := range req.Ingredients {
if _, ingredientError := transaction.Exec(ctx, `
INSERT INTO recipe_ingredients (recipe_id, name, amount, unit_code, is_optional, sort_order)
VALUES ($1, $2, $3, NULLIF($4,''), $5, $6)`,
recipeID, ingredient.Name, ingredient.Amount, ingredient.Unit, ingredient.IsOptional, i,
); ingredientError != nil {
return "", fmt.Errorf("insert ingredient %d: %w", i, ingredientError)
}
}
for _, step := range req.Steps {
stepNumber := step.Number
if stepNumber <= 0 {
stepNumber = 1
}
if _, stepError := transaction.Exec(ctx, `
INSERT INTO recipe_steps (recipe_id, step_number, timer_seconds, description)
VALUES ($1, $2, $3, $4)`,
recipeID, stepNumber, step.TimerSeconds, step.Description,
); stepError != nil {
return "", fmt.Errorf("insert step %d: %w", stepNumber, stepError)
}
}
if commitError := transaction.Commit(ctx); commitError != nil {
return "", fmt.Errorf("commit: %w", commitError)
}
return recipeID, nil
}
// Create creates a new dish + recipe row from a CreateRequest.
// Returns the recipe ID to be used in menu_items or user_saved_recipes.
func (r *Repository) Create(ctx context.Context, req CreateRequest) (recipeID string, err error) {

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 {

View File

@@ -398,14 +398,9 @@ CREATE TABLE meal_diary (
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
date DATE NOT NULL,
meal_type TEXT NOT NULL,
name TEXT NOT NULL,
portions DECIMAL(5,2) NOT NULL DEFAULT 1,
calories DECIMAL(8,2),
protein_g DECIMAL(8,2),
fat_g DECIMAL(8,2),
carbs_g DECIMAL(8,2),
source TEXT NOT NULL DEFAULT 'manual',
dish_id UUID REFERENCES dishes(id) ON DELETE SET NULL,
dish_id UUID NOT NULL REFERENCES dishes(id) ON DELETE RESTRICT,
recipe_id UUID REFERENCES recipes(id) ON DELETE SET NULL,
portion_g DECIMAL(10,2),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()

View File

@@ -39,8 +39,17 @@ func authorizedRequest(method, target string, body []byte) *http.Request {
return request
}
func newHandler(diaryRepo diary.DiaryRepository, dishRepo diary.DishRepository, recipeRepo diary.RecipeRepository) *diary.Handler {
return diary.NewHandler(diaryRepo, dishRepo, recipeRepo)
}
func defaultMocks() (*diarymocks.MockDiaryRepository, *diarymocks.MockDishRepository, *diarymocks.MockRecipeRepository) {
return &diarymocks.MockDiaryRepository{}, &diarymocks.MockDishRepository{}, &diarymocks.MockRecipeRepository{}
}
func TestGetByDate_MissingQueryParam(t *testing.T) {
handler := diary.NewHandler(&diarymocks.MockDiaryRepository{})
diaryRepo, dishRepo, recipeRepo := defaultMocks()
handler := newHandler(diaryRepo, dishRepo, recipeRepo)
router := buildRouter(handler, "user-1")
recorder := httptest.NewRecorder()
@@ -59,7 +68,8 @@ func TestGetByDate_Success(t *testing.T) {
}, nil
},
}
handler := diary.NewHandler(mockRepo)
_, dishRepo, recipeRepo := defaultMocks()
handler := newHandler(mockRepo, dishRepo, recipeRepo)
router := buildRouter(handler, "user-1")
recorder := httptest.NewRecorder()
@@ -79,7 +89,8 @@ func TestGetByDate_Success(t *testing.T) {
}
func TestCreate_MissingDate(t *testing.T) {
handler := diary.NewHandler(&diarymocks.MockDiaryRepository{})
diaryRepo, dishRepo, recipeRepo := defaultMocks()
handler := newHandler(diaryRepo, dishRepo, recipeRepo)
router := buildRouter(handler, "user-1")
body, _ := json.Marshal(map[string]string{"name": "Oatmeal", "meal_type": "breakfast"})
@@ -91,8 +102,9 @@ func TestCreate_MissingDate(t *testing.T) {
}
}
func TestCreate_MissingName(t *testing.T) {
handler := diary.NewHandler(&diarymocks.MockDiaryRepository{})
func TestCreate_MissingNameAndDishID(t *testing.T) {
diaryRepo, dishRepo, recipeRepo := defaultMocks()
handler := newHandler(diaryRepo, dishRepo, recipeRepo)
router := buildRouter(handler, "user-1")
body, _ := json.Marshal(map[string]string{"date": "2026-03-15", "meal_type": "breakfast"})
@@ -105,7 +117,8 @@ func TestCreate_MissingName(t *testing.T) {
}
func TestCreate_MissingMealType(t *testing.T) {
handler := diary.NewHandler(&diarymocks.MockDiaryRepository{})
diaryRepo, dishRepo, recipeRepo := defaultMocks()
handler := newHandler(diaryRepo, dishRepo, recipeRepo)
router := buildRouter(handler, "user-1")
body, _ := json.Marshal(map[string]string{"date": "2026-03-15", "name": "Oatmeal"})
@@ -118,20 +131,31 @@ func TestCreate_MissingMealType(t *testing.T) {
}
func TestCreate_Success(t *testing.T) {
mockRepo := &diarymocks.MockDiaryRepository{
mockDiaryRepo := &diarymocks.MockDiaryRepository{
CreateFn: func(ctx context.Context, userID string, req diary.CreateRequest) (*diary.Entry, error) {
return &diary.Entry{
ID: "entry-1",
Date: req.Date,
MealType: req.MealType,
Name: req.Name,
Name: "Oatmeal",
Portions: 1,
Source: "manual",
DishID: "dish-1",
CreatedAt: time.Now(),
}, nil
},
}
handler := diary.NewHandler(mockRepo)
mockDishRepo := &diarymocks.MockDishRepository{
FindOrCreateFn: func(ctx context.Context, name string) (string, bool, error) {
return "dish-1", false, nil
},
}
mockRecipeRepo := &diarymocks.MockRecipeRepository{
FindOrCreateRecipeFn: func(ctx context.Context, dishID string, calories, proteinG, fatG, carbsG float64) (string, bool, error) {
return "recipe-1", false, nil
},
}
handler := newHandler(mockDiaryRepo, mockDishRepo, mockRecipeRepo)
router := buildRouter(handler, "user-1")
body, _ := json.Marshal(diary.CreateRequest{
@@ -154,7 +178,8 @@ func TestDelete_NotFound(t *testing.T) {
return diary.ErrNotFound
},
}
handler := diary.NewHandler(mockRepo)
_, dishRepo, recipeRepo := defaultMocks()
handler := newHandler(mockRepo, dishRepo, recipeRepo)
router := buildRouter(handler, "user-1")
recorder := httptest.NewRecorder()
@@ -171,7 +196,8 @@ func TestDelete_Success(t *testing.T) {
return nil
},
}
handler := diary.NewHandler(mockRepo)
_, dishRepo, recipeRepo := defaultMocks()
handler := newHandler(mockRepo, dishRepo, recipeRepo)
router := buildRouter(handler, "user-1")
recorder := httptest.NewRecorder()

View File

@@ -14,7 +14,6 @@ import '../../features/products/add_product_screen.dart';
import '../../features/scan/scan_screen.dart';
import '../../features/scan/recognition_confirm_screen.dart';
import '../../features/scan/recognition_service.dart';
import '../../features/menu/diary_screen.dart';
import '../../features/menu/menu_screen.dart';
import '../../features/menu/shopping_list_screen.dart';
import '../../features/recipes/recipe_detail_screen.dart';
@@ -129,14 +128,6 @@ final routerProvider = Provider<GoRouter>((ref) {
return ShoppingListScreen(week: week);
},
),
// Diary — full-screen, no bottom nav.
GoRoute(
path: '/menu/diary',
builder: (context, state) {
final date = state.extra as String? ?? '';
return DiaryScreen(date: date);
},
),
// Scan / recognition flow — all without bottom nav.
GoRoute(
path: '/scan',

View File

@@ -89,7 +89,7 @@ class HomeScreen extends ConsumerWidget {
_ExpiringBanner(items: expiringSoon),
],
const SizedBox(height: 16),
_QuickActionsRow(date: dateString),
_QuickActionsRow(),
if (recommendations.isNotEmpty) ...[
const SizedBox(height: 20),
_SectionTitle('Рекомендуем приготовить'),
@@ -988,8 +988,7 @@ class _ExpiringBanner extends StatelessWidget {
// ── Quick actions ─────────────────────────────────────────────
class _QuickActionsRow extends StatelessWidget {
final String date;
const _QuickActionsRow({required this.date});
const _QuickActionsRow();
@override
Widget build(BuildContext context) {
@@ -1010,14 +1009,6 @@ class _QuickActionsRow extends StatelessWidget {
onTap: () => context.push('/menu'),
),
),
const SizedBox(width: 8),
Expanded(
child: _ActionButton(
icon: Icons.book_outlined,
label: 'Дневник',
onTap: () => context.push('/menu/diary', extra: date),
),
),
],
);
}

View File

@@ -150,20 +150,6 @@ class _MenuContent extends StatelessWidget {
label: const Text('Список покупок'),
),
),
const SizedBox(height: 8),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: OutlinedButton.icon(
onPressed: () {
final today = DateTime.now();
final dateStr =
'${today.year}-${today.month.toString().padLeft(2, '0')}-${today.day.toString().padLeft(2, '0')}';
context.push('/menu/diary', extra: dateStr);
},
icon: const Icon(Icons.book_outlined),
label: const Text('Дневник питания'),
),
),
],
);
}

View File

@@ -106,6 +106,7 @@ class _DishResultSheetState extends ConsumerState<DishResultSheet> {
'carbs_g': scaledCarbs,
'portion_g': _portionGrams,
'source': 'recognition',
if (_selected.dishId != null) 'dish_id': _selected.dishId,
});
if (mounted) widget.onAdded();
} catch (addError) {

View File

@@ -65,6 +65,7 @@ class ReceiptResult {
/// A single dish recognition candidate with estimated nutrition for the portion in the photo.
class DishCandidate {
final String? dishId;
final String dishName;
final int weightGrams;
final double calories;
@@ -74,6 +75,7 @@ class DishCandidate {
final double confidence;
const DishCandidate({
this.dishId,
required this.dishName,
required this.weightGrams,
required this.calories,
@@ -85,6 +87,7 @@ class DishCandidate {
factory DishCandidate.fromJson(Map<String, dynamic> json) {
return DishCandidate(
dishId: json['dish_id'] as String?,
dishName: json['dish_name'] as String? ?? '',
weightGrams: json['weight_grams'] as int? ?? 0,
calories: (json['calories'] as num?)?.toDouble() ?? 0,