diff --git a/backend/internal/domain/dish/repository.go b/backend/internal/domain/dish/repository.go index e7d049a..3049bd4 100644 --- a/backend/internal/domain/dish/repository.go +++ b/backend/internal/domain/dish/repository.go @@ -146,6 +146,23 @@ func (r *Repository) FindOrCreateRecipe(ctx context.Context, dishID string, calo return recipeID, true, nil } +// GetTranslation returns the translated dish name for a given language. +// Returns ("", false, nil) if no translation exists yet. +func (r *Repository) GetTranslation(ctx context.Context, dishID, lang string) (string, bool, error) { + var name string + queryError := r.pool.QueryRow(ctx, + `SELECT name FROM dish_translations WHERE dish_id = $1 AND lang = $2`, + dishID, lang, + ).Scan(&name) + if errors.Is(queryError, pgx.ErrNoRows) { + return "", false, nil + } + if queryError != nil { + return "", false, fmt.Errorf("get dish translation %s/%s: %w", dishID, lang, queryError) + } + return name, 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, diff --git a/backend/internal/domain/recognition/handler.go b/backend/internal/domain/recognition/handler.go index 4aa4146..f2cdc27 100644 --- a/backend/internal/domain/recognition/handler.go +++ b/backend/internal/domain/recognition/handler.go @@ -22,6 +22,7 @@ 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 + GetTranslation(ctx context.Context, dishID, lang string) (string, bool, error) AddRecipe(ctx context.Context, dishID string, req dish.CreateRequest) (string, error) } @@ -249,6 +250,23 @@ func (handler *Handler) ListTodayJobs(responseWriter http.ResponseWriter, reques writeJSON(responseWriter, http.StatusOK, summaries) } +// ListAllJobs handles GET /ai/jobs/history — returns all recognition jobs for the current user. +func (handler *Handler) ListAllJobs(responseWriter http.ResponseWriter, request *http.Request) { + userID := middleware.UserIDFromCtx(request.Context()) + + summaries, listError := handler.jobRepo.ListAll(request.Context(), userID) + if listError != nil { + slog.Error("list all jobs", "err", listError) + writeErrorJSON(responseWriter, http.StatusInternalServerError, "failed to list jobs") + return + } + + if summaries == nil { + summaries = []*JobSummary{} + } + writeJSON(responseWriter, http.StatusOK, summaries) +} + // GetJobStream handles GET /ai/jobs/{id}/stream — SSE endpoint for job updates. func (handler *Handler) GetJobStream(responseWriter http.ResponseWriter, request *http.Request) { handler.sseBroker.ServeSSE(responseWriter, request) diff --git a/backend/internal/domain/recognition/job_repository.go b/backend/internal/domain/recognition/job_repository.go index 6b96ac8..559395e 100644 --- a/backend/internal/domain/recognition/job_repository.go +++ b/backend/internal/domain/recognition/job_repository.go @@ -17,6 +17,7 @@ type JobRepository interface { QueuePosition(ctx context.Context, userPlan string, createdAt time.Time) (int, error) NotifyJobUpdate(ctx context.Context, jobID string) error ListTodayUnlinked(ctx context.Context, userID string) ([]*JobSummary, error) + ListAll(ctx context.Context, userID string) ([]*JobSummary, error) } // PostgresJobRepository implements JobRepository using a pgxpool. @@ -127,6 +128,46 @@ func (repository *PostgresJobRepository) NotifyJobUpdate(queryContext context.Co return notifyError } +// ListAll returns all recognition jobs for the given user, newest first. +func (repository *PostgresJobRepository) ListAll(queryContext context.Context, userID string) ([]*JobSummary, error) { + rows, queryError := repository.pool.Query(queryContext, + `SELECT id, status, target_date::text, target_meal_type, + result, error, created_at + FROM dish_recognition_jobs + WHERE user_id = $1 + ORDER BY created_at DESC`, + userID, + ) + if queryError != nil { + return nil, queryError + } + defer rows.Close() + + var summaries []*JobSummary + for rows.Next() { + var summary JobSummary + var resultJSON []byte + scanError := rows.Scan( + &summary.ID, &summary.Status, &summary.TargetDate, &summary.TargetMealType, + &resultJSON, &summary.Error, &summary.CreatedAt, + ) + if scanError != nil { + return nil, scanError + } + if resultJSON != nil { + var dishResult ai.DishResult + if unmarshalError := json.Unmarshal(resultJSON, &dishResult); unmarshalError == nil { + summary.Result = &dishResult + } + } + summaries = append(summaries, &summary) + } + if rowsError := rows.Err(); rowsError != nil { + return nil, rowsError + } + return summaries, nil +} + // ListTodayUnlinked returns today's jobs for the given user that have not yet been // linked to any meal_diary entry. func (repository *PostgresJobRepository) ListTodayUnlinked(queryContext context.Context, userID string) ([]*JobSummary, error) { diff --git a/backend/internal/domain/recognition/worker.go b/backend/internal/domain/recognition/worker.go index 84f7700..bbbde82 100644 --- a/backend/internal/domain/recognition/worker.go +++ b/backend/internal/domain/recognition/worker.go @@ -89,17 +89,28 @@ func (pool *WorkerPool) processJob(workerContext context.Context, jobID string) go func(candidateIndex int) { defer wg.Done() candidate := result.Candidates[candidateIndex] - dishID, created, findError := pool.dishRepo.FindOrCreate(workerContext, candidate.DishName) + englishName := candidate.DishName + + dishID, created, findError := pool.dishRepo.FindOrCreate(workerContext, englishName) if findError != nil { - slog.Warn("worker: find or create dish", "name", candidate.DishName, "err", findError) + slog.Warn("worker: find or create dish", "name", englishName, "err", findError) return } + + localizedName := englishName + if job.Lang != "en" { + // Translate synchronously so the saved result JSON already has the right name. + // resolveLocalizedDishName saves all language translations as a side-effect, + // so enrichDishInBackground is not needed afterwards. + localizedName = pool.resolveLocalizedDishName(workerContext, dishID, englishName, job.Lang, created) + } else if created { + go enrichDishInBackground(pool.recognizer, pool.dishRepo, dishID, englishName) + } + mu.Lock() result.Candidates[candidateIndex].DishID = &dishID + result.Candidates[candidateIndex].DishName = localizedName mu.Unlock() - if created { - go enrichDishInBackground(pool.recognizer, pool.dishRepo, dishID, candidate.DishName) - } recipeID, _, recipeError := pool.dishRepo.FindOrCreateRecipe( workerContext, dishID, @@ -140,3 +151,40 @@ func enrichDishInBackground(recognizer Recognizer, dishRepo DishRepository, dish } } } + +// resolveLocalizedDishName returns the dish name in the requested language. +// For a newly created dish it always translates via AI (synchronously) and saves +// all translations to dish_translations as a side-effect. +// For an existing dish it checks dish_translations first; if the row is missing +// (e.g. background enrichment is still in flight) it falls back to AI translation. +// Returns englishName unchanged on any unrecoverable error. +func (pool *WorkerPool) resolveLocalizedDishName( + workerContext context.Context, + dishID, englishName, lang string, + isNewDish bool, +) string { + if !isNewDish { + translatedName, found, getError := pool.dishRepo.GetTranslation(workerContext, dishID, lang) + if getError != nil { + slog.Warn("worker: get dish translation", "dish_id", dishID, "lang", lang, "err", getError) + } else if found { + return translatedName + } + // Fall through to AI translation. + } + + translations, translateError := pool.recognizer.TranslateDishName(workerContext, englishName) + if translateError != nil { + slog.Warn("worker: translate dish name", "name", englishName, "err", translateError) + return englishName + } + for languageCode, nameInLang := range translations { + if upsertError := pool.dishRepo.UpsertTranslation(workerContext, dishID, languageCode, nameInLang); upsertError != nil { + slog.Warn("worker: upsert dish translation", "dish_id", dishID, "lang", languageCode, "err", upsertError) + } + } + if localizedName, ok := translations[lang]; ok { + return localizedName + } + return englishName +} diff --git a/backend/internal/infra/server/server.go b/backend/internal/infra/server/server.go index 89b2264..8f0c4c4 100644 --- a/backend/internal/infra/server/server.go +++ b/backend/internal/infra/server/server.go @@ -121,6 +121,7 @@ func NewRouter( r.Post("/recognize-products", recognitionHandler.RecognizeProducts) r.Post("/recognize-dish", recognitionHandler.RecognizeDish) r.Get("/jobs", recognitionHandler.ListTodayJobs) + r.Get("/jobs/history", recognitionHandler.ListAllJobs) r.Get("/jobs/{id}", recognitionHandler.GetJob) r.Get("/jobs/{id}/stream", recognitionHandler.GetJobStream) r.Post("/generate-menu", menuHandler.GenerateMenu)