Files
gochat/backend/internal/service/captain_assistant_service.go
T
Rogeeandrogee d948222ac6 H-337: restore Captain inbox takeover and KB citations (#65)
* fix(captain): restore inbox takeover and KB citations

* fix(captain): harden grounded citations and smoke seed

---------

Co-authored-by: Rogee <rogee@ipao.vip>
2026-08-20 22:33:38 +08:00

1004 lines
40 KiB
Go

package service
import (
"context"
"encoding/json"
"errors"
"fmt"
"math"
"net/url"
"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 ResponseRepoIface
llmProvider llm.Provider
cache *redis.Client
promptBuilder *SystemPromptBuilder
toolExecSvc *ToolExecutionService
}
var (
ErrCaptainAssistantDisabled = errors.New("Captain assistant is disabled")
ErrCaptainKnowledgeRetrieval = errors.New("Captain knowledge retrieval failed")
)
// 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,
llmProvider: llmProvider,
promptBuilder: NewSystemPromptBuilder(),
}
if responseRepo != nil {
svc.responseRepo = responseRepo
}
if len(cache) > 0 {
svc.cache = cache[0]
}
return svc
}
func (s *CaptainAssistantService) SetToolExecutionService(toolExecSvc *ToolExecutionService) {
s.toolExecSvc = toolExecSvc
}
// --- 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)
}
if assistant.Status != model.AssistantStatusActive {
return "", fmt.Errorf("%w: status=%s", ErrCaptainAssistantDisabled, assistant.Status)
}
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,
}
if s.llmProvider == nil {
return "", llm.ErrProviderNotConfigured
}
resp, err := s.llmProvider.ChatCompletion(ctx, req)
if err != 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
}
// 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 assistant.Status != model.AssistantStatusActive {
return nil, fmt.Errorf("%w: status=%s", ErrCaptainAssistantDisabled, assistant.Status)
}
if s.captainV2Enabled(ctx, accountID) {
history := playgroundMessageHistory(req.MessageHistory, req.MessageContent)
content, err := s.generatePlaygroundLLMResponse(ctx, assistant, history)
if err != nil {
return nil, err
}
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 "", llm.ErrProviderNotConfigured
}
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, err := s.retrieveFAQContext(ctx, assistant.ID, history, cfg.FeatureCitation)
if err != nil {
return "", err
}
if ragContext != "" {
systemPrompt += "\n\n" + ragContext
systemPrompt += "\n\nUse the above FAQ entries as reference when answering. If the FAQ entries are not relevant, rely on your general knowledge."
if cfg.FeatureCitation {
systemPrompt += " Cite each used sourced FAQ as [[n](URL)] with its exact Source URL; never invent a URL."
}
}
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})
}
if s.toolExecSvc != nil {
content, skillsBound, err := s.toolExecSvc.RunAssistantToolCallLoop(ctx, CaptainToolScope{
AccountID: assistant.AccountID, AssistantID: assistant.ID,
}, messages, cfg.Model, cfg.Temperature, 1024, 5, true)
if err != nil {
if skillsBound {
return "", fmt.Errorf("captain skill runtime unavailable: %w", err)
}
return "", fmt.Errorf("llm generation failed: %w", err)
}
if strings.TrimSpace(content) == "" {
return "", fmt.Errorf("no response from LLM")
}
return content, nil
}
resp, err := s.llmProvider.ChatCompletion(ctx, llm.ChatRequest{
Model: cfg.Model,
Messages: messages,
Temperature: cfg.Temperature,
MaxTokens: 1024,
})
if err != 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 when there is no user query or no matching approved knowledge.
// feature_faq controls FAQ generation from resolved conversations upstream; it
// does not disable retrieval of already-approved knowledge.
func (s *CaptainAssistantService) retrieveFAQContext(ctx context.Context, assistantID uint, history []PlaygroundMessage, requireSource bool) (string, error) {
// 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 "", nil
}
// Generate embedding for the question
embedResp, err := s.llmProvider.CreateEmbedding(ctx, llm.EmbeddingRequest{
Input: []string{userMsg},
})
if err != nil {
return "", fmt.Errorf("%w: generate FAQ query embedding: %w", ErrCaptainKnowledgeRetrieval, err)
}
if embedResp == nil || len(embedResp.Data) == 0 || len(embedResp.Data[0].Embedding) == 0 {
return "", fmt.Errorf("%w: provider returned an empty FAQ query embedding", ErrCaptainKnowledgeRetrieval)
}
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
if s.responseRepo == nil {
return "", ErrCaptainKnowledgeRetrieval
}
results, err := s.responseRepo.SearchByEmbedding(ctx, assistantID, pgvectorEmb, 5)
if err != nil {
return "", fmt.Errorf("%w: %v", ErrCaptainKnowledgeRetrieval, err)
}
if len(results) == 0 {
return "", nil
}
var contextParts []string
for _, r := range results {
if requireSource && !validCitationSource(r.SourceURL) {
continue
}
entry := fmt.Sprintf("[FAQ %d]\nQ: %s\nA: %s", len(contextParts)+1, r.Question, r.Answer)
if r.SourceURL != "" {
entry += "\nSource: " + r.SourceURL
}
contextParts = append(contextParts, entry)
}
if len(contextParts) == 0 {
return "", nil
}
return "Knowledge Base Context:\n" + strings.Join(contextParts, "\n\n"), nil
}
func validCitationSource(raw string) bool {
u, err := url.ParseRequestURI(strings.TrimSpace(raw))
return err == nil && (u.Scheme == "http" || u.Scheme == "https") && u.Host != ""
}
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})
}