package recognition import ( "context" "log/slog" "strings" "github.com/food-ai/backend/internal/adapters/ai" "github.com/food-ai/backend/internal/domain/product" ) // validProductCategories mirrors the product_categories slugs seeded in the DB. var validProductCategories = map[string]struct{}{ "dairy": {}, "meat": {}, "produce": {}, "bakery": {}, "frozen": {}, "beverages": {}, "other": {}, } // normalizeProductCategory returns a pointer to a valid product_categories slug. // It lowercases and trims the AI-returned value; if it is not recognised it falls // back to "other" rather than letting an invalid string reach the FK constraint. func normalizeProductCategory(category string) *string { normalized := strings.ToLower(strings.TrimSpace(category)) if _, ok := validProductCategories[normalized]; ok { return &normalized } fallback := "other" return &fallback } // 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, QuantityConfidence: item.QuantityConfidence, 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: normalizeProductCategory(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 }