package service import ( "context" "fmt" "strings" "github.com/gochat/gochat/internal/llm" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/repository" pkgvalidator "github.com/gochat/gochat/pkg/validator" applogger "github.com/gochat/gochat/pkg/logger" ) // CaptainAssistantResponseService processes assistant responses for conversations. // It generates AI-powered responses and optionally stores them as messages. // Reference: Chatwoot Captain::AssistantResponsesController + M12 PRD type CaptainAssistantResponseService struct { assistantRepo *repository.CaptainAssistantRepo responseRepo *repository.CaptainAssistantResponseRepo conversationRepo *repository.ConversationRepo messageRepo *repository.MessageRepo preferenceRepo *repository.CaptainPreferenceRepo llmProvider llm.Provider } func NewCaptainAssistantResponseService( assistantRepo *repository.CaptainAssistantRepo, responseRepo *repository.CaptainAssistantResponseRepo, conversationRepo *repository.ConversationRepo, messageRepo *repository.MessageRepo, preferenceRepo *repository.CaptainPreferenceRepo, llmProvider llm.Provider, ) *CaptainAssistantResponseService { return &CaptainAssistantResponseService{ assistantRepo: assistantRepo, responseRepo: responseRepo, conversationRepo: conversationRepo, messageRepo: messageRepo, preferenceRepo: preferenceRepo, llmProvider: llmProvider, } } // --- Request/Response DTOs --- // ProcessResponseRequest is the input for generating and storing an assistant response. type ProcessResponseRequest struct { ConversationID uint `json:"conversation_id" validate:"required"` AssistantID uint `json:"assistant_id" validate:"required"` SendMessage bool `json:"send_message"` // if true, store response as outgoing message ContentHint string `json:"content_hint,omitempty"` // optional context for response generation } // ProcessResponseResult holds the generated response and its storage status. type ProcessResponseResult struct { Content string `json:"content"` MessageID uint `json:"message_id,omitempty"` // set if send_message was true AssistantID uint `json:"assistant_id"` ConversationID uint `json:"conversation_id"` Sent bool `json:"sent"` // true if the message was stored } // --- Business logic --- // ProcessResponse generates an AI-powered response for a conversation and optionally stores it as a message. func (s *CaptainAssistantResponseService) ProcessResponse(ctx context.Context, accountID uint, req *ProcessResponseRequest) (*ProcessResponseResult, error) { // Validate request if err := pkgvalidator.ValidateStruct(req); err != nil { return nil, fmt.Errorf("validation error: %w", err) } // Fetch assistant config for guidelines assistant, err := s.assistantRepo.GetByID(ctx, req.AssistantID) if err != nil { return nil, fmt.Errorf("assistant not found: %w", err) } // Verify assistant belongs to this account if assistant.AccountID != accountID { return nil, fmt.Errorf("assistant %d does not belong to account %d", req.AssistantID, accountID) } cfg, _ := assistant.GetConfig() // Fetch conversation to get InboxID for message creation conversation, err := s.conversationRepo.FindByID(ctx, req.ConversationID) if err != nil { return nil, fmt.Errorf("conversation not found: %w", err) } // Verify conversation belongs to this account if conversation.AccountID != accountID { return nil, fmt.Errorf("conversation %d does not belong to account %d", req.ConversationID, accountID) } // Fetch conversation messages for context messages, _, err := s.messageRepo.FindByConversation(ctx, req.ConversationID, 0, 50) if err != nil { return nil, fmt.Errorf("fetch messages: %w", err) } var msgParts []string for _, m := range messages { role := "contact" if m.SenderType == "user" || m.SenderType == "bot" { role = "agent" } msgParts = append(msgParts, fmt.Sprintf("[%s]: %s", role, m.Content)) } conversationContext := strings.Join(msgParts, "\n") // Build system prompt from assistant config + preference var instructions string if cfg != nil && cfg.Instructions != "" { instructions = cfg.Instructions } pref, _ := s.preferenceRepo.GetByAccountID(ctx, accountID) maxLen := 500 tone := "professional" promptSuffix := "" if pref != nil { maxLen = pref.MaxResponseLength tone = pref.Tone promptSuffix = pref.CustomPromptSuffix } systemPrompt := fmt.Sprintf(`You are a customer support AI assistant named "%s". Respond to the customer in a %s tone. Keep your response concise (max ~%d characters). %s %s`, assistant.Name, tone, maxLen, instructions, promptSuffix) if req.ContentHint != "" { conversationContext += fmt.Sprintf("\n\nAgent note: %s", req.ContentHint) } llmResp, err := s.llmProvider.ChatCompletion(ctx, llm.ChatRequest{ Model: "", Messages: []llm.ChatMessage{ {Role: "system", Content: systemPrompt}, {Role: "user", Content: conversationContext}, }, Temperature: 0.7, MaxTokens: maxLen, }) if err != nil { applogger.L().Errorf("LLM assistant response generation failed: %v", err) return nil, fmt.Errorf("generate response: %w", err) } if len(llmResp.Choices) == 0 { return nil, fmt.Errorf("no LLM response") } content := llmResp.Choices[0].Message.Content result := &ProcessResponseResult{ Content: content, AssistantID: req.AssistantID, ConversationID: req.ConversationID, Sent: false, } // Optionally send as a message in the conversation if req.SendMessage && conversation != nil { msg := &model.Message{ ConversationID: req.ConversationID, AccountID: accountID, InboxID: conversation.InboxID, SenderType: "bot", Content: content, ContentType: "text", MessageType: "outgoing", } if err := s.messageRepo.Create(ctx, msg); err != nil { applogger.L().Warnf("Send assistant response as message: %v", err) // Don't fail the whole operation, just skip the message } else { result.MessageID = msg.ID result.Sent = true } } return result, nil } // --- CRUD methods for Chatwoot resources :assistant_responses (flat namespace) --- // List returns paginated assistant responses for an account. // Reference: Chatwoot Captain::AssistantResponsesController#index func (s *CaptainAssistantResponseService) List(ctx context.Context, accountID uint, assistantID uint, documentID uint, status string, search string, page, pageSize int) ([]model.CaptainAssistantResponse, int64, error) { db := s.responseRepo.DB().WithContext(ctx).Model(&model.CaptainAssistantResponse{}). Where("account_id = ?", accountID) // Apply filters — 1:1 matching Chatwoot apply_filters if assistantID > 0 { db = db.Where("assistant_id = ?", assistantID) } if documentID > 0 { db = db.Where("documentable_id = ? AND documentable_type = ?", documentID, "Captain::Document") } if status != "" { db = db.Where("status = ?", status) } if search != "" { searchTerm := "%" + search + "%" db = db.Where("question ILIKE ? OR answer ILIKE ?", searchTerm, searchTerm) } // Eager load assistant and documentable — matches Chatwoot includes(:assistant, :documentable) var total int64 if err := db.Count(&total).Error; err != nil { return nil, 0, fmt.Errorf("count responses: %w", err) } var responses []model.CaptainAssistantResponse // Ordered by created_at DESC — matches Chatwoot scope :ordered if err := db.Preload("Assistant").Order("created_at DESC"). Offset((page - 1) * pageSize).Limit(pageSize). Find(&responses).Error; err != nil { return nil, 0, fmt.Errorf("list responses: %w", err) } return responses, total, nil } // Get returns a single assistant response by ID. // Reference: Chatwoot Captain::AssistantResponsesController#show func (s *CaptainAssistantResponseService) Get(ctx context.Context, accountID uint, responseID uint) (*model.CaptainAssistantResponse, error) { resp, err := s.responseRepo.GetByID(ctx, responseID) if err != nil { return nil, fmt.Errorf("get response: %w", err) } if resp.AccountID != accountID { return nil, fmt.Errorf("response does not belong to account") } return resp, nil } // Create creates a new assistant response. // Reference: Chatwoot Captain::AssistantResponsesController#create // documentable = Current.user (polymorphic User) func (s *CaptainAssistantResponseService) Create(ctx context.Context, accountID uint, userID uint, assistantID uint, question, answer, status string) (*model.CaptainAssistantResponse, error) { // Validate assistant exists and belongs to account assistant, err := s.assistantRepo.GetByID(ctx, assistantID) if err != nil { return nil, fmt.Errorf("assistant not found: %w", err) } if assistant.AccountID != accountID { return nil, fmt.Errorf("assistant does not belong to account") } resp := &model.CaptainAssistantResponse{ AccountID: accountID, AssistantID: assistantID, Question: question, Answer: answer, DocumentableID: &userID, DocumentableType: "User", // Chatwoot: documentable = Current.user } // ensure_status — default approved if status != "" { resp.Status = model.ResponseStatus(status) } else { resp.Status = model.ResponseStatusApproved } // ensure_account — derived from assistant.account (already validated above) if err := s.responseRepo.Create(ctx, resp); err != nil { return nil, fmt.Errorf("create response: %w", err) } return resp, nil } // Update modifies an existing assistant response. // Reference: Chatwoot Captain::AssistantResponsesController#update // mark_as_edited: only when question or answer actually changed func (s *CaptainAssistantResponseService) Update(ctx context.Context, accountID uint, responseID uint, question, answer string, status string) (*model.CaptainAssistantResponse, error) { resp, err := s.Get(ctx, accountID, responseID) if err != nil { return nil, err } // 1:1 Chatwoot mark_as_edited: only set edited=true if question or answer actually changed questionChanged := question != "" && question != resp.Question answerChanged := answer != "" && answer != resp.Answer if questionChanged || answerChanged { resp.Edited = true } if questionChanged { resp.Question = question } if answerChanged { resp.Answer = answer } if status != "" { resp.Status = model.ResponseStatus(status) } if err := s.responseRepo.Update(ctx, resp); err != nil { return nil, fmt.Errorf("update response: %w", err) } return resp, nil } // Delete removes an assistant response. // Reference: Chatwoot Captain::AssistantResponsesController#destroy func (s *CaptainAssistantResponseService) Delete(ctx context.Context, accountID uint, responseID uint) error { resp, err := s.Get(ctx, accountID, responseID) if err != nil { return err } return s.responseRepo.Delete(ctx, resp.ID) }