package service import ( "context" "encoding/json" "errors" "fmt" "strings" "github.com/gochat/gochat/internal/llm" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/repository" "github.com/gochat/gochat/internal/worker" applogger "github.com/gochat/gochat/pkg/logger" ) // CopilotService implements business logic for Copilot (conversation assistance) operations. // Reference: Chatwoot enterprise/app/controllers/api/v1/copilot_threads_controller.rb type CopilotService struct { threadRepo *repository.CopilotThreadRepo messageRepo *repository.CopilotMessageRepo suggestionRepo *repository.CopilotSuggestionRepo assistantRepo *repository.CaptainAssistantRepo llmProvider llm.Provider responseBackend CopilotResponseBackend worker *worker.WorkerPool } type CopilotResponseBackend interface { GenerateCopilotResponse(ctx context.Context, req CopilotResponseRequest) ([]CopilotGeneratedMessage, error) } type CopilotResponseRequest struct { AccountID uint UserID uint ConversationID uint Thread *model.CopilotThread Assistant *model.CaptainAssistant Message string } type CopilotGeneratedMessage struct { MessageType model.CopilotMessageType Message map[string]any } // NewCopilotService creates a new CopilotService. func NewCopilotService( threadRepo *repository.CopilotThreadRepo, messageRepo *repository.CopilotMessageRepo, suggestionRepo *repository.CopilotSuggestionRepo, llmProvider llm.Provider, assistantRepo ...*repository.CaptainAssistantRepo, ) *CopilotService { s := &CopilotService{ threadRepo: threadRepo, messageRepo: messageRepo, suggestionRepo: suggestionRepo, llmProvider: llmProvider, } if len(assistantRepo) > 0 { s.assistantRepo = assistantRepo[0] } return s } func (s *CopilotService) SetWorkerPool(wp *worker.WorkerPool) { s.worker = wp RegisterCopilotResponseJobs(wp, s) } func (s *CopilotService) SetResponseBackend(backend CopilotResponseBackend) { s.responseBackend = backend } const CopilotUnavailableMessage = "You are out of Copilot credits. You can buy more credits from the billing section." // --- Thread CRUD --- // CreateThreadRequest is the DTO for creating a copilot thread. type CreateThreadRequest struct { Title string `json:"title"` Message string `json:"message"` AssistantID uint `json:"assistant_id"` ConversationID uint `json:"conversation_id"` } // CreateThread creates a new CopilotThread for a user. func (s *CopilotService) CreateThread(ctx context.Context, accountID, userID uint, req *CreateThreadRequest) (*model.CopilotThread, error) { message := strings.TrimSpace(req.Message) if message == "" { message = strings.TrimSpace(req.Title) } if message == "" { return nil, errors.New("Message is required") } if req.AssistantID == 0 { return nil, errors.New("assistant_id is required") } if s.assistantRepo != nil { if _, err := s.assistantRepo.GetByAccountAndID(ctx, accountID, req.AssistantID); err != nil { return nil, fmt.Errorf("assistant not found: %w", err) } } assistantID := req.AssistantID thread := &model.CopilotThread{ AccountID: accountID, UserID: userID, AssistantID: &assistantID, Title: message, } if err := s.threadRepo.Create(ctx, thread); err != nil { applogger.L().Errorf("Create copilot thread: %v", err) return nil, fmt.Errorf("create thread: %w", err) } userMsg, err := s.CreateThreadMessage(ctx, accountID, userID, thread.ID, &SendMessageRequest{Content: message, ConversationID: req.ConversationID}) if err != nil { return nil, err } if s.worker != nil { if err := s.enqueueCopilotResponse(ctx, accountID, userID, thread.ID, req.ConversationID, userMsg); err != nil { return nil, err } return s.threadRepo.GetByAccountUserAndID(ctx, accountID, userID, thread.ID) } if _, err := s.createAssistantReply(ctx, accountID, thread.ID, userMsg, req.ConversationID); err != nil { return nil, err } return s.threadRepo.GetByAccountUserAndID(ctx, accountID, userID, thread.ID) } // GetThread retrieves an account/user scoped thread by ID, including its messages. func (s *CopilotService) GetThread(ctx context.Context, accountID, userID, id uint) (*model.CopilotThread, error) { thread, err := s.threadRepo.GetByAccountUserAndID(ctx, accountID, userID, id) if err != nil { applogger.L().Errorf("Get copilot thread: %v", err) return nil, fmt.Errorf("get thread: %w", err) } return thread, nil } func (s *CopilotService) GetThreadByID(ctx context.Context, id uint) (*model.CopilotThread, error) { thread, err := s.threadRepo.GetByID(ctx, id) if err != nil { applogger.L().Errorf("Get copilot thread: %v", err) return nil, fmt.Errorf("get thread: %w", err) } return thread, nil } // ListThreads retrieves threads for a user with pagination. func (s *CopilotService) ListThreads(ctx context.Context, accountID, userID uint, offset, limit int) ([]model.CopilotThread, int64, error) { threads, count, err := s.threadRepo.ListByUser(ctx, accountID, userID, offset, limit) if err != nil { applogger.L().Errorf("List copilot threads: %v", err) return nil, 0, fmt.Errorf("list threads: %w", err) } return threads, count, nil } // --- Message Operations --- // SendMessageRequest is the DTO for sending a message in a copilot thread. type SendMessageRequest struct { Content string `json:"content"` Message string `json:"message"` ConversationID uint `json:"conversation_id"` } // SendMessageResult holds the result of sending a message (includes the assistant reply). type SendMessageResult struct { UserMessage *model.CopilotMessage `json:"user_message"` AssistantMessage *model.CopilotMessage `json:"assistant_message"` } // SendMessage sends a user message and generates an assistant reply via LLM. // Reference: Chatwoot CopilotThread#send_message func (s *CopilotService) SendMessage(ctx context.Context, accountID, userID, threadID uint, req *SendMessageRequest) (*SendMessageResult, error) { thread, err := s.threadRepo.GetByAccountUserAndID(ctx, accountID, userID, threadID) if err != nil { return nil, fmt.Errorf("thread not found: %w", err) } userMsg, err := s.CreateThreadMessage(ctx, accountID, userID, threadID, req) if err != nil { return nil, err } if s.worker != nil { if err := s.enqueueCopilotResponse(ctx, accountID, userID, threadID, req.ConversationID, userMsg); err != nil { return &SendMessageResult{UserMessage: userMsg}, err } return &SendMessageResult{UserMessage: userMsg}, nil } assistantMsg, err := s.createAssistantReply(ctx, accountID, threadID, userMsg, req.ConversationID) if err != nil { return &SendMessageResult{UserMessage: userMsg}, err } _ = thread return &SendMessageResult{UserMessage: userMsg, AssistantMessage: assistantMsg}, nil } func (s *CopilotService) enqueueCopilotResponse(ctx context.Context, accountID, userID, threadID, conversationID uint, userMsg *model.CopilotMessage) error { if s.worker == nil || userMsg == nil { return nil } _, err := s.worker.Enqueue(ctx, TaskTypeCaptainCopilotResponse, captainCopilotResponseJob{ AccountID: accountID, UserID: userID, ConversationID: conversationID, CopilotThreadID: threadID, MessageID: userMsg.ID, Message: userMsg.GetMessageContent(), }, worker.WithMaxAttempts(3), worker.WithIdempotencyKey(fmt.Sprintf("captain:copilot_response:%d", userMsg.ID)), ) return err } func (s *CopilotService) GenerateCopilotResponseByAccount(ctx context.Context, accountID, userID, threadID, conversationID, messageID uint, message string) ([]model.CopilotMessage, error) { thread, err := s.threadRepo.GetByAccountUserAndID(ctx, accountID, userID, threadID) if err != nil { return nil, fmt.Errorf("thread not found: %w", err) } if messageID != 0 { stored, err := s.messageRepo.GetByID(ctx, messageID) if err != nil { return nil, fmt.Errorf("message not found: %w", err) } if stored.AccountID != accountID || stored.CopilotThreadID != threadID || stored.MessageType != model.CopilotMessageTypeUser { return nil, fmt.Errorf("message does not belong to copilot thread") } message = stored.GetMessageContent() } assistant := &thread.Assistant if thread.AssistantID == nil || thread.Assistant.ID == 0 { return nil, fmt.Errorf("assistant not found") } generated, err := s.generateCopilotMessages(ctx, accountID, userID, conversationID, thread, assistant, message) if err != nil { return nil, err } created := make([]model.CopilotMessage, 0, len(generated)) for _, item := range generated { messageType := item.MessageType if messageType == "" { messageType = model.CopilotMessageTypeAssistant } raw, err := json.Marshal(item.Message) if err != nil { return created, fmt.Errorf("marshal copilot response message: %w", err) } msg := &model.CopilotMessage{AccountID: accountID, CopilotThreadID: threadID, MessageType: messageType, Message: raw} if err := s.messageRepo.Create(ctx, msg); err != nil { return created, fmt.Errorf("save copilot response message: %w", err) } reloaded, err := s.messageRepo.GetByID(ctx, msg.ID) if err != nil { return created, err } created = append(created, *reloaded) } return created, nil } func (s *CopilotService) generateCopilotMessages(ctx context.Context, accountID, userID, conversationID uint, thread *model.CopilotThread, assistant *model.CaptainAssistant, message string) ([]CopilotGeneratedMessage, error) { if s.responseBackend != nil { return s.responseBackend.GenerateCopilotResponse(ctx, CopilotResponseRequest{AccountID: accountID, UserID: userID, ConversationID: conversationID, Thread: thread, Assistant: assistant, Message: message}) } content := CopilotUnavailableMessage if s.llmProvider != nil { generated, err := s.generateAssistantContent(ctx, thread, "") if err != nil { return nil, err } if strings.TrimSpace(generated) != "" { content = generated } } return []CopilotGeneratedMessage{{MessageType: model.CopilotMessageTypeAssistant, Message: map[string]any{"content": content}}}, nil } func (s *CopilotService) CreateThreadMessage(ctx context.Context, accountID, userID, threadID uint, req *SendMessageRequest) (*model.CopilotMessage, error) { content := strings.TrimSpace(req.Message) if content == "" { content = strings.TrimSpace(req.Content) } if content == "" { return nil, errors.New("Message is required") } thread, err := s.threadRepo.GetByAccountUserAndID(ctx, accountID, userID, threadID) if err != nil { return nil, fmt.Errorf("thread not found: %w", err) } message, _ := json.Marshal(map[string]any{"content": content}) userMsg := &model.CopilotMessage{AccountID: thread.AccountID, CopilotThreadID: thread.ID, MessageType: model.CopilotMessageTypeUser, Message: message} if err := s.messageRepo.Create(ctx, userMsg); err != nil { applogger.L().Errorf("SendMessage create user message: %v", err) return nil, fmt.Errorf("save user message: %w", err) } return s.messageRepo.GetByID(ctx, userMsg.ID) } func (s *CopilotService) ListThreadMessages(ctx context.Context, accountID, userID, threadID uint, page, perPage int) ([]model.CopilotMessage, int64, error) { if page <= 0 { page = 1 } if perPage <= 0 { perPage = 1000 } thread, err := s.threadRepo.GetByAccountUserAndID(ctx, accountID, userID, threadID) if err != nil { return nil, 0, fmt.Errorf("thread not found: %w", err) } return s.messageRepo.FindByThreadID(ctx, thread.ID, (page-1)*perPage, perPage) } func (s *CopilotService) createAssistantReply(ctx context.Context, accountID, threadID uint, userMsg *model.CopilotMessage, conversationID uint) (*model.CopilotMessage, error) { thread, err := s.threadRepo.GetByID(ctx, threadID) if err != nil { return nil, fmt.Errorf("thread not found: %w", err) } content := CopilotUnavailableMessage if s.llmProvider != nil { generated, err := s.generateAssistantContent(ctx, thread, userMsg.GetMessageContent()) if err == nil && strings.TrimSpace(generated) != "" { content = generated } else if err != nil { applogger.L().Warnf("Copilot LLM unavailable: %v", err) } } message, _ := json.Marshal(map[string]any{"content": content}) assistantMsg := &model.CopilotMessage{AccountID: accountID, CopilotThreadID: threadID, MessageType: model.CopilotMessageTypeAssistant, Message: message} if err := s.messageRepo.Create(ctx, assistantMsg); err != nil { return nil, fmt.Errorf("save assistant message: %w", err) } _ = conversationID return s.messageRepo.GetByID(ctx, assistantMsg.ID) } func (s *CopilotService) generateAssistantContent(ctx context.Context, thread *model.CopilotThread, content string) (string, error) { // Build conversation history for LLM // Convert model.ChatMessage (from PreviousHistory) to llm.ChatMessage modelHistory := thread.PreviousHistory(thread.Messages) var llmHistory []llm.ChatMessage for _, m := range modelHistory { llmHistory = append(llmHistory, llm.ChatMessage{ Role: m.Role, Content: m.Content, }) } if strings.TrimSpace(content) != "" { llmHistory = append(llmHistory, llm.ChatMessage{ Role: "user", Content: content, }) } // Build system prompt systemPrompt := "You are an AI assistant helping a customer support agent." if thread.AssistantID != nil { systemPrompt += " Use the context provided by the captain assistant." } messages := []llm.ChatMessage{ {Role: "system", Content: systemPrompt}, } messages = append(messages, llmHistory...) // Call LLM for assistant response llmResp, err := s.llmProvider.ChatCompletion(ctx, llm.ChatRequest{ Model: "gpt-4", Messages: messages, Temperature: 0.7, MaxTokens: 1024, }) if err != nil { applogger.L().Errorf("SendMessage LLM call: %v", err) return "", fmt.Errorf("llm generation failed: %w", err) } assistantContent := "" if len(llmResp.Choices) > 0 { assistantContent = llmResp.Choices[0].Message.Content } return assistantContent, nil } // --- Suggested Replies & Summary --- // SuggestedRepliesResult holds suggested reply options for a conversation. type SuggestedRepliesResult struct { Replies []string `json:"replies"` } // GetSuggestedReplies generates reply suggestions for a conversation. // Reference: Chatwoot Captain::AssistanceDriver#suggested_replies func (s *CopilotService) GetSuggestedReplies(ctx context.Context, accountID uint, conversationContext string) (*SuggestedRepliesResult, error) { systemPrompt := "You are an AI assistant helping a customer support agent. Based on the conversation context, suggest 3 concise reply options. Return them as a JSON array of strings." messages := []llm.ChatMessage{ {Role: "system", Content: systemPrompt}, {Role: "user", Content: "Conversation context:\n" + conversationContext + "\n\nSuggest 3 reply options."}, } llmResp, err := s.llmProvider.ChatCompletion(ctx, llm.ChatRequest{ Model: "gpt-4", Messages: messages, Temperature: 0.7, MaxTokens: 512, }) if err != nil { applogger.L().Errorf("GetSuggestedReplies LLM call: %v", err) return nil, fmt.Errorf("llm generation failed: %w", err) } if len(llmResp.Choices) == 0 { return &SuggestedRepliesResult{Replies: []string{}}, nil } // Try to parse the response as a JSON array of strings var replies []string content := llmResp.Choices[0].Message.Content if err := json.Unmarshal([]byte(content), &replies); err != nil { // Fallback: split by newline if not valid JSON array replies = splitReplies(content) } return &SuggestedRepliesResult{Replies: replies}, nil } // SummarizeConversationResult holds the conversation summary. type SummarizeConversationResult struct { Summary string `json:"summary"` } // SummarizeConversation generates a summary of the conversation. // Reference: Chatwoot Captain::AssistanceDriver#summarize func (s *CopilotService) SummarizeConversation(ctx context.Context, accountID uint, conversationContext string) (*SummarizeConversationResult, error) { systemPrompt := "You are an AI assistant. Summarize the following customer support conversation concisely, highlighting key issues, resolution status, and any action items." messages := []llm.ChatMessage{ {Role: "system", Content: systemPrompt}, {Role: "user", Content: "Conversation:\n" + conversationContext}, } llmResp, err := s.llmProvider.ChatCompletion(ctx, llm.ChatRequest{ Model: "gpt-4", Messages: messages, Temperature: 0.3, MaxTokens: 256, }) if err != nil { applogger.L().Errorf("SummarizeConversation LLM call: %v", err) return nil, fmt.Errorf("llm generation failed: %w", err) } if len(llmResp.Choices) == 0 { return &SummarizeConversationResult{Summary: ""}, nil } return &SummarizeConversationResult{ Summary: llmResp.Choices[0].Message.Content, }, nil } // jsonEscape escapes a string for safe embedding in JSON content. func jsonEscape(s string) string { b, _ := json.Marshal(s) // Strip surrounding quotes from json.Marshal output if len(b) >= 2 { return string(b[1 : len(b)-1]) } return s } // splitReplies tries to split raw LLM text into individual reply suggestions. func splitReplies(content string) []string { var replies []string lines := splitLines(content) for _, line := range lines { trimmed := trimReplyPrefix(line) if trimmed != "" { replies = append(replies, trimmed) } } if len(replies) == 0 && content != "" { replies = []string{content} } return replies } func splitLines(s string) []string { var lines []string start := 0 for i := 0; i < len(s); i++ { if s[i] == '\n' { line := s[start:i] if line != "" { lines = append(lines, line) } start = i + 1 } } if start < len(s) { lines = append(lines, s[start:]) } return lines } func trimReplyPrefix(s string) string { // Remove common numbered prefixes like "1.", "1) ", "- " for i := 0; i < len(s); i++ { if s[i] == '.' || s[i] == ')' || s[i] == '-' { return s[i+1:] } if s[i] == ' ' { continue } break } return s } // --- Thread deletion & translation --- // DeleteThread deletes an account/user scoped copilot thread by ID. func (s *CopilotService) DeleteThread(ctx context.Context, accountID, userID, id uint) error { if _, err := s.threadRepo.GetByAccountUserAndID(ctx, accountID, userID, id); err != nil { return fmt.Errorf("thread not found: %w", err) } if err := s.messageRepo.DeleteByThread(ctx, id); err != nil { return fmt.Errorf("delete thread messages: %w", err) } if err := s.threadRepo.DeleteByAccountUser(ctx, accountID, userID, id); err != nil { applogger.L().Errorf("Delete copilot thread: %v", err) return fmt.Errorf("delete thread: %w", err) } return nil } // TranslateRequest is the DTO for translating a message. type TranslateRequest struct { Content string `json:"content" validate:"required"` TargetLanguage string `json:"target_language" validate:"required"` } // TranslateResult holds the translated message. type TranslateResult struct { TranslatedContent string `json:"translated_content"` SourceLanguage string `json:"source_language"` TargetLanguage string `json:"target_language"` } // TranslateMessage translates a message to the target language using LLM. func (s *CopilotService) TranslateMessage(ctx context.Context, accountID uint, req *TranslateRequest) (*TranslateResult, error) { llmResp, err := s.llmProvider.ChatCompletion(ctx, llm.ChatRequest{ Messages: []llm.ChatMessage{ {Role: "system", Content: fmt.Sprintf("You are a translator. Translate the user's message to %s. Return only the translated text, nothing else.", req.TargetLanguage)}, {Role: "user", Content: req.Content}, }, }) if err != nil { applogger.L().Errorf("TranslateMessage LLM: %v", err) return nil, fmt.Errorf("translate message: %w", err) } translated := "" if len(llmResp.Choices) > 0 { translated = llmResp.Choices[0].Message.Content } return &TranslateResult{ TranslatedContent: translated, TargetLanguage: req.TargetLanguage, }, nil } // --- Copilot Suggestion Messages --- // Reference: Chatwoot enterprise/app/controllers/api/v1/copilot_messages_controller.rb // Conversation-level suggestion/reply/summary messages (distinct from thread-based CopilotMessage) // CreateSuggestionRequest is the DTO for creating a copilot suggestion message. type CreateSuggestionRequest struct { ConversationID uint `json:"conversation_id" validate:"required"` Content string `json:"content" validate:"required"` SuggestionType model.CopilotSuggestionType `json:"suggestion_type"` } // ListSuggestionResult holds paginated suggestion messages. type ListSuggestionResult struct { Messages []model.CopilotSuggestionMessage `json:"messages"` TotalCount int64 `json:"total_count"` Page int `json:"page"` PerPage int `json:"per_page"` } // GetCopilotSuggestions retrieves suggestion messages for a conversation. func (s *CopilotService) GetCopilotSuggestions(ctx context.Context, accountID, conversationID uint, page, perPage int) (*ListSuggestionResult, error) { offset := (page - 1) * perPage msgs, count, err := s.suggestionRepo.FindByConversationID(ctx, accountID, conversationID, offset, perPage) if err != nil { applogger.L().Errorf("GetCopilotSuggestions: %v", err) return nil, fmt.Errorf("get copilot suggestions: %w", err) } return &ListSuggestionResult{ Messages: msgs, TotalCount: count, Page: page, PerPage: perPage, }, nil } // CreateCopilotSuggestion creates a new copilot suggestion message. func (s *CopilotService) CreateCopilotSuggestion(ctx context.Context, accountID uint, req *CreateSuggestionRequest) (*model.CopilotSuggestionMessage, error) { if req.ConversationID == 0 || strings.TrimSpace(req.Content) == "" { return nil, errors.New("conversation_id and content are required") } suggestionType := req.SuggestionType if suggestionType == "" { suggestionType = model.CopilotSuggestionTypeSuggestion } msg := &model.CopilotSuggestionMessage{ AccountID: accountID, ConversationID: req.ConversationID, Content: req.Content, SuggestionType: suggestionType, Status: model.CopilotSuggestionStatusPending, } if err := s.suggestionRepo.Create(ctx, msg); err != nil { applogger.L().Errorf("CreateCopilotSuggestion: %v", err) return nil, fmt.Errorf("create copilot suggestion: %w", err) } return msg, nil } // UpdateSuggestionStatus updates the status of a copilot suggestion message. func (s *CopilotService) UpdateSuggestionStatus(ctx context.Context, id uint, status model.CopilotSuggestionStatus) (*model.CopilotSuggestionMessage, error) { msg, err := s.suggestionRepo.GetByID(ctx, id) if err != nil { return nil, fmt.Errorf("suggestion not found: %w", err) } if err := s.suggestionRepo.UpdateStatus(ctx, id, status); err != nil { applogger.L().Errorf("UpdateSuggestionStatus: %v", err) return nil, fmt.Errorf("update suggestion status: %w", err) } msg.Status = status return msg, nil }