diff --git a/internal/company/recommendation.go b/internal/company/recommendation.go index 9af7186..38e858c 100644 --- a/internal/company/recommendation.go +++ b/internal/company/recommendation.go @@ -19,6 +19,11 @@ var ErrAIRecommendationNotConfigured = errors.New("AI recommendation service is const aiRecommendationCacheKeyPrefix = "ai:recommendations:" +const ( + recommendationFetchMaxAttempts = 6 + recommendationFetchInitialDelay = 10 * time.Second +) + func aiRecommendationCacheKey(companyID string) string { return aiRecommendationCacheKeyPrefix + companyID } @@ -53,7 +58,7 @@ func (s *companyService) StartAIOnboarding(ctx context.Context, companyID string return nil, err } - s.refreshAIRecommendationsCacheAsync(companyID) + s.scheduleRecommendationRefreshAfterOnboarding(companyID) return result, nil } @@ -86,7 +91,7 @@ func (s *companyService) triggerAIOnboardingAsync(companyID string) { return } - if _, err := s.fetchAndCacheAIRecommendations(ctx, companyID); err != nil { + if err := s.fetchAndCacheAIRecommendationsWithRetry(ctx, companyID); err != nil { s.logger.Error("Failed to refresh AI recommendations after onboarding", map[string]interface{}{ "company_id": companyID, "error": err.Error(), @@ -170,14 +175,14 @@ func (s *companyService) GetAIRecommendations(ctx context.Context, companyID str return cached, nil } - s.logger.Debug("AI recommendations cache miss; returning empty list", map[string]interface{}{ + s.logger.Info("AI recommendations cache miss; returning empty list", map[string]interface{}{ "company_id": companyID, }) return []RecommendedTenderResponse{}, nil } -func (s *companyService) refreshAIRecommendationsCacheAsync(companyID string) { +func (s *companyService) scheduleRecommendationRefreshAfterOnboarding(companyID string) { if s.aiRecommendationClient == nil { return } @@ -189,33 +194,76 @@ func (s *companyService) refreshAIRecommendationsCacheAsync(companyID string) { go func() { ctx := context.Background() - s.logger.Info("Refreshing AI recommendation cache in background", map[string]interface{}{ - "company_id": companyID, - }) - - if _, err := s.repository.GetByID(ctx, companyID); err != nil { - s.logger.Error("Failed to load company for AI recommendation refresh", map[string]interface{}{ + if err := s.fetchAndCacheAIRecommendationsWithRetry(ctx, companyID); err != nil { + s.logger.Error("Failed to refresh AI recommendation cache after onboarding", map[string]interface{}{ "company_id": companyID, "error": err.Error(), }) - return } - - if _, err := s.fetchAndCacheAIRecommendations(ctx, companyID); err != nil { - s.logger.Error("Failed to refresh AI recommendation cache", map[string]interface{}{ - "company_id": companyID, - "error": err.Error(), - }) - return - } - - s.logger.Info("AI recommendation cache refreshed", map[string]interface{}{ - "company_id": companyID, - }) }() } -func (s *companyService) fetchAndCacheAIRecommendations(ctx context.Context, companyID string) ([]RecommendedTenderResponse, error) { +// fetchAndCacheAIRecommendationsWithRetry waits for the AI service to finish indexing after onboarding. +// Empty results are not cached so reads keep returning empty until real recommendations exist. +func (s *companyService) fetchAndCacheAIRecommendationsWithRetry(ctx context.Context, companyID string) error { + delay := recommendationFetchInitialDelay + + for attempt := 1; attempt <= recommendationFetchMaxAttempts; attempt++ { + if attempt > 1 { + s.logger.Info("Retrying AI recommendation fetch after onboarding", map[string]interface{}{ + "company_id": companyID, + "attempt": attempt, + "delay_sec": int(delay.Seconds()), + }) + } else { + s.logger.Info("Waiting before first AI recommendation fetch after onboarding", map[string]interface{}{ + "company_id": companyID, + "delay_sec": int(delay.Seconds()), + }) + } + + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(delay): + } + + if attempt > 1 { + delay *= 2 + } + + responses, err := s.fetchAIRecommendations(ctx, companyID) + if err != nil { + if attempt == recommendationFetchMaxAttempts { + return err + } + continue + } + + if len(responses) == 0 { + if attempt == recommendationFetchMaxAttempts { + s.logger.Warn("AI returned no recommendations after onboarding retries; cache not updated", map[string]interface{}{ + "company_id": companyID, + "attempts": attempt, + }) + return nil + } + continue + } + + s.cacheAIRecommendations(ctx, companyID, responses) + s.logger.Info("AI recommendation cache populated after onboarding", map[string]interface{}{ + "company_id": companyID, + "count": len(responses), + "attempt": attempt, + }) + return nil + } + + return nil +} + +func (s *companyService) fetchAIRecommendations(ctx context.Context, companyID string) ([]RecommendedTenderResponse, error) { s.logger.Info("Fetching AI tender recommendations from AI service", map[string]interface{}{ "company_id": companyID, }) @@ -240,8 +288,6 @@ func (s *companyService) fetchAndCacheAIRecommendations(ctx context.Context, com }) } - s.cacheAIRecommendations(ctx, companyID, responses) - return responses, nil } @@ -271,6 +317,11 @@ func (s *companyService) getCachedAIRecommendations(ctx context.Context, company return nil, false } + if len(responses) == 0 { + _ = s.redisClient.Del(ctx, aiRecommendationCacheKey(companyID)) + return nil, false + } + s.logger.Debug("AI tender recommendations served from cache", map[string]interface{}{ "company_id": companyID, "count": len(responses), @@ -280,7 +331,7 @@ func (s *companyService) getCachedAIRecommendations(ctx context.Context, company } func (s *companyService) cacheAIRecommendations(ctx context.Context, companyID string, responses []RecommendedTenderResponse) { - if s.redisClient == nil { + if s.redisClient == nil || len(responses) == 0 { return }