Enhance company service with AI recommendation caching and onboarding improvements
continuous-integration/drone/push Build is passing

- Updated the `companyService` to include Redis caching for AI recommendations, improving performance and reducing redundant AI calls.
- Introduced asynchronous AI onboarding triggered after company profile updates, enhancing user experience by offloading processing.
- Added configuration for recommendation cache TTL in the `AISummarizerConfig`, allowing for flexible cache management.
- Implemented methods for caching, retrieving, and invalidating AI recommendations in the `companyService`, ensuring efficient data handling.

This update enhances the company's AI recommendation capabilities, providing faster responses and a more efficient onboarding process.
This commit is contained in:
Mazyar
2026-06-23 13:24:45 +03:30
parent a7a49fc411
commit a2661651c9
5 changed files with 141 additions and 9 deletions
+2
View File
@@ -69,6 +69,8 @@ type AISummarizerConfig struct {
APIRetryCount int `env:"AI_SUMMARIZER_API_RETRY_COUNT" envDefault:"2"` APIRetryCount int `env:"AI_SUMMARIZER_API_RETRY_COUNT" envDefault:"2"`
APIRetryDelay time.Duration `env:"AI_SUMMARIZER_API_RETRY_DELAY" envDefault:"3s"` APIRetryDelay time.Duration `env:"AI_SUMMARIZER_API_RETRY_DELAY" envDefault:"3s"`
DefaultLanguage string `env:"AI_SUMMARIZER_DEFAULT_LANGUAGE" envDefault:"en"` DefaultLanguage string `env:"AI_SUMMARIZER_DEFAULT_LANGUAGE" envDefault:"en"`
// RecommendationCacheTTL caches AI /recommend responses per company. Set to 0 to disable.
RecommendationCacheTTL time.Duration `env:"AI_RECOMMENDATION_CACHE_TTL" envDefault:"15m"`
// MinIO storage settings // MinIO storage settings
MinioEndpoint string `env:"AI_SUMMARIZER_MINIO_ENDPOINT" envDefault:""` MinioEndpoint string `env:"AI_SUMMARIZER_MINIO_ENDPOINT" envDefault:""`
+1 -1
View File
@@ -220,7 +220,7 @@ func main() {
auditLogger := audit.NewLogger(logger) auditLogger := audit.NewLogger(logger)
userService := user.NewService(userRepository, logger, userAuthService, userValidator, notificationSDK, redisClient, auditLogger) userService := user.NewService(userRepository, logger, userAuthService, userValidator, notificationSDK, redisClient, auditLogger)
categoryService := company_category.NewService(categoryRepository, logger) categoryService := company_category.NewService(categoryRepository, logger)
companyService := company.NewService(companyRepository, categoryService, fileStoreService, aiRecommendationClient, logger) companyService := company.NewService(companyRepository, categoryService, fileStoreService, aiRecommendationClient, redisClient, conf.AISummarizer.RecommendationCacheTTL, logger)
customerService := customer.NewService(customerRepository, logger, customerAuthService, companyService, redisClient, notificationSDK, customerValidator, auditLogger) customerService := customer.NewService(customerRepository, logger, customerAuthService, companyService, redisClient, notificationSDK, customerValidator, auditLogger)
tenderService := tender.NewService(tenderRepository, companyService, customerService, logger, aiSummarizerClient, aiSummarizerStorage, conf.AISummarizer.DefaultLanguage) tenderService := tender.NewService(tenderRepository, companyService, customerService, logger, aiSummarizerClient, aiSummarizerStorage, conf.AISummarizer.DefaultLanguage)
feedbackService := feedback.NewService(feedbackRepo, tenderService, companyService, logger) feedbackService := feedback.NewService(feedbackRepo, tenderService, companyService, logger)
+2
View File
@@ -98,6 +98,8 @@ func (s *companyService) UploadDocuments(ctx context.Context, companyID, uploade
"documents_total": len(company.DocumentFileIDs), "documents_total": len(company.DocumentFileIDs),
}) })
s.triggerAIOnboardingAsync(companyID)
return &UploadDocumentsResponse{ return &UploadDocumentsResponse{
DocumentFileIDs: company.DocumentFileIDs, DocumentFileIDs: company.DocumentFileIDs,
Uploaded: uploaded, Uploaded: uploaded,
+118 -1
View File
@@ -9,11 +9,19 @@ import (
"io" "io"
"strings" "strings"
"github.com/redis/go-redis/v9"
"tm/pkg/ai_summarizer" "tm/pkg/ai_summarizer"
) )
var ErrAIRecommendationNotConfigured = errors.New("AI recommendation service is not configured") var ErrAIRecommendationNotConfigured = errors.New("AI recommendation service is not configured")
const aiRecommendationCacheKeyPrefix = "ai:recommendations:"
func aiRecommendationCacheKey(companyID string) string {
return aiRecommendationCacheKeyPrefix + companyID
}
// AIRecommendationClient defines the interface for company tender recommendation AI operations. // AIRecommendationClient defines the interface for company tender recommendation AI operations.
type AIRecommendationClient interface { type AIRecommendationClient interface {
StartOnboarding(ctx context.Context, reqBody ai_summarizer.OnboardingRequest) (*ai_summarizer.OnboardingResponse, error) StartOnboarding(ctx context.Context, reqBody ai_summarizer.OnboardingRequest) (*ai_summarizer.OnboardingResponse, error)
@@ -77,18 +85,56 @@ func (s *companyService) StartAIOnboarding(ctx context.Context, companyID string
return nil, fmt.Errorf("failed to start AI onboarding: %w", err) return nil, fmt.Errorf("failed to start AI onboarding: %w", err)
} }
s.invalidateAIRecommendationCache(ctx, companyID)
return &OnboardingResponse{Status: result.Status}, nil return &OnboardingResponse{Status: result.Status}, nil
} }
// triggerAIOnboardingAsync starts AI onboarding in the background after company profile changes.
func (s *companyService) triggerAIOnboardingAsync(companyID string) {
if s.aiRecommendationClient == nil {
return
}
companyID = strings.TrimSpace(companyID)
if companyID == "" {
return
}
go func() {
ctx := context.Background()
s.logger.Info("Starting async AI company onboarding", map[string]interface{}{
"company_id": companyID,
})
if _, err := s.StartAIOnboarding(ctx, companyID); err != nil {
s.logger.Error("Async AI company onboarding failed", map[string]interface{}{
"company_id": companyID,
"error": err.Error(),
})
return
}
s.logger.Info("Async AI company onboarding completed", map[string]interface{}{
"company_id": companyID,
})
}()
}
// GetAIRecommendations retrieves ranked tender recommendations from the AI team. // GetAIRecommendations retrieves ranked tender recommendations from the AI team.
func (s *companyService) GetAIRecommendations(ctx context.Context, companyID string) ([]RecommendedTenderResponse, error) { func (s *companyService) GetAIRecommendations(ctx context.Context, companyID string) ([]RecommendedTenderResponse, error) {
if s.aiRecommendationClient == nil { if s.aiRecommendationClient == nil {
return nil, ErrAIRecommendationNotConfigured return nil, ErrAIRecommendationNotConfigured
} }
if strings.TrimSpace(companyID) == "" { companyID = strings.TrimSpace(companyID)
if companyID == "" {
return nil, errors.New("company ID is required") return nil, errors.New("company ID is required")
} }
if cached, ok := s.getCachedAIRecommendations(ctx, companyID); ok {
return cached, nil
}
if _, err := s.repository.GetByID(ctx, companyID); err != nil { if _, err := s.repository.GetByID(ctx, companyID); err != nil {
s.logger.Error("Failed to load company for AI recommendations", map[string]interface{}{ s.logger.Error("Failed to load company for AI recommendations", map[string]interface{}{
"company_id": companyID, "company_id": companyID,
@@ -121,9 +167,80 @@ func (s *companyService) GetAIRecommendations(ctx context.Context, companyID str
}) })
} }
s.cacheAIRecommendations(ctx, companyID, responses)
return responses, nil return responses, nil
} }
func (s *companyService) getCachedAIRecommendations(ctx context.Context, companyID string) ([]RecommendedTenderResponse, bool) {
if s.redisClient == nil || s.recommendationCacheTTL <= 0 {
return nil, false
}
raw, err := s.redisClient.Get(ctx, aiRecommendationCacheKey(companyID))
if err != nil {
if err != redis.Nil {
s.logger.Warn("Failed to read AI recommendation cache", map[string]interface{}{
"company_id": companyID,
"error": err.Error(),
})
}
return nil, false
}
var responses []RecommendedTenderResponse
if err := json.Unmarshal([]byte(raw), &responses); err != nil {
s.logger.Warn("Failed to decode AI recommendation cache", map[string]interface{}{
"company_id": companyID,
"error": err.Error(),
})
_ = 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),
})
return responses, true
}
func (s *companyService) cacheAIRecommendations(ctx context.Context, companyID string, responses []RecommendedTenderResponse) {
if s.redisClient == nil || s.recommendationCacheTTL <= 0 {
return
}
encoded, err := json.Marshal(responses)
if err != nil {
s.logger.Warn("Failed to encode AI recommendations for cache", map[string]interface{}{
"company_id": companyID,
"error": err.Error(),
})
return
}
if err := s.redisClient.Set(ctx, aiRecommendationCacheKey(companyID), string(encoded), s.recommendationCacheTTL); err != nil {
s.logger.Warn("Failed to store AI recommendation cache", map[string]interface{}{
"company_id": companyID,
"error": err.Error(),
})
}
}
func (s *companyService) invalidateAIRecommendationCache(ctx context.Context, companyID string) {
if s.redisClient == nil || s.recommendationCacheTTL <= 0 {
return
}
if err := s.redisClient.Del(ctx, aiRecommendationCacheKey(companyID)); err != nil {
s.logger.Warn("Failed to invalidate AI recommendation cache", map[string]interface{}{
"company_id": companyID,
"error": err.Error(),
})
}
}
func (s *companyService) buildOnboardingDocuments(fileIDs []string) (string, error) { func (s *companyService) buildOnboardingDocuments(fileIDs []string) (string, error) {
if len(fileIDs) == 0 { if len(fileIDs) == 0 {
return "[]", nil return "[]", nil
+18 -7
View File
@@ -3,12 +3,13 @@ package company
import ( import (
"context" "context"
"errors" "errors"
"mime/multipart" "mime/multipart"
"time"
"tm/internal/company_category" "tm/internal/company_category"
"tm/pkg/filestore" "tm/pkg/filestore"
"tm/pkg/logger" "tm/pkg/logger"
"tm/pkg/redis"
"tm/pkg/response" "tm/pkg/response"
) )
@@ -47,11 +48,13 @@ type Service interface {
// companyService implements the Service interface // companyService implements the Service interface
type companyService struct { type companyService struct {
repository Repository repository Repository
categoryService company_category.Service categoryService company_category.Service
fileStore filestore.FileStoreService fileStore filestore.FileStoreService
aiRecommendationClient AIRecommendationClient aiRecommendationClient AIRecommendationClient
logger logger.Logger redisClient redis.Client
recommendationCacheTTL time.Duration
logger logger.Logger
} }
// NewService creates a new company service // NewService creates a new company service
@@ -60,13 +63,17 @@ func NewService(
categoryService company_category.Service, categoryService company_category.Service,
fileStore filestore.FileStoreService, fileStore filestore.FileStoreService,
aiRecommendationClient AIRecommendationClient, aiRecommendationClient AIRecommendationClient,
redisClient redis.Client,
recommendationCacheTTL time.Duration,
logger logger.Logger, logger logger.Logger,
) Service { ) Service {
return &companyService{ return &companyService{
repository: repository, repository: repository,
categoryService: categoryService, categoryService: categoryService,
fileStore: fileStore, fileStore: fileStore,
aiRecommendationClient: aiRecommendationClient, aiRecommendationClient: aiRecommendationClient,
redisClient: redisClient,
recommendationCacheTTL: recommendationCacheTTL,
logger: logger, logger: logger,
} }
} }
@@ -150,6 +157,8 @@ func (s *companyService) Create(ctx context.Context, form *CompanyForm) (*Compan
"type": company.Type, "type": company.Type,
}) })
s.triggerAIOnboardingAsync(company.GetID())
return company.ToResponse(nil), nil return company.ToResponse(nil), nil
} }
@@ -323,6 +332,8 @@ func (s *companyService) Update(ctx context.Context, id string, form *CompanyFor
"name": company.Name, "name": company.Name,
}) })
s.triggerAIOnboardingAsync(company.GetID())
return company.ToResponse(nil), nil return company.ToResponse(nil), nil
} }