package service import ( "context" "encoding/json" "errors" "fmt" "math" "strconv" "strings" "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" "github.com/pgvector/pgvector-go" "github.com/redis/go-redis/v9" "gorm.io/gorm" "gorm.io/gorm/clause" ) // CaptainAssistantService implements business logic for CaptainAssistant operations. // Reference: Chatwoot enterprise/app/controllers/api/v1/captain/assistants_controller.rb type CaptainAssistantService struct { assistantRepo *repository.CaptainAssistantRepo inboxRepo *repository.CaptainInboxRepo documentRepo *repository.CaptainDocumentRepo responseRepo *repository.CaptainAssistantResponseRepo llmProvider llm.Provider cache *redis.Client promptBuilder *SystemPromptBuilder } // NewCaptainAssistantService creates a new CaptainAssistantService. func NewCaptainAssistantService( assistantRepo *repository.CaptainAssistantRepo, inboxRepo *repository.CaptainInboxRepo, documentRepo *repository.CaptainDocumentRepo, responseRepo *repository.CaptainAssistantResponseRepo, llmProvider llm.Provider, cache ...*redis.Client, ) *CaptainAssistantService { svc := &CaptainAssistantService{ assistantRepo: assistantRepo, inboxRepo: inboxRepo, documentRepo: documentRepo, responseRepo: responseRepo, llmProvider: llmProvider, promptBuilder: NewSystemPromptBuilder(), } if len(cache) > 0 { svc.cache = cache[0] } return svc } // --- Request DTOs --- // CreateAssistantRequest is the DTO for creating an assistant. type CreateAssistantRequest struct { Name string `json:"name" validate:"required,min=2"` Description string `json:"description"` Config json.RawMessage `json:"config"` Guardrails json.RawMessage `json:"guardrails"` ResponseGuidelines json.RawMessage `json:"response_guidelines"` } // UpdateAssistantRequest is the DTO for updating an assistant. type UpdateAssistantRequest struct { Name string `json:"name"` Description string `json:"description"` Config json.RawMessage `json:"config"` Guardrails json.RawMessage `json:"guardrails"` ResponseGuidelines json.RawMessage `json:"response_guidelines"` Status string `json:"status"` } type PlaygroundMessage struct { Role string `json:"role"` Content string `json:"content"` AgentName string `json:"agent_name,omitempty"` } type PlaygroundRequest struct { MessageContent string `json:"message_content"` MessageHistory []PlaygroundMessage `json:"message_history"` } type CaptainMetricValue struct { Current float64 `json:"current"` Previous float64 `json:"previous"` Trend float64 `json:"trend"` } type CaptainKnowledgeStats struct { Approved int64 `json:"approved"` Pending int64 `json:"pending"` Documents int64 `json:"documents"` Coverage float64 `json:"coverage"` } type CaptainAssistantStats struct { ConversationsHandled CaptainMetricValue `json:"conversations_handled"` AutoResolutionRate CaptainMetricValue `json:"auto_resolution_rate"` HandoffRate CaptainMetricValue `json:"handoff_rate"` HoursSaved CaptainMetricValue `json:"hours_saved"` ReopenRate CaptainMetricValue `json:"reopen_rate"` ConversationDepth CaptainMetricValue `json:"conversation_depth"` Knowledge CaptainKnowledgeStats `json:"knowledge"` } type CaptainDrilldownParams struct { Metric string Range string TimezoneOffset float64 Page int PerPage int } type CaptainDrilldownResult struct { Meta map[string]any `json:"meta"` Payload []map[string]any `json:"payload"` } var captainReportReasons = map[string]bool{ "incorrect_information": true, "inappropriate_response": true, "incomplete_response": true, "outdated_information": true, "other": true, } var captainResolvedEvents = []string{"conversation_captain_inference_resolved", "conversation_bot_resolved"} var captainHandoffEvents = []string{"conversation_captain_inference_handoff", "conversation_bot_handoff"} // --- CRUD Operations --- // Create creates a new CaptainAssistant. func (s *CaptainAssistantService) Create(ctx context.Context, accountID uint, req *CreateAssistantRequest) (*model.CaptainAssistant, error) { if req.Name == "" { return nil, fmt.Errorf("name is required") } if req.Description == "" { return nil, fmt.Errorf("description is required") } config := req.Config if len(config) == 0 || string(config) == "null" { defaultCfg := model.DefaultAssistantConfig() data, err := json.Marshal(defaultCfg) if err != nil { return nil, fmt.Errorf("marshal default config: %w", err) } config = data } assistant := &model.CaptainAssistant{ AccountID: accountID, Name: req.Name, Description: req.Description, Config: config, Guardrails: req.Guardrails, ResponseGuidelines: req.ResponseGuidelines, Status: model.AssistantStatusActive, } if err := s.assistantRepo.Create(ctx, assistant); err != nil { applogger.L().Errorf("Create captain assistant: %v", err) return nil, fmt.Errorf("create assistant: %w", err) } return assistant, nil } // Get retrieves an assistant by ID scoped to an account. func (s *CaptainAssistantService) Get(ctx context.Context, accountID, id uint) (*model.CaptainAssistant, error) { assistant, err := s.assistantRepo.GetByAccountAndID(ctx, accountID, id) if err != nil { applogger.L().Errorf("Get captain assistant: %v", err) return nil, fmt.Errorf("get assistant: %w", err) } return assistant, nil } // Update updates an existing assistant scoped to an account. func (s *CaptainAssistantService) Update(ctx context.Context, accountID, id uint, req *UpdateAssistantRequest) (*model.CaptainAssistant, error) { assistant, err := s.assistantRepo.GetByAccountAndID(ctx, accountID, id) if err != nil { return nil, fmt.Errorf("assistant not found: %w", err) } if req.Name != "" { assistant.Name = req.Name } if req.Description != "" { assistant.Description = req.Description } if len(req.Config) > 0 && string(req.Config) != "null" { assistant.Config = req.Config } if len(req.Guardrails) > 0 && string(req.Guardrails) != "null" { assistant.Guardrails = req.Guardrails } if len(req.ResponseGuidelines) > 0 && string(req.ResponseGuidelines) != "null" { assistant.ResponseGuidelines = req.ResponseGuidelines } if req.Status != "" { assistant.Status = model.AssistantStatus(req.Status) } if err := s.assistantRepo.Update(ctx, assistant); err != nil { applogger.L().Errorf("Update captain assistant: %v", err) return nil, fmt.Errorf("update assistant: %w", err) } return assistant, nil } // Delete deletes an assistant by ID scoped to an account. func (s *CaptainAssistantService) Delete(ctx context.Context, accountID, id uint) error { if _, err := s.assistantRepo.GetByAccountAndID(ctx, accountID, id); err != nil { return fmt.Errorf("assistant not found: %w", err) } if err := s.assistantRepo.Delete(ctx, id); err != nil { applogger.L().Errorf("Delete captain assistant: %v", err) return fmt.Errorf("delete assistant: %w", err) } return nil } // List retrieves assistants for an account with pagination. func (s *CaptainAssistantService) List(ctx context.Context, accountID uint, offset, limit int) ([]model.CaptainAssistant, int64, error) { assistants, count, err := s.assistantRepo.ListByAccount(ctx, accountID, offset, limit) if err != nil { applogger.L().Errorf("List captain assistants: %v", err) return nil, 0, fmt.Errorf("list assistants: %w", err) } return assistants, count, nil } func (s *CaptainAssistantService) Stats(ctx context.Context, accountID, assistantID uint, rangeValue string, timezoneOffset float64) (*CaptainAssistantStats, error) { if _, err := s.assistantRepo.GetByAccountAndID(ctx, accountID, assistantID); err != nil { return nil, err } currentStart, currentEnd, previousStart, previousEnd := captainStatsWindows(rangeValue, timezoneOffset, time.Now()) current, err := s.captainWindowMetrics(ctx, accountID, assistantID, currentStart, currentEnd) if err != nil { return nil, err } previous, err := s.captainWindowMetrics(ctx, accountID, assistantID, previousStart, previousEnd) if err != nil { return nil, err } knowledge, err := s.captainKnowledgeStats(ctx, assistantID) if err != nil { return nil, err } return &CaptainAssistantStats{ ConversationsHandled: captainPack(current.handled, previous.handled, true), AutoResolutionRate: captainPack(current.autoResolutionRate, previous.autoResolutionRate, false), HandoffRate: captainPack(current.handoffRate, previous.handoffRate, false), HoursSaved: captainPack(current.hoursSaved, previous.hoursSaved, true), ReopenRate: captainPack(current.reopenRate, previous.reopenRate, false), ConversationDepth: captainPack(current.depth, previous.depth, false), Knowledge: knowledge, }, nil } func (s *CaptainAssistantService) Summary(ctx context.Context, accountID, assistantID, userID uint, rangeValue string, timezoneOffset float64) (string, error) { cacheKey := fmt.Sprintf("captain_overview_summary/%d/%d/%s/%s", assistantID, userID, rangeValue, time.Now().Format("2006-01-02")) if s.cache != nil { if cached, err := s.cache.Get(ctx, cacheKey).Result(); err == nil { return cached, nil } } if s.llmProvider == nil { return "", fmt.Errorf("Captain summary is unavailable") } stats, err := s.Stats(ctx, accountID, assistantID, rangeValue, timezoneOffset) if err != nil { return "", err } var user model.User _ = s.assistantRepo.DB().WithContext(ctx).First(&user, userID).Error firstName := "" if names := strings.Fields(user.Name); len(names) > 0 { firstName = names[0] } statsJSON, _ := json.Marshal(stats) response, err := s.llmProvider.ChatCompletion(llm.WithAccountFeature(ctx, accountID, "assistant"), llm.ChatRequest{ Messages: []llm.ChatMessage{ {Role: "system", Content: "Summarize these Captain support metrics in one concise, useful paragraph. Address the user by first name when available. Return only the summary."}, {Role: "user", Content: fmt.Sprintf("User: %s\nPeriod: %s\nMetrics: %s", firstName, rangeValue, statsJSON)}, }, Temperature: 0.3, MaxTokens: 300, }) if err != nil { return "", err } if response == nil || len(response.Choices) == 0 || strings.TrimSpace(response.Choices[0].Message.Content) == "" { return "", fmt.Errorf("Captain summary is unavailable") } message := strings.TrimSpace(response.Choices[0].Message.Content) if s.cache != nil { _ = s.cache.Set(ctx, cacheKey, message, time.Hour).Err() } return message, nil } type captainWindowMetricValues struct { handled, autoResolutionRate, handoffRate, hoursSaved, reopenRate, depth float64 } func (s *CaptainAssistantService) captainWindowMetrics(ctx context.Context, accountID, assistantID uint, since, until time.Time) (captainWindowMetricValues, error) { db := s.assistantRepo.DB().WithContext(ctx) base := db.Model(&model.Message{}).Where("account_id = ? AND sender_id = ? AND sender_type IN ? AND created_at BETWEEN ? AND ?", accountID, assistantID, []string{"Captain::Assistant", "CaptainAssistant", "captain_assistant"}, since, until) var handled int64 if err := base.Distinct("conversation_id").Count(&handled).Error; err != nil { return captainWindowMetricValues{}, err } var publicReplies int64 if err := base.Where("message_type = ? AND private = ?", "outgoing", false).Count(&publicReplies).Error; err != nil { return captainWindowMetricValues{}, err } var depthConversations int64 if err := base.Where("message_type = ? AND private = ?", "outgoing", false).Distinct("conversation_id").Count(&depthConversations).Error; err != nil { return captainWindowMetricValues{}, err } var handledIDs []uint if err := base.Distinct().Pluck("conversation_id", &handledIDs).Error; err != nil { return captainWindowMetricValues{}, err } resolved, handoffs, reopened := int64(0), int64(0), int64(0) if len(handledIDs) > 0 { resolvedEvents := s.captainResolvedEventQuery(ctx, accountID, handledIDs, since, until) if err := resolvedEvents.Distinct("conversation_id").Count(&resolved).Error; err != nil { return captainWindowMetricValues{}, err } if err := db.Model(&model.ReportingEvent{}).Where("account_id = ? AND conversation_id IN ? AND name IN ? AND created_at BETWEEN ? AND ?", accountID, handledIDs, captainHandoffEvents, since, until).Distinct("conversation_id").Count(&handoffs).Error; err != nil { return captainWindowMetricValues{}, err } resolvedSubquery := resolvedEvents.Select("conversation_id, event_end_time") if err := db.Table("reporting_events AS reopens").Joins("INNER JOIN (?) resolves ON resolves.conversation_id = reopens.conversation_id AND reopens.event_end_time >= resolves.event_end_time", resolvedSubquery). Where("reopens.account_id = ? AND reopens.name = ? AND reopens.value > 0 AND reopens.event_end_time <= ?", accountID, "conversation_opened", until). Distinct("reopens.conversation_id").Count(&reopened).Error; err != nil { return captainWindowMetricValues{}, err } } return captainWindowMetricValues{ handled: float64(handled), autoResolutionRate: captainRate(resolved, handled), handoffRate: captainRate(handoffs, handled), hoursSaved: math.Round(float64(publicReplies) * 120 / 3600), reopenRate: captainRate(reopened, resolved), depth: captainDivide(publicReplies, depthConversations), }, nil } func (s *CaptainAssistantService) captainResolvedEventQuery(ctx context.Context, accountID uint, handledIDs []uint, since, until time.Time) *gorm.DB { db := s.assistantRepo.DB().WithContext(ctx) handoffIDs := db.Model(&model.ReportingEvent{}).Select("conversation_id").Where("account_id = ? AND name IN ? AND created_at BETWEEN ? AND ?", accountID, captainHandoffEvents, since, until) return db.Model(&model.ReportingEvent{}). Where("account_id = ? AND conversation_id IN ? AND name IN ? AND created_at BETWEEN ? AND ?", accountID, handledIDs, captainResolvedEvents, since, until). Where("NOT (name = ? AND conversation_id IN (?))", "conversation_bot_resolved", handoffIDs) } func (s *CaptainAssistantService) captainKnowledgeStats(ctx context.Context, assistantID uint) (CaptainKnowledgeStats, error) { db := s.assistantRepo.DB().WithContext(ctx) var approved, pending, documents int64 if err := db.Model(&model.CaptainAssistantResponse{}).Where("assistant_id = ? AND status = ?", assistantID, model.ResponseStatusApproved).Count(&approved).Error; err != nil { return CaptainKnowledgeStats{}, err } if err := db.Model(&model.CaptainAssistantResponse{}).Where("assistant_id = ? AND status = ?", assistantID, model.ResponseStatusPending).Count(&pending).Error; err != nil { return CaptainKnowledgeStats{}, err } if err := db.Model(&model.CaptainDocument{}).Where("assistant_id = ?", assistantID).Count(&documents).Error; err != nil { return CaptainKnowledgeStats{}, err } total := approved + pending coverage := float64(0) if total > 0 { coverage = math.Round(float64(approved) / float64(total) * 100) } return CaptainKnowledgeStats{Approved: approved, Pending: pending, Documents: documents, Coverage: coverage}, nil } func (s *CaptainAssistantService) Drilldown(ctx context.Context, accountID, assistantID uint, params CaptainDrilldownParams) (*CaptainDrilldownResult, error) { supported := map[string]bool{"conversations_handled": true, "auto_resolution_rate": true, "handoff_rate": true, "reopen_rate": true} if !supported[params.Metric] { return nil, fmt.Errorf("unsupported metric") } if _, err := s.assistantRepo.GetByAccountAndID(ctx, accountID, assistantID); err != nil { return nil, err } since, until, _, _ := captainStatsWindows(params.Range, params.TimezoneOffset, time.Now()) db := s.assistantRepo.DB().WithContext(ctx) var handledIDs []uint if err := db.Model(&model.Message{}).Where("account_id = ? AND sender_id = ? AND sender_type IN ? AND created_at BETWEEN ? AND ?", accountID, assistantID, []string{"Captain::Assistant", "CaptainAssistant", "captain_assistant"}, since, until).Distinct().Pluck("conversation_id", &handledIDs).Error; err != nil { return nil, err } ids := handledIDs if params.Metric != "conversations_handled" { var eventIDs []uint resolvedEvents := s.captainResolvedEventQuery(ctx, accountID, handledIDs, since, until) query := resolvedEvents conversationIDColumn := "conversation_id" if params.Metric == "handoff_rate" { query = db.Model(&model.ReportingEvent{}).Where("account_id = ? AND conversation_id IN ? AND name IN ? AND created_at BETWEEN ? AND ?", accountID, handledIDs, captainHandoffEvents, since, until) } else if params.Metric == "reopen_rate" { query = db.Table("reporting_events AS reopens").Joins("INNER JOIN (?) resolves ON resolves.conversation_id = reopens.conversation_id AND reopens.event_end_time >= resolves.event_end_time", resolvedEvents.Select("conversation_id, event_end_time")). Where("reopens.account_id = ? AND reopens.name = ? AND reopens.value > 0 AND reopens.event_end_time <= ?", accountID, "conversation_opened", until) conversationIDColumn = "reopens.conversation_id" } if err := query.Distinct().Pluck(conversationIDColumn, &eventIDs).Error; err != nil { return nil, err } ids = eventIDs } params.Page = max(params.Page, 1) if params.PerPage <= 0 { params.PerPage = 25 } if params.PerPage > 100 { params.PerPage = 100 } var total int64 query := db.Model(&model.Conversation{}).Where("account_id = ? AND id IN ?", accountID, ids) if err := query.Count(&total).Error; err != nil { return nil, err } var conversations []model.Conversation if err := query.Preload("Contact").Preload("Inbox").Preload("Assignee").Order("created_at DESC").Offset((params.Page - 1) * params.PerPage).Limit(params.PerPage).Find(&conversations).Error; err != nil { return nil, err } payload := make([]map[string]any, 0, len(conversations)) for i := range conversations { payload = append(payload, s.captainConversationDrilldownRecord(ctx, &conversations[i])) } return &CaptainDrilldownResult{Meta: map[string]any{"metric": params.Metric, "current_page": params.Page, "per_page": params.PerPage, "total_count": total, "conversation_count": total, "range": map[string]any{"since": since.Unix(), "until": until.Unix()}}, Payload: payload}, nil } func (s *CaptainAssistantService) captainConversationDrilldownRecord(ctx context.Context, conversation *model.Conversation) map[string]any { db := s.assistantRepo.DB().WithContext(ctx) var last model.Message _ = db.Where("conversation_id = ? AND message_type <> ?", conversation.ID, "activity").Order("created_at DESC, id DESC").First(&last).Error var lastPayload any if last.ID != 0 { lastPayload = map[string]any{"id": last.ID, "content": last.Content, "message_type": last.MessageType, "sender_name": nil, "created_at": last.CreatedAt.Unix()} } assigneeName := "" if conversation.Assignee != nil { assigneeName = conversation.Assignee.Name } return map[string]any{"record_type": "conversation", "conversation": map[string]any{ "id": conversation.ID, "display_id": conversation.DisplayID, "contact_id": conversation.ContactID, "contact_name": conversation.Contact.Name, "inbox_id": conversation.InboxID, "inbox_name": conversation.Inbox.Name, "assignee_id": conversation.AssigneeID, "assignee_name": assigneeName, "status": conversation.Status, "created_at": conversation.CreatedAt.Unix(), "last_activity_at": valueOrZero(conversation.LastActivityAt), "last_message": lastPayload, }, "message": nil, "metric_value": nil, "occurred_at": conversation.CreatedAt.Unix()} } func (s *CaptainAssistantService) CreateMessageReport(ctx context.Context, accountID, userID, messageID uint, reason, description string) (*model.CaptainMessageReport, error) { if !captainReportReasons[reason] { return nil, fmt.Errorf("invalid report_reason") } var message model.Message if err := s.assistantRepo.DB().WithContext(ctx).Where("account_id = ? AND id = ?", accountID, messageID).First(&message).Error; err != nil { return nil, err } if message.SenderType != "Captain::Assistant" && message.SenderType != "CaptainAssistant" && message.SenderType != "captain_assistant" { return nil, fmt.Errorf("Only Captain messages can be reported") } report := &model.CaptainMessageReport{AccountID: accountID, ConversationID: message.ConversationID, MessageID: message.ID, UserID: userID, ReportReason: reason, Description: description} if err := s.assistantRepo.DB().WithContext(ctx).Create(report).Error; err != nil { return nil, err } return report, nil } func captainStatsWindows(rangeValue string, timezoneOffset float64, now time.Time) (time.Time, time.Time, time.Time, time.Time) { allowed := map[string]bool{"7": true, "30": true, "90": true, "this_month": true, "last_month": true} if !allowed[rangeValue] { rangeValue = "30" } loc := time.FixedZone("captain", int(timezoneOffset*3600)) localNow := now.In(loc) if rangeValue == "this_month" || rangeValue == "last_month" { start := time.Date(localNow.Year(), localNow.Month(), 1, 0, 0, 0, 0, loc) end := localNow if rangeValue == "last_month" { end = start.Add(-time.Nanosecond) start = time.Date(end.Year(), end.Month(), 1, 0, 0, 0, 0, loc) } previousEnd := start.Add(-time.Nanosecond) previousStart := time.Date(previousEnd.Year(), previousEnd.Month(), 1, 0, 0, 0, 0, loc) if rangeValue == "this_month" { elapsed := end.Sub(start) candidate := previousStart.Add(elapsed) if candidate.Before(previousEnd) { previousEnd = candidate } } return start.UTC(), end.UTC(), previousStart.UTC(), previousEnd.UTC() } days, _ := strconv.Atoi(rangeValue) duration := time.Duration(days) * 24 * time.Hour return now.Add(-duration), now, now.Add(-2 * duration), now.Add(-duration) } func captainRate(numerator, denominator int64) float64 { if denominator == 0 { return 0 } return math.Round(float64(numerator)/float64(denominator)*1000) / 10 } func captainDivide(numerator, denominator int64) float64 { if denominator == 0 { return 0 } return math.Round(float64(numerator)/float64(denominator)*10) / 10 } func captainPack(current, previous float64, percent bool) CaptainMetricValue { trend := current - previous if percent { if previous == 0 { trend = 0 } else { trend = (current - previous) / previous * 100 } } return CaptainMetricValue{Current: current, Previous: previous, Trend: math.Round(trend*10) / 10} } func valueOrZero(value *int64) int64 { if value == nil { return 0 } return *value } // --- Config Management --- // GetConfig reads and parses the assistant's JSONB config. func (s *CaptainAssistantService) GetConfig(ctx context.Context, id uint) (*model.AssistantConfig, error) { assistant, err := s.assistantRepo.GetByID(ctx, id) if err != nil { return nil, fmt.Errorf("assistant not found: %w", err) } return assistant.GetConfig() } // SetConfig updates the assistant's JSONB config. func (s *CaptainAssistantService) SetConfig(ctx context.Context, id uint, cfg *model.AssistantConfig) error { assistant, err := s.assistantRepo.GetByID(ctx, id) if err != nil { return fmt.Errorf("assistant not found: %w", err) } if err := assistant.SetConfig(cfg); err != nil { return fmt.Errorf("set config: %w", err) } if err := s.assistantRepo.Update(ctx, assistant); err != nil { applogger.L().Errorf("SetConfig captain assistant: %v", err) return fmt.Errorf("update assistant config: %w", err) } return nil } // --- Inbox Association --- // AssociateInbox binds an assistant to an inbox. func (s *CaptainAssistantService) AssociateInbox(ctx context.Context, assistantID, inboxID, accountID uint) (*model.Inbox, error) { assistant, err := s.assistantRepo.GetByAccountAndID(ctx, accountID, assistantID) if err != nil { return nil, fmt.Errorf("assistant not found: %w", err) } inbox, err := s.inboxRepo.FindAccountInbox(ctx, accountID, inboxID) if err != nil { return nil, fmt.Errorf("inbox not found: %w", err) } db := s.assistantRepo.DB().WithContext(ctx) err = db.Transaction(func(tx *gorm.DB) error { ci := &model.CaptainInbox{AssistantID: assistantID, InboxID: inboxID, AccountID: accountID} if err := tx.Clauses(clause.OnConflict{ Columns: []clause.Column{{Name: "inbox_id"}}, TargetWhere: clause.Where{Exprs: []clause.Expression{clause.Expr{SQL: "deleted_at IS NULL"}}}, DoNothing: true, }).Create(ci).Error; err != nil { return err } var persisted model.CaptainInbox if err := tx.Where("inbox_id = ?", inboxID).First(&persisted).Error; err != nil { return err } if persisted.AssistantID != assistantID { return errors.New("inbox is already associated with another assistant") } _, err := ensureCaptainAgentBotBinding(ctx, tx, assistant, inboxID) return err }) if err != nil { applogger.L().Errorf("AssociateInbox: %v", err) return nil, fmt.Errorf("associate inbox: %w", err) } return inbox, nil } // DissociateInbox unbinds an assistant from an inbox. func (s *CaptainAssistantService) DissociateInbox(ctx context.Context, accountID, assistantID, inboxID uint) error { if _, err := s.assistantRepo.GetByAccountAndID(ctx, accountID, assistantID); err != nil { return fmt.Errorf("assistant not found: %w", err) } if ci, err := s.inboxRepo.FindByAssistantAndInbox(ctx, assistantID, inboxID); err != nil { return fmt.Errorf("captain inbox not found: %w", err) } else if ci.AccountID != accountID { return fmt.Errorf("captain inbox not found") } db := s.assistantRepo.DB().WithContext(ctx) if err := db.Transaction(func(tx *gorm.DB) error { if err := tx.Where("account_id = ? AND captain_assistant_id = ? AND inbox_id = ?", accountID, assistantID, inboxID).Delete(&model.CaptainInbox{}).Error; err != nil { return err } var bot model.AgentBot if err := tx.Where("account_id = ? AND captain_assistant_id = ?", accountID, assistantID).First(&bot).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil } return err } return tx.Where("inbox_id = ? AND agent_bot_id = ?", inboxID, bot.ID).Delete(&model.AgentBotInbox{}).Error }); err != nil { applogger.L().Errorf("DissociateInbox: %v", err) return fmt.Errorf("dissociate inbox: %w", err) } return nil } func ensureCaptainAgentBotBinding(ctx context.Context, db *gorm.DB, assistant *model.CaptainAssistant, inboxID uint) (*model.AgentBot, error) { var legacyBots []model.AgentBot if err := db.WithContext(ctx).Where("account_id = ? AND bot_type = ? AND captain_assistant_id IS NULL", assistant.AccountID, "captain").Find(&legacyBots).Error; err != nil { return nil, err } for i := range legacyBots { if extractAssistantIDFromBotConfig(legacyBots[i].Config) == assistant.ID { if err := db.WithContext(ctx).Model(&legacyBots[i]).Where("captain_assistant_id IS NULL").Update("captain_assistant_id", assistant.ID).Error; err != nil { return nil, err } break } } token, err := generateBotAccessToken() if err != nil { return nil, err } secret, err := generateBotSecret() if err != nil { return nil, err } bot := &model.AgentBot{AccountID: &assistant.AccountID, CaptainAssistantID: &assistant.ID, Name: fmt.Sprintf("Captain Assistant #%d", assistant.ID), Description: assistant.Name, BotType: "captain", AccessToken: token, Secret: secret, Config: json.RawMessage(fmt.Sprintf(`{"assistant_id":%d}`, assistant.ID))} if err := db.WithContext(ctx).Clauses(clause.OnConflict{ Columns: []clause.Column{{Name: "account_id"}, {Name: "captain_assistant_id"}}, DoNothing: true, }).Create(bot).Error; err != nil { return nil, err } var persisted model.AgentBot if err := db.WithContext(ctx).Where("account_id = ? AND captain_assistant_id = ?", assistant.AccountID, assistant.ID).First(&persisted).Error; err != nil { return nil, fmt.Errorf("load captain bot: %w", err) } bot = &persisted binding := &model.AgentBotInbox{AgentBotID: bot.ID, InboxID: inboxID, AccountID: &assistant.AccountID, Status: model.AgentBotInboxActive} if err := db.WithContext(ctx).Clauses(clause.OnConflict{ Columns: []clause.Column{{Name: "agent_bot_id"}, {Name: "inbox_id"}}, DoUpdates: clause.Assignments(map[string]any{"status": model.AgentBotInboxActive, "account_id": assistant.AccountID}), }).Create(binding).Error; err != nil { return nil, err } return bot, nil } func (s *CaptainAssistantService) ListInboxes(ctx context.Context, accountID, assistantID uint) ([]model.Inbox, error) { if _, err := s.assistantRepo.GetByAccountAndID(ctx, accountID, assistantID); err != nil { return nil, fmt.Errorf("assistant not found: %w", err) } return s.inboxRepo.ListInboxesByAssistant(ctx, accountID, assistantID) } func (s *CaptainAssistantService) AvailableTools(ctx context.Context, accountID uint) []map[string]any { return []map[string]any{ {"id": "add_contact_note", "title": "Add Contact Note", "description": "Add a note to a contact profile", "icon": "note-add"}, {"id": "add_private_note", "title": "Add Private Note", "description": "Add a private note to a conversation (internal only)", "icon": "eye-off"}, {"id": "update_priority", "title": "Update Priority", "description": "Update conversation priority level", "icon": "exclamation-triangle"}, {"id": "add_label_to_conversation", "title": "Add Label to Conversation", "description": "Add a label to a conversation", "icon": "tag"}, {"id": "faq_lookup", "title": "FAQ Lookup", "description": "Search FAQ responses using semantic similarity", "icon": "search"}, {"id": "resolve_conversation", "title": "Resolve Conversation", "description": "Resolve a conversation when the issue has been addressed", "icon": "checkmark"}, {"id": "handoff", "title": "Handoff to Human", "description": "Hand off the conversation to a human agent", "icon": "user-switch"}, } } // --- Document Management --- // AddDocument creates a document linked to the assistant. func (s *CaptainAssistantService) AddDocument(ctx context.Context, assistantID uint, doc *model.CaptainDocument) error { doc.AssistantID = assistantID if err := s.documentRepo.Create(ctx, doc); err != nil { applogger.L().Errorf("AddDocument: %v", err) return fmt.Errorf("add document: %w", err) } return nil } // RemoveDocument removes a document from the assistant. func (s *CaptainAssistantService) RemoveDocument(ctx context.Context, documentID uint) error { if err := s.documentRepo.Delete(ctx, documentID); err != nil { applogger.L().Errorf("RemoveDocument: %v", err) return fmt.Errorf("remove document: %w", err) } return nil } // --- RAG Response Generation --- // GenerateResponse calls the LLM provider to generate a response based on RAG context. // Reference: Chatwoot Captain::Assistant::ResponseGenerator func (s *CaptainAssistantService) GenerateResponse(ctx context.Context, assistantID uint, query string) (string, error) { assistant, err := s.assistantRepo.GetByID(ctx, assistantID) if err != nil { return "", fmt.Errorf("assistant not found: %w", err) } ctx = llm.WithAccountFeature(ctx, assistant.AccountID, "assistant") // Build system prompt from assistant config and response guidelines cfg, _ := assistant.GetConfig() ctx = withAssistantGenerationConfig(ctx, cfg) systemPrompt := s.promptBuilder.BuildAssistantPrompt(assistant, cfg) // Build messages for LLM messages := []llm.ChatMessage{ {Role: "system", Content: systemPrompt}, {Role: "user", Content: query}, } req := llm.ChatRequest{ Model: cfg.Model, Messages: messages, Temperature: cfg.Temperature, MaxTokens: 1024, } resp, err := s.llmProvider.ChatCompletion(ctx, req) if err != nil { if errors.Is(err, llm.ErrProviderNotConfigured) { applogger.L().Warnf("GenerateResponse: LLM provider not configured, returning fallback") return captainPlaygroundFallbackMessage, nil } applogger.L().Errorf("GenerateResponse LLM call: %v", err) return "", fmt.Errorf("llm generation failed: %w", err) } if len(resp.Choices) == 0 { return "", fmt.Errorf("no response from LLM") } return resp.Choices[0].Message.Content, nil } const captainPlaygroundFallbackMessage = "Captain assistant response generation is not configured for this account." // GeneratePlaygroundResponse follows Chatwoot Captain assistant playground behavior. func (s *CaptainAssistantService) GeneratePlaygroundResponse(ctx context.Context, accountID, assistantID uint, req PlaygroundRequest) (map[string]any, error) { ctx = llm.WithAccountFeature(ctx, accountID, "assistant") assistant, err := s.assistantRepo.GetByAccountAndID(ctx, accountID, assistantID) if err != nil { return nil, fmt.Errorf("assistant not found: %w", err) } if s.captainV2Enabled(ctx, accountID) { history := playgroundMessageHistory(req.MessageHistory, req.MessageContent) content, err := s.generatePlaygroundLLMResponse(ctx, assistant, history) if err != nil { return captainPlaygroundV2ErrorResponse(err), nil } return map[string]any{"response": content}, nil } history := append([]PlaygroundMessage{}, req.MessageHistory...) content, err := s.generatePlaygroundLLMResponse(ctx, assistant, appendAdditionalPlaygroundMessage(history, req.MessageContent)) if err != nil { return nil, err } return map[string]any{"content": content}, nil } func captainPlaygroundV2ErrorResponse(err error) map[string]any { return map[string]any{ "response": "conversation_handoff", "reasoning": fmt.Sprintf("Error occurred: %v", err), "handoff_tool_called": false, } } func (s *CaptainAssistantService) generatePlaygroundLLMResponse(ctx context.Context, assistant *model.CaptainAssistant, history []PlaygroundMessage) (string, error) { if s.llmProvider == nil { return captainPlaygroundFallbackMessage, nil } cfg, _ := assistant.GetConfig() ctx = withAssistantGenerationConfig(ctx, cfg) // RAG: embed the latest user message and search approved FAQ responses. // This mirrors Chatwoot's Captain playground which injects knowledge base context. systemPrompt := s.promptBuilder.BuildAssistantPrompt(assistant, cfg) ragContext := s.retrieveFAQContext(ctx, assistant.ID, cfg, history) if ragContext != "" { systemPrompt += "\n\n" + ragContext systemPrompt += "\n\nUse the above FAQ entries as reference when answering. If the FAQ entries are relevant, incorporate their information. If not, rely on your general knowledge." } messages := []llm.ChatMessage{{Role: "system", Content: systemPrompt}} for _, message := range history { if message.Role == "" || message.Content == "" { continue } messages = append(messages, llm.ChatMessage{Role: message.Role, Content: message.Content}) } resp, err := s.llmProvider.ChatCompletion(ctx, llm.ChatRequest{ Model: cfg.Model, Messages: messages, Temperature: cfg.Temperature, MaxTokens: 1024, }) if err != nil { if errors.Is(err, llm.ErrProviderNotConfigured) { applogger.L().Warnf("GeneratePlaygroundResponse: LLM provider not configured, returning fallback") return captainPlaygroundFallbackMessage, nil } applogger.L().Errorf("GeneratePlaygroundResponse LLM call: %v", err) return "", fmt.Errorf("llm generation failed: %w", err) } if len(resp.Choices) == 0 { return "", fmt.Errorf("no response from LLM") } return resp.Choices[0].Message.Content, nil } // retrieveFAQContext generates an embedding for the latest user message, // searches approved FAQ responses via pgvector, and returns formatted context. // Returns empty string if RAG is disabled, no embedding available, or no results. func (s *CaptainAssistantService) retrieveFAQContext(ctx context.Context, assistantID uint, cfg *model.AssistantConfig, history []PlaygroundMessage) string { if cfg != nil && !cfg.FeatureFAQ { return "" } // Extract the latest user message userMsg := "" for i := len(history) - 1; i >= 0; i-- { if history[i].Role == "user" { userMsg = history[i].Content break } } if userMsg == "" { return "" } // Generate embedding for the question embedResp, err := s.llmProvider.CreateEmbedding(ctx, llm.EmbeddingRequest{ Input: []string{userMsg}, }) if err != nil { applogger.L().Warnf("retrieveFAQContext: embedding generation failed: %v", err) return "" } if len(embedResp.Data) == 0 { return "" } float32Emb := make([]float32, len(embedResp.Data[0].Embedding)) for i, v := range embedResp.Data[0].Embedding { float32Emb[i] = float32(v) } pgvectorEmb := pgvector.NewVector(float32Emb) // Search approved FAQ responses by embedding similarity results, err := s.responseRepo.SearchByEmbedding(ctx, assistantID, pgvectorEmb, 5) if err != nil { applogger.L().Warnf("retrieveFAQContext: FAQ search failed: %v", err) return "" } if len(results) == 0 { return "" } var contextParts []string for i, r := range results { contextParts = append(contextParts, fmt.Sprintf("[FAQ %d]\nQ: %s\nA: %s", i+1, r.Question, r.Answer)) } return "Knowledge Base Context:\n" + strings.Join(contextParts, "\n\n") } func withAssistantGenerationConfig(ctx context.Context, cfg *model.AssistantConfig) context.Context { if cfg == nil || !cfg.TemperatureConfigured { return ctx } return llm.WithTemperatureOverride(ctx, cfg.Temperature) } func (s *CaptainAssistantService) captainV2Enabled(ctx context.Context, accountID uint) bool { flags, err := s.assistantRepo.GetAccountFeatureFlags(ctx, accountID) if err != nil { return false } return featureFlagStringEnabled(flags, "captain_integration_v2") } func featureFlagStringEnabled(raw, flag string) bool { raw = strings.TrimSpace(raw) if raw == "" { return false } var objectFlags map[string]bool if err := json.Unmarshal([]byte(raw), &objectFlags); err == nil { return objectFlags[flag] } var arrayFlags []string if err := json.Unmarshal([]byte(raw), &arrayFlags); err == nil { for _, item := range arrayFlags { if item == flag { return true } } return false } for _, item := range strings.Split(raw, ",") { if strings.TrimSpace(item) == flag { return true } } return false } func playgroundMessageHistory(history []PlaygroundMessage, current string) []PlaygroundMessage { result := append([]PlaygroundMessage{}, history...) if strings.TrimSpace(current) == "" { return result } currentMessage := PlaygroundMessage{Role: "user", Content: current} if len(result) > 0 { last := result[len(result)-1] if last.Role == currentMessage.Role && last.Content == currentMessage.Content && last.AgentName == "" { return result } } return append(result, currentMessage) } func appendAdditionalPlaygroundMessage(history []PlaygroundMessage, current string) []PlaygroundMessage { if strings.TrimSpace(current) == "" { return history } return append(history, PlaygroundMessage{Role: "user", Content: current}) }