feat: async dish recognition (Kafka/Watermill/SSE) + remove Wire + consolidate migrations

Async recognition pipeline:
- POST /ai/recognize-dish → 202 {job_id, queue_position, estimated_seconds}
- GET /ai/jobs/{id}/stream — SSE stream: queued → processing → done/failed
- Kafka topics: ai.recognize.paid (3 partitions) + ai.recognize.free (1 partition)
- 5-worker WorkerPool with priority loop (paid consumers first)
- SSEBroker via PostgreSQL LISTEN/NOTIFY
- Kafka adapter migrated from franz-go to Watermill (watermill-kafka/v2)
- Docker Compose: added Kafka + Zookeeper + kafka-init service
- Flutter: recognition_service.dart uses SSE; home_screen shows live job status

Remove google/wire (archived):
- Deleted wire.go (wireinject spec) and wire_gen.go
- Added cmd/server/init.go — plain Go manual DI, same initApp() logic
- Removed github.com/google/wire from go.mod

Consolidate migrations:
- Merged 001_initial_schema + 002_seed_data + 003_recognition_jobs into single 001_initial_schema.sql
- Deleted 002_seed_data.sql and 003_recognition_jobs.sql

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
dbastrikin
2026-03-18 16:32:06 +02:00
parent ad00998344
commit 39193ec13c
22 changed files with 1574 additions and 582 deletions

View File

@@ -8,6 +8,8 @@ import (
"strings"
"sync"
"github.com/go-chi/chi/v5"
"github.com/food-ai/backend/internal/adapters/ai"
"github.com/food-ai/backend/internal/domain/dish"
"github.com/food-ai/backend/internal/domain/ingredient"
@@ -15,7 +17,7 @@ import (
"github.com/food-ai/backend/internal/infra/middleware"
)
// DishRepository is the subset of dish.Repository used by this handler.
// DishRepository is the subset of dish.Repository used by workers and the 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)
@@ -41,16 +43,35 @@ type Recognizer interface {
TranslateDishName(ctx context.Context, name string) (map[string]string, error)
}
// KafkaPublisher publishes job IDs to a Kafka topic.
type KafkaPublisher interface {
Publish(ctx context.Context, topic, message string) error
}
// Handler handles POST /ai/* recognition endpoints.
type Handler struct {
recognizer Recognizer
ingredientRepo IngredientRepository
dishRepo DishRepository
jobRepo JobRepository
kafkaProducer KafkaPublisher
sseBroker *SSEBroker
}
// NewHandler creates a new Handler.
func NewHandler(recognizer Recognizer, repo IngredientRepository, dishRepo DishRepository) *Handler {
return &Handler{recognizer: recognizer, ingredientRepo: repo, dishRepo: dishRepo}
// NewHandler creates a new Handler with async dish recognition support.
func NewHandler(
recognizer Recognizer,
ingredientRepo IngredientRepository,
jobRepo JobRepository,
kafkaProducer KafkaPublisher,
sseBroker *SSEBroker,
) *Handler {
return &Handler{
recognizer: recognizer,
ingredientRepo: ingredientRepo,
jobRepo: jobRepo,
kafkaProducer: kafkaProducer,
sseBroker: sseBroker,
}
}
// ---------------------------------------------------------------------------
@@ -81,39 +102,36 @@ type EnrichedItem struct {
// ReceiptResponse is the response for POST /ai/recognize-receipt.
type ReceiptResponse struct {
Items []EnrichedItem `json:"items"`
Items []EnrichedItem `json:"items"`
Unrecognized []ai.UnrecognizedItem `json:"unrecognized"`
}
// DishResponse is the response for POST /ai/recognize-dish.
type DishResponse = ai.DishResult
// ---------------------------------------------------------------------------
// Handlers
// ---------------------------------------------------------------------------
// RecognizeReceipt handles POST /ai/recognize-receipt.
// Body: {"image_base64": "...", "mime_type": "image/jpeg"}
func (h *Handler) RecognizeReceipt(w http.ResponseWriter, r *http.Request) {
userID := middleware.UserIDFromCtx(r.Context())
func (handler *Handler) RecognizeReceipt(responseWriter http.ResponseWriter, request *http.Request) {
userID := middleware.UserIDFromCtx(request.Context())
_ = userID // logged for tracing
var req imageRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.ImageBase64 == "" {
writeErrorJSON(w, http.StatusBadRequest, "image_base64 is required")
if decodeError := json.NewDecoder(request.Body).Decode(&req); decodeError != nil || req.ImageBase64 == "" {
writeErrorJSON(responseWriter, http.StatusBadRequest, "image_base64 is required")
return
}
lang := locale.FromContext(r.Context())
result, err := h.recognizer.RecognizeReceipt(r.Context(), req.ImageBase64, req.MimeType, lang)
if err != nil {
slog.Error("recognize receipt", "err", err)
writeErrorJSON(w, http.StatusServiceUnavailable, "recognition failed, please try again")
lang := locale.FromContext(request.Context())
result, recognizeError := handler.recognizer.RecognizeReceipt(request.Context(), req.ImageBase64, req.MimeType, lang)
if recognizeError != nil {
slog.Error("recognize receipt", "err", recognizeError)
writeErrorJSON(responseWriter, http.StatusServiceUnavailable, "recognition failed, please try again")
return
}
enriched := h.enrichItems(r.Context(), result.Items)
writeJSON(w, http.StatusOK, ReceiptResponse{
enriched := handler.enrichItems(request.Context(), result.Items)
writeJSON(responseWriter, http.StatusOK, ReceiptResponse{
Items: enriched,
Unrecognized: result.Unrecognized,
})
@@ -121,92 +139,108 @@ func (h *Handler) RecognizeReceipt(w http.ResponseWriter, r *http.Request) {
// RecognizeProducts handles POST /ai/recognize-products.
// Body: {"images": [{"image_base64": "...", "mime_type": "image/jpeg"}, ...]}
func (h *Handler) RecognizeProducts(w http.ResponseWriter, r *http.Request) {
func (handler *Handler) RecognizeProducts(responseWriter http.ResponseWriter, request *http.Request) {
var req imagesRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || len(req.Images) == 0 {
writeErrorJSON(w, http.StatusBadRequest, "at least one image is required")
if decodeError := json.NewDecoder(request.Body).Decode(&req); decodeError != nil || len(req.Images) == 0 {
writeErrorJSON(responseWriter, http.StatusBadRequest, "at least one image is required")
return
}
if len(req.Images) > 3 {
req.Images = req.Images[:3] // cap at 3 photos as per spec
}
// Process each image in parallel.
lang := locale.FromContext(r.Context())
lang := locale.FromContext(request.Context())
allItems := make([][]ai.RecognizedItem, len(req.Images))
var wg sync.WaitGroup
for i, img := range req.Images {
wg.Add(1)
go func(i int, img imageRequest) {
go func(index int, imageReq imageRequest) {
defer wg.Done()
items, err := h.recognizer.RecognizeProducts(r.Context(), img.ImageBase64, img.MimeType, lang)
if err != nil {
slog.Warn("recognize products from image", "index", i, "err", err)
items, recognizeError := handler.recognizer.RecognizeProducts(request.Context(), imageReq.ImageBase64, imageReq.MimeType, lang)
if recognizeError != nil {
slog.Warn("recognize products from image", "index", index, "err", recognizeError)
return
}
allItems[i] = items
allItems[index] = items
}(i, img)
}
wg.Wait()
merged := MergeAndDeduplicate(allItems)
enriched := h.enrichItems(r.Context(), merged)
writeJSON(w, http.StatusOK, map[string]any{"items": enriched})
enriched := handler.enrichItems(request.Context(), merged)
writeJSON(responseWriter, http.StatusOK, map[string]any{"items": enriched})
}
// RecognizeDish handles POST /ai/recognize-dish.
// RecognizeDish handles POST /ai/recognize-dish (async).
// Enqueues the image for AI processing and returns 202 Accepted with a job_id.
// Body: {"image_base64": "...", "mime_type": "image/jpeg"}
func (h *Handler) RecognizeDish(w http.ResponseWriter, r *http.Request) {
func (handler *Handler) RecognizeDish(responseWriter http.ResponseWriter, request *http.Request) {
var req imageRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.ImageBase64 == "" {
writeErrorJSON(w, http.StatusBadRequest, "image_base64 is required")
if decodeError := json.NewDecoder(request.Body).Decode(&req); decodeError != nil || req.ImageBase64 == "" {
writeErrorJSON(responseWriter, http.StatusBadRequest, "image_base64 is required")
return
}
lang := locale.FromContext(r.Context())
result, err := h.recognizer.RecognizeDish(r.Context(), req.ImageBase64, req.MimeType, lang)
if err != nil {
slog.Error("recognize dish", "err", err)
writeErrorJSON(w, http.StatusServiceUnavailable, "recognition failed, please try again")
userID := middleware.UserIDFromCtx(request.Context())
userPlan := middleware.UserPlanFromCtx(request.Context())
lang := locale.FromContext(request.Context())
job := &Job{
UserID: userID,
UserPlan: userPlan,
ImageBase64: req.ImageBase64,
MimeType: req.MimeType,
Lang: lang,
}
if insertError := handler.jobRepo.InsertJob(request.Context(), job); insertError != nil {
slog.Error("insert recognition job", "err", insertError)
writeErrorJSON(responseWriter, http.StatusInternalServerError, "failed to create job")
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)
position, positionError := handler.jobRepo.QueuePosition(request.Context(), userPlan, job.CreatedAt)
if positionError != nil {
position = 0
}
wg.Wait()
writeJSON(w, http.StatusOK, result)
topic := TopicFree
if userPlan == "paid" {
topic = TopicPaid
}
if publishError := handler.kafkaProducer.Publish(request.Context(), topic, job.ID); publishError != nil {
slog.Error("publish recognition job", "job_id", job.ID, "err", publishError)
writeErrorJSON(responseWriter, http.StatusInternalServerError, "failed to enqueue job")
return
}
estimatedSeconds := (position + 1) * 6
writeJSON(responseWriter, http.StatusAccepted, map[string]any{
"job_id": job.ID,
"queue_position": position,
"estimated_seconds": estimatedSeconds,
})
}
// 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)
}
// GetJob handles GET /ai/jobs/{id} — fetches a job result (for app re-open after backgrounding).
func (handler *Handler) GetJob(responseWriter http.ResponseWriter, request *http.Request) {
jobID := chi.URLParam(request, "id")
userID := middleware.UserIDFromCtx(request.Context())
job, fetchError := handler.jobRepo.GetJobByID(request.Context(), jobID)
if fetchError != nil {
writeErrorJSON(responseWriter, http.StatusNotFound, "job not found")
return
}
if job.UserID != userID {
writeErrorJSON(responseWriter, http.StatusForbidden, "forbidden")
return
}
writeJSON(responseWriter, http.StatusOK, job)
}
// ---------------------------------------------------------------------------
@@ -214,8 +248,8 @@ func (h *Handler) RecognizeDish(w http.ResponseWriter, r *http.Request) {
// ---------------------------------------------------------------------------
// enrichItems matches each recognized item against ingredient_mappings.
// Items without a match trigger a Gemini classification call and upsert into the DB.
func (h *Handler) enrichItems(ctx context.Context, items []ai.RecognizedItem) []EnrichedItem {
// Items without a match trigger a classification call and upsert into the DB.
func (handler *Handler) enrichItems(ctx context.Context, items []ai.RecognizedItem) []EnrichedItem {
result := make([]EnrichedItem, 0, len(items))
for _, item := range items {
enriched := EnrichedItem{
@@ -227,13 +261,12 @@ func (h *Handler) enrichItems(ctx context.Context, items []ai.RecognizedItem) []
StorageDays: 7, // sensible default
}
mapping, err := h.ingredientRepo.FuzzyMatch(ctx, item.Name)
if err != nil {
slog.Warn("fuzzy match ingredient", "name", item.Name, "err", err)
mapping, matchError := handler.ingredientRepo.FuzzyMatch(ctx, item.Name)
if matchError != nil {
slog.Warn("fuzzy match ingredient", "name", item.Name, "err", matchError)
}
if mapping != nil {
// Found existing mapping — use its canonical data.
id := mapping.ID
enriched.MappingID = &id
if mapping.DefaultUnit != nil {
@@ -246,12 +279,11 @@ func (h *Handler) enrichItems(ctx context.Context, items []ai.RecognizedItem) []
enriched.Category = *mapping.Category
}
} else {
// No mapping — ask AI to classify and save for future reuse.
classification, err := h.recognizer.ClassifyIngredient(ctx, item.Name)
if err != nil {
slog.Warn("classify unknown ingredient", "name", item.Name, "err", err)
classification, classifyError := handler.recognizer.ClassifyIngredient(ctx, item.Name)
if classifyError != nil {
slog.Warn("classify unknown ingredient", "name", item.Name, "err", classifyError)
} else {
saved := h.saveClassification(ctx, classification)
saved := handler.saveClassification(ctx, classification)
if saved != nil {
id := saved.ID
enriched.MappingID = &id
@@ -267,41 +299,41 @@ func (h *Handler) enrichItems(ctx context.Context, items []ai.RecognizedItem) []
}
// saveClassification upserts an AI-produced ingredient classification into the DB.
func (h *Handler) saveClassification(ctx context.Context, c *ai.IngredientClassification) *ingredient.IngredientMapping {
if c == nil || c.CanonicalName == "" {
func (handler *Handler) saveClassification(ctx context.Context, classification *ai.IngredientClassification) *ingredient.IngredientMapping {
if classification == nil || classification.CanonicalName == "" {
return nil
}
m := &ingredient.IngredientMapping{
CanonicalName: c.CanonicalName,
Category: strPtr(c.Category),
DefaultUnit: strPtr(c.DefaultUnit),
CaloriesPer100g: c.CaloriesPer100g,
ProteinPer100g: c.ProteinPer100g,
FatPer100g: c.FatPer100g,
CarbsPer100g: c.CarbsPer100g,
StorageDays: intPtr(c.StorageDays),
mapping := &ingredient.IngredientMapping{
CanonicalName: classification.CanonicalName,
Category: strPtr(classification.Category),
DefaultUnit: strPtr(classification.DefaultUnit),
CaloriesPer100g: classification.CaloriesPer100g,
ProteinPer100g: classification.ProteinPer100g,
FatPer100g: classification.FatPer100g,
CarbsPer100g: classification.CarbsPer100g,
StorageDays: intPtr(classification.StorageDays),
}
saved, err := h.ingredientRepo.Upsert(ctx, m)
if err != nil {
slog.Warn("upsert classified ingredient", "name", c.CanonicalName, "err", err)
saved, upsertError := handler.ingredientRepo.Upsert(ctx, mapping)
if upsertError != nil {
slog.Warn("upsert classified ingredient", "name", classification.CanonicalName, "err", upsertError)
return nil
}
if len(c.Aliases) > 0 {
if err := h.ingredientRepo.UpsertAliases(ctx, saved.ID, "en", c.Aliases); err != nil {
slog.Warn("upsert ingredient aliases", "id", saved.ID, "err", err)
if len(classification.Aliases) > 0 {
if aliasError := handler.ingredientRepo.UpsertAliases(ctx, saved.ID, "en", classification.Aliases); aliasError != nil {
slog.Warn("upsert ingredient aliases", "id", saved.ID, "err", aliasError)
}
}
for _, t := range c.Translations {
if err := h.ingredientRepo.UpsertTranslation(ctx, saved.ID, t.Lang, t.Name); err != nil {
slog.Warn("upsert ingredient translation", "id", saved.ID, "lang", t.Lang, "err", err)
for _, translation := range classification.Translations {
if translationError := handler.ingredientRepo.UpsertTranslation(ctx, saved.ID, translation.Lang, translation.Name); translationError != nil {
slog.Warn("upsert ingredient translation", "id", saved.ID, "lang", translation.Lang, "err", translationError)
}
if len(t.Aliases) > 0 {
if err := h.ingredientRepo.UpsertAliases(ctx, saved.ID, t.Lang, t.Aliases); err != nil {
slog.Warn("upsert ingredient translation aliases", "id", saved.ID, "lang", t.Lang, "err", err)
if len(translation.Aliases) > 0 {
if aliasError := handler.ingredientRepo.UpsertAliases(ctx, saved.ID, translation.Lang, translation.Aliases); aliasError != nil {
slog.Warn("upsert ingredient translation aliases", "id", saved.ID, "lang", translation.Lang, "err", aliasError)
}
}
}
@@ -309,58 +341,6 @@ 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 {
@@ -373,7 +353,6 @@ func MergeAndDeduplicate(batches [][]ai.RecognizedItem) []ai.RecognizedItem {
key := normalizeName(item.Name)
if existing, ok := seen[key]; ok {
existing.Quantity += item.Quantity
// Keep the higher confidence estimate.
if item.Confidence > existing.Confidence {
existing.Confidence = item.Confidence
}
@@ -414,14 +393,14 @@ type errorResponse struct {
Error string `json:"error"`
}
func writeErrorJSON(w http.ResponseWriter, status int, msg string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(errorResponse{Error: msg})
func writeErrorJSON(responseWriter http.ResponseWriter, status int, msg string) {
responseWriter.Header().Set("Content-Type", "application/json")
responseWriter.WriteHeader(status)
_ = json.NewEncoder(responseWriter).Encode(errorResponse{Error: msg})
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
func writeJSON(responseWriter http.ResponseWriter, status int, value any) {
responseWriter.Header().Set("Content-Type", "application/json")
responseWriter.WriteHeader(status)
_ = json.NewEncoder(responseWriter).Encode(value)
}