Files
gochat/internal/service/captain_bulk_action_service.go
T

284 lines
9.4 KiB
Go

package service
import (
"context"
"encoding/json"
"fmt"
"time"
"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"
pkgvalidator "github.com/gochat/gochat/pkg/validator"
)
// BulkActionType defines the type of bulk AI operation.
type BulkActionType string
const (
BulkActionLabelSuggestion BulkActionType = "label_suggestion"
BulkActionReplySuggestion BulkActionType = "reply_suggestion"
BulkActionFollowUp BulkActionType = "follow_up"
)
// CaptainBulkActionService handles batch AI operations on multiple conversations.
// Reference: Chatwoot Captain::BulkActionsController + M12 PRD
type CaptainBulkActionService struct {
conversationRepo *repository.ConversationRepo
messageRepo *repository.MessageRepo
assistantRepo *repository.CaptainAssistantRepo
preferenceRepo *repository.CaptainPreferenceRepo
llmProvider llm.Provider
taskExtendedSvc *CaptainTaskExtendedService
responseSvc *CaptainAssistantResponseService
responseRepo *repository.CaptainAssistantResponseRepo
documentRepo *repository.CaptainDocumentRepo
}
func NewCaptainBulkActionService(
conversationRepo *repository.ConversationRepo,
messageRepo *repository.MessageRepo,
assistantRepo *repository.CaptainAssistantRepo,
preferenceRepo *repository.CaptainPreferenceRepo,
llmProvider llm.Provider,
taskExtendedSvc *CaptainTaskExtendedService,
responseSvc *CaptainAssistantResponseService,
) *CaptainBulkActionService {
return &CaptainBulkActionService{
conversationRepo: conversationRepo,
messageRepo: messageRepo,
assistantRepo: assistantRepo,
preferenceRepo: preferenceRepo,
llmProvider: llmProvider,
taskExtendedSvc: taskExtendedSvc,
responseSvc: responseSvc,
}
}
func (s *CaptainBulkActionService) SetCaptainResourceRepos(responseRepo *repository.CaptainAssistantResponseRepo, documentRepo *repository.CaptainDocumentRepo) {
s.responseRepo = responseRepo
s.documentRepo = documentRepo
}
// --- Request/Response DTOs ---
// BulkActionRequest is the input for a bulk AI operation.
type BulkActionRequest struct {
Action BulkActionType `json:"action" validate:"required"`
ConversationIDs []uint `json:"conversation_ids" validate:"required,min=1"`
AssistantID uint `json:"assistant_id,omitempty"`
// Optional: for reply_suggestion, whether to auto-send
SendMessages bool `json:"send_messages,omitempty"`
}
// BulkActionResult holds the results of a bulk operation.
type BulkActionResult struct {
Action BulkActionType `json:"action"`
Processed int `json:"processed"`
Failed int `json:"failed"`
Results []json.RawMessage `json:"results"`
}
type ChatwootBulkActionRequest struct {
Type string `json:"type"`
IDs []uint `json:"ids"`
Fields struct {
Status string `json:"status"`
} `json:"fields"`
}
type ChatwootBulkActionResult struct {
AssistantResponses []model.CaptainAssistantResponse
IDs []uint
Count int64
Empty bool
}
// --- Business logic ---
// Execute performs a bulk AI action on multiple conversations.
func (s *CaptainBulkActionService) Execute(ctx context.Context, accountID uint, req *BulkActionRequest) (*BulkActionResult, error) {
// Validate request
if err := pkgvalidator.ValidateStruct(req); err != nil {
return nil, fmt.Errorf("validation error: %w", err)
}
// Validate action type
validActions := map[BulkActionType]bool{
BulkActionLabelSuggestion: true,
BulkActionReplySuggestion: true,
BulkActionFollowUp: true,
}
if !validActions[req.Action] {
return nil, fmt.Errorf("unsupported bulk action: %s", req.Action)
}
switch req.Action {
case BulkActionLabelSuggestion:
return s.bulkLabelSuggestion(ctx, accountID, req)
case BulkActionReplySuggestion:
return s.bulkReplySuggestion(ctx, accountID, req)
case BulkActionFollowUp:
return s.bulkFollowUp(ctx, accountID, req)
default:
return nil, fmt.Errorf("unsupported bulk action: %s", req.Action)
}
}
func (s *CaptainBulkActionService) ExecuteChatwoot(ctx context.Context, accountID uint, req *ChatwootBulkActionRequest) (*ChatwootBulkActionResult, error) {
if req == nil || req.Type == "" || len(req.IDs) == 0 || req.Fields.Status == "" {
return nil, fmt.Errorf("invalid bulk action request")
}
switch req.Type {
case "AssistantResponse":
return s.executeAssistantResponseBulk(ctx, accountID, req)
case "AssistantDocument":
return s.executeAssistantDocumentBulk(ctx, accountID, req)
default:
return nil, fmt.Errorf("unsupported bulk action type: %s", req.Type)
}
}
func (s *CaptainBulkActionService) executeAssistantResponseBulk(ctx context.Context, accountID uint, req *ChatwootBulkActionRequest) (*ChatwootBulkActionResult, error) {
if s.responseRepo == nil {
return nil, fmt.Errorf("assistant response repository unavailable")
}
db := s.responseRepo.DB().WithContext(ctx)
switch req.Fields.Status {
case "approve":
if err := db.Model(&model.CaptainAssistantResponse{}).
Where("account_id = ? AND id IN ? AND status = ?", accountID, req.IDs, model.ResponseStatusPending).
Update("status", model.ResponseStatusApproved).Error; err != nil {
return nil, fmt.Errorf("approve assistant responses: %w", err)
}
var responses []model.CaptainAssistantResponse
if err := db.Preload("Assistant").Where("account_id = ? AND id IN ?", accountID, req.IDs).Find(&responses).Error; err != nil {
return nil, fmt.Errorf("load assistant responses: %w", err)
}
return &ChatwootBulkActionResult{AssistantResponses: responses}, nil
case "delete":
if err := db.Where("account_id = ? AND id IN ?", accountID, req.IDs).Delete(&model.CaptainAssistantResponse{}).Error; err != nil {
return nil, fmt.Errorf("delete assistant responses: %w", err)
}
return &ChatwootBulkActionResult{Empty: true}, nil
default:
return nil, fmt.Errorf("unsupported assistant response status: %s", req.Fields.Status)
}
}
func (s *CaptainBulkActionService) executeAssistantDocumentBulk(ctx context.Context, accountID uint, req *ChatwootBulkActionRequest) (*ChatwootBulkActionResult, error) {
if s.documentRepo == nil {
return nil, fmt.Errorf("document repository unavailable")
}
db := s.documentRepo.DB().WithContext(ctx)
switch req.Fields.Status {
case "delete":
result := db.Where("account_id = ? AND id IN ?", accountID, req.IDs).Delete(&model.CaptainDocument{})
if result.Error != nil {
return nil, fmt.Errorf("delete documents: %w", result.Error)
}
return &ChatwootBulkActionResult{Count: result.RowsAffected}, nil
case "sync":
now := time.Now().Unix()
result := db.Model(&model.CaptainDocument{}).
Where("account_id = ? AND id IN ?", accountID, req.IDs).
Updates(map[string]any{
"sync_status": model.DocumentSyncStatusPending,
"last_sync_attempted_at": &now,
"last_sync_error_code": "",
})
if result.Error != nil {
return nil, fmt.Errorf("sync documents: %w", result.Error)
}
return &ChatwootBulkActionResult{IDs: req.IDs, Count: result.RowsAffected}, nil
default:
return nil, fmt.Errorf("unsupported document status: %s", req.Fields.Status)
}
}
func (s *CaptainBulkActionService) bulkLabelSuggestion(ctx context.Context, accountID uint, req *BulkActionRequest) (*BulkActionResult, error) {
query := &LabelSuggestionQuery{
ConversationIDs: req.ConversationIDs,
AssistantID: req.AssistantID,
}
result, err := s.taskExtendedSvc.SuggestLabels(ctx, accountID, query)
if err != nil {
return nil, fmt.Errorf("bulk label suggestion: %w", err)
}
var results []json.RawMessage
for _, s := range result.Suggestions {
raw, _ := json.Marshal(s)
results = append(results, raw)
}
return &BulkActionResult{
Action: BulkActionLabelSuggestion,
Processed: len(result.Suggestions),
Failed: len(req.ConversationIDs) - len(result.Suggestions),
Results: results,
}, nil
}
func (s *CaptainBulkActionService) bulkReplySuggestion(ctx context.Context, accountID uint, req *BulkActionRequest) (*BulkActionResult, error) {
var results []json.RawMessage
processed := 0
failed := 0
for _, convID := range req.ConversationIDs {
respReq := &ProcessResponseRequest{
ConversationID: convID,
AssistantID: req.AssistantID,
SendMessage: req.SendMessages,
}
result, err := s.responseSvc.ProcessResponse(ctx, accountID, respReq)
if err != nil {
applogger.L().Warnf("Bulk reply suggestion failed for conversation %d: %v", convID, err)
failed++
continue
}
raw, _ := json.Marshal(result)
results = append(results, raw)
processed++
}
return &BulkActionResult{
Action: BulkActionReplySuggestion,
Processed: processed,
Failed: failed,
Results: results,
}, nil
}
func (s *CaptainBulkActionService) bulkFollowUp(ctx context.Context, accountID uint, req *BulkActionRequest) (*BulkActionResult, error) {
query := &FollowUpQuery{
ConversationIDs: req.ConversationIDs,
AssistantID: req.AssistantID,
}
result, err := s.taskExtendedSvc.SuggestFollowUp(ctx, accountID, query)
if err != nil {
return nil, fmt.Errorf("bulk follow-up: %w", err)
}
var results []json.RawMessage
for _, f := range result.FollowUps {
raw, _ := json.Marshal(f)
results = append(results, raw)
}
return &BulkActionResult{
Action: BulkActionFollowUp,
Processed: len(result.FollowUps),
Failed: len(req.ConversationIDs) - len(result.FollowUps),
Results: results,
}, nil
}