package service import ( "context" "fmt" "strings" "github.com/pgvector/pgvector-go" "github.com/gochat/gochat/internal/llm" "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 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, ) *CaptainTaskService { return &CaptainTaskService{ assistantRepo: assistantRepo, responseRepo: responseRepo, customToolRepo: customToolRepo, conversationRepo: conversationRepo, messageRepo: messageRepo, 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" validate:"required"` ConversationID uint `json:"conversation_id" validate:"required"` Tone string `json:"tone"` // e.g. "friendly", "formal", "professional" } // TaskSummarizeRequest is the DTO for summarize task. type TaskSummarizeRequest struct { ConversationID uint `json:"conversation_id" validate:"required"` Language string `json:"language"` // summary output language, default "zh-CN" } // TaskRewriteRequest is the DTO for rewrite task. type TaskRewriteRequest struct { Content string `json:"content" validate:"required"` Tone string `json:"tone"` // desired tone, e.g. "friendly", "formal" Language string `json:"language"` // desired output language AssistantID uint `json:"assistant_id"` // optional: use assistant style guidelines } // TaskReplySuggestionResult holds the reply suggestion result. type TaskReplySuggestionResult struct { Suggestions []string `json:"suggestions"` Source string `json:"source"` // "llm", "rag+llm" } // TaskSummarizeResult holds the conversation summary result. type TaskSummarizeResult struct { Summary string `json:"summary"` } // TaskRewriteResult holds the rewritten content result. type TaskRewriteResult struct { RewrittenContent string `json:"rewritten_content"` Tone string `json:"tone"` } // --- 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) { // Fetch assistant config for prompt building assistant, err := s.assistantRepo.GetByID(ctx, req.AssistantID) if err != nil { return nil, fmt.Errorf("assistant not found: %w", err) } // Fetch conversation messages as context messages, err := s.fetchConversationContext(ctx, req.ConversationID) 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" 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, _ := assistant.GetConfig() systemPrompt := s.promptBuilder.BuildReplySuggestionPrompt(assistant, 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) return &TaskReplySuggestionResult{ 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) { // Fetch conversation messages messages, err := s.fetchConversationContext(ctx, req.ConversationID) if err != nil { return nil, fmt.Errorf("fetch conversation context: %w", err) } if messages == "" { return nil, fmt.Errorf("no messages found for conversation %d", req.ConversationID) } // 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") } return &TaskSummarizeResult{ Summary: resp.Choices[0].Message.Content, }, 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) { tone := req.Tone if tone == "" { tone = "professional" } language := req.Language if language == "" { language = "zh-CN" } // Optionally use assistant guidelines for style styleGuidelines := "" if req.AssistantID > 0 { assistant, err := s.assistantRepo.GetByID(ctx, 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)) } } } // Build rewrite prompt using prompt builder systemPrompt := s.promptBuilder.BuildRewritePrompt(tone, 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") } return &TaskRewriteResult{ RewrittenContent: resp.Choices[0].Message.Content, Tone: tone, }, 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 } // 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 { assistant, err := s.assistantRepo.GetByID(ctx, req.AssistantID) if err != nil { return fmt.Errorf("assistant not found: %w", err) } messages, err := s.fetchConversationContext(ctx, req.ConversationID) 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, req.ConversationID) 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 := "" 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 { conversationContext, err := s.fetchConversationContext(ctx, req.ConversationID) if err != nil { return fmt.Errorf("fetch conversation context: %w", err) } // Enrich context with CopilotContextService (M12) if s.copilotContextSvc != nil && conversationContext != "" { ctxView, err := s.copilotContextSvc.GetCurrentViewingContext(ctx, accountID, req.ConversationID) 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 { tone := req.Tone if tone == "" { tone = "professional" } language := req.Language if language == "" { language = "zh-CN" } styleGuidelines := "" if req.AssistantID > 0 { assistant, err := s.assistantRepo.GetByID(ctx, 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(tone, 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) }