diff --git a/Makefile b/Makefile index 782b605..786feb4 100644 --- a/Makefile +++ b/Makefile @@ -37,3 +37,6 @@ generatemocks: cd $(CURDIR)/service/slack && minimock -i ISlackService -s _mock.go -o $(CURDIR)/mocks cd $(CURDIR)/service/incident && minimock -i IIncidentService -s _mock.go -o $(CURDIR)/mocks cd $(CURDIR)/repository/rca && minimock -i IRcaRepository -s _mock.go -o $(CURDIR)/mocks + cd $(CURDIR)/pkg/documentService && minimock -i ServiceActions -s _mock.go -o $(CURDIR)/mocks + cd $(CURDIR)/repository/rcaInput && minimock -i IRcaInputRepository -s _mock.go -o $(CURDIR)/mocks + cd $(CURDIR)/model/user && minimock -i IUserRepositoryInterface -s _mock.go -o $(CURDIR)/mocks diff --git a/cmd/app/handler/slack_handler.go b/cmd/app/handler/slack_handler.go index 577a10b..c36d865 100644 --- a/cmd/app/handler/slack_handler.go +++ b/cmd/app/handler/slack_handler.go @@ -14,8 +14,13 @@ import ( "houston/model/tag" "houston/model/team" "houston/model/user" + "houston/pkg/rest" "houston/pkg/slackbot" + rcaRepository "houston/repository/rca/impl" + "houston/repository/rcaInput" + "houston/service/documentService" incidentServiceV2 "houston/service/incident" + "houston/service/rca" slack2 "houston/service/slack" "github.com/slack-go/slack" @@ -52,6 +57,12 @@ func NewSlackHandler(gormClient *gorm.DB, socketModeClient *socketmode.Client) * diagnosticCommandProcessor := processor.NewDiagnosticCommandProcessor(socketModeClient, grafanaRepository) incidentServiceV2 := incidentServiceV2.NewIncidentServiceV2(gormClient) slackService := slack2.NewSlackService() + // new services + rcaRepository := rcaRepository.NewRcaRepository(gormClient) + rcaInputRepository := rcaInput.NewRcaInputRepository(gormClient) + restClient := rest.NewClientActionsImpl() + documentService := documentService.NewActionsImpl(restClient) + rcaService := rca.NewRcaService(incidentServiceV2, slackService, restClient, documentService, rcaRepository, rcaInputRepository, userService) cron.RunJob( socketModeClient, @@ -73,7 +84,7 @@ func NewSlackHandler(gormClient *gorm.DB, socketModeClient *socketmode.Client) * socketModeClient, incidentService, teamService, severityService, ), blockActionProcessor: processor.NewBlockActionProcessor( - socketModeClient, incidentService, teamService, severityService, tagService, slackbotClient, incidentServiceV2, slackService, + socketModeClient, incidentService, teamService, severityService, tagService, slackbotClient, incidentServiceV2, slackService, rcaService, ), viewSubmissionProcessor: processor.NewViewSubmissionProcessor( socketModeClient, incidentService, teamService, severityService, tagService, teamService, slackbotClient, gormClient, diff --git a/cmd/app/server.go b/cmd/app/server.go index 5c84054..d759887 100644 --- a/cmd/app/server.go +++ b/cmd/app/server.go @@ -11,9 +11,13 @@ import ( "houston/internal/metrics" "houston/logger" "houston/model/ingester" + "houston/model/user" + "houston/pkg/rest" "houston/pkg/slackbot" rcaRepository "houston/repository/rca/impl" + "houston/repository/rcaInput" "houston/service" + "houston/service/documentService" "houston/service/incident" "houston/service/rca" "houston/service/slack" @@ -158,7 +162,11 @@ func (s *Server) rcaHandler(houstonGroup *gin.RouterGroup) { slackService := slack.NewSlackService() incidentServiceV2 := incident.NewIncidentServiceV2(s.db) rcaRepository := rcaRepository.NewRcaRepository(s.db) - rcaService := rca.NewRcaService(incidentServiceV2, slackService, rcaRepository) + rcaInputRepository := rcaInput.NewRcaInputRepository(s.db) + restClient := rest.NewClientActionsImpl() + documentService := documentService.NewActionsImpl(restClient) + userRepository := user.NewUserRepository(s.db) + rcaService := rca.NewRcaService(incidentServiceV2, slackService, restClient, documentService, rcaRepository, rcaInputRepository, userRepository) rcaHandler := handler.NewRcaHandler(s.gin, rcaService) houstonGroup.POST("/rca", rcaHandler.HandlePostRca) diff --git a/common/util/constant.go b/common/util/constant.go index adc29dd..58b9b12 100644 --- a/common/util/constant.go +++ b/common/util/constant.go @@ -63,16 +63,25 @@ const ( ) const ( - DocumentServiceFlowId = "HOUSTON_KRAKATOA_GRAFANA_IMAGE" + DocumentServiceFlowIdForSlack = "HOUSTON_GEN_AI_SLACK" + DocumentServiceFlowIdForTranscript = "HOUSTON_GEN_AI_TRANSCRIPT" ) const ( DocumentServicePNGFileType = "PNG" DocumentServiceDOCFileType = "DOC" DocumentServiceDOCXFileType = "DOCX" + DocumentServiceJSONFileType = "JSON" + SlackConversationDataType = "SLACK" ) const ( RcaStatusSuccess = "SUCCESS" RcaStatusFailure = "FAILURE" + RcaStatusPending = "PENDING" +) + +// service names for uploading documents to cloud +const ( + DocumentServiceProvider = "SA_DOCUMENT_SERVICE" ) diff --git a/db/migration/000007_add_rca_input_schema.up.sql b/db/migration/000007_add_rca_input_schema.up.sql new file mode 100644 index 0000000..02fca4b --- /dev/null +++ b/db/migration/000007_add_rca_input_schema.up.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS rca_input ( + id SERIAL PRIMARY KEY, + incident_id integer NOT NULL REFERENCES incident(id), + document_type character varying(100), + identifier_keys text[] DEFAULT '{}'::text[], + provider_name character varying(100), + is_active boolean DEFAULT true, + created_at timestamp without time zone, + updated_at timestamp without time zone, + deleted_at timestamp without time zone +); \ No newline at end of file diff --git a/internal/processor/action/incident_resolve_action.go b/internal/processor/action/incident_resolve_action.go index 237813c..d511f18 100644 --- a/internal/processor/action/incident_resolve_action.go +++ b/internal/processor/action/incident_resolve_action.go @@ -2,13 +2,14 @@ package action import ( "fmt" + "github.com/spf13/viper" "houston/common/util" "houston/logger" "houston/model/incident" "houston/model/severity" "houston/model/tag" "houston/model/team" - "strings" + "houston/service/rca" "time" "github.com/slack-go/slack" @@ -22,15 +23,17 @@ type ResolveIncidentAction struct { tagService *tag.Repository teamRepository *team.Repository severityRepository *severity.Repository + rcaService *rca.RcaService } -func NewIncidentResolveProcessor(client *socketmode.Client, incidentService *incident.Repository, tagService *tag.Repository, teamRepository *team.Repository, severityRepository *severity.Repository) *ResolveIncidentAction { +func NewIncidentResolveProcessor(client *socketmode.Client, incidentService *incident.Repository, tagService *tag.Repository, teamRepository *team.Repository, severityRepository *severity.Repository, rcaService *rca.RcaService) *ResolveIncidentAction { return &ResolveIncidentAction{ client: client, incidentService: incidentService, tagService: tagService, teamRepository: teamRepository, severityRepository: severityRepository, + rcaService: rcaService, } } @@ -94,16 +97,6 @@ func (irp *ResolveIncidentAction) IncidentResolveProcess(callback slack.Interact return } - // check if resolution text is set - if strings.TrimSpace(incidentEntity.RCA) == "" { - msgOption := slack.MsgOptionText(fmt.Sprintf("`RCA is not written`"), false) - _, errMessage := irp.client.PostEphemeral(callback.Channel.ID, callback.User.ID, msgOption) - if errMessage != nil { - logger.Error("post response failed for set RCA", zap.Error(errMessage)) - return - } - } - logger.Info("successfully resolved the incident", zap.String("channel", channelId), zap.String("user_id", callback.User.ID)) @@ -125,6 +118,21 @@ func (irp *ResolveIncidentAction) IncidentResolveProcess(callback slack.Interact logger.Error("failed to post archiving time to incident channel", zap.String("channel id", channelId), zap.Error(err)) } } + if viper.GetBool("RCA_GENERATION_ENABLED") { + err = irp.rcaService.SendConversationDataForGeneratingRCA(incidentEntity.ID, channelId) + if err != nil { + logger.Error(fmt.Sprintf("failed to generate rca for incident id: %d of channel id: %s", incidentEntity.ID, channelId), zap.Error(err)) + _, _, errMessage := irp.client.PostMessage(channelId, slack.MsgOptionText("`Some issue occurred while generating RCA`", false)) + if errMessage != nil { + logger.Error("post response failed for rca failure message", zap.Error(errMessage)) + } + } else { + _, _, errMessage := irp.client.PostMessage(channelId, slack.MsgOptionText("System RCA generation is in progress and might take 2 to 4 minutes.", false)) + if errMessage != nil { + logger.Error("post response failed for rca generated message", zap.Error(errMessage)) + } + } + } }() } else { msgOption := slack.MsgOptionText(fmt.Sprintf("`Please set tag value`"), false) diff --git a/internal/processor/event_type_interactive_processor.go b/internal/processor/event_type_interactive_processor.go index 15c5198..ae583ac 100644 --- a/internal/processor/event_type_interactive_processor.go +++ b/internal/processor/event_type_interactive_processor.go @@ -12,6 +12,7 @@ import ( "houston/model/team" "houston/pkg/slackbot" incidentService "houston/service/incident" + "houston/service/rca" slack2 "houston/service/slack" "github.com/slack-go/slack" @@ -52,6 +53,7 @@ func NewBlockActionProcessor( slackbotClient *slackbot.Client, incidentServiceV2 *incidentService.IncidentServiceV2, slackService *slack2.SlackService, + rcaService *rca.RcaService, ) *BlockActionProcessor { return &BlockActionProcessor{ @@ -61,7 +63,7 @@ func NewBlockActionProcessor( showIncidentsAction: action.ShowIncidentsBlockAction(socketModeClient, teamService), assignIncidentAction: action.NewAssignIncidentAction(socketModeClient, incidentRepository), incidentResolveAction: action.NewIncidentResolveProcessor(socketModeClient, incidentRepository, - tagService, teamService, severityService), + tagService, teamService, severityService, rcaService), incidentUpdateAction: action.NewIncidentUpdateAction(socketModeClient, incidentRepository, tagService, teamService, severityService), incidentUpdateTypeAction: action.NewIncidentUpdateTypeAction(socketModeClient, incidentRepository, diff --git a/model/rcaInput/entity.go b/model/rcaInput/entity.go new file mode 100644 index 0000000..884f035 --- /dev/null +++ b/model/rcaInput/entity.go @@ -0,0 +1,19 @@ +package rcaInput + +import ( + "github.com/lib/pq" + "gorm.io/gorm" +) + +type RcaInputEntity struct { + gorm.Model + IncidentID uint `gorm:"column:incident_id;index"` + DocumentType string `gorm:"column:document_type"` + IdentifierKeys pq.StringArray `gorm:"column:identifier_keys;type:text[]"` + ProviderName string `gorm:"column:provider_name"` + IsActive bool `gorm:"column:is_active;default:true"` +} + +func (RcaInputEntity) TableName() string { + return "rca_input" +} diff --git a/model/user/user_repository_interface.go b/model/user/user_repository_interface.go new file mode 100644 index 0000000..e5415e0 --- /dev/null +++ b/model/user/user_repository_interface.go @@ -0,0 +1,12 @@ +package user + +type IUserRepositoryInterface interface { + InsertHoustonUsers(users []UserEntity) error + UpdateHoustonUser(user UserEntity) error + GetAllHoustonUsers() (*[]UserEntity, error) + IsAHoustonUser(nameOrSlackUserId string) (bool, *UserEntity) + FindHoustonUserBySlackUserId(slackUserId string) (*UserEntity, error) + InsertHoustonUser(user *UserEntity) error + GetHoustonUsersBySlackId(slackUserId []string) (*[]UserEntity, error) + GetAllActiveHoustonUserBots() ([]UserEntity, error) +} diff --git a/repository/rca/impl/rca_repository.go b/repository/rca/impl/rca_repository.go index a22426c..b8a6ce8 100644 --- a/repository/rca/impl/rca_repository.go +++ b/repository/rca/impl/rca_repository.go @@ -39,3 +39,11 @@ func (r *Repository) UpdateRca(rcaEntity *rca.RcaEntity) error { return nil } + +func (r *Repository) CreateRca(rcaEntity *rca.RcaEntity) error { + result := r.gormClient.Create(rcaEntity) + if result.Error != nil { + return result.Error + } + return nil +} diff --git a/repository/rca/rca_repository_interface.go b/repository/rca/rca_repository_interface.go index bae4f10..c3e0977 100644 --- a/repository/rca/rca_repository_interface.go +++ b/repository/rca/rca_repository_interface.go @@ -5,4 +5,5 @@ import "houston/model/rca" type IRcaRepository interface { FetchRcaByIncidentId(incidentID uint) (*rca.RcaEntity, error) UpdateRca(rcaEntity *rca.RcaEntity) error + CreateRca(rcaEntity *rca.RcaEntity) error } diff --git a/repository/rcaInput/rac_input_repository.go b/repository/rcaInput/rac_input_repository.go new file mode 100644 index 0000000..d847b31 --- /dev/null +++ b/repository/rcaInput/rac_input_repository.go @@ -0,0 +1,24 @@ +package rcaInput + +import ( + "gorm.io/gorm" + "houston/model/rcaInput" +) + +type Repository struct { + gormClient *gorm.DB +} + +func NewRcaInputRepository(gormClient *gorm.DB) *Repository { + return &Repository{ + gormClient: gormClient, + } +} + +func (r *Repository) CreateRcaInput(rcaInputEntity *rcaInput.RcaInputEntity) error { + result := r.gormClient.Create(rcaInputEntity) + if result.Error != nil { + return result.Error + } + return nil +} diff --git a/repository/rcaInput/rca_input_repository_interface.go b/repository/rcaInput/rca_input_repository_interface.go new file mode 100644 index 0000000..6d27d91 --- /dev/null +++ b/repository/rcaInput/rca_input_repository_interface.go @@ -0,0 +1,7 @@ +package rcaInput + +import "houston/model/rcaInput" + +type IRcaInputRepository interface { + CreateRcaInput(rcaInputEntity *rcaInput.RcaInputEntity) error +} diff --git a/service/rca/rca_service.go b/service/rca/rca_service.go index 6c71744..54d17c6 100644 --- a/service/rca/rca_service.go +++ b/service/rca/rca_service.go @@ -1,32 +1,56 @@ package rca import ( + "bytes" "encoding/json" "errors" "fmt" + "github.com/spf13/viper" "go.uber.org/zap" "houston/common/util" "houston/logger" - "houston/repository/rca" + "houston/model" + "houston/model/rca" + "houston/model/rcaInput" + "houston/model/user" + "houston/pkg/documentService" + "houston/pkg/rest" + rcaRepository "houston/repository/rca" + rcaInputRepository "houston/repository/rcaInput" "houston/service/incident" service "houston/service/request" + response "houston/service/response" + common "houston/service/response/common" slackService "houston/service/slack" + "io" + "net/http" + "regexp" "time" ) type RcaService struct { - incidentService incident.IIncidentService - slackService slackService.ISlackService - rcaRepository rca.IRcaRepository + incidentService incident.IIncidentService + slackService slackService.ISlackService + restClient rest.ClientActions + documentService documentService.ServiceActions + rcaRepository rcaRepository.IRcaRepository + rcaInputRepository rcaInputRepository.IRcaInputRepository + userRepository user.IUserRepositoryInterface } const logTag = "[post-rca]" +const SlackIdMentionPatternString = `<@(\w+)>` -func NewRcaService(incidentService incident.IIncidentService, slackService slackService.ISlackService, rcaRepository rca.IRcaRepository) *RcaService { +func NewRcaService(incidentService incident.IIncidentService, slackService slackService.ISlackService, + restClient rest.ClientActions, documentService documentService.ServiceActions, rcaRepository rcaRepository.IRcaRepository, rcaInputRepository rcaInputRepository.IRcaInputRepository, userRepository user.IUserRepositoryInterface) *RcaService { return &RcaService{ - incidentService: incidentService, - slackService: slackService, - rcaRepository: rcaRepository, + incidentService: incidentService, + slackService: slackService, + restClient: restClient, + documentService: documentService, + rcaRepository: rcaRepository, + rcaInputRepository: rcaInputRepository, + userRepository: userRepository, } } @@ -81,7 +105,7 @@ func (r *RcaService) PostRcaToIncidentChannel(postRcaRequest service.PostRcaRequ } _, err = r.slackService.PostMessageByChannelID( - fmt.Sprintf("This incident’s generated RCA can be viewed *<%s|here>*", successData.RcaLink), + fmt.Sprintf(" System generated *DRAFT* version of RCA for this incident can be viewed *<%s|here>*", successData.RcaLink), false, incidentEntity.SlackChannel, ) @@ -123,7 +147,7 @@ func (r *RcaService) PostRcaToIncidentChannel(postRcaRequest service.PostRcaRequ } _, err = r.slackService.PostMessageByChannelID( - "Some issue occurred while generating RCA", + "`Some issue occurred while generating RCA`", false, incidentEntity.SlackChannel, ) @@ -143,3 +167,222 @@ func (r *RcaService) PostRcaToIncidentChannel(postRcaRequest service.PostRcaRequ return nil } + +func (r *RcaService) SendConversationDataForGeneratingRCA(incidentId uint, channelId string) error { + logger.Info(fmt.Sprintf("Sending conversation data for generating RCA for incident id %d", incidentId)) + slackUrl, identifierKey, err := r.uploadSlackConversationHistory(channelId, incidentId) + if err != nil { + logger.Error(fmt.Sprintf("Error while uploading slack conversation of channel id %s to cloud", channelId)) + return err + } + // TO DO : Get google meet transcript urls + transcriptUrls := []string{} + + // save to database + rcaInputEntity := rcaInput.RcaInputEntity{ + IncidentID: incidentId, + DocumentType: util.SlackConversationDataType, + IdentifierKeys: []string{identifierKey}, + ProviderName: util.DocumentServiceProvider, + } + err = r.rcaInputRepository.CreateRcaInput(&rcaInputEntity) + if err != nil { + logger.Error(fmt.Sprintf("Error while saving rca input to db for incident id: %d", incidentId), zap.Error(err)) + return err + } + _, err = r.rcaRepository.FetchRcaByIncidentId(incidentId) + if err != nil { + createErr := r.rcaRepository.CreateRca(&rca.RcaEntity{ + IncidentID: incidentId, + Status: util.RcaStatusPending, + }) + if createErr != nil { + logger.Error(fmt.Sprintf("Error while creating rca entity for incident id: %d", incidentId), zap.Error(createErr)) + return createErr + } + } + + err = r.uploadConversationURLs(incidentId, slackUrl, transcriptUrls) + if err != nil { + logger.Error("Error while sending urls to GenAI", zap.Error(err)) + return err + } + logger.Info(fmt.Sprintf("Successfully sent conversation data for generating RCA for incident id %d", incidentId)) + return nil +} +func (r *RcaService) uploadSlackConversationHistory(channelId string, incidentId uint) (string, string, error) { + const emptyString = "" + + conversations, slackError := r.slackService.GetSlackConversationHistoryWithReplies(channelId) + if slackError != nil { + logger.Error(fmt.Sprintf("%s Error while fetching slack conversation history for channel id %s", logTag, channelId)) + return emptyString, emptyString, slackError + } + extraConversation, err := r.getExtraConversationData(incidentId) + if err != nil { + logger.Error(fmt.Sprintf("%s Error while fetching extra conversation data for incident id %d", logTag, incidentId)) + } else { + conversations = append(conversations, *extraConversation) + } + // Replace user ids with user names + userIDtoNameMap := make(map[string]string) + r.extractUserIDs(&conversations, userIDtoNameMap) + if len(userIDtoNameMap) > 0 { + var slackUserIds []string + for slackUserId, _ := range userIDtoNameMap { + slackUserIds = append(slackUserIds, slackUserId) + } + + users2, err := r.userRepository.GetHoustonUsersBySlackId(slackUserIds) + if err != nil { + logger.Error(fmt.Sprintf("%s Error while fetching houston users info for slack user ids %s", logTag, slackUserIds)) + return emptyString, emptyString, err + } + + for _, currentUser := range *users2 { + userIDtoNameMap[currentUser.SlackUserId] = currentUser.RealName + } + r.replaceUsernames(&conversations, userIDtoNameMap) + } + + jsonResponse := common.SuccessResponse(conversations, http.StatusOK) + jsonData, marshalError := json.Marshal(jsonResponse) + if marshalError != nil { + logger.Error(fmt.Sprintf("%s Error while marshalling slack conversation history for channel id %s", logTag, channelId)) + return emptyString, emptyString, marshalError + } + // getting url for uploading file + fileUploadResponse, err := r.documentService.GenerateFileUploadPreSignedURL(util.DocumentServiceJSONFileType, util.DocumentServiceFlowIdForSlack) + if err != nil { + logger.Error("Error generating pre-signed URL", zap.Error(err)) + return emptyString, emptyString, err + } + // uploading file with the url + var buffer bytes.Buffer + buffer.Write(jsonData) + uploadError := r.documentService.UploadFileWithPreSignedURL(fileUploadResponse, channelId, &buffer, util.ContentTypeJSON) + if uploadError != nil { + logger.Error("Error uploading file", zap.Error(uploadError)) + return emptyString, emptyString, uploadError + } + // getting url for downloading file + downloadURLRequest := model.FileDownloadPreSignedURLRequest{ + FlowId: util.DocumentServiceFlowIdForSlack, + IdentifierKey: fileUploadResponse.FormData.Key, + } + downloadUrl, err := r.documentService.GenerateFileDownloadPreSignedURL(downloadURLRequest) + if err != nil { + logger.Error("Error generating downloadable URL", zap.Error(err)) + return emptyString, emptyString, err + } + + return downloadUrl, fileUploadResponse.FormData.Key, nil +} + +func (r *RcaService) extractUserIDs(conversations *[]response.ConversationResponse, userIDtoNameMap map[string]string) { + idPattern := regexp.MustCompile(SlackIdMentionPatternString) + + for _, conversation := range *conversations { + slackId := conversation.UserName + if _, ok := userIDtoNameMap[slackId]; !ok { + userIDtoNameMap[slackId] = slackId + } + matches := idPattern.FindAllStringSubmatch(conversation.Text, -1) // '-1' signifies to return all matches + if len(matches) > 0 { + for _, match := range matches { + if _, ok := userIDtoNameMap[match[1]]; !ok { + userIDtoNameMap[match[1]] = "" + } + } + } + r.extractUserIDs(&conversation.Replies, userIDtoNameMap) + } +} +func (r *RcaService) replaceUsernames(conversations *[]response.ConversationResponse, userIDtoNameMap map[string]string) { + idPattern := regexp.MustCompile(SlackIdMentionPatternString) + + for index := range *conversations { + conversation := &(*conversations)[index] + if name, ok := userIDtoNameMap[conversation.UserName]; ok { + conversation.UserName = name + } + conversation.Text = idPattern.ReplaceAllStringFunc(conversation.Text, func(match string) string { + userID := idPattern.FindStringSubmatch(match)[1] + if name, ok := userIDtoNameMap[userID]; ok { + return name + } + return match + }) + r.replaceUsernames(&conversation.Replies, userIDtoNameMap) + } +} + +func (r *RcaService) uploadConversationURLs(incidentId uint, slackUrl string, transcriptUrls []string) error { + baseURL := viper.GetString("MAVERICK_BASE_URL") + fullURL := baseURL + viper.GetString("MAVERICK_CONFIGURATION_URL") + requestHeaders := map[string]string{ + "X-Correlation-Id": viper.GetString("MAVERICK_CORRELATION_ID"), + "Authorization": viper.GetString("MAVERICK_AUTHORIZATION_TOKEN"), + "Content-Type": util.ContentTypeJSON, + } + timeOut := viper.GetDuration("DEFAULT_MAVERICK_TIMEOUT") + genAiRequestPayload := service.GenAiRequest{ + Inputs: []service.Input{ + { + ToolName: viper.GetString("MAVERICK_TOOL_NAME"), + ToolParametersRequest: service.ToolParametersRequest{ + IncidentId: incidentId, + SlackConversationDocUrl: slackUrl, + GoogleMeetTranscriptUrls: transcriptUrls, + }, + }, + }, + } + payload, marshalError := json.Marshal(genAiRequestPayload) + if marshalError != nil { + logger.Error("Error while marshalling json", zap.Any("payload", payload), zap.Error(marshalError)) + return marshalError + } + + currentResponse, clientErr := r.restClient.PostWithTimeout(fullURL, *bytes.NewBuffer(payload), requestHeaders, timeOut, nil) + if clientErr != nil { + logger.Error("Error while sending to GenAI", zap.Error(clientErr)) + return clientErr + } + if currentResponse.StatusCode != http.StatusOK { + logger.Error(fmt.Sprintf("error while sending payload to GenAI with status code %d", currentResponse.StatusCode)) + return errors.New("error while sending payload to GenAI") + } + + responseBody, err := io.ReadAll(currentResponse.Body) + if err != nil { + logger.Error("error while reading response body", zap.Error(err)) + return err + } + var genAiResponse service.GenAiResponse + err = json.Unmarshal(responseBody, &genAiResponse) + if err != nil { + logger.Error("error while unmarshalling response body", zap.Any("response body", responseBody), zap.Error(err)) + return err + } + if len(genAiResponse.Errors) > 0 { + logger.Error("error occurred in response from GenAI", zap.Any("errors", genAiResponse.Errors)) + return errors.New("error occurred in response from GenAI") + } + return nil +} + +// getting title, description of an incident to be attached to the Slack conversation data +func (r *RcaService) getExtraConversationData(incidentId uint) (*response.ConversationResponse, error) { + incidentEntity, err := r.incidentService.GetIncidentById(incidentId) + if err != nil { + return nil, err + } + timeStamp := fmt.Sprintf("%d", incidentEntity.StartTime.Unix()) + conversation := response.ConversationResponse{ + UserName: viper.GetString("HOUSTON_BOT_NAME"), + Text: fmt.Sprintf("%s and Incident Title: %s , Incident Description: %s", viper.GetString("MAVERICK_PROMPT"), incidentEntity.Title, incidentEntity.Description), + TimeStamp: timeStamp, + } + return &conversation, nil +} diff --git a/service/rca/rca_service_test.go b/service/rca/rca_service_test.go index cbb5cae..cecc664 100644 --- a/service/rca/rca_service_test.go +++ b/service/rca/rca_service_test.go @@ -1,32 +1,45 @@ package rca import ( + "bytes" "encoding/json" "errors" "fmt" + "github.com/spf13/viper" "github.com/stretchr/testify/assert" "gorm.io/gorm" + "houston/common/util" "houston/logger" "houston/mocks" + "houston/model" incident "houston/model/incident" "houston/model/rca" + "houston/model/rcaInput" + "houston/model/user" service "houston/service/request" + response "houston/service/response" + "io" + "net/http" "testing" "time" ) -func setupTest(t *testing.T) (*mocks.IIncidentServiceMock, *mocks.ISlackServiceMock, *mocks.IRcaRepositoryMock, *RcaService) { +func setupTest(t *testing.T) (*mocks.IIncidentServiceMock, *mocks.ISlackServiceMock, *mocks.IRcaRepositoryMock, *RcaService, *mocks.IRcaInputRepositoryMock, *mocks.ServiceActionsMock, *mocks.ClientActionsMock, *mocks.IUserRepositoryInterfaceMock) { logger.InitLogger() incidentService := mocks.NewIIncidentServiceMock(t) slackService := mocks.NewISlackServiceMock(t) rcaRepository := mocks.NewIRcaRepositoryMock(t) - rcaService := NewRcaService(incidentService, slackService, rcaRepository) + rcaInputRepository := mocks.NewIRcaInputRepositoryMock(t) + documentService := mocks.NewServiceActionsMock(t) + restClient := mocks.NewClientActionsMock(t) + userRepository := mocks.NewIUserRepositoryInterfaceMock(t) + rcaService := NewRcaService(incidentService, slackService, restClient, documentService, rcaRepository, rcaInputRepository, userRepository) - return incidentService, slackService, rcaRepository, rcaService + return incidentService, slackService, rcaRepository, rcaService, rcaInputRepository, documentService, restClient, userRepository } func TestRcaService_PostRcaToIncidentChannel_Success(t *testing.T) { - incidentService, slackService, rcaRepository, rcaService := setupTest(t) + incidentService, slackService, rcaRepository, rcaService, _, _, _, _ := setupTest(t) incidentService.GetIncidentByIdMock.When(1).Then(GetMockIncident(), nil) @@ -37,7 +50,7 @@ func TestRcaService_PostRcaToIncidentChannel_Success(t *testing.T) { rcaRepository.UpdateRcaMock.When(testRca).Then(nil) slackService.PostMessageByChannelIDMock.When( - fmt.Sprintf("This incident’s generated RCA can be viewed *<%s|here>*", "test_link"), + fmt.Sprintf(" System generated RCA for this incident can be viewed *<%s|here>*", "test_link"), false, "1234", ).Then("", nil) @@ -53,7 +66,7 @@ func TestRcaService_PostRcaToIncidentChannel_Success(t *testing.T) { } func TestRcaService_PostRcaToIncidentChannel_Failure(t *testing.T) { - incidentService, slackService, rcaRepository, rcaService := setupTest(t) + incidentService, slackService, rcaRepository, rcaService, _, _, _, _ := setupTest(t) incidentService.GetIncidentByIdMock.When(1).Then(GetMockIncident(), nil) @@ -64,7 +77,7 @@ func TestRcaService_PostRcaToIncidentChannel_Failure(t *testing.T) { rcaRepository.UpdateRcaMock.When(testRca).Then(nil) slackService.PostMessageByChannelIDMock.When( - "Some issue occurred while generating RCA", + "`Some issue occurred while generating RCA`", false, "1234", ).Then("", nil) @@ -81,7 +94,7 @@ func TestRcaService_PostRcaToIncidentChannel_Failure(t *testing.T) { } func TestRcaService_PostRcaToIncidentChannel_InvalidIncident(t *testing.T) { - incidentService, _, _, rcaService := setupTest(t) + incidentService, _, _, rcaService, _, _, _, _ := setupTest(t) incidentService.GetIncidentByIdMock.When(1).Then(nil, errors.New("record not found")) @@ -96,7 +109,7 @@ func TestRcaService_PostRcaToIncidentChannel_InvalidIncident(t *testing.T) { } func TestRcaService_PostRcaToIncidentChannel_NonExistingRca(t *testing.T) { - incidentService, _, rcaRepository, rcaService := setupTest(t) + incidentService, _, rcaRepository, rcaService, _, _, _, _ := setupTest(t) incidentService.GetIncidentByIdMock.When(1).Then(GetMockIncident(), nil) @@ -113,7 +126,7 @@ func TestRcaService_PostRcaToIncidentChannel_NonExistingRca(t *testing.T) { } func TestRcaService_PostRcaToIncidentChannel_UpdateFailure(t *testing.T) { - incidentService, _, rcaRepository, rcaService := setupTest(t) + incidentService, _, rcaRepository, rcaService, _, _, _, _ := setupTest(t) incidentService.GetIncidentByIdMock.When(1).Then(GetMockIncident(), nil) @@ -135,7 +148,7 @@ func TestRcaService_PostRcaToIncidentChannel_UpdateFailure(t *testing.T) { } func TestRcaService_PostRcaToIncidentChannel_SlackFailure_RcaSuccessCase(t *testing.T) { - incidentService, slackService, rcaRepository, rcaService := setupTest(t) + incidentService, slackService, rcaRepository, rcaService, _, _, _, _ := setupTest(t) incidentService.GetIncidentByIdMock.When(1).Then(GetMockIncident(), nil) @@ -148,7 +161,7 @@ func TestRcaService_PostRcaToIncidentChannel_SlackFailure_RcaSuccessCase(t *test rcaRepository.UpdateRcaMock.When(testRca).Then(nil) slackService.PostMessageByChannelIDMock.When( - fmt.Sprintf("This incident’s generated RCA can be viewed *<%s|here>*", "test_link"), + fmt.Sprintf(" System generated RCA for this incident can be viewed *<%s|here>*", "test_link"), false, "1234", ).Then("", errors.New("failed to send message")) @@ -163,7 +176,7 @@ func TestRcaService_PostRcaToIncidentChannel_SlackFailure_RcaSuccessCase(t *test } func TestRcaService_PostRcaToIncidentChannel_SlackFailure_RcaFailureCase(t *testing.T) { - incidentService, slackService, rcaRepository, rcaService := setupTest(t) + incidentService, slackService, rcaRepository, rcaService, _, _, _, _ := setupTest(t) incidentService.GetIncidentByIdMock.When(1).Then(GetMockIncident(), nil) @@ -174,7 +187,7 @@ func TestRcaService_PostRcaToIncidentChannel_SlackFailure_RcaFailureCase(t *test rcaRepository.UpdateRcaMock.When(testRca).Then(nil) slackService.PostMessageByChannelIDMock.When( - "Some issue occurred while generating RCA", + "`Some issue occurred while generating RCA`", false, "1234", ).Then("", errors.New("failed to send message")) @@ -190,6 +203,297 @@ func TestRcaService_PostRcaToIncidentChannel_SlackFailure_RcaFailureCase(t *test assert.EqualError(t, err, "Could not post failure message to the given incident channel") } +func TestSendConversationDataForGeneratingRCA(t *testing.T) { + incidentService, slackService, rcaRepository, rcaService, rcaInputRepository, documentService, restClient, userRepository := setupTest(t) + // successfully upload conversation history + testUploadResponse := &model.FileUploadURLGeneratorResponse{ + FormData: model.FormData{Key: "key"}, + Url: "url", + } + incidentService.GetIncidentByIdMock.When(1).Then(GetMockIncident(), nil) + slackService.GetSlackConversationHistoryWithRepliesMock.When("channelID").Then(*getMockConversation(), nil) + userRepository.GetHoustonUsersBySlackIdMock.Inspect(func(slackIds []string) { + assert.Contains(t, slackIds, "user1") + }).Return(getMockUsers(), nil) + + documentService.GenerateFileUploadPreSignedURLMock.When(util.DocumentServiceJSONFileType, util.DocumentServiceFlowIdForSlack).Then(testUploadResponse, nil) + documentService.UploadFileWithPreSignedURLMock.Inspect(func(url *model.FileUploadURLGeneratorResponse, channelID string, file io.Reader, fileType string) { + assert.Equal(t, "url", url.Url) + assert.Equal(t, "channelID", channelID) + }).Return(nil) + documentService.GenerateFileDownloadPreSignedURLMock.When(model.FileDownloadPreSignedURLRequest{ + FlowId: util.DocumentServiceFlowIdForSlack, + IdentifierKey: "key", + }).Then("download_url", nil) + + //successfully uploading conversation urls + setMockEnvironmentalVariables() + genAiHeaders, genAiRequest, genAiResponse := getMockHeadersAndRequestAndResponse(1) + payload, _ := json.Marshal(genAiRequest) + responseBody, _ := json.Marshal(genAiResponse) + + restClient.PostWithTimeoutMock.When("base_urlconfig_url", *bytes.NewBuffer(payload), genAiHeaders, 4*time.Second, nil).Then(&http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewBufferString(string(responseBody))), + }, nil) + + // successfully sending conversations to gen ai + rcaInputRepository.CreateRcaInputMock.Inspect(func(rcaInput *rcaInput.RcaInputEntity) { + assert.Equal(t, uint(1), rcaInput.IncidentID) + }).Return(nil) + rcaRepository.FetchRcaByIncidentIdMock.When(1).Then(nil, nil) + + err := rcaService.SendConversationDataForGeneratingRCA(1, "channelID") + assert.Nil(t, err) +} +func TestUploadSlackConversationHistory(t *testing.T) { + incidentService, slackService, _, rcaService, _, documentService, _, userRepository := setupTest(t) + + //successfully uploading conversation history + testUploadResponse := &model.FileUploadURLGeneratorResponse{ + FormData: model.FormData{Key: "key"}, + Url: "url", + } + incidentService.GetIncidentByIdMock.When(1).Then(GetMockIncident(), nil) + slackService.GetSlackConversationHistoryWithRepliesMock.When("channelID").Then(*getMockConversation(), nil) + userRepository.GetHoustonUsersBySlackIdMock.Inspect(func(slackIds []string) { + assert.Contains(t, slackIds, "user1") + }).Return(getMockUsers(), nil) + + documentService.GenerateFileUploadPreSignedURLMock.When(util.DocumentServiceJSONFileType, util.DocumentServiceFlowIdForSlack).Then(testUploadResponse, nil) + documentService.UploadFileWithPreSignedURLMock.Inspect(func(url *model.FileUploadURLGeneratorResponse, channelID string, file io.Reader, fileType string) { + assert.Equal(t, "url", url.Url) + assert.Equal(t, "channelID", channelID) + }).Return(nil) + documentService.GenerateFileDownloadPreSignedURLMock.When(model.FileDownloadPreSignedURLRequest{ + FlowId: util.DocumentServiceFlowIdForSlack, + IdentifierKey: "key", + }).Then("download_url", nil) + + expectedUrl, expectedKey, expectedErr := rcaService.uploadSlackConversationHistory("channelID", 1) + assert.Nil(t, expectedErr) + assert.Equal(t, "download_url", expectedUrl) + assert.Equal(t, "key", expectedKey) +} + +func TestUploadConversationURLsSuccess(t *testing.T) { + _, _, _, rcaService, _, _, restClient, _ := setupTest(t) + setMockEnvironmentalVariables() + genAiHeaders, genAiRequest, genAiResponse := getMockHeadersAndRequestAndResponse(1) + payload, _ := json.Marshal(genAiRequest) + responseBody, _ := json.Marshal(genAiResponse) + + restClient.PostWithTimeoutMock.When("base_urlconfig_url", *bytes.NewBuffer(payload), genAiHeaders, 4*time.Second, nil).Then(&http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewBufferString(string(responseBody))), + }, nil) + + err := rcaService.uploadConversationURLs(1, "download_url", []string{}) + + assert.Nil(t, err) +} + +func TestUploadConversationURLsFailure(t *testing.T) { + _, _, _, rcaService, _, _, restClient, _ := setupTest(t) + setMockEnvironmentalVariables() + genAiHeaders, genAiRequest, _ := getMockHeadersAndRequestAndResponse(1) + genAiResponse := service.GenAiResponse{ + Errors: []string{"error"}, + } + responseBody, _ := json.Marshal(genAiResponse) + payload, _ := json.Marshal(genAiRequest) + // if status code is not 200 + restClient.PostWithTimeoutMock.When("base_urlconfig_url", *bytes.NewBuffer(payload), genAiHeaders, 4*time.Second, nil).Then(&http.Response{ + StatusCode: http.StatusBadRequest, + }, nil) + + err := rcaService.uploadConversationURLs(1, "download_url", []string{}) + assert.EqualError(t, err, "error while sending payload to GenAI") + // if gen ai response has errors + viper.Set("DEFAULT_MAVERICK_TIMEOUT", 3*time.Second) + restClient.PostWithTimeoutMock.When("base_urlconfig_url", *bytes.NewBuffer(payload), genAiHeaders, 3*time.Second, nil).Then(&http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewBufferString(string(responseBody))), + }, nil) + + err = rcaService.uploadConversationURLs(1, "download_url", []string{}) + assert.EqualError(t, err, "error occurred in response from GenAI") +} +func TestExtractUserIds(t *testing.T) { + _, _, _, rcaService, _, _, _, _ := setupTest(t) + + testConversation := getMockConversation() + testUserIdMap := make(map[string]string) + expectedUserIdMap := getMockUserIdMap() + + rcaService.extractUserIDs(testConversation, testUserIdMap) + + assert.Equal(t, len(expectedUserIdMap), len(testUserIdMap)) + for key, _ := range expectedUserIdMap { + assert.Contains(t, testUserIdMap, key) + } + // if there is no user id in the conversation text + testConversation2 := &[]response.ConversationResponse{ + { + UserName: "User1", + Text: "Hello, user 2 How are you?", + Replies: nil, + }, + } + testUserIdMap2 := make(map[string]string) + + rcaService.extractUserIDs(testConversation2, testUserIdMap2) + + assert.Equal(t, len(testUserIdMap2), 1) +} + +func TestReplaceUserIds(t *testing.T) { + _, _, _, rcaService, _, _, _, _ := setupTest(t) + // replacing user ids successfully + testConversation := getMockConversation() + testUserIdMap := getMockUserIdMap() + expectedConversation1 := getMockReplacedConversation() + + rcaService.replaceUsernames(testConversation, testUserIdMap) + + assert.Equal(t, expectedConversation1, testConversation) + + //if map is empty + testConversation2 := getMockConversation() + expectedConversation2 := getMockConversation() + + rcaService.replaceUsernames(testConversation2, map[string]string{}) + assert.Equal(t, expectedConversation2, testConversation2) + + // if conversation doesn't have any user ids + testConversation3 := getMockReplacedConversation() + rcaService.replaceUsernames(testConversation3, testUserIdMap) + assert.Equal(t, expectedConversation1, testConversation3) +} + +func TestGetExtraConversationData(t *testing.T) { + incidentService, _, _, rcaService, _, _, _, _ := setupTest(t) + incidentService.GetIncidentByIdMock.When(1).Then(&incident.IncidentEntity{StartTime: time.Now()}, nil) + + actualResponse, err := rcaService.getExtraConversationData(1) + assert.Nil(t, err) + assert.NotEmpty(t, actualResponse) + + incidentService.GetIncidentByIdMock.When(2).Then(nil, errors.New("record not found")) + actualResponse, err = rcaService.getExtraConversationData(2) + + assert.EqualError(t, err, "record not found") + assert.Nil(t, actualResponse) +} +func setMockEnvironmentalVariables() { + viper.Set("MAVERICK_BASE_URL", "base_url") + viper.Set("MAVERICK_CONFIGURATION_URL", "config_url") + viper.Set("MAVERICK_AUTHORIZATION_TOKEN", "test_token") + viper.Set("DEFAULT_MAVERICK_TIMEOUT", 4*time.Second) + viper.Set("MAVERICK_TOOL_NAME", "tool_name") + viper.Set("MAVERICK_CORRELATION_ID", "correlation_id") +} +func getMockHeadersAndRequestAndResponse(incidentId uint) (map[string]string, service.GenAiRequest, service.GenAiResponse) { + genAiHeaders := map[string]string{ + "X-Correlation-Id": "correlation_id", + "Authorization": "test_token", + "Content-Type": util.ContentTypeJSON, + } + genAiRequest := service.GenAiRequest{ + Inputs: []service.Input{ + { + ToolName: "tool_name", + ToolParametersRequest: service.ToolParametersRequest{ + IncidentId: incidentId, + SlackConversationDocUrl: "download_url", + GoogleMeetTranscriptUrls: []string{}, + }, + }, + }, + } + genAiResponse := service.GenAiResponse{ + Errors: []string{}, + Data: service.Data{ + Status: "Accepted", + }, + } + return genAiHeaders, genAiRequest, genAiResponse +} +func getMockConversation() *[]response.ConversationResponse { + return &[]response.ConversationResponse{ + { + UserName: "User1", + Text: "Hello, <@user2b7>! How are you?", + Replies: []response.ConversationResponse{ + { + UserName: "User2", + Text: "Hi, <@user1>! I'm doing well.", + }, + { + UserName: "User3", + Text: "Hey there!", + }, + }, + }, + { + UserName: "User4", + Text: "Meeting at 3 PM. <@user3>", + }, + } +} +func getMockUserIdMap() map[string]string { + return map[string]string{ + "user1": "John", + "user2b7": "Alice", + "user3": "Eve", + "User1": "John", + "User2": "User2", + "User3": "Eve", + "User4": "User4", + } +} + +func getMockReplacedConversation() *[]response.ConversationResponse { + return &[]response.ConversationResponse{ + { + UserName: "John", + Text: "Hello, Alice! How are you?", + Replies: []response.ConversationResponse{ + { + UserName: "User2", + Text: "Hi, John! I'm doing well.", + }, + { + UserName: "Eve", + Text: "Hey there!", + }, + }, + }, + { + UserName: "User4", + Text: "Meeting at 3 PM. Eve", + }, + } +} + +func getMockUsers() *[]user.UserEntity { + return &[]user.UserEntity{ + { + SlackUserId: "user1", + RealName: "John", + }, + { + SlackUserId: "user2b7", + RealName: "Alice", + }, + { + SlackUserId: "user3", + RealName: "Eve", + }, + } + +} + func GetMockIncident() *incident.IncidentEntity { return &incident.IncidentEntity{ Model: gorm.Model{}, diff --git a/service/request/post_rca.go b/service/request/post_rca.go index 160b64d..f4d04eb 100644 --- a/service/request/post_rca.go +++ b/service/request/post_rca.go @@ -15,3 +15,24 @@ type SuccessData struct { type FailureData struct { Reason string `json:"reason"` } + +type GenAiRequest struct { + Inputs []Input `json:"input"` +} +type Input struct { + ToolName string `json:"tool_name"` + ToolParametersRequest ToolParametersRequest `json:"tool_parameters"` +} +type ToolParametersRequest struct { + IncidentId uint `json:"incident_id"` + SlackConversationDocUrl string `json:"slack_conversation_doc_url"` + GoogleMeetTranscriptUrls []string `json:"google_meet_transcription_urls"` +} + +type GenAiResponse struct { + Errors []string `json:"errors"` + Data Data `json:"data"` +} +type Data struct { + Status string `json:"status"` +} diff --git a/service/slack/slack_service.go b/service/slack/slack_service.go index d4ce3d0..73bf2e7 100644 --- a/service/slack/slack_service.go +++ b/service/slack/slack_service.go @@ -305,8 +305,11 @@ func (s *SlackService) GetSlackConversationHistoryWithReplies(channelId string) var conversationResponse []service.ConversationResponse for _, message := range messages { - if message.SubType == "" && message.Text != "" { - conversation := convertToConversationResponse(userNamesMap, message) + if message.SubType == "" { + if message.Text == "" && message.ReplyCount == 0 { + continue + } + conversation := convertToConversationResponse(message) if message.ReplyCount > 0 { numberOfReplies := 400 @@ -316,7 +319,7 @@ func (s *SlackService) GetSlackConversationHistoryWithReplies(channelId string) for index, reply := range replies { //neglecting first reply since it is a duplicate of original message if index != 0 { - replyConversation := convertToConversationResponse(userNamesMap, reply) + replyConversation := convertToConversationResponse(reply) conversation.Replies = append(conversation.Replies, replyConversation) } } @@ -390,8 +393,9 @@ func (s *SlackService) GetUsersInfo(users ...string) *[]slack.User { usersInfoResponse, err := s.SocketModeClient.GetUsersInfo(splitUsersList[usersList]...) if err != nil { logger.Error(fmt.Sprintf("%s failed to get users info for [%+v]. %+v", logTag, splitUsersList[usersList], err)) + } else { + usersInfo = append(usersInfo, *usersInfoResponse...) } - usersInfo = append(usersInfo, *usersInfoResponse...) } return &usersInfo @@ -425,13 +429,9 @@ func splitUsers(users []string, chunkSize int) [][]string { } return result } -func convertToConversationResponse(userNamesMap map[string]string, message slack.Message) service.ConversationResponse { - userName, ok := userNamesMap[message.User] - if !ok { - userName = message.User - } +func convertToConversationResponse(message slack.Message) service.ConversationResponse { return service.ConversationResponse{ - UserName: userName, + UserName: message.User, Text: message.Text, TimeStamp: message.Timestamp, }