feat: async product/receipt recognition via Kafka

Backend:
- Migration 002: product_recognition_jobs table with JSONB images column
  and job_type CHECK ('receipt' | 'products')
- New Kafka topics: ai.products.paid / ai.products.free
- ProductJob model, ProductJobRepository (mirrors dish job pattern)
- itemEnricher extracted from Handler — shared by HTTP handler and worker
- ProductSSEBroker: PG LISTEN on product_job_update channel
- ProductWorkerPool: 5 workers, branches on job_type to call
  RecognizeReceipt or RecognizeProducts per image in parallel
- Handler: RecognizeReceipt and RecognizeProducts now return 202 Accepted
  instead of blocking; 4 new endpoints: GET /ai/product-jobs,
  /product-jobs/history, /product-jobs/{id}, /product-jobs/{id}/stream
- cmd/worker: extended to run ProductWorkerPool alongside dish WorkerPool
- cmd/server: wires productJobRepository + productSSEBroker; both SSE
  brokers started in App.Start()

Flutter client:
- ProductJobCreated, ProductJobResult, ProductJobSummary, ProductJobEvent
  models + submitReceiptRecognition/submitProductsRecognition/stream methods
- Shared _openSseStream helper eliminates duplicate SSE parsing loop
- ScanScreen: replace blocking AI calls with async submit + navigate to
  ProductJobWatchScreen
- ProductJobWatchScreen: watches SSE stream, navigates to /scan/confirm
  when done, shows error on failure
- ProductsScreen: prepends _RecentScansSection (hidden when empty); compact
  horizontal list of recent scans with "See all" → history
- ProductJobHistoryScreen: full list of all product recognition jobs
- New routes: /scan/product-job-watch, /products/job-history
- L10n: 7 new keys in all 12 ARB files

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
dbastrikin
2026-03-23 23:01:30 +02:00
parent bffeb05a43
commit c7317c4335
43 changed files with 2073 additions and 239 deletions

View File

@@ -9,8 +9,9 @@ import (
// App bundles the HTTP handler with background services that need lifecycle management.
type App struct {
handler http.Handler
sseBroker *recognition.SSEBroker
handler http.Handler
sseBroker *recognition.SSEBroker
productSSEBroker *recognition.ProductSSEBroker
}
// ServeHTTP implements http.Handler.
@@ -18,8 +19,9 @@ func (application *App) ServeHTTP(responseWriter http.ResponseWriter, request *h
application.handler.ServeHTTP(responseWriter, request)
}
// Start launches the SSE broker's LISTEN loop.
// Start launches the SSE brokers' LISTEN loops.
// Call this once before the HTTP server begins accepting connections.
func (application *App) Start(applicationContext context.Context) {
application.sseBroker.Start(applicationContext)
application.productSSEBroker.Start(applicationContext)
}

View File

@@ -56,7 +56,9 @@ func initApp(appConfig *config.Config, pool *pgxpool.Pool) (*App, error) {
// Recognition pipeline
jobRepository := recognition.NewJobRepository(pool)
sseBroker := recognition.NewSSEBroker(pool, jobRepository)
recognitionHandler := recognition.NewHandler(openaiClient, productRepository, jobRepository, kafkaProducer, sseBroker)
productJobRepository := recognition.NewProductJobRepository(pool)
productSSEBroker := recognition.NewProductSSEBroker(pool, productJobRepository)
recognitionHandler := recognition.NewHandler(openaiClient, productRepository, jobRepository, productJobRepository, kafkaProducer, sseBroker, productSSEBroker)
menuRepository := menu.NewRepository(pool)
menuHandler := menu.NewHandler(menuRepository, openaiClient, openaiClient, dishRepository, pexelsClient, userRepository, userProductRepository, dishRepository)
@@ -93,7 +95,8 @@ func initApp(appConfig *config.Config, pool *pgxpool.Pool) (*App, error) {
mainTagListHandler,
)
return &App{
handler: httpHandler,
sseBroker: sseBroker,
handler: httpHandler,
sseBroker: sseBroker,
productSSEBroker: productSSEBroker,
}, nil
}

View File

@@ -6,6 +6,7 @@ import (
"github.com/food-ai/backend/internal/adapters/kafka"
"github.com/food-ai/backend/internal/adapters/openai"
"github.com/food-ai/backend/internal/domain/dish"
"github.com/food-ai/backend/internal/domain/product"
"github.com/food-ai/backend/internal/domain/recognition"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/kelseyhightower/envconfig"
@@ -28,31 +29,57 @@ func loadConfig() (*workerConfig, error) {
// WorkerApp bundles background services that need lifecycle management.
type WorkerApp struct {
workerPool *recognition.WorkerPool
workerPool *recognition.WorkerPool
productWorkerPool *recognition.ProductWorkerPool
}
// Start launches the worker pool goroutines.
// Start launches the dish and product worker pool goroutines.
func (workerApp *WorkerApp) Start(applicationContext context.Context) {
workerApp.workerPool.Start(applicationContext)
workerApp.productWorkerPool.Start(applicationContext)
}
func initWorker(workerCfg *workerConfig, pool *pgxpool.Pool) (*WorkerApp, error) {
openaiClient := openai.NewClient(workerCfg.OpenAIAPIKey)
// Dish recognition worker.
dishRepository := dish.NewRepository(pool)
jobRepository := recognition.NewJobRepository(pool)
topic := recognition.TopicFree
groupID := "dish-recognition-free"
dishTopic := recognition.TopicFree
dishGroupID := "dish-recognition-free"
if workerCfg.WorkerPlan == "paid" {
topic = recognition.TopicPaid
groupID = "dish-recognition-paid"
dishTopic = recognition.TopicPaid
dishGroupID = "dish-recognition-paid"
}
consumer, consumerError := kafka.NewConsumer(workerCfg.KafkaBrokers, groupID, topic)
if consumerError != nil {
return nil, consumerError
dishConsumer, dishConsumerError := kafka.NewConsumer(workerCfg.KafkaBrokers, dishGroupID, dishTopic)
if dishConsumerError != nil {
return nil, dishConsumerError
}
workerPool := recognition.NewWorkerPool(jobRepository, openaiClient, dishRepository, consumer)
return &WorkerApp{workerPool: workerPool}, nil
workerPool := recognition.NewWorkerPool(jobRepository, openaiClient, dishRepository, dishConsumer)
// Product recognition worker.
productRepository := product.NewRepository(pool)
productJobRepository := recognition.NewProductJobRepository(pool)
productTopic := recognition.ProductTopicFree
productGroupID := "product-recognition-free"
if workerCfg.WorkerPlan == "paid" {
productTopic = recognition.ProductTopicPaid
productGroupID = "product-recognition-paid"
}
productConsumer, productConsumerError := kafka.NewConsumer(workerCfg.KafkaBrokers, productGroupID, productTopic)
if productConsumerError != nil {
return nil, productConsumerError
}
productWorkerPool := recognition.NewProductWorkerPool(productJobRepository, openaiClient, productRepository, productConsumer)
return &WorkerApp{
workerPool: workerPool,
productWorkerPool: productWorkerPool,
}, nil
}

View File

@@ -6,7 +6,6 @@ import (
"log/slog"
"net/http"
"strings"
"sync"
"github.com/go-chi/chi/v5"
@@ -49,27 +48,33 @@ type KafkaPublisher interface {
// Handler handles POST /ai/* recognition endpoints.
type Handler struct {
recognizer Recognizer
productRepo ProductRepository
jobRepo JobRepository
kafkaProducer KafkaPublisher
sseBroker *SSEBroker
enricher *itemEnricher
recognizer Recognizer
jobRepo JobRepository
productJobRepo ProductJobRepository
kafkaProducer KafkaPublisher
sseBroker *SSEBroker
productSSEBroker *ProductSSEBroker
}
// NewHandler creates a new Handler with async dish recognition support.
// NewHandler creates a new Handler with async dish and product recognition support.
func NewHandler(
recognizer Recognizer,
productRepo ProductRepository,
jobRepo JobRepository,
productJobRepo ProductJobRepository,
kafkaProducer KafkaPublisher,
sseBroker *SSEBroker,
productSSEBroker *ProductSSEBroker,
) *Handler {
return &Handler{
recognizer: recognizer,
productRepo: productRepo,
jobRepo: jobRepo,
kafkaProducer: kafkaProducer,
sseBroker: sseBroker,
enricher: newItemEnricher(recognizer, productRepo),
recognizer: recognizer,
jobRepo: jobRepo,
productJobRepo: productJobRepo,
kafkaProducer: kafkaProducer,
sseBroker: sseBroker,
productSSEBroker: productSSEBroker,
}
}
@@ -117,34 +122,23 @@ type ReceiptResponse struct {
// Handlers
// ---------------------------------------------------------------------------
// RecognizeReceipt handles POST /ai/recognize-receipt.
// RecognizeReceipt handles POST /ai/recognize-receipt (async).
// Enqueues the receipt image for AI processing and returns 202 Accepted with a job_id.
// Body: {"image_base64": "...", "mime_type": "image/jpeg"}
func (handler *Handler) RecognizeReceipt(responseWriter http.ResponseWriter, request *http.Request) {
userID := middleware.UserIDFromCtx(request.Context())
_ = userID // logged for tracing
var req imageRequest
if decodeError := json.NewDecoder(request.Body).Decode(&req); decodeError != nil || req.ImageBase64 == "" {
writeErrorJSON(responseWriter, request, http.StatusBadRequest, "image_base64 is required")
return
}
lang := locale.FromContext(request.Context())
result, recognizeError := handler.recognizer.RecognizeReceipt(request.Context(), req.ImageBase64, req.MimeType, lang)
if recognizeError != nil {
slog.ErrorContext(request.Context(), "recognize receipt", "err", recognizeError)
writeErrorJSON(responseWriter, request, http.StatusServiceUnavailable, "recognition failed, please try again")
return
}
enriched := handler.enrichItems(request.Context(), result.Items)
writeJSON(responseWriter, http.StatusOK, ReceiptResponse{
Items: enriched,
Unrecognized: result.Unrecognized,
handler.submitProductJob(responseWriter, request, "receipt", []ProductImagePayload{
{ImageBase64: req.ImageBase64, MimeType: req.MimeType},
})
}
// RecognizeProducts handles POST /ai/recognize-products.
// RecognizeProducts handles POST /ai/recognize-products (async).
// Enqueues up to 3 product images for AI processing and returns 202 Accepted with a job_id.
// Body: {"images": [{"image_base64": "...", "mime_type": "image/jpeg"}, ...]}
func (handler *Handler) RecognizeProducts(responseWriter http.ResponseWriter, request *http.Request) {
var req imagesRequest
@@ -153,29 +147,118 @@ func (handler *Handler) RecognizeProducts(responseWriter http.ResponseWriter, re
return
}
if len(req.Images) > 3 {
req.Images = req.Images[:3] // cap at 3 photos as per spec
req.Images = req.Images[:3]
}
images := make([]ProductImagePayload, len(req.Images))
for index, img := range req.Images {
images[index] = ProductImagePayload{ImageBase64: img.ImageBase64, MimeType: img.MimeType}
}
handler.submitProductJob(responseWriter, request, "products", images)
}
// submitProductJob is shared by RecognizeReceipt and RecognizeProducts.
// It inserts a product job, publishes to Kafka, and writes the 202 response.
func (handler *Handler) submitProductJob(
responseWriter http.ResponseWriter,
request *http.Request,
jobType string,
images []ProductImagePayload,
) {
userID := middleware.UserIDFromCtx(request.Context())
userPlan := middleware.UserPlanFromCtx(request.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(index int, imageReq imageRequest) {
defer wg.Done()
items, recognizeError := handler.recognizer.RecognizeProducts(request.Context(), imageReq.ImageBase64, imageReq.MimeType, lang)
if recognizeError != nil {
slog.WarnContext(request.Context(), "recognize products from image", "index", index, "err", recognizeError)
return
}
allItems[index] = items
}(i, img)
}
wg.Wait()
merged := MergeAndDeduplicate(allItems)
enriched := handler.enrichItems(request.Context(), merged)
writeJSON(responseWriter, http.StatusOK, map[string]any{"items": enriched})
job := &ProductJob{
UserID: userID,
UserPlan: userPlan,
JobType: jobType,
Images: images,
Lang: lang,
}
if insertError := handler.productJobRepo.InsertProductJob(request.Context(), job); insertError != nil {
slog.ErrorContext(request.Context(), "insert product recognition job", "err", insertError)
writeErrorJSON(responseWriter, request, http.StatusInternalServerError, "failed to create job")
return
}
position, positionError := handler.productJobRepo.ProductQueuePosition(request.Context(), userPlan, job.CreatedAt)
if positionError != nil {
position = 0
}
topic := ProductTopicFree
if userPlan == "paid" {
topic = ProductTopicPaid
}
if publishError := handler.kafkaProducer.Publish(request.Context(), topic, job.ID); publishError != nil {
slog.ErrorContext(request.Context(), "publish product recognition job", "job_id", job.ID, "err", publishError)
writeErrorJSON(responseWriter, request, 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,
})
}
// ListRecentProductJobs handles GET /ai/product-jobs — returns the last 7 days of product jobs.
func (handler *Handler) ListRecentProductJobs(responseWriter http.ResponseWriter, request *http.Request) {
userID := middleware.UserIDFromCtx(request.Context())
summaries, listError := handler.productJobRepo.ListRecentProductJobs(request.Context(), userID)
if listError != nil {
slog.ErrorContext(request.Context(), "list recent product jobs", "err", listError)
writeErrorJSON(responseWriter, request, http.StatusInternalServerError, "failed to list jobs")
return
}
if summaries == nil {
summaries = []*ProductJobSummary{}
}
writeJSON(responseWriter, http.StatusOK, summaries)
}
// ListAllProductJobs handles GET /ai/product-jobs/history — returns all product jobs for the user.
func (handler *Handler) ListAllProductJobs(responseWriter http.ResponseWriter, request *http.Request) {
userID := middleware.UserIDFromCtx(request.Context())
summaries, listError := handler.productJobRepo.ListAllProductJobs(request.Context(), userID)
if listError != nil {
slog.ErrorContext(request.Context(), "list all product jobs", "err", listError)
writeErrorJSON(responseWriter, request, http.StatusInternalServerError, "failed to list jobs")
return
}
if summaries == nil {
summaries = []*ProductJobSummary{}
}
writeJSON(responseWriter, http.StatusOK, summaries)
}
// GetProductJob handles GET /ai/product-jobs/{id}.
func (handler *Handler) GetProductJob(responseWriter http.ResponseWriter, request *http.Request) {
jobID := chi.URLParam(request, "id")
userID := middleware.UserIDFromCtx(request.Context())
job, fetchError := handler.productJobRepo.GetProductJobByID(request.Context(), jobID)
if fetchError != nil {
writeErrorJSON(responseWriter, request, http.StatusNotFound, "job not found")
return
}
if job.UserID != userID {
writeErrorJSON(responseWriter, request, http.StatusForbidden, "forbidden")
return
}
writeJSON(responseWriter, http.StatusOK, job)
}
// GetProductJobStream handles GET /ai/product-jobs/{id}/stream — SSE stream for product job updates.
func (handler *Handler) GetProductJobStream(responseWriter http.ResponseWriter, request *http.Request) {
handler.productSSEBroker.ServeSSE(responseWriter, request)
}
// RecognizeDish handles POST /ai/recognize-dish (async).
@@ -287,87 +370,6 @@ func (handler *Handler) GetJob(responseWriter http.ResponseWriter, request *http
writeJSON(responseWriter, http.StatusOK, job)
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
// enrichItems matches each recognized item against the product catalog.
// 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{
Name: item.Name,
Quantity: item.Quantity,
Unit: item.Unit,
Category: item.Category,
Confidence: item.Confidence,
StorageDays: 7, // sensible default
}
catalogProduct, matchError := handler.productRepo.FuzzyMatch(ctx, item.Name)
if matchError != nil {
slog.WarnContext(ctx, "fuzzy match product", "name", item.Name, "err", matchError)
}
if catalogProduct != nil {
id := catalogProduct.ID
enriched.MappingID = &id
if catalogProduct.DefaultUnit != nil {
enriched.Unit = *catalogProduct.DefaultUnit
}
if catalogProduct.StorageDays != nil {
enriched.StorageDays = *catalogProduct.StorageDays
}
if catalogProduct.Category != nil {
enriched.Category = *catalogProduct.Category
}
} else {
classification, classifyError := handler.recognizer.ClassifyIngredient(ctx, item.Name)
if classifyError != nil {
slog.WarnContext(ctx, "classify unknown product", "name", item.Name, "err", classifyError)
} else {
saved := handler.saveClassification(ctx, classification)
if saved != nil {
id := saved.ID
enriched.MappingID = &id
}
enriched.Category = classification.Category
enriched.Unit = classification.DefaultUnit
enriched.StorageDays = classification.StorageDays
}
}
result = append(result, enriched)
}
return result
}
// saveClassification upserts an AI-produced classification into the product catalog.
func (handler *Handler) saveClassification(ctx context.Context, classification *ai.IngredientClassification) *product.Product {
if classification == nil || classification.CanonicalName == "" {
return nil
}
catalogProduct := &product.Product{
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, upsertError := handler.productRepo.Upsert(ctx, catalogProduct)
if upsertError != nil {
slog.WarnContext(ctx, "upsert classified product", "name", classification.CanonicalName, "err", upsertError)
return nil
}
return saved
}
// 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

@@ -0,0 +1,98 @@
package recognition
import (
"context"
"log/slog"
"github.com/food-ai/backend/internal/adapters/ai"
"github.com/food-ai/backend/internal/domain/product"
)
// itemEnricher matches recognized items against the product catalog,
// triggering AI classification for unknown items.
// Extracted from Handler so both the HTTP handler and the product worker pool can use it.
type itemEnricher struct {
recognizer Recognizer
productRepo ProductRepository
}
func newItemEnricher(recognizer Recognizer, productRepo ProductRepository) *itemEnricher {
return &itemEnricher{recognizer: recognizer, productRepo: productRepo}
}
// enrich matches each recognized item against the product catalog.
// Items without a match trigger a classification call and upsert into the DB.
func (enricher *itemEnricher) enrich(enrichContext context.Context, items []ai.RecognizedItem) []EnrichedItem {
result := make([]EnrichedItem, 0, len(items))
for _, item := range items {
enriched := EnrichedItem{
Name: item.Name,
Quantity: item.Quantity,
Unit: item.Unit,
Category: item.Category,
Confidence: item.Confidence,
StorageDays: 7, // sensible default
}
catalogProduct, matchError := enricher.productRepo.FuzzyMatch(enrichContext, item.Name)
if matchError != nil {
slog.WarnContext(enrichContext, "fuzzy match product", "name", item.Name, "err", matchError)
}
if catalogProduct != nil {
productID := catalogProduct.ID
enriched.MappingID = &productID
if catalogProduct.DefaultUnit != nil {
enriched.Unit = *catalogProduct.DefaultUnit
}
if catalogProduct.StorageDays != nil {
enriched.StorageDays = *catalogProduct.StorageDays
}
if catalogProduct.Category != nil {
enriched.Category = *catalogProduct.Category
}
} else {
classification, classifyError := enricher.recognizer.ClassifyIngredient(enrichContext, item.Name)
if classifyError != nil {
slog.WarnContext(enrichContext, "classify unknown product", "name", item.Name, "err", classifyError)
} else {
saved := enricher.saveClassification(enrichContext, classification)
if saved != nil {
savedID := saved.ID
enriched.MappingID = &savedID
}
enriched.Category = classification.Category
enriched.Unit = classification.DefaultUnit
enriched.StorageDays = classification.StorageDays
}
}
result = append(result, enriched)
}
return result
}
// saveClassification upserts an AI-produced classification into the product catalog.
func (enricher *itemEnricher) saveClassification(enrichContext context.Context, classification *ai.IngredientClassification) *product.Product {
if classification == nil || classification.CanonicalName == "" {
return nil
}
catalogProduct := &product.Product{
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, upsertError := enricher.productRepo.Upsert(enrichContext, catalogProduct)
if upsertError != nil {
slog.WarnContext(enrichContext, "upsert classified product", "name", classification.CanonicalName, "err", upsertError)
return nil
}
return saved
}

View File

@@ -0,0 +1,63 @@
package recognition
import (
"time"
"github.com/food-ai/backend/internal/adapters/ai"
)
// Kafka topic names for product recognition.
const (
ProductTopicPaid = "ai.products.paid"
ProductTopicFree = "ai.products.free"
)
// ProductImagePayload is a single image stored in the product_recognition_jobs.images JSONB column.
type ProductImagePayload struct {
ImageBase64 string `json:"image_base64"`
MimeType string `json:"mime_type"`
}
// ProductJobResultItem is an enriched product item stored in the result JSONB.
type ProductJobResultItem struct {
Name string `json:"name"`
Quantity float64 `json:"quantity"`
Unit string `json:"unit"`
Category string `json:"category"`
Confidence float64 `json:"confidence"`
MappingID *string `json:"mapping_id,omitempty"`
StorageDays int `json:"storage_days"`
}
// ProductJobResult is the JSONB payload stored in product_recognition_jobs.result.
type ProductJobResult struct {
JobType string `json:"job_type"`
Items []ProductJobResultItem `json:"items"`
Unrecognized []ai.UnrecognizedItem `json:"unrecognized,omitempty"`
}
// ProductJob represents an async product/receipt recognition task.
type ProductJob struct {
ID string
UserID string
UserPlan string
JobType string // "receipt" | "products"
Images []ProductImagePayload
Lang string
Status string
Result *ProductJobResult
Error *string
CreatedAt time.Time
StartedAt *time.Time
CompletedAt *time.Time
}
// ProductJobSummary is a lightweight record for list endpoints (omits image payloads).
type ProductJobSummary struct {
ID string `json:"id"`
JobType string `json:"job_type"`
Status string `json:"status"`
Result *ProductJobResult `json:"result,omitempty"`
Error *string `json:"error,omitempty"`
CreatedAt time.Time `json:"created_at"`
}

View File

@@ -0,0 +1,203 @@
package recognition
import (
"context"
"encoding/json"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
// ProductJobRepository provides all DB operations on product_recognition_jobs.
type ProductJobRepository interface {
InsertProductJob(ctx context.Context, job *ProductJob) error
GetProductJobByID(ctx context.Context, jobID string) (*ProductJob, error)
UpdateProductJobStatus(ctx context.Context, jobID, status string, result *ProductJobResult, errMsg *string) error
ProductQueuePosition(ctx context.Context, userPlan string, createdAt time.Time) (int, error)
NotifyProductJobUpdate(ctx context.Context, jobID string) error
ListRecentProductJobs(ctx context.Context, userID string) ([]*ProductJobSummary, error)
ListAllProductJobs(ctx context.Context, userID string) ([]*ProductJobSummary, error)
}
// PostgresProductJobRepository implements ProductJobRepository using a pgxpool.
type PostgresProductJobRepository struct {
pool *pgxpool.Pool
}
// NewProductJobRepository creates a new PostgresProductJobRepository.
func NewProductJobRepository(pool *pgxpool.Pool) *PostgresProductJobRepository {
return &PostgresProductJobRepository{pool: pool}
}
// InsertProductJob inserts a new product recognition job and populates the ID and CreatedAt fields.
func (repository *PostgresProductJobRepository) InsertProductJob(queryContext context.Context, job *ProductJob) error {
imagesJSON, marshalError := json.Marshal(job.Images)
if marshalError != nil {
return marshalError
}
return repository.pool.QueryRow(queryContext,
`INSERT INTO product_recognition_jobs (user_id, user_plan, job_type, images, lang)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, created_at`,
job.UserID, job.UserPlan, job.JobType, imagesJSON, job.Lang,
).Scan(&job.ID, &job.CreatedAt)
}
// GetProductJobByID fetches a single product job by primary key.
func (repository *PostgresProductJobRepository) GetProductJobByID(queryContext context.Context, jobID string) (*ProductJob, error) {
var job ProductJob
var imagesJSON []byte
var resultJSON []byte
queryError := repository.pool.QueryRow(queryContext,
`SELECT id, user_id, user_plan, job_type, images, lang,
status, result, error, created_at, started_at, completed_at
FROM product_recognition_jobs WHERE id = $1`,
jobID,
).Scan(
&job.ID, &job.UserID, &job.UserPlan, &job.JobType, &imagesJSON, &job.Lang,
&job.Status, &resultJSON, &job.Error, &job.CreatedAt, &job.StartedAt, &job.CompletedAt,
)
if queryError != nil {
return nil, queryError
}
if imagesJSON != nil {
if unmarshalError := json.Unmarshal(imagesJSON, &job.Images); unmarshalError != nil {
return nil, unmarshalError
}
}
if resultJSON != nil {
var productResult ProductJobResult
if unmarshalError := json.Unmarshal(resultJSON, &productResult); unmarshalError == nil {
job.Result = &productResult
}
}
return &job, nil
}
// UpdateProductJobStatus transitions a job to a new status and records the result or error.
func (repository *PostgresProductJobRepository) UpdateProductJobStatus(
queryContext context.Context,
jobID, status string,
result *ProductJobResult,
errMsg *string,
) error {
var resultJSON []byte
if result != nil {
marshalledBytes, marshalError := json.Marshal(result)
if marshalError != nil {
return marshalError
}
resultJSON = marshalledBytes
}
switch status {
case JobStatusProcessing:
_, updateError := repository.pool.Exec(queryContext,
`UPDATE product_recognition_jobs SET status = $1, started_at = now() WHERE id = $2`,
status, jobID,
)
return updateError
default:
_, updateError := repository.pool.Exec(queryContext,
`UPDATE product_recognition_jobs
SET status = $1, result = $2, error = $3, completed_at = now()
WHERE id = $4`,
status, resultJSON, errMsg, jobID,
)
return updateError
}
}
// ProductQueuePosition counts product jobs ahead of createdAt in the same plan's queue.
func (repository *PostgresProductJobRepository) ProductQueuePosition(
queryContext context.Context,
userPlan string,
createdAt time.Time,
) (int, error) {
var position int
queryError := repository.pool.QueryRow(queryContext,
`SELECT COUNT(*) FROM product_recognition_jobs
WHERE status IN ('pending', 'processing')
AND user_plan = $1
AND created_at < $2`,
userPlan, createdAt,
).Scan(&position)
return position, queryError
}
// NotifyProductJobUpdate sends a PostgreSQL NOTIFY on the product_job_update channel.
func (repository *PostgresProductJobRepository) NotifyProductJobUpdate(queryContext context.Context, jobID string) error {
_, notifyError := repository.pool.Exec(queryContext, `SELECT pg_notify('product_job_update', $1)`, jobID)
return notifyError
}
// ListRecentProductJobs returns product recognition jobs from the last 7 days for the given user.
func (repository *PostgresProductJobRepository) ListRecentProductJobs(queryContext context.Context, userID string) ([]*ProductJobSummary, error) {
rows, queryError := repository.pool.Query(queryContext,
`SELECT id, job_type, status, result, error, created_at
FROM product_recognition_jobs
WHERE user_id = $1
AND created_at >= now() - interval '7 days'
ORDER BY created_at DESC`,
userID,
)
if queryError != nil {
return nil, queryError
}
defer rows.Close()
return scanProductJobSummaries(rows)
}
// ListAllProductJobs returns all product recognition jobs for the given user, newest first.
func (repository *PostgresProductJobRepository) ListAllProductJobs(queryContext context.Context, userID string) ([]*ProductJobSummary, error) {
rows, queryError := repository.pool.Query(queryContext,
`SELECT id, job_type, status, result, error, created_at
FROM product_recognition_jobs
WHERE user_id = $1
ORDER BY created_at DESC`,
userID,
)
if queryError != nil {
return nil, queryError
}
defer rows.Close()
return scanProductJobSummaries(rows)
}
type productSummaryScanner interface {
Next() bool
Scan(dest ...any) error
Err() error
}
func scanProductJobSummaries(rows productSummaryScanner) ([]*ProductJobSummary, error) {
var summaries []*ProductJobSummary
for rows.Next() {
var summary ProductJobSummary
var resultJSON []byte
scanError := rows.Scan(
&summary.ID, &summary.JobType, &summary.Status,
&resultJSON, &summary.Error, &summary.CreatedAt,
)
if scanError != nil {
return nil, scanError
}
if resultJSON != nil {
var productResult ProductJobResult
if unmarshalError := json.Unmarshal(resultJSON, &productResult); unmarshalError == nil {
summary.Result = &productResult
}
}
summaries = append(summaries, &summary)
}
if rowsError := rows.Err(); rowsError != nil {
return nil, rowsError
}
return summaries, nil
}

View File

@@ -0,0 +1,196 @@
package recognition
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"sync"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/food-ai/backend/internal/infra/middleware"
)
// ProductSSEBroker manages Server-Sent Events for product recognition job status updates.
// It listens on the PostgreSQL "product_job_update" NOTIFY channel and fans out events
// to all HTTP clients currently streaming a given job.
type ProductSSEBroker struct {
pool *pgxpool.Pool
productJobRepo ProductJobRepository
mu sync.RWMutex
clients map[string][]chan sseEvent
}
// NewProductSSEBroker creates a new ProductSSEBroker.
func NewProductSSEBroker(pool *pgxpool.Pool, productJobRepo ProductJobRepository) *ProductSSEBroker {
return &ProductSSEBroker{
pool: pool,
productJobRepo: productJobRepo,
clients: make(map[string][]chan sseEvent),
}
}
// Start launches the PostgreSQL LISTEN loop in a background goroutine.
func (broker *ProductSSEBroker) Start(brokerContext context.Context) {
go broker.listenLoop(brokerContext)
}
func (broker *ProductSSEBroker) listenLoop(brokerContext context.Context) {
conn, acquireError := broker.pool.Acquire(brokerContext)
if acquireError != nil {
slog.Error("ProductSSEBroker: acquire PG connection", "err", acquireError)
return
}
defer conn.Release()
if _, listenError := conn.Exec(brokerContext, "LISTEN product_job_update"); listenError != nil {
slog.Error("ProductSSEBroker: LISTEN product_job_update", "err", listenError)
return
}
for {
notification, waitError := conn.Conn().WaitForNotification(brokerContext)
if brokerContext.Err() != nil {
return
}
if waitError != nil {
slog.Error("ProductSSEBroker: wait for notification", "err", waitError)
return
}
broker.fanOut(brokerContext, notification.Payload)
}
}
func (broker *ProductSSEBroker) subscribe(jobID string) chan sseEvent {
channel := make(chan sseEvent, 10)
broker.mu.Lock()
broker.clients[jobID] = append(broker.clients[jobID], channel)
broker.mu.Unlock()
return channel
}
func (broker *ProductSSEBroker) unsubscribe(jobID string, channel chan sseEvent) {
broker.mu.Lock()
defer broker.mu.Unlock()
existing := broker.clients[jobID]
for index, existingChannel := range existing {
if existingChannel == channel {
broker.clients[jobID] = append(broker.clients[jobID][:index], broker.clients[jobID][index+1:]...)
break
}
}
if len(broker.clients[jobID]) == 0 {
delete(broker.clients, jobID)
}
}
func (broker *ProductSSEBroker) fanOut(fanContext context.Context, jobID string) {
job, fetchError := broker.productJobRepo.GetProductJobByID(fanContext, jobID)
if fetchError != nil {
slog.Warn("ProductSSEBroker: get job for fan-out", "job_id", jobID, "err", fetchError)
return
}
event, ok := productJobToSSEEvent(job)
if !ok {
return
}
broker.mu.RLock()
channels := make([]chan sseEvent, len(broker.clients[jobID]))
copy(channels, broker.clients[jobID])
broker.mu.RUnlock()
for _, channel := range channels {
select {
case channel <- event:
default:
// channel full; skip this delivery
}
}
}
func productJobToSSEEvent(job *ProductJob) (sseEvent, bool) {
switch job.Status {
case JobStatusProcessing:
return sseEvent{name: "processing", data: "{}"}, true
case JobStatusDone:
resultJSON, marshalError := json.Marshal(job.Result)
if marshalError != nil {
return sseEvent{}, false
}
return sseEvent{name: "done", data: string(resultJSON)}, true
case JobStatusFailed:
errMsg := "recognition failed, please try again"
if job.Error != nil {
errMsg = *job.Error
}
errorData, _ := json.Marshal(map[string]string{"error": errMsg})
return sseEvent{name: "failed", data: string(errorData)}, true
default:
return sseEvent{}, false
}
}
// ServeSSE handles GET /ai/product-jobs/{id}/stream — streams SSE events until the job completes.
func (broker *ProductSSEBroker) ServeSSE(responseWriter http.ResponseWriter, request *http.Request) {
jobID := chi.URLParam(request, "id")
userID := middleware.UserIDFromCtx(request.Context())
job, fetchError := broker.productJobRepo.GetProductJobByID(request.Context(), jobID)
if fetchError != nil {
writeErrorJSON(responseWriter, request, http.StatusNotFound, "job not found")
return
}
if job.UserID != userID {
writeErrorJSON(responseWriter, request, http.StatusForbidden, "forbidden")
return
}
flusher, supported := responseWriter.(http.Flusher)
if !supported {
writeErrorJSON(responseWriter, request, http.StatusInternalServerError, "streaming not supported")
return
}
responseWriter.Header().Set("Content-Type", "text/event-stream")
responseWriter.Header().Set("Cache-Control", "no-cache")
responseWriter.Header().Set("Connection", "keep-alive")
responseWriter.Header().Set("X-Accel-Buffering", "no")
if job.Status == JobStatusDone || job.Status == JobStatusFailed {
if event, ok := productJobToSSEEvent(job); ok {
fmt.Fprintf(responseWriter, "event: %s\ndata: %s\n\n", event.name, event.data)
flusher.Flush()
}
return
}
eventChannel := broker.subscribe(jobID)
defer broker.unsubscribe(jobID, eventChannel)
position, _ := broker.productJobRepo.ProductQueuePosition(request.Context(), job.UserPlan, job.CreatedAt)
estimatedSeconds := (position + 1) * 6
queuedData, _ := json.Marshal(map[string]any{
"position": position,
"estimated_seconds": estimatedSeconds,
})
fmt.Fprintf(responseWriter, "event: queued\ndata: %s\n\n", queuedData)
flusher.Flush()
for {
select {
case event := <-eventChannel:
fmt.Fprintf(responseWriter, "event: %s\ndata: %s\n\n", event.name, event.data)
flusher.Flush()
if event.name == "done" || event.name == "failed" {
return
}
case <-request.Context().Done():
return
}
}
}

View File

@@ -0,0 +1,152 @@
package recognition
import (
"context"
"log/slog"
"sync"
"github.com/food-ai/backend/internal/adapters/ai"
"github.com/food-ai/backend/internal/adapters/kafka"
)
// ProductWorkerPool processes product/receipt recognition jobs from a single Kafka topic.
type ProductWorkerPool struct {
productJobRepo ProductJobRepository
enricher *itemEnricher
recognizer Recognizer
consumer *kafka.Consumer
workerCount int
jobs chan string
}
// NewProductWorkerPool creates a ProductWorkerPool with five workers consuming from a single consumer.
func NewProductWorkerPool(
productJobRepo ProductJobRepository,
recognizer Recognizer,
productRepo ProductRepository,
consumer *kafka.Consumer,
) *ProductWorkerPool {
return &ProductWorkerPool{
productJobRepo: productJobRepo,
enricher: newItemEnricher(recognizer, productRepo),
recognizer: recognizer,
consumer: consumer,
workerCount: defaultWorkerCount,
jobs: make(chan string, 100),
}
}
// Start launches the Kafka feeder goroutine and all worker goroutines.
func (pool *ProductWorkerPool) Start(workerContext context.Context) {
go pool.consumer.Run(workerContext, pool.jobs)
for i := 0; i < pool.workerCount; i++ {
go pool.runWorker(workerContext)
}
}
func (pool *ProductWorkerPool) runWorker(workerContext context.Context) {
for {
select {
case jobID := <-pool.jobs:
pool.processJob(workerContext, jobID)
case <-workerContext.Done():
return
}
}
}
func (pool *ProductWorkerPool) processJob(workerContext context.Context, jobID string) {
job, fetchError := pool.productJobRepo.GetProductJobByID(workerContext, jobID)
if fetchError != nil {
slog.Error("product worker: fetch job", "job_id", jobID, "err", fetchError)
return
}
if updateError := pool.productJobRepo.UpdateProductJobStatus(workerContext, jobID, JobStatusProcessing, nil, nil); updateError != nil {
slog.Error("product worker: set processing status", "job_id", jobID, "err", updateError)
}
if notifyError := pool.productJobRepo.NotifyProductJobUpdate(workerContext, jobID); notifyError != nil {
slog.Warn("product worker: notify processing", "job_id", jobID, "err", notifyError)
}
var recognizedItems []ai.RecognizedItem
var unrecognized []ai.UnrecognizedItem
var recognizeError error
switch job.JobType {
case "receipt":
if len(job.Images) == 0 {
errMsg := "no images in job"
_ = pool.productJobRepo.UpdateProductJobStatus(workerContext, jobID, JobStatusFailed, nil, &errMsg)
_ = pool.productJobRepo.NotifyProductJobUpdate(workerContext, jobID)
return
}
imagePayload := job.Images[0]
var receiptResult *ai.ReceiptResult
receiptResult, recognizeError = pool.recognizer.RecognizeReceipt(workerContext, imagePayload.ImageBase64, imagePayload.MimeType, job.Lang)
if recognizeError == nil && receiptResult != nil {
recognizedItems = receiptResult.Items
unrecognized = receiptResult.Unrecognized
}
case "products":
allItems := make([][]ai.RecognizedItem, len(job.Images))
var wg sync.WaitGroup
for index, imagePayload := range job.Images {
wg.Add(1)
go func(workerIndex int, payload ProductImagePayload) {
defer wg.Done()
items, itemsError := pool.recognizer.RecognizeProducts(workerContext, payload.ImageBase64, payload.MimeType, job.Lang)
if itemsError != nil {
slog.WarnContext(workerContext, "product worker: recognize products from image", "index", workerIndex, "err", itemsError)
return
}
allItems[workerIndex] = items
}(index, imagePayload)
}
wg.Wait()
recognizedItems = MergeAndDeduplicate(allItems)
default:
errMsg := "unknown job type: " + job.JobType
slog.Error("product worker: unknown job type", "job_id", jobID, "job_type", job.JobType)
_ = pool.productJobRepo.UpdateProductJobStatus(workerContext, jobID, JobStatusFailed, nil, &errMsg)
_ = pool.productJobRepo.NotifyProductJobUpdate(workerContext, jobID)
return
}
if recognizeError != nil {
slog.Error("product worker: recognize", "job_id", jobID, "err", recognizeError)
errMsg := "recognition failed, please try again"
_ = pool.productJobRepo.UpdateProductJobStatus(workerContext, jobID, JobStatusFailed, nil, &errMsg)
_ = pool.productJobRepo.NotifyProductJobUpdate(workerContext, jobID)
return
}
enriched := pool.enricher.enrich(workerContext, recognizedItems)
resultItems := make([]ProductJobResultItem, len(enriched))
for index, item := range enriched {
resultItems[index] = ProductJobResultItem{
Name: item.Name,
Quantity: item.Quantity,
Unit: item.Unit,
Category: item.Category,
Confidence: item.Confidence,
MappingID: item.MappingID,
StorageDays: item.StorageDays,
}
}
jobResult := &ProductJobResult{
JobType: job.JobType,
Items: resultItems,
Unrecognized: unrecognized,
}
if updateError := pool.productJobRepo.UpdateProductJobStatus(workerContext, jobID, JobStatusDone, jobResult, nil); updateError != nil {
slog.Error("product worker: set done status", "job_id", jobID, "err", updateError)
}
if notifyError := pool.productJobRepo.NotifyProductJobUpdate(workerContext, jobID); notifyError != nil {
slog.Warn("product worker: notify done", "job_id", jobID, "err", notifyError)
}
}

View File

@@ -127,6 +127,10 @@ func NewRouter(
r.Get("/jobs/history", recognitionHandler.ListAllJobs)
r.Get("/jobs/{id}", recognitionHandler.GetJob)
r.Get("/jobs/{id}/stream", recognitionHandler.GetJobStream)
r.Get("/product-jobs", recognitionHandler.ListRecentProductJobs)
r.Get("/product-jobs/history", recognitionHandler.ListAllProductJobs)
r.Get("/product-jobs/{id}", recognitionHandler.GetProductJob)
r.Get("/product-jobs/{id}/stream", recognitionHandler.GetProductJobStream)
r.Post("/generate-menu", menuHandler.GenerateMenu)
})
})

View File

@@ -0,0 +1,24 @@
-- +goose Up
CREATE TABLE product_recognition_jobs (
id UUID PRIMARY KEY DEFAULT uuid_generate_v7(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
user_plan TEXT NOT NULL,
job_type TEXT NOT NULL CHECK (job_type IN ('receipt', 'products')),
images JSONB NOT NULL,
lang TEXT NOT NULL DEFAULT 'en',
status TEXT NOT NULL DEFAULT 'pending',
result JSONB,
error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ
);
CREATE INDEX idx_product_recognition_jobs_user
ON product_recognition_jobs (user_id, created_at DESC);
CREATE INDEX idx_product_recognition_jobs_status
ON product_recognition_jobs (status, user_plan, created_at ASC);
-- +goose Down
DROP TABLE IF EXISTS product_recognition_jobs;