From 8dcda0e03133185eb3e5c17f1fb87098f2121819 Mon Sep 17 00:00:00 2001 From: Mazyar Date: Sun, 4 Jan 2026 13:44:45 +0330 Subject: [PATCH] added AI auto-generated keywords to customer profile --- cmd/web/bootstrap/bootstrap.go | 47 +++++++ cmd/web/bootstrap/config.go | 30 +++++ cmd/web/main.go | 7 +- internal/customer/entity.go | 16 ++- internal/customer/form.go | 41 ++++--- internal/customer/service.go | 216 ++++++++++++++++++++++++++++++++- internal/tender/form.go | 1 + internal/tender/handler.go | 15 ++- internal/tender/service.go | 110 +++++++++++++---- 9 files changed, 435 insertions(+), 48 deletions(-) diff --git a/cmd/web/bootstrap/bootstrap.go b/cmd/web/bootstrap/bootstrap.go index e21fae1..155b6b6 100644 --- a/cmd/web/bootstrap/bootstrap.go +++ b/cmd/web/bootstrap/bootstrap.go @@ -5,6 +5,7 @@ import ( "time" "tm/pkg/authorization" "tm/pkg/config" + "tm/pkg/glm" "tm/pkg/hcaptcha" "tm/pkg/logger" "tm/pkg/mongo" @@ -268,3 +269,49 @@ func InitHCaptchaService(conf config.HCaptchaConfig, log logger.Logger) hcaptcha return hcaptchaVerifier } + +// InitGLMService initializes the GLM service +func InitGLMService(conf GLMConfig, log logger.Logger) *glm.SDK { + glmSDK, err := glm.New( + &glm.Config{ + BaseURL: conf.BaseURL, + APIKey: conf.APIKey, + Timeout: conf.Timeout, + RetryAttempts: conf.RetryAttempts, + RetryDelay: conf.RetryDelay, + EnableLogging: conf.EnableLogging, + UserAgent: conf.UserAgent, + DefaultModel: conf.DefaultModel, + DefaultMaxTokens: conf.DefaultMaxTokens, + DefaultTopP: conf.DefaultTopP, + DefaultTemperature: conf.DefaultTemperature, + DefaultFrequencyPenalty: conf.DefaultFrequencyPenalty, + DefaultPresencePenalty: conf.DefaultPresencePenalty, + EnableStreaming: conf.EnableStreaming, + EnableThinking: conf.EnableThinking, + TranslationOptions: glm.TranslationOption{ + Model: conf.TranslateOptions.Model, + MaxTokens: conf.TranslateOptions.MaxTokens, + Temperature: conf.TranslateOptions.Temperature, + FrequencyPenalty: conf.TranslateOptions.FrequencyPenalty, + PresencePenalty: conf.TranslateOptions.PresencePenalty, + TopP: conf.TranslateOptions.TopP, + EnableThinking: conf.TranslateOptions.EnableThinking, + }, + }, log) + + if err != nil { + log.Error("Failed to initialize GLM SDK", map[string]interface{}{ + "error": err.Error(), + }) + log.Warn("GLM service will be disabled, keyword generation features will not be available", map[string]interface{}{}) + return nil + } + + log.Info("GLM SDK initialized successfully", map[string]interface{}{ + "base_url": conf.BaseURL, + "timeout": conf.Timeout, + }) + + return glmSDK +} diff --git a/cmd/web/bootstrap/config.go b/cmd/web/bootstrap/config.go index 092d5f5..db3eb10 100644 --- a/cmd/web/bootstrap/config.go +++ b/cmd/web/bootstrap/config.go @@ -16,11 +16,41 @@ type Config struct { Notification config.NotificationConfig Ollama config.OllamaConfig HCaptcha config.HCaptchaConfig + GLM GLMConfig Scraper ScraperConfig UserAuth AuthConfig CustomerAuth AuthConfig } +type GLMConfig struct { + BaseURL string `env:"GLM_BASE_URL" envDefault:"https://api.z.ai"` + APIKey string `env:"GLM_API_KEY" envDefault:""` + Timeout time.Duration `env:"GLM_TIMEOUT" envDefault:"300s"` + RetryAttempts int `env:"GLM_RETRY_ATTEMPTS" envDefault:"3"` + RetryDelay time.Duration `env:"GLM_RETRY_DELAY" envDefault:"2s"` + EnableLogging bool `env:"GLM_ENABLE_LOGGING" envDefault:"true"` + UserAgent string `env:"GLM_USER_AGENT" envDefault:"Opplens-GLMSDK/1.0"` + DefaultModel string `env:"GLM_DEFAULT_MODEL" envDefault:"glm-4.5"` + DefaultMaxTokens int `env:"GLM_DEFAULT_MAX_TOKENS" envDefault:"4096"` + DefaultTopP float64 `env:"GLM_DEFAULT_TOP_P" envDefault:"1.0"` + DefaultTemperature float64 `env:"GLM_DEFAULT_TEMPERATURE" envDefault:"0.7"` + DefaultFrequencyPenalty float64 `env:"GLM_DEFAULT_FREQUENCY_PENALTY" envDefault:"0"` + DefaultPresencePenalty float64 `env:"GLM_DEFAULT_PRESENCE_PENALTY" envDefault:"0"` + EnableStreaming bool `env:"GLM_ENABLE_STREAMING" envDefault:"false"` + EnableThinking bool `env:"GLM_ENABLE_THINKING" envDefault:"false"` + TranslateOptions TranslateOptions +} + +type TranslateOptions struct { + Model string `env:"GLM_TRANSLATION_MODEL" envDefault:"glm-4.5"` + MaxTokens int `env:"GLM_TRANSLATION_MAX_TOKENS" envDefault:"2048"` + Temperature float64 `env:"GLM_TRANSLATION_TEMPERATURE" envDefault:"0.3"` + FrequencyPenalty float64 `env:"GLM_TRANSLATION_FREQUENCY_PENALTY" envDefault:"0.1"` + PresencePenalty float64 `env:"GLM_TRANSLATION_PRESENCE_PENALTY" envDefault:"0.1"` + TopP float64 `env:"GLM_TRANSLATION_TOP_P" envDefault:"1.0"` + EnableThinking bool `env:"GLM_TRANSLATION_ENABLE_THINKING" envDefault:"false"` +} + type ScraperConfig struct { BaseURL string `env:"SCRAPER_BASE_URL"` Timeout time.Duration `env:"SCRAPER_TIMEOUT"` diff --git a/cmd/web/main.go b/cmd/web/main.go index 53f15c6..14eaeff 100644 --- a/cmd/web/main.go +++ b/cmd/web/main.go @@ -149,6 +149,9 @@ func main() { // Initialize hCaptcha verifier hcaptchaVerifier := bootstrap.InitHCaptchaService(conf.HCaptcha, logger) + // Initialize GLM service + glmSDK := bootstrap.InitGLMService(conf.GLM, logger) + // Initialize authorization service userAuthService := bootstrap.InitAuthorizationService(conf.UserAuth.JWT, redisClient, logger) customerAuthService := bootstrap.InitAuthorizationService(conf.CustomerAuth.JWT, redisClient, logger) @@ -176,8 +179,8 @@ func main() { userService := user.NewService(userRepository, logger, userAuthService, userValidator, notificationSDK) categoryService := company_category.NewService(categoryRepository, logger) companyService := company.NewService(companyRepository, categoryService, logger) - customerService := customer.NewService(customerRepository, logger, customerAuthService, companyService, redisClient, notificationSDK, customerValidator) - tenderService := tender.NewService(tenderRepository, companyService, logger) + customerService := customer.NewService(customerRepository, logger, customerAuthService, companyService, redisClient, notificationSDK, customerValidator, glmSDK) + tenderService := tender.NewService(tenderRepository, companyService, customerService, logger) feedbackService := feedback.NewService(feedbackRepo, tenderService, companyService, logger) tenderApprovalService := tender_approval.NewService(tenderApprovalRepository, tenderService, logger) inquiryService := inquiry.NewService(inquiryRepository, notificationSDK, logger, xssPolicy) diff --git a/internal/customer/entity.go b/internal/customer/entity.go index 6b6d3c7..f7b2ac3 100644 --- a/internal/customer/entity.go +++ b/internal/customer/entity.go @@ -32,6 +32,16 @@ const ( CustomerRoleAnalyst CustomerRole = "analyst" ) +// KeywordsStatus represents the status of keyword processing +type KeywordsStatus string + +const ( + KeywordsStatusPending KeywordsStatus = "pending" + KeywordsStatusInProgress KeywordsStatus = "in_progress" + KeywordsStatusReady KeywordsStatus = "ready" + KeywordsStatusFailed KeywordsStatus = "failed" +) + // Customer represents a customer in the tender management system // A customer can have an associated company for login purposes type Customer struct { @@ -44,8 +54,10 @@ type Customer struct { Type CustomerType `bson:"type"` Role CustomerRole `bson:"role"` Phone *string `bson:"phone"` - Companies []string `bson:"companies"` - DeviceToken []string `bson:"device_token"` + Companies []string `bson:"companies"` + Keywords []string `bson:"keywords,omitempty"` + KeywordsStatus KeywordsStatus `bson:"keywords_status,omitempty"` + DeviceToken []string `bson:"device_token"` LastLoginAt *int64 `bson:"last_login_at"` UpdatedAt int64 `bson:"updated_at"` CreatedAt int64 `bson:"created_at"` diff --git a/internal/customer/form.go b/internal/customer/form.go index 086d00b..0b95e54 100644 --- a/internal/customer/form.go +++ b/internal/customer/form.go @@ -28,6 +28,7 @@ type UpdateCustomerForm struct { Username string `json:"username" valid:"required,username,length(3|30)" example:"user"` Email string `json:"email" valid:"required,email" example:"user@opplens.com"` Phone *string `json:"phone,omitempty" valid:"optional,length(10|20)" example:"+1234567890"` + Keywords []string `json:"keywords,omitempty" valid:"optional"` } // CustomerStatusForm represents the form for suspending a customer @@ -63,25 +64,37 @@ type CustomerResponse struct { UpdatedAt int64 `json:"updated_at"` CreatedAt int64 `json:"created_at"` LastLoginAt *int64 `json:"last_login_at"` - Companies []*CompanySummary `json:"companies"` - DeviceToken []string `json:"-"` + Companies []*CompanySummary `json:"companies"` + Keywords []string `json:"keywords"` + KeywordsStatus string `json:"keywords_status"` + DeviceToken []string `json:"-"` } // ToResponse converts Customer to CustomerResponse func (c *Customer) ToResponse(companies []*CompanySummary) *CustomerResponse { + keywords := c.Keywords + if keywords == nil { + keywords = []string{} + } + keywordsStatus := string(c.KeywordsStatus) + if keywordsStatus == "" { + keywordsStatus = string(KeywordsStatusReady) + } return &CustomerResponse{ - ID: c.ID.Hex(), - FullName: c.FullName, - Username: c.Username, - Email: c.Email, - Type: string(c.Type), - Status: string(c.Status), - Role: string(c.Role), - Phone: c.Phone, - UpdatedAt: c.UpdatedAt, - CreatedAt: c.CreatedAt, - LastLoginAt: c.LastLoginAt, - Companies: companies, + ID: c.ID.Hex(), + FullName: c.FullName, + Username: c.Username, + Email: c.Email, + Type: string(c.Type), + Status: string(c.Status), + Role: string(c.Role), + Phone: c.Phone, + UpdatedAt: c.UpdatedAt, + CreatedAt: c.CreatedAt, + LastLoginAt: c.LastLoginAt, + Companies: companies, + Keywords: keywords, + KeywordsStatus: keywordsStatus, } } diff --git a/internal/customer/service.go b/internal/customer/service.go index 7c68b7a..8fd547a 100644 --- a/internal/customer/service.go +++ b/internal/customer/service.go @@ -11,6 +11,7 @@ import ( "time" "tm/internal/company" + "tm/pkg/glm" "tm/pkg/logger" "tm/pkg/notification" "tm/pkg/redis" @@ -61,10 +62,11 @@ type customerService struct { redisClient redis.Client notification notification.SDK validator ValidationService + glmSDK *glm.SDK } // New creates a new customer service -func NewService(repository Repository, logger logger.Logger, authService authorization.AuthorizationService, companyService company.Service, redisClient redis.Client, notificationSDK notification.SDK, validator ValidationService) Service { +func NewService(repository Repository, logger logger.Logger, authService authorization.AuthorizationService, companyService company.Service, redisClient redis.Client, notificationSDK notification.SDK, validator ValidationService, glmSDK *glm.SDK) Service { return &customerService{ repository: repository, logger: logger, @@ -73,6 +75,7 @@ func NewService(repository Repository, logger logger.Logger, authService authori redisClient: redisClient, notification: notificationSDK, validator: validator, + glmSDK: glmSDK, } } @@ -127,6 +130,21 @@ func (s *customerService) Register(ctx context.Context, form *CreateCustomerForm Phone: form.Phone, } + // Generate keywords using GLM AI + customer.KeywordsStatus = KeywordsStatusInProgress + keywords, err := s.generateKeywords(ctx, customer) + if err != nil { + s.logger.Warn("Failed to generate keywords, continuing without keywords", map[string]interface{}{ + "error": err.Error(), + "customer_id": customer.ID, + }) + keywords = []string{} + customer.KeywordsStatus = KeywordsStatusFailed + } else { + customer.Keywords = keywords + customer.KeywordsStatus = KeywordsStatusReady + } + // Save to database err = s.repository.Register(ctx, customer) if err != nil { @@ -335,7 +353,24 @@ func (s *customerService) Update(ctx context.Context, id string, form *UpdateCus // Handle company assignments companies := make([]*CompanySummary, 0) + companiesChanged := false if len(form.Companies) > 0 { + // Check if companies changed + if len(form.Companies) != len(customer.Companies) { + companiesChanged = true + } else { + companyMap := make(map[string]bool) + for _, id := range customer.Companies { + companyMap[id] = true + } + for _, id := range form.Companies { + if !companyMap[id] { + companiesChanged = true + break + } + } + } + customer.Companies = form.Companies companies, err = s.loadCompaniesForCustomer(ctx, customer) if err != nil { s.logger.Error("Failed to load companies for customer", map[string]interface{}{ @@ -345,17 +380,101 @@ func (s *customerService) Update(ctx context.Context, id string, form *UpdateCus } } - if form.FullName != nil { + infoChanged := false + if form.FullName != nil && (customer.FullName == nil || *form.FullName != *customer.FullName) { customer.FullName = form.FullName + infoChanged = true } - if form.Phone != nil { + if form.Phone != nil && (customer.Phone == nil || *form.Phone != *customer.Phone) { customer.Phone = form.Phone + infoChanged = true } // Update role if provided - if form.Role != "" { + if form.Role != "" && customer.Role != CustomerRole(form.Role) { customer.Role = CustomerRole(form.Role) + infoChanged = true + } + + // Handle keywords + if form.Keywords != nil { + // Keywords explicitly provided by customer, set status to in_progress + // Customer's manual keywords are saved, but we'll enhance them with AI + customer.Keywords = form.Keywords + customer.KeywordsStatus = KeywordsStatusInProgress + + // Save customer with in_progress status first, then process asynchronously + customerID := customer.ID.Hex() + go func() { + ctx := context.Background() + // Get fresh customer data for processing + cust, err := s.repository.GetByID(ctx, customerID) + if err != nil { + s.logger.Error("Failed to get customer for keyword processing", map[string]interface{}{ + "error": err.Error(), + "customer_id": customerID, + }) + return + } + + // If GLM SDK is not available, keep manual keywords and set status to ready + if s.glmSDK == nil { + s.logger.Info("GLM SDK not available, keeping manual keywords", map[string]interface{}{ + "customer_id": customerID, + }) + cust.KeywordsStatus = KeywordsStatusReady + } else { + // Regenerate keywords based on updated customer info + keywords, err := s.generateKeywords(ctx, cust) + if err != nil { + s.logger.Warn("Failed to process keywords after manual update", map[string]interface{}{ + "error": err.Error(), + "customer_id": customerID, + }) + // Keep manual keywords but set status to failed + cust.KeywordsStatus = KeywordsStatusFailed + } else if len(keywords) > 0 { + // Update with AI-generated keywords + cust.Keywords = keywords + cust.KeywordsStatus = KeywordsStatusReady + } else { + // Empty keywords from GLM, keep manual keywords and set status to ready + s.logger.Warn("GLM returned empty keywords, keeping manual keywords", map[string]interface{}{ + "customer_id": customerID, + }) + cust.KeywordsStatus = KeywordsStatusReady + } + } + + // Update customer with new status + err = s.repository.Update(ctx, cust) + if err != nil { + s.logger.Error("Failed to update customer keywords status", map[string]interface{}{ + "error": err.Error(), + "customer_id": customerID, + }) + } else { + s.logger.Info("Keywords processed successfully", map[string]interface{}{ + "customer_id": customerID, + "status": cust.KeywordsStatus, + }) + } + }() + } else if infoChanged || companiesChanged { + // Customer info changed, regenerate keywords + customer.KeywordsStatus = KeywordsStatusInProgress + keywords, err := s.generateKeywords(ctx, customer) + if err != nil { + s.logger.Warn("Failed to regenerate keywords, keeping existing keywords", map[string]interface{}{ + "error": err.Error(), + "customer_id": customer.ID, + }) + customer.KeywordsStatus = KeywordsStatusFailed + } else { + customer.Keywords = keywords + customer.KeywordsStatus = KeywordsStatusReady + } } // Update in database @@ -1244,3 +1363,92 @@ func (s *customerService) AssignRole(ctx context.Context, customerID string, for Success: true, }, nil } + +// generateKeywords generates keywords for a customer using GLM AI based on their information +func (s *customerService) generateKeywords(ctx context.Context, customer *Customer) ([]string, error) { + // If GLM SDK is not available, return empty keywords + if s.glmSDK == nil { + s.logger.Warn("GLM SDK not available, skipping keyword generation", map[string]interface{}{ + "customer_id": customer.ID, + }) + return []string{}, nil + } + + // Build customer information string for AI + var customerInfo strings.Builder + if customer.FullName != nil { + customerInfo.WriteString(fmt.Sprintf("Name: %s\n", *customer.FullName)) + } + customerInfo.WriteString(fmt.Sprintf("Type: %s\n", customer.Type)) + customerInfo.WriteString(fmt.Sprintf("Role: %s\n", customer.Role)) + if customer.Phone != nil { + customerInfo.WriteString(fmt.Sprintf("Phone: %s\n", *customer.Phone)) + } + + // Get company information if available + if len(customer.Companies) > 0 { + customerInfo.WriteString("Companies: ") + for i, companyID := range customer.Companies { + comp, err := s.companyService.GetByID(ctx, companyID) + if err == nil { + if i > 0 { + customerInfo.WriteString(", ") + } + customerInfo.WriteString(comp.Name) + if comp.Industry != "" { + customerInfo.WriteString(fmt.Sprintf(" (Industry: %s)", comp.Industry)) + } + } + } + customerInfo.WriteString("\n") + } + + systemPrompt := `You are a keyword extraction assistant for a tender management system. +Analyze the customer information and generate 5-10 relevant keywords that would help match them with appropriate tenders. +Keywords should be: +- Relevant to tender/procurement categories +- Based on customer type, role, and company information +- Professional and specific +- Suitable for matching with tender descriptions + +Return ONLY a comma-separated list of keywords, no explanations or additional text.` + + userMessage := fmt.Sprintf("Generate keywords for this customer:\n\n%s", customerInfo.String()) + + s.logger.Info("Generating keywords using GLM AI", map[string]interface{}{ + "customer_id": customer.ID, + }) + + response, err := s.glmSDK.QuickChatWithSystem(ctx, systemPrompt, userMessage) + if err != nil { + s.logger.Error("Failed to generate keywords using GLM AI", map[string]interface{}{ + "error": err.Error(), + "customer_id": customer.ID, + }) + return []string{}, fmt.Errorf("failed to generate keywords: %w", err) + } + + // Parse comma-separated keywords + keywordsStr := strings.TrimSpace(response) + if keywordsStr == "" { + return []string{}, nil + } + + // Split by comma and clean up + keywordList := strings.Split(keywordsStr, ",") + keywords := make([]string, 0, len(keywordList)) + for _, kw := range keywordList { + kw = strings.TrimSpace(kw) + if kw != "" { + keywords = append(keywords, kw) + } + } + + s.logger.Info("Keywords generated successfully", map[string]interface{}{ + "customer_id": customer.ID, + "keywords": keywords, + "count": len(keywords), + }) + + return keywords, nil +} diff --git a/internal/tender/form.go b/internal/tender/form.go index d1ae145..fca4f04 100644 --- a/internal/tender/form.go +++ b/internal/tender/form.go @@ -21,6 +21,7 @@ type SearchForm struct { CountryCodes []string `query:"country_codes" valid:"optional"` RegionCodes []string `query:"region_codes" valid:"optional"` CompanyID *string `query:"company_id" valid:"optional"` + CustomerID *string `query:"customer_id" valid:"optional"` Status []string `query:"status" valid:"optional"` Classifications []string `query:"classifications" valid:"optional"` MinEstimatedValue *float64 `query:"min_estimated_value" valid:"optional"` diff --git a/internal/tender/handler.go b/internal/tender/handler.go index 3752ff6..712eef9 100644 --- a/internal/tender/handler.go +++ b/internal/tender/handler.go @@ -216,13 +216,19 @@ func (h *TenderHandler) GetPublicTenders(c echo.Context) error { form.Status = []string{string(TenderStatusActive)} - // Get company ID from customer context for automatic matching + // Get company ID and customer ID from customer context for automatic matching var companyID *string if customerCompanyID, ok := c.Get("company_id").(string); ok { companyID = &customerCompanyID } form.CompanyID = companyID + var customerID *string + if customerIDStr, ok := c.Get("customer_id").(string); ok { + customerID = &customerIDStr + } + form.CustomerID = customerID + result, err := h.service.Search(c.Request().Context(), form, response.NewPagination(c)) if err != nil { return response.InternalServerError(c, "Failed to retrieve tenders") @@ -277,9 +283,14 @@ func (h *TenderHandler) RecommendTenders(c echo.Context) error { if customerCompanyID, ok := c.Get("company_id").(string); ok { companyID = &customerCompanyID } - form.CompanyID = companyID + var customerID *string + if customerIDStr, ok := c.Get("customer_id").(string); ok { + customerID = &customerIDStr + } + form.CustomerID = customerID + result, err := h.service.Recommend(c.Request().Context(), form, response.NewPagination(c)) if err != nil { return response.InternalServerError(c, "Failed to retrieve tenders") diff --git a/internal/tender/service.go b/internal/tender/service.go index 6a4b7f8..72817f4 100644 --- a/internal/tender/service.go +++ b/internal/tender/service.go @@ -3,8 +3,10 @@ package tender import ( "context" "fmt" + "strings" "time" "tm/internal/company" + "tm/internal/customer" "tm/pkg/logger" "tm/pkg/response" ) @@ -26,17 +28,19 @@ type Service interface { // tenderService implements TenderService interface type tenderService struct { - repository TenderRepository - companyService company.Service - logger logger.Logger + repository TenderRepository + companyService company.Service + customerService customer.Service + logger logger.Logger } // NewService creates a new tender service -func NewService(repository TenderRepository, companyService company.Service, logger logger.Logger) Service { +func NewService(repository TenderRepository, companyService company.Service, customerService customer.Service, logger logger.Logger) Service { return &tenderService{ - repository: repository, - companyService: companyService, - logger: logger, + repository: repository, + companyService: companyService, + customerService: customerService, + logger: logger, } } @@ -156,8 +160,8 @@ func (s *tenderService) GetDocumentSummaries(ctx context.Context, id string) ([] } s.logger.Info("Retrieved document summaries for tender", map[string]interface{}{ - "tender_id": id, - "summaries_count": len(tender.DocumentSummaries), + "tender_id": id, + "summaries_count": len(tender.DocumentSummaries), }) return tender.DocumentSummaries, nil @@ -166,13 +170,41 @@ func (s *tenderService) GetDocumentSummaries(ctx context.Context, id string) ([] // ListTenders retrieves tenders with pagination and filtering func (s *tenderService) Search(ctx context.Context, form *SearchForm, pagination *response.Pagination) (*SearchResponse, error) { s.logger.Info("Listing tenders", map[string]interface{}{ - "limit": pagination.Limit, - "offset": pagination.Offset, - "company_id": form.CompanyID, - "from": form.DeadlineFrom, - "to": form.DeadlineTo, + "limit": pagination.Limit, + "offset": pagination.Offset, + "company_id": form.CompanyID, + "customer_id": form.CustomerID, + "from": form.DeadlineFrom, + "to": form.DeadlineTo, }) + // If customer ID is provided, get customer keywords and add to search + if form.CustomerID != nil && *form.CustomerID != "" { + customerResp, err := s.customerService.GetByID(ctx, *form.CustomerID) + if err == nil && customerResp != nil { + // Only use keywords if status is ready + if customerResp.KeywordsStatus == "ready" && len(customerResp.Keywords) > 0 { + // Combine customer keywords with existing search query + keywordsQuery := strings.Join(customerResp.Keywords, " ") + if form.Search != nil && *form.Search != "" { + combinedSearch := *form.Search + " " + keywordsQuery + form.Search = &combinedSearch + } else { + form.Search = &keywordsQuery + } + s.logger.Info("Using customer keywords for search", map[string]interface{}{ + "customer_id": *form.CustomerID, + "keywords": customerResp.Keywords, + }) + } else { + s.logger.Info("Customer keywords not ready, skipping keyword-based search", map[string]interface{}{ + "customer_id": *form.CustomerID, + "keywords_status": customerResp.KeywordsStatus, + }) + } + } + } + tenders, total, err := s.repository.Search(ctx, form, pagination) if err != nil { s.logger.Error("Failed to list tenders", map[string]interface{}{ @@ -240,20 +272,50 @@ func (s *tenderService) Search(ctx context.Context, form *SearchForm, pagination // Recommend retrieves tenders with pagination and filtering func (s *tenderService) Recommend(ctx context.Context, form *SearchForm, pagination *response.Pagination) (*SearchResponse, error) { s.logger.Info("Recommend tenders", map[string]interface{}{ - "company_id": form.CompanyID, + "company_id": form.CompanyID, + "customer_id": form.CustomerID, }) - company, err := s.companyService.GetByID(ctx, *form.CompanyID) - if err != nil { - s.logger.Error("Failed to get company", map[string]interface{}{ - "company_id": form.CompanyID, - "error": err.Error(), - }) - return nil, fmt.Errorf("failed to get company: %w", err) + form.Status = []string{string(TenderStatusActive)} + + // Priority: Use customer keywords if available, otherwise use company CPV codes + if form.CustomerID != nil && *form.CustomerID != "" { + customerResp, err := s.customerService.GetByID(ctx, *form.CustomerID) + if err == nil && customerResp != nil { + // Only use keywords if status is ready + if customerResp.KeywordsStatus == "ready" && len(customerResp.Keywords) > 0 { + // Use customer keywords for recommendation + keywordsQuery := strings.Join(customerResp.Keywords, " ") + form.Search = &keywordsQuery + s.logger.Info("Using customer keywords for recommendation", map[string]interface{}{ + "customer_id": *form.CustomerID, + "keywords": customerResp.Keywords, + }) + } else { + s.logger.Info("Customer keywords not ready, falling back to company CPV codes", map[string]interface{}{ + "customer_id": *form.CustomerID, + "keywords_status": customerResp.KeywordsStatus, + }) + } + } } - form.Status = []string{string(TenderStatusActive)} - form.Classifications = company.Tags.CPVCodes + // Fallback to company CPV codes if no customer keywords + if form.Search == nil || *form.Search == "" { + if form.CompanyID != nil && *form.CompanyID != "" { + company, err := s.companyService.GetByID(ctx, *form.CompanyID) + if err != nil { + s.logger.Error("Failed to get company", map[string]interface{}{ + "company_id": form.CompanyID, + "error": err.Error(), + }) + return nil, fmt.Errorf("failed to get company: %w", err) + } + if company.Tags != nil && len(company.Tags.CPVCodes) > 0 { + form.Classifications = company.Tags.CPVCodes + } + } + } tenders, total, err := s.repository.Search(ctx, form, pagination) if err != nil {