feat: recognition job context, diary linkage, worker improvements
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user