326e49886b
continuous-integration/drone/push Build is passing
- Introduced `refreshAIRecommendationsCacheAsync` method to refresh AI recommendations in the background, improving responsiveness by serving the previous cache until the refresh completes. - Updated `StartAIOnboarding` to call the new asynchronous cache refresh method instead of invalidating the cache directly. - Added logging for cache refresh operations, including success and error handling, to enhance observability. This update enhances the AI recommendation caching mechanism, providing a smoother onboarding experience and reducing latency in recommendation retrieval.
334 lines
9.5 KiB
Go
334 lines
9.5 KiB
Go
package company
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"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)
|
|
FetchRecommendations(ctx context.Context, reqBody ai_summarizer.RecommendRequest) ([]ai_summarizer.RecommendedTender, error)
|
|
}
|
|
|
|
type onboardingDocumentPayload struct {
|
|
Filename string `json:"filename"`
|
|
ContentType string `json:"content_type"`
|
|
Content string `json:"content"`
|
|
}
|
|
|
|
// StartAIOnboarding sends company profile data to the AI team to start recommendation onboarding.
|
|
func (s *companyService) StartAIOnboarding(ctx context.Context, companyID string) (*OnboardingResponse, error) {
|
|
if s.aiRecommendationClient == nil {
|
|
return nil, ErrAIRecommendationNotConfigured
|
|
}
|
|
if strings.TrimSpace(companyID) == "" {
|
|
return nil, errors.New("company ID is required")
|
|
}
|
|
|
|
company, err := s.repository.GetByID(ctx, companyID)
|
|
if err != nil {
|
|
s.logger.Error("Failed to load company for AI onboarding", map[string]interface{}{
|
|
"company_id": companyID,
|
|
"error": err.Error(),
|
|
})
|
|
return nil, errors.New("company not found")
|
|
}
|
|
|
|
documents, err := s.buildOnboardingDocuments(company.DocumentFileIDs)
|
|
if err != nil {
|
|
s.logger.Error("Failed to prepare company documents for AI onboarding", map[string]interface{}{
|
|
"company_id": companyID,
|
|
"error": err.Error(),
|
|
})
|
|
return nil, fmt.Errorf("failed to prepare company documents: %w", err)
|
|
}
|
|
|
|
websiteURL := ""
|
|
if company.Website != nil {
|
|
websiteURL = strings.TrimSpace(*company.Website)
|
|
}
|
|
|
|
s.logger.Info("Starting AI company onboarding", map[string]interface{}{
|
|
"company_id": companyID,
|
|
"documents_count": len(company.DocumentFileIDs),
|
|
"has_website_url": websiteURL != "",
|
|
})
|
|
|
|
result, err := s.aiRecommendationClient.StartOnboarding(ctx, ai_summarizer.OnboardingRequest{
|
|
CompanyID: companyID,
|
|
Documents: documents,
|
|
WebsiteURL: websiteURL,
|
|
})
|
|
if err != nil {
|
|
s.logger.Error("AI onboarding request failed", map[string]interface{}{
|
|
"company_id": companyID,
|
|
"error": err.Error(),
|
|
})
|
|
return nil, fmt.Errorf("failed to start AI onboarding: %w", err)
|
|
}
|
|
|
|
// Prefetch recommendations in the background; keep serving the previous cache until refresh completes.
|
|
s.refreshAIRecommendationsCacheAsync(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
|
|
}
|
|
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,
|
|
"error": err.Error(),
|
|
})
|
|
return nil, errors.New("company not found")
|
|
}
|
|
|
|
return s.fetchAndCacheAIRecommendations(ctx, companyID)
|
|
}
|
|
|
|
func (s *companyService) refreshAIRecommendationsCacheAsync(companyID string) {
|
|
if s.aiRecommendationClient == nil {
|
|
return
|
|
}
|
|
|
|
companyID = strings.TrimSpace(companyID)
|
|
if companyID == "" {
|
|
return
|
|
}
|
|
|
|
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{}{
|
|
"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) {
|
|
s.logger.Info("Fetching AI tender recommendations", map[string]interface{}{
|
|
"company_id": companyID,
|
|
})
|
|
|
|
items, err := s.aiRecommendationClient.FetchRecommendations(ctx, ai_summarizer.RecommendRequest{
|
|
CompanyID: companyID,
|
|
})
|
|
if err != nil {
|
|
s.logger.Error("AI recommend request failed", map[string]interface{}{
|
|
"company_id": companyID,
|
|
"error": err.Error(),
|
|
})
|
|
return nil, fmt.Errorf("failed to fetch AI recommendations: %w", err)
|
|
}
|
|
|
|
responses := make([]RecommendedTenderResponse, 0, len(items))
|
|
for _, item := range items {
|
|
responses = append(responses, RecommendedTenderResponse{
|
|
Rank: item.Rank,
|
|
TenderID: item.TenderID,
|
|
Analysis: item.Analysis,
|
|
})
|
|
}
|
|
|
|
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) buildOnboardingDocuments(fileIDs []string) (string, error) {
|
|
if len(fileIDs) == 0 {
|
|
return "[]", nil
|
|
}
|
|
if s.fileStore == nil {
|
|
return "", errors.New("file storage is not configured")
|
|
}
|
|
|
|
payloads := make([]onboardingDocumentPayload, 0, len(fileIDs))
|
|
for _, fileID := range fileIDs {
|
|
if strings.TrimSpace(fileID) == "" {
|
|
continue
|
|
}
|
|
|
|
reader, info, err := s.fileStore.Download(fileID)
|
|
if err != nil {
|
|
s.logger.Warn("Skipping company document for AI onboarding", map[string]interface{}{
|
|
"file_id": fileID,
|
|
"error": err.Error(),
|
|
})
|
|
continue
|
|
}
|
|
|
|
content, readErr := io.ReadAll(reader)
|
|
closeErr := reader.Close()
|
|
if readErr != nil {
|
|
return "", fmt.Errorf("failed to read document %s: %w", fileID, readErr)
|
|
}
|
|
if closeErr != nil {
|
|
s.logger.Warn("Failed to close company document reader", map[string]interface{}{
|
|
"file_id": fileID,
|
|
"error": closeErr.Error(),
|
|
})
|
|
}
|
|
|
|
filename := fileID
|
|
contentType := "application/octet-stream"
|
|
if info != nil {
|
|
if info.Filename != "" {
|
|
filename = info.Filename
|
|
}
|
|
if info.ContentType != "" {
|
|
contentType = info.ContentType
|
|
}
|
|
}
|
|
|
|
payloads = append(payloads, onboardingDocumentPayload{
|
|
Filename: filename,
|
|
ContentType: contentType,
|
|
Content: base64.StdEncoding.EncodeToString(content),
|
|
})
|
|
}
|
|
|
|
encoded, err := json.Marshal(payloads)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to encode company documents: %w", err)
|
|
}
|
|
|
|
return string(encoded), nil
|
|
}
|