added AI auto-generated keywords to customer profile
This commit is contained in:
@@ -5,6 +5,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
"tm/pkg/authorization"
|
"tm/pkg/authorization"
|
||||||
"tm/pkg/config"
|
"tm/pkg/config"
|
||||||
|
"tm/pkg/glm"
|
||||||
"tm/pkg/hcaptcha"
|
"tm/pkg/hcaptcha"
|
||||||
"tm/pkg/logger"
|
"tm/pkg/logger"
|
||||||
"tm/pkg/mongo"
|
"tm/pkg/mongo"
|
||||||
@@ -268,3 +269,49 @@ func InitHCaptchaService(conf config.HCaptchaConfig, log logger.Logger) hcaptcha
|
|||||||
|
|
||||||
return hcaptchaVerifier
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,11 +16,41 @@ type Config struct {
|
|||||||
Notification config.NotificationConfig
|
Notification config.NotificationConfig
|
||||||
Ollama config.OllamaConfig
|
Ollama config.OllamaConfig
|
||||||
HCaptcha config.HCaptchaConfig
|
HCaptcha config.HCaptchaConfig
|
||||||
|
GLM GLMConfig
|
||||||
Scraper ScraperConfig
|
Scraper ScraperConfig
|
||||||
UserAuth AuthConfig
|
UserAuth AuthConfig
|
||||||
CustomerAuth 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 {
|
type ScraperConfig struct {
|
||||||
BaseURL string `env:"SCRAPER_BASE_URL"`
|
BaseURL string `env:"SCRAPER_BASE_URL"`
|
||||||
Timeout time.Duration `env:"SCRAPER_TIMEOUT"`
|
Timeout time.Duration `env:"SCRAPER_TIMEOUT"`
|
||||||
|
|||||||
+5
-2
@@ -149,6 +149,9 @@ func main() {
|
|||||||
// Initialize hCaptcha verifier
|
// Initialize hCaptcha verifier
|
||||||
hcaptchaVerifier := bootstrap.InitHCaptchaService(conf.HCaptcha, logger)
|
hcaptchaVerifier := bootstrap.InitHCaptchaService(conf.HCaptcha, logger)
|
||||||
|
|
||||||
|
// Initialize GLM service
|
||||||
|
glmSDK := bootstrap.InitGLMService(conf.GLM, logger)
|
||||||
|
|
||||||
// Initialize authorization service
|
// Initialize authorization service
|
||||||
userAuthService := bootstrap.InitAuthorizationService(conf.UserAuth.JWT, redisClient, logger)
|
userAuthService := bootstrap.InitAuthorizationService(conf.UserAuth.JWT, redisClient, logger)
|
||||||
customerAuthService := bootstrap.InitAuthorizationService(conf.CustomerAuth.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)
|
userService := user.NewService(userRepository, logger, userAuthService, userValidator, notificationSDK)
|
||||||
categoryService := company_category.NewService(categoryRepository, logger)
|
categoryService := company_category.NewService(categoryRepository, logger)
|
||||||
companyService := company.NewService(companyRepository, categoryService, logger)
|
companyService := company.NewService(companyRepository, categoryService, logger)
|
||||||
customerService := customer.NewService(customerRepository, logger, customerAuthService, companyService, redisClient, notificationSDK, customerValidator)
|
customerService := customer.NewService(customerRepository, logger, customerAuthService, companyService, redisClient, notificationSDK, customerValidator, glmSDK)
|
||||||
tenderService := tender.NewService(tenderRepository, companyService, logger)
|
tenderService := tender.NewService(tenderRepository, companyService, customerService, logger)
|
||||||
feedbackService := feedback.NewService(feedbackRepo, tenderService, companyService, logger)
|
feedbackService := feedback.NewService(feedbackRepo, tenderService, companyService, logger)
|
||||||
tenderApprovalService := tender_approval.NewService(tenderApprovalRepository, tenderService, logger)
|
tenderApprovalService := tender_approval.NewService(tenderApprovalRepository, tenderService, logger)
|
||||||
inquiryService := inquiry.NewService(inquiryRepository, notificationSDK, logger, xssPolicy)
|
inquiryService := inquiry.NewService(inquiryRepository, notificationSDK, logger, xssPolicy)
|
||||||
|
|||||||
@@ -32,6 +32,16 @@ const (
|
|||||||
CustomerRoleAnalyst CustomerRole = "analyst"
|
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
|
// Customer represents a customer in the tender management system
|
||||||
// A customer can have an associated company for login purposes
|
// A customer can have an associated company for login purposes
|
||||||
type Customer struct {
|
type Customer struct {
|
||||||
@@ -44,8 +54,10 @@ type Customer struct {
|
|||||||
Type CustomerType `bson:"type"`
|
Type CustomerType `bson:"type"`
|
||||||
Role CustomerRole `bson:"role"`
|
Role CustomerRole `bson:"role"`
|
||||||
Phone *string `bson:"phone"`
|
Phone *string `bson:"phone"`
|
||||||
Companies []string `bson:"companies"`
|
Companies []string `bson:"companies"`
|
||||||
DeviceToken []string `bson:"device_token"`
|
Keywords []string `bson:"keywords,omitempty"`
|
||||||
|
KeywordsStatus KeywordsStatus `bson:"keywords_status,omitempty"`
|
||||||
|
DeviceToken []string `bson:"device_token"`
|
||||||
LastLoginAt *int64 `bson:"last_login_at"`
|
LastLoginAt *int64 `bson:"last_login_at"`
|
||||||
UpdatedAt int64 `bson:"updated_at"`
|
UpdatedAt int64 `bson:"updated_at"`
|
||||||
CreatedAt int64 `bson:"created_at"`
|
CreatedAt int64 `bson:"created_at"`
|
||||||
|
|||||||
+27
-14
@@ -28,6 +28,7 @@ type UpdateCustomerForm struct {
|
|||||||
Username string `json:"username" valid:"required,username,length(3|30)" example:"user"`
|
Username string `json:"username" valid:"required,username,length(3|30)" example:"user"`
|
||||||
Email string `json:"email" valid:"required,email" example:"user@opplens.com"`
|
Email string `json:"email" valid:"required,email" example:"user@opplens.com"`
|
||||||
Phone *string `json:"phone,omitempty" valid:"optional,length(10|20)" example:"+1234567890"`
|
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
|
// CustomerStatusForm represents the form for suspending a customer
|
||||||
@@ -63,25 +64,37 @@ type CustomerResponse struct {
|
|||||||
UpdatedAt int64 `json:"updated_at"`
|
UpdatedAt int64 `json:"updated_at"`
|
||||||
CreatedAt int64 `json:"created_at"`
|
CreatedAt int64 `json:"created_at"`
|
||||||
LastLoginAt *int64 `json:"last_login_at"`
|
LastLoginAt *int64 `json:"last_login_at"`
|
||||||
Companies []*CompanySummary `json:"companies"`
|
Companies []*CompanySummary `json:"companies"`
|
||||||
DeviceToken []string `json:"-"`
|
Keywords []string `json:"keywords"`
|
||||||
|
KeywordsStatus string `json:"keywords_status"`
|
||||||
|
DeviceToken []string `json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ToResponse converts Customer to CustomerResponse
|
// ToResponse converts Customer to CustomerResponse
|
||||||
func (c *Customer) ToResponse(companies []*CompanySummary) *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{
|
return &CustomerResponse{
|
||||||
ID: c.ID.Hex(),
|
ID: c.ID.Hex(),
|
||||||
FullName: c.FullName,
|
FullName: c.FullName,
|
||||||
Username: c.Username,
|
Username: c.Username,
|
||||||
Email: c.Email,
|
Email: c.Email,
|
||||||
Type: string(c.Type),
|
Type: string(c.Type),
|
||||||
Status: string(c.Status),
|
Status: string(c.Status),
|
||||||
Role: string(c.Role),
|
Role: string(c.Role),
|
||||||
Phone: c.Phone,
|
Phone: c.Phone,
|
||||||
UpdatedAt: c.UpdatedAt,
|
UpdatedAt: c.UpdatedAt,
|
||||||
CreatedAt: c.CreatedAt,
|
CreatedAt: c.CreatedAt,
|
||||||
LastLoginAt: c.LastLoginAt,
|
LastLoginAt: c.LastLoginAt,
|
||||||
Companies: companies,
|
Companies: companies,
|
||||||
|
Keywords: keywords,
|
||||||
|
KeywordsStatus: keywordsStatus,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"tm/internal/company"
|
"tm/internal/company"
|
||||||
|
"tm/pkg/glm"
|
||||||
"tm/pkg/logger"
|
"tm/pkg/logger"
|
||||||
"tm/pkg/notification"
|
"tm/pkg/notification"
|
||||||
"tm/pkg/redis"
|
"tm/pkg/redis"
|
||||||
@@ -61,10 +62,11 @@ type customerService struct {
|
|||||||
redisClient redis.Client
|
redisClient redis.Client
|
||||||
notification notification.SDK
|
notification notification.SDK
|
||||||
validator ValidationService
|
validator ValidationService
|
||||||
|
glmSDK *glm.SDK
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new customer service
|
// 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{
|
return &customerService{
|
||||||
repository: repository,
|
repository: repository,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
@@ -73,6 +75,7 @@ func NewService(repository Repository, logger logger.Logger, authService authori
|
|||||||
redisClient: redisClient,
|
redisClient: redisClient,
|
||||||
notification: notificationSDK,
|
notification: notificationSDK,
|
||||||
validator: validator,
|
validator: validator,
|
||||||
|
glmSDK: glmSDK,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,6 +130,21 @@ func (s *customerService) Register(ctx context.Context, form *CreateCustomerForm
|
|||||||
Phone: form.Phone,
|
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
|
// Save to database
|
||||||
err = s.repository.Register(ctx, customer)
|
err = s.repository.Register(ctx, customer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -335,7 +353,24 @@ func (s *customerService) Update(ctx context.Context, id string, form *UpdateCus
|
|||||||
|
|
||||||
// Handle company assignments
|
// Handle company assignments
|
||||||
companies := make([]*CompanySummary, 0)
|
companies := make([]*CompanySummary, 0)
|
||||||
|
companiesChanged := false
|
||||||
if len(form.Companies) > 0 {
|
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)
|
companies, err = s.loadCompaniesForCustomer(ctx, customer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.logger.Error("Failed to load companies for customer", map[string]interface{}{
|
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
|
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
|
customer.Phone = form.Phone
|
||||||
|
infoChanged = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update role if provided
|
// Update role if provided
|
||||||
if form.Role != "" {
|
if form.Role != "" && customer.Role != CustomerRole(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
|
// Update in database
|
||||||
@@ -1244,3 +1363,92 @@ func (s *customerService) AssignRole(ctx context.Context, customerID string, for
|
|||||||
Success: true,
|
Success: true,
|
||||||
}, nil
|
}, 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
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ type SearchForm struct {
|
|||||||
CountryCodes []string `query:"country_codes" valid:"optional"`
|
CountryCodes []string `query:"country_codes" valid:"optional"`
|
||||||
RegionCodes []string `query:"region_codes" valid:"optional"`
|
RegionCodes []string `query:"region_codes" valid:"optional"`
|
||||||
CompanyID *string `query:"company_id" valid:"optional"`
|
CompanyID *string `query:"company_id" valid:"optional"`
|
||||||
|
CustomerID *string `query:"customer_id" valid:"optional"`
|
||||||
Status []string `query:"status" valid:"optional"`
|
Status []string `query:"status" valid:"optional"`
|
||||||
Classifications []string `query:"classifications" valid:"optional"`
|
Classifications []string `query:"classifications" valid:"optional"`
|
||||||
MinEstimatedValue *float64 `query:"min_estimated_value" valid:"optional"`
|
MinEstimatedValue *float64 `query:"min_estimated_value" valid:"optional"`
|
||||||
|
|||||||
@@ -216,13 +216,19 @@ func (h *TenderHandler) GetPublicTenders(c echo.Context) error {
|
|||||||
|
|
||||||
form.Status = []string{string(TenderStatusActive)}
|
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
|
var companyID *string
|
||||||
if customerCompanyID, ok := c.Get("company_id").(string); ok {
|
if customerCompanyID, ok := c.Get("company_id").(string); ok {
|
||||||
companyID = &customerCompanyID
|
companyID = &customerCompanyID
|
||||||
}
|
}
|
||||||
form.CompanyID = companyID
|
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))
|
result, err := h.service.Search(c.Request().Context(), form, response.NewPagination(c))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return response.InternalServerError(c, "Failed to retrieve tenders")
|
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 {
|
if customerCompanyID, ok := c.Get("company_id").(string); ok {
|
||||||
companyID = &customerCompanyID
|
companyID = &customerCompanyID
|
||||||
}
|
}
|
||||||
|
|
||||||
form.CompanyID = companyID
|
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))
|
result, err := h.service.Recommend(c.Request().Context(), form, response.NewPagination(c))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return response.InternalServerError(c, "Failed to retrieve tenders")
|
return response.InternalServerError(c, "Failed to retrieve tenders")
|
||||||
|
|||||||
+86
-24
@@ -3,8 +3,10 @@ package tender
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
"tm/internal/company"
|
"tm/internal/company"
|
||||||
|
"tm/internal/customer"
|
||||||
"tm/pkg/logger"
|
"tm/pkg/logger"
|
||||||
"tm/pkg/response"
|
"tm/pkg/response"
|
||||||
)
|
)
|
||||||
@@ -26,17 +28,19 @@ type Service interface {
|
|||||||
|
|
||||||
// tenderService implements TenderService interface
|
// tenderService implements TenderService interface
|
||||||
type tenderService struct {
|
type tenderService struct {
|
||||||
repository TenderRepository
|
repository TenderRepository
|
||||||
companyService company.Service
|
companyService company.Service
|
||||||
logger logger.Logger
|
customerService customer.Service
|
||||||
|
logger logger.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewService creates a new tender service
|
// 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{
|
return &tenderService{
|
||||||
repository: repository,
|
repository: repository,
|
||||||
companyService: companyService,
|
companyService: companyService,
|
||||||
logger: logger,
|
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{}{
|
s.logger.Info("Retrieved document summaries for tender", map[string]interface{}{
|
||||||
"tender_id": id,
|
"tender_id": id,
|
||||||
"summaries_count": len(tender.DocumentSummaries),
|
"summaries_count": len(tender.DocumentSummaries),
|
||||||
})
|
})
|
||||||
|
|
||||||
return tender.DocumentSummaries, nil
|
return tender.DocumentSummaries, nil
|
||||||
@@ -166,13 +170,41 @@ func (s *tenderService) GetDocumentSummaries(ctx context.Context, id string) ([]
|
|||||||
// ListTenders retrieves tenders with pagination and filtering
|
// ListTenders retrieves tenders with pagination and filtering
|
||||||
func (s *tenderService) Search(ctx context.Context, form *SearchForm, pagination *response.Pagination) (*SearchResponse, error) {
|
func (s *tenderService) Search(ctx context.Context, form *SearchForm, pagination *response.Pagination) (*SearchResponse, error) {
|
||||||
s.logger.Info("Listing tenders", map[string]interface{}{
|
s.logger.Info("Listing tenders", map[string]interface{}{
|
||||||
"limit": pagination.Limit,
|
"limit": pagination.Limit,
|
||||||
"offset": pagination.Offset,
|
"offset": pagination.Offset,
|
||||||
"company_id": form.CompanyID,
|
"company_id": form.CompanyID,
|
||||||
"from": form.DeadlineFrom,
|
"customer_id": form.CustomerID,
|
||||||
"to": form.DeadlineTo,
|
"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)
|
tenders, total, err := s.repository.Search(ctx, form, pagination)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.logger.Error("Failed to list tenders", map[string]interface{}{
|
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
|
// Recommend retrieves tenders with pagination and filtering
|
||||||
func (s *tenderService) Recommend(ctx context.Context, form *SearchForm, pagination *response.Pagination) (*SearchResponse, error) {
|
func (s *tenderService) Recommend(ctx context.Context, form *SearchForm, pagination *response.Pagination) (*SearchResponse, error) {
|
||||||
s.logger.Info("Recommend tenders", map[string]interface{}{
|
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)
|
form.Status = []string{string(TenderStatusActive)}
|
||||||
if err != nil {
|
|
||||||
s.logger.Error("Failed to get company", map[string]interface{}{
|
// Priority: Use customer keywords if available, otherwise use company CPV codes
|
||||||
"company_id": form.CompanyID,
|
if form.CustomerID != nil && *form.CustomerID != "" {
|
||||||
"error": err.Error(),
|
customerResp, err := s.customerService.GetByID(ctx, *form.CustomerID)
|
||||||
})
|
if err == nil && customerResp != nil {
|
||||||
return nil, fmt.Errorf("failed to get company: %w", err)
|
// 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)}
|
// Fallback to company CPV codes if no customer keywords
|
||||||
form.Classifications = company.Tags.CPVCodes
|
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)
|
tenders, total, err := s.repository.Search(ctx, form, pagination)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user