package service import ( "context" "fmt" "strings" "github.com/pgvector/pgvector-go" "github.com/gochat/gochat/internal/llm" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/repository" applogger "github.com/gochat/gochat/pkg/logger" ) // CaptainTaskService implements standalone AI task endpoints for Captain. // Reference: Chatwoot enterprise/app/controllers/api/v1/accounts/captain/tasks_controller.rb // These tasks (reply_suggestion, summarize, rewrite) are independent of // CopilotThread flow — they provide one-shot AI suggestions for agents. type CaptainTaskService struct { assistantRepo *repository.CaptainAssistantRepo responseRepo *repository.CaptainAssistantResponseRepo customToolRepo *repository.CaptainCustomToolRepo conversationRepo *repository.ConversationRepo messageRepo *repository.MessageRepo suggestionRepo *repository.CopilotSuggestionRepo llmProvider llm.Provider promptBuilder *SystemPromptBuilder copilotContextSvc *CopilotContextService // M12: context enrichment for richer prompts } // NewCaptainTaskService creates a new CaptainTaskService. func NewCaptainTaskService( assistantRepo *repository.CaptainAssistantRepo, responseRepo *repository.CaptainAssistantResponseRepo, customToolRepo *repository.CaptainCustomToolRepo, conversationRepo *repository.ConversationRepo, messageRepo *repository.MessageRepo, llmProvider llm.Provider, copilotContextSvc *CopilotContextService, suggestionRepos ...*repository.CopilotSuggestionRepo, ) *CaptainTaskService { var suggestionRepo *repository.CopilotSuggestionRepo if len(suggestionRepos) > 0 { suggestionRepo = suggestionRepos[0] } return &CaptainTaskService{ assistantRepo: assistantRepo, responseRepo: responseRepo, customToolRepo: customToolRepo, conversationRepo: conversationRepo, messageRepo: messageRepo, suggestionRepo: suggestionRepo, llmProvider: llmProvider, promptBuilder: NewSystemPromptBuilder(), copilotContextSvc: copilotContextSvc, } } // --- Task Request/Response DTOs --- // TaskReplySuggestionRequest is the DTO for reply_suggestion task. type TaskReplySuggestionRequest struct { AssistantID uint `json:"assistant_id"` ConversationID uint `json:"conversation_id"` ConversationDisplayID uint `json:"conversation_display_id"` Tone string `json:"tone"` // legacy local compatibility } // TaskSummarizeRequest is the DTO for summarize task. type TaskSummarizeRequest struct { ConversationID uint `json:"conversation_id"` ConversationDisplayID uint `json:"conversation_display_id"` Language string `json:"language"` // legacy local compatibility } // TaskRewriteRequest is the DTO for rewrite task. type TaskRewriteRequest struct { Content string `json:"content" validate:"required"` Operation string `json:"operation"` ConversationDisplayID uint `json:"conversation_display_id"` Tone string `json:"tone"` // legacy local compatibility Language string `json:"language"` // legacy local compatibility AssistantID uint `json:"assistant_id"` // legacy local compatibility } // TaskReplySuggestionResult holds the reply suggestion result. type TaskReplySuggestionResult struct { Message string `json:"message"` FollowUpContext map[string]interface{} `json:"follow_up_context,omitempty"` Suggestions []string `json:"suggestions"` Source string `json:"source"` // "llm", "rag+llm" } // TaskSummarizeResult holds the conversation summary result. type TaskSummarizeResult struct { Message string `json:"message"` FollowUpContext map[string]interface{} `json:"follow_up_context,omitempty"` Summary string `json:"summary"` } // TaskRewriteResult holds the rewritten content result. type TaskRewriteResult struct { Message string `json:"message"` FollowUpContext map[string]interface{} `json:"follow_up_context,omitempty"` RewrittenContent string `json:"rewritten_content"` Tone string `json:"tone"` } type CaptainTaskError struct { Status int Message string } func (e *CaptainTaskError) Error() string { return e.Message } func taskError(status int, message string) error { return &CaptainTaskError{Status: status, Message: message} } func CaptainTaskErrorStatus(err error) (int, string, bool) { if err == nil { return 0, "", false } if taskErr, ok := err.(*CaptainTaskError); ok { return taskErr.Status, taskErr.Message, true } return 0, "", false } // --- Reply Suggestion Task --- // Reference: Chatwoot Captain::Llm::SearchReplyDocumentationService // ReplySuggestion generates AI-powered reply suggestions for a conversation. // It combines RAG (document-based similarity search) with LLM generation: // 1. Fetch recent conversation messages as context // 2. Generate embedding for the conversation context // 3. Search similar FAQ responses from the assistant documents // 4. Build prompt with conversation context + relevant FAQ answers // 5. Call LLM to generate reply suggestions func (s *CaptainTaskService) ReplySuggestion(ctx context.Context, accountID uint, req *TaskReplySuggestionRequest) (*TaskReplySuggestionResult, error) { if s.llmProvider == nil { return nil, taskError(422, "Captain is disabled") } var assistantCfg *model.CaptainAssistant var err error if req.AssistantID > 0 { assistantCfg, err = s.assistantRepo.GetByAccountAndID(ctx, accountID, req.AssistantID) if err != nil { return nil, taskError(422, "assistant not found") } } else { assistantCfg = &model.CaptainAssistant{AccountID: accountID, Name: "Captain"} assistantCfg.Config = []byte(`{}`) } conversation, err := s.resolveTaskConversation(ctx, accountID, req.ConversationDisplayID, req.ConversationID) if err != nil { return nil, taskError(422, "Conversation not found") } messages, err := s.fetchConversationContext(ctx, conversation.ID) if err != nil { applogger.L().Warnf("ReplySuggestion: conversation context fetch failed: %v, proceeding without context", err) messages = "" } // Search relevant documents via embedding similarity (RAG) ragContext, source := "", "llm" if req.AssistantID > 0 { docContext, err := s.searchDocumentation(ctx, req.AssistantID, messages) if err != nil { applogger.L().Warnf("ReplySuggestion: document search failed: %v, proceeding without RAG", err) } else if docContext != "" { ragContext = docContext source = "rag+llm" } } // Build system prompt using prompt builder cfg, _ := assistantCfg.GetConfig() systemPrompt := s.promptBuilder.BuildReplySuggestionPrompt(assistantCfg, cfg, req.Tone, ragContext) // Build messages for LLM chatMessages := []llm.ChatMessage{ {Role: "system", Content: systemPrompt}, {Role: "user", Content: messages}, } llmReq := llm.ChatRequest{ Model: cfg.Model, Messages: chatMessages, Temperature: 0.7, MaxTokens: 1024, } resp, err := s.llmProvider.ChatCompletion(ctx, llmReq) if err != nil { applogger.L().Errorf("ReplySuggestion LLM call: %v", err) return nil, fmt.Errorf("llm generation failed: %w", err) } if len(resp.Choices) == 0 { return nil, fmt.Errorf("no response from LLM") } // Parse suggestions from LLM response suggestions := parseSuggestions(resp.Choices[0].Message.Content) message := strings.Join(suggestions, "\n") if message == "" { message = resp.Choices[0].Message.Content } s.persistTaskSuggestion(ctx, accountID, conversation.ID, message, model.CopilotSuggestionTypeReply) return &TaskReplySuggestionResult{ Message: message, FollowUpContext: s.buildFollowUpContext("reply_suggestion", messages, message, conversation), Suggestions: suggestions, Source: source, }, nil } // --- Summarize Task --- // Reference: Chatwoot Captain::Copilot::ChatService summarize action // Summarize generates a concise summary of a conversation. func (s *CaptainTaskService) Summarize(ctx context.Context, accountID uint, req *TaskSummarizeRequest) (*TaskSummarizeResult, error) { if s.llmProvider == nil { return nil, taskError(422, "Captain is disabled") } conversation, err := s.resolveTaskConversation(ctx, accountID, req.ConversationDisplayID, req.ConversationID) if err != nil { return nil, taskError(422, "Conversation not found") } // Fetch conversation messages messages, err := s.fetchConversationContext(ctx, conversation.ID) if err != nil { return nil, fmt.Errorf("fetch conversation context: %w", err) } if messages == "" { return nil, taskError(422, fmt.Sprintf("no messages found for conversation %d", conversation.ID)) } // Determine output language language := req.Language if language == "" { language = "zh-CN" } // Build summarization prompt using prompt builder systemPrompt := s.promptBuilder.BuildSummarizePrompt(language) chatMessages := []llm.ChatMessage{ {Role: "system", Content: systemPrompt}, {Role: "user", Content: messages}, } llmReq := llm.ChatRequest{ Messages: chatMessages, Temperature: 0.3, MaxTokens: 512, } resp, err := s.llmProvider.ChatCompletion(ctx, llmReq) if err != nil { applogger.L().Errorf("Summarize LLM call: %v", err) return nil, fmt.Errorf("llm summarization failed: %w", err) } if len(resp.Choices) == 0 { return nil, fmt.Errorf("no response from LLM") } message := resp.Choices[0].Message.Content s.persistTaskSuggestion(ctx, accountID, conversation.ID, message, model.CopilotSuggestionTypeSummary) return &TaskSummarizeResult{ Message: message, FollowUpContext: s.buildFollowUpContext("summarize", messages, message, conversation), Summary: message, }, nil } // --- Rewrite Task --- // Reference: Chatwoot Captain::TasksController rewrite action // Rewrite rewrites a draft message to improve tone, clarity, or language. func (s *CaptainTaskService) Rewrite(ctx context.Context, accountID uint, req *TaskRewriteRequest) (*TaskRewriteResult, error) { if s.llmProvider == nil { return nil, taskError(422, "Captain is disabled") } operation := req.Operation if operation == "" { operation = req.Tone } if operation == "" { operation = "professional" } if !isAllowedRewriteOperation(operation) { return nil, taskError(422, "Invalid operation: "+operation) } language := req.Language if language == "" { language = "zh-CN" } styleGuidelines := "" var conversation *model.Conversation conversationContext := "" if req.ConversationDisplayID > 0 { resolved, err := s.resolveTaskConversation(ctx, accountID, req.ConversationDisplayID, 0) if err != nil { return nil, taskError(422, "Conversation not found") } conversation = resolved if operation == "improve" { conversationContext, _ = s.fetchConversationContext(ctx, conversation.ID) } } if req.AssistantID > 0 { assistant, err := s.assistantRepo.GetByAccountAndID(ctx, accountID, req.AssistantID) if err == nil { cfg, _ := assistant.GetConfig() if cfg.Instructions != "" { styleGuidelines = fmt.Sprintf("\nStyle guidelines from your assistant config: %s", cfg.Instructions) } if len(assistant.ResponseGuidelines) > 0 && string(assistant.ResponseGuidelines) != "null" { styleGuidelines += fmt.Sprintf("\nResponse guidelines: %s", string(assistant.ResponseGuidelines)) } } } if conversationContext != "" { styleGuidelines += "\nConversation context: " + conversationContext } // Build rewrite prompt using prompt builder systemPrompt := s.promptBuilder.BuildRewritePrompt(operation, language, styleGuidelines) chatMessages := []llm.ChatMessage{ {Role: "system", Content: systemPrompt}, {Role: "user", Content: req.Content}, } llmReq := llm.ChatRequest{ Messages: chatMessages, Temperature: 0.5, MaxTokens: 512, } resp, err := s.llmProvider.ChatCompletion(ctx, llmReq) if err != nil { applogger.L().Errorf("Rewrite LLM call: %v", err) return nil, fmt.Errorf("llm rewrite failed: %w", err) } if len(resp.Choices) == 0 { return nil, fmt.Errorf("no response from LLM") } message := resp.Choices[0].Message.Content if conversation != nil { s.persistTaskSuggestion(ctx, accountID, conversation.ID, message, model.CopilotSuggestionTypeSuggestion) } return &TaskRewriteResult{ Message: message, FollowUpContext: s.buildFollowUpContext(operation, req.Content, message, conversation), RewrittenContent: message, Tone: operation, }, nil } // --- Helper Methods --- // fetchConversationContext retrieves recent messages from a conversation and formats them. func (s *CaptainTaskService) fetchConversationContext(ctx context.Context, conversationID uint) (string, error) { msgs, _, err := s.messageRepo.FindByConversation(ctx, conversationID, 0, 20) if err != nil { return "", fmt.Errorf("fetch messages: %w", err) } if len(msgs) == 0 { return "", nil } var builder strings.Builder for _, msg := range msgs { sender := "Customer" if msg.SenderType == "agent" || msg.SenderType == "user" { sender = "Agent" } builder.WriteString(fmt.Sprintf("[%s]: %s\n", sender, msg.Content)) } return builder.String(), nil } func (s *CaptainTaskService) resolveTaskConversation(ctx context.Context, accountID, displayID, legacyID uint) (*model.Conversation, error) { if displayID > 0 { return s.conversationRepo.FindByAccountAndDisplayIDOrID(ctx, accountID, displayID) } if legacyID > 0 { return s.conversationRepo.FindByAccountAndDisplayIDOrID(ctx, accountID, legacyID) } return nil, fmt.Errorf("conversation_display_id required") } func isAllowedRewriteOperation(operation string) bool { switch operation { case "fix_spelling_grammar", "improve", "casual", "professional", "friendly", "confident", "straightforward": return true default: return false } } func (s *CaptainTaskService) buildFollowUpContext(eventName, originalContext, lastResponse string, conversation *model.Conversation) map[string]interface{} { ctx := map[string]interface{}{ "event_name": eventName, "original_context": originalContext, "last_response": lastResponse, "conversation_history": []map[string]string{}, } if conversation != nil { ctx["channel_type"] = conversation.ChannelType } return ctx } func (s *CaptainTaskService) persistTaskSuggestion(ctx context.Context, accountID, conversationID uint, content string, suggestionType model.CopilotSuggestionType) { if s.suggestionRepo == nil || strings.TrimSpace(content) == "" || conversationID == 0 { return } msg := &model.CopilotSuggestionMessage{ AccountID: accountID, ConversationID: conversationID, Content: content, SuggestionType: suggestionType, Status: model.CopilotSuggestionStatusPending, } if err := s.suggestionRepo.Create(ctx, msg); err != nil { applogger.L().Warnf("Captain task suggestion persistence failed: %v", err) } } // searchDocumentation performs embedding-based similarity search on assistant responses. // Reference: Chatwoot Captain::Llm::SearchReplyDocumentationService func (s *CaptainTaskService) searchDocumentation(ctx context.Context, assistantID uint, query string) (string, error) { if query == "" { return "", nil } // Generate embedding for the query embResp, err := s.llmProvider.CreateEmbedding(ctx, llm.EmbeddingRequest{ Input: []string{query}, Model: "text-embedding-ada-002", }) if err != nil { return "", fmt.Errorf("create embedding: %w", err) } if len(embResp.Data) == 0 { return "", fmt.Errorf("no embedding generated") } // Convert float64 embedding to float32 for pgvector float64Emb := embResp.Data[0].Embedding float32Emb := make([]float32, len(float64Emb)) for i, v := range float64Emb { float32Emb[i] = float32(v) } embedding := pgvector.NewVector(float32Emb) // Search similar responses results, err := s.responseRepo.SimilaritySearch(ctx, assistantID, embedding, 5) if err != nil { return "", fmt.Errorf("similarity search: %w", err) } if len(results) == 0 { return "", nil } // Format relevant FAQ answers as context var builder strings.Builder builder.WriteString("Relevant FAQ answers from documentation:\n") for i, r := range results { builder.WriteString(fmt.Sprintf("%d. Q: %s\n A: %s\n", i+1, r.Question, r.Answer)) } return builder.String(), nil } // parseSuggestions splits LLM output into individual suggestions. // The LLM may return numbered lines, bullet points, or comma-separated items. func parseSuggestions(content string) []string { lines := strings.Split(content, "\n") var suggestions []string for _, line := range lines { line = strings.TrimSpace(line) if line == "" { continue } // Strip leading numbers/bullets for _, prefix := range []string{"1.", "2.", "3.", "4.", "5.", "1)", "2)", "3)", "4)", "5)", "- ", "* "} { if strings.HasPrefix(line, prefix) { line = strings.TrimSpace(strings.TrimPrefix(line, prefix)) break } } if line != "" { suggestions = append(suggestions, line) } } // If only one block, try comma separation if len(suggestions) <= 1 { parts := strings.Split(content, ",") suggestions = nil for _, p := range parts { p = strings.TrimSpace(p) if p != "" { suggestions = append(suggestions, p) } } } if len(suggestions) == 0 { suggestions = []string{content} } return suggestions } // --- Streaming Task Methods (M12: SSE streaming support) --- // These methods build the same prompts as the non-streaming variants but use // ChatCompletionStream to deliver tokens incrementally via an onChunk callback. // ReplySuggestionStream streams reply suggestions via an onChunk callback. // The callback receives StreamChunk events; the caller (handler) writes SSE events. func (s *CaptainTaskService) ReplySuggestionStream(ctx context.Context, accountID uint, req *TaskReplySuggestionRequest, onChunk func(llm.StreamChunk) error) error { if s.llmProvider == nil { return taskError(422, "Captain is disabled") } var assistant *model.CaptainAssistant var err error if req.AssistantID > 0 { assistant, err = s.assistantRepo.GetByAccountAndID(ctx, accountID, req.AssistantID) if err != nil { return taskError(422, "assistant not found") } } else { assistant = &model.CaptainAssistant{AccountID: accountID, Name: "Captain"} assistant.Config = []byte(`{}`) } conversation, err := s.resolveTaskConversation(ctx, accountID, req.ConversationDisplayID, req.ConversationID) if err != nil { return taskError(422, "Conversation not found") } messages, err := s.fetchConversationContext(ctx, conversation.ID) if err != nil { applogger.L().Warnf("ReplySuggestionStream: conversation context fetch failed: %v, proceeding without context", err) messages = "" } // Enrich context with CopilotContextService (M12) if s.copilotContextSvc != nil && messages != "" { ctxView, err := s.copilotContextSvc.GetCurrentViewingContext(ctx, accountID, conversation.ID) if err != nil { applogger.L().Warnf("ReplySuggestionStream: context enrichment failed: %v", err) } else if ctxView != nil { var enriched strings.Builder if ctxView.ContactName != "" { enriched.WriteString(fmt.Sprintf("Contact: %s (%s)\n", ctxView.ContactName, ctxView.ContactEmail)) } for _, m := range ctxView.Messages { enriched.WriteString(fmt.Sprintf("[%s]: %s\n", m.Role, m.Content)) } if enriched.Len() > 0 { messages = enriched.String() } } } ragContext := "" if req.AssistantID > 0 { docContext, err := s.searchDocumentation(ctx, req.AssistantID, messages) if err != nil { applogger.L().Warnf("ReplySuggestionStream: document search failed: %v, proceeding without RAG", err) } else if docContext != "" { ragContext = docContext } } cfg, _ := assistant.GetConfig() systemPrompt := s.promptBuilder.BuildReplySuggestionPrompt(assistant, cfg, req.Tone, ragContext) chatMessages := []llm.ChatMessage{ {Role: "system", Content: systemPrompt}, {Role: "user", Content: messages}, } llmReq := llm.ChatRequest{ Messages: chatMessages, Temperature: 0.7, MaxTokens: 1024, Stream: true, } return s.llmProvider.ChatCompletionStream(ctx, llmReq, onChunk) } // SummarizeStream streams a conversation summary via an onChunk callback. func (s *CaptainTaskService) SummarizeStream(ctx context.Context, accountID uint, req *TaskSummarizeRequest, onChunk func(llm.StreamChunk) error) error { if s.llmProvider == nil { return taskError(422, "Captain is disabled") } conversation, err := s.resolveTaskConversation(ctx, accountID, req.ConversationDisplayID, req.ConversationID) if err != nil { return taskError(422, "Conversation not found") } conversationContext, err := s.fetchConversationContext(ctx, conversation.ID) if err != nil { return fmt.Errorf("fetch conversation context: %w", err) } if conversationContext == "" { return taskError(422, fmt.Sprintf("no messages found for conversation %d", conversation.ID)) } // Enrich context with CopilotContextService (M12) if s.copilotContextSvc != nil && conversationContext != "" { ctxView, err := s.copilotContextSvc.GetCurrentViewingContext(ctx, accountID, conversation.ID) if err != nil { applogger.L().Warnf("SummarizeStream: context enrichment failed: %v", err) } else if ctxView != nil { var enriched strings.Builder if ctxView.ContactName != "" { enriched.WriteString(fmt.Sprintf("Contact: %s (%s)\n", ctxView.ContactName, ctxView.ContactEmail)) } for _, m := range ctxView.Messages { enriched.WriteString(fmt.Sprintf("[%s]: %s\n", m.Role, m.Content)) } if enriched.Len() > 0 { conversationContext = enriched.String() } } } language := req.Language if language == "" { language = "zh-CN" } systemPrompt := s.promptBuilder.BuildSummarizePrompt(language) chatMessages := []llm.ChatMessage{ {Role: "system", Content: systemPrompt}, {Role: "user", Content: conversationContext}, } llmReq := llm.ChatRequest{ Messages: chatMessages, Temperature: 0.3, MaxTokens: 512, Stream: true, } return s.llmProvider.ChatCompletionStream(ctx, llmReq, onChunk) } // RewriteStream streams a rewritten message via an onChunk callback. func (s *CaptainTaskService) RewriteStream(ctx context.Context, accountID uint, req *TaskRewriteRequest, onChunk func(llm.StreamChunk) error) error { if s.llmProvider == nil { return taskError(422, "Captain is disabled") } operation := req.Operation if operation == "" { operation = req.Tone } if operation == "" { operation = "professional" } if !isAllowedRewriteOperation(operation) { return taskError(422, "Invalid operation: "+operation) } language := req.Language if language == "" { language = "zh-CN" } styleGuidelines := "" if req.ConversationDisplayID > 0 { conversation, err := s.resolveTaskConversation(ctx, accountID, req.ConversationDisplayID, 0) if err != nil { return taskError(422, "Conversation not found") } if operation == "improve" { conversationContext, _ := s.fetchConversationContext(ctx, conversation.ID) if conversationContext != "" { styleGuidelines += "\nConversation context: " + conversationContext } } } if req.AssistantID > 0 { assistant, err := s.assistantRepo.GetByAccountAndID(ctx, accountID, req.AssistantID) if err == nil { cfg, _ := assistant.GetConfig() if cfg.Instructions != "" { styleGuidelines = fmt.Sprintf("\nStyle guidelines from your assistant config: %s", cfg.Instructions) } if len(assistant.ResponseGuidelines) > 0 && string(assistant.ResponseGuidelines) != "null" { styleGuidelines += fmt.Sprintf("\nResponse guidelines: %s", string(assistant.ResponseGuidelines)) } } } systemPrompt := s.promptBuilder.BuildRewritePrompt(operation, language, styleGuidelines) chatMessages := []llm.ChatMessage{ {Role: "system", Content: systemPrompt}, {Role: "user", Content: req.Content}, } llmReq := llm.ChatRequest{ Messages: chatMessages, Temperature: 0.5, MaxTokens: 512, Stream: true, } return s.llmProvider.ChatCompletionStream(ctx, llmReq, onChunk) }