diff --git a/cmd/web/bootstrap/config.go b/cmd/web/bootstrap/config.go index 237f953..92f6869 100644 --- a/cmd/web/bootstrap/config.go +++ b/cmd/web/bootstrap/config.go @@ -69,6 +69,8 @@ type AISummarizerConfig struct { APIRetryCount int `env:"AI_SUMMARIZER_API_RETRY_COUNT" envDefault:"2"` APIRetryDelay time.Duration `env:"AI_SUMMARIZER_API_RETRY_DELAY" envDefault:"3s"` 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 MinioEndpoint string `env:"AI_SUMMARIZER_MINIO_ENDPOINT" envDefault:""` diff --git a/cmd/web/main.go b/cmd/web/main.go index e58d2de..b10d9f6 100644 --- a/cmd/web/main.go +++ b/cmd/web/main.go @@ -220,7 +220,7 @@ func main() { auditLogger := audit.NewLogger(logger) userService := user.NewService(userRepository, logger, userAuthService, userValidator, notificationSDK, redisClient, auditLogger) 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) tenderService := tender.NewService(tenderRepository, companyService, customerService, logger, aiSummarizerClient, aiSummarizerStorage, conf.AISummarizer.DefaultLanguage) feedbackService := feedback.NewService(feedbackRepo, tenderService, companyService, logger) diff --git a/internal/company/documents.go b/internal/company/documents.go index 4e468be..a966483 100644 --- a/internal/company/documents.go +++ b/internal/company/documents.go @@ -98,6 +98,8 @@ func (s *companyService) UploadDocuments(ctx context.Context, companyID, uploade "documents_total": len(company.DocumentFileIDs), }) + s.triggerAIOnboardingAsync(companyID) + return &UploadDocumentsResponse{ DocumentFileIDs: company.DocumentFileIDs, Uploaded: uploaded, diff --git a/internal/company/recommendation.go b/internal/company/recommendation.go index 7d9654b..619c77b 100644 --- a/internal/company/recommendation.go +++ b/internal/company/recommendation.go @@ -9,11 +9,19 @@ import ( "io" "strings" + "github.com/redis/go-redis/v9" + "tm/pkg/ai_summarizer" ) 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. type AIRecommendationClient interface { 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) } + s.invalidateAIRecommendationCache(ctx, companyID) + 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. func (s *companyService) GetAIRecommendations(ctx context.Context, companyID string) ([]RecommendedTenderResponse, error) { if s.aiRecommendationClient == nil { return nil, ErrAIRecommendationNotConfigured } - if strings.TrimSpace(companyID) == "" { + companyID = strings.TrimSpace(companyID) + if companyID == "" { 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 { s.logger.Error("Failed to load company for AI recommendations", map[string]interface{}{ "company_id": companyID, @@ -121,9 +167,80 @@ func (s *companyService) GetAIRecommendations(ctx context.Context, companyID str }) } + s.cacheAIRecommendations(ctx, companyID, responses) + 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) { if len(fileIDs) == 0 { return "[]", nil diff --git a/internal/company/service.go b/internal/company/service.go index a0b8014..db99f42 100644 --- a/internal/company/service.go +++ b/internal/company/service.go @@ -3,12 +3,13 @@ package company import ( "context" "errors" - "mime/multipart" + "time" "tm/internal/company_category" "tm/pkg/filestore" "tm/pkg/logger" + "tm/pkg/redis" "tm/pkg/response" ) @@ -47,11 +48,13 @@ type Service interface { // companyService implements the Service interface type companyService struct { - repository Repository - categoryService company_category.Service - fileStore filestore.FileStoreService - aiRecommendationClient AIRecommendationClient - logger logger.Logger + repository Repository + categoryService company_category.Service + fileStore filestore.FileStoreService + aiRecommendationClient AIRecommendationClient + redisClient redis.Client + recommendationCacheTTL time.Duration + logger logger.Logger } // NewService creates a new company service @@ -60,13 +63,17 @@ func NewService( categoryService company_category.Service, fileStore filestore.FileStoreService, aiRecommendationClient AIRecommendationClient, + redisClient redis.Client, + recommendationCacheTTL time.Duration, logger logger.Logger, ) Service { return &companyService{ - repository: repository, + repository: repository, categoryService: categoryService, fileStore: fileStore, aiRecommendationClient: aiRecommendationClient, + redisClient: redisClient, + recommendationCacheTTL: recommendationCacheTTL, logger: logger, } } @@ -150,6 +157,8 @@ func (s *companyService) Create(ctx context.Context, form *CompanyForm) (*Compan "type": company.Type, }) + s.triggerAIOnboardingAsync(company.GetID()) + return company.ToResponse(nil), nil } @@ -323,6 +332,8 @@ func (s *companyService) Update(ctx context.Context, id string, form *CompanyFor "name": company.Name, }) + s.triggerAIOnboardingAsync(company.GetID()) + return company.ToResponse(nil), nil }