feat: recognition job context, diary linkage, worker improvements

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
dbastrikin
2026-03-19 22:22:44 +02:00
parent cf69a4a3d9
commit 9357c194eb
5 changed files with 130 additions and 5 deletions

View File

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

View File

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

View File

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