package service import ( "context" "encoding/json" "fmt" "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" ) // 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 } // NewCaptainAssistantService creates a new CaptainAssistantService. func NewCaptainAssistantService( assistantRepo *repository.CaptainAssistantRepo, inboxRepo *repository.CaptainInboxRepo, documentRepo *repository.CaptainDocumentRepo, responseRepo *repository.CaptainAssistantResponseRepo, llmProvider llm.Provider, ) *CaptainAssistantService { return &CaptainAssistantService{ assistantRepo: assistantRepo, inboxRepo: inboxRepo, documentRepo: documentRepo, responseRepo: responseRepo, llmProvider: llmProvider, } } // --- 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"` } // --- CRUD Operations --- // Create creates a new CaptainAssistant. func (s *CaptainAssistantService) Create(ctx context.Context, accountID uint, req *CreateAssistantRequest) (*model.CaptainAssistant, error) { 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. func (s *CaptainAssistantService) Get(ctx context.Context, id uint) (*model.CaptainAssistant, error) { assistant, err := s.assistantRepo.GetByID(ctx, 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. func (s *CaptainAssistantService) Update(ctx context.Context, id uint, req *UpdateAssistantRequest) (*model.CaptainAssistant, error) { assistant, err := s.assistantRepo.GetByID(ctx, 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. func (s *CaptainAssistantService) Delete(ctx context.Context, id uint) error { 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 } // --- 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) error { // Verify assistant exists and belongs to account assistant, err := s.assistantRepo.GetByID(ctx, assistantID) if err != nil { return fmt.Errorf("assistant not found: %w", err) } if assistant.AccountID != accountID { return fmt.Errorf("assistant does not belong to account") } ci := &model.CaptainInbox{ AssistantID: assistantID, InboxID: inboxID, AccountID: accountID, } if err := s.inboxRepo.Create(ctx, ci); err != nil { applogger.L().Errorf("AssociateInbox: %v", err) return fmt.Errorf("associate inbox: %w", err) } return nil } // DissociateInbox unbinds an assistant from an inbox. func (s *CaptainAssistantService) DissociateInbox(ctx context.Context, assistantID, inboxID uint) error { if err := s.inboxRepo.Delete(ctx, assistantID, inboxID); err != nil { applogger.L().Errorf("DissociateInbox: %v", err) return fmt.Errorf("dissociate inbox: %w", err) } return nil } // --- 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) } // Build system prompt from assistant config and response guidelines cfg, _ := assistant.GetConfig() systemPrompt := buildSystemPrompt(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 { 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 } // buildSystemPrompt constructs the system prompt from assistant config and guidelines. func buildSystemPrompt(assistant *model.CaptainAssistant, cfg *model.AssistantConfig) string { prompt := fmt.Sprintf("You are %s, an AI assistant.", assistant.Name) if cfg.ProductName != "" { prompt += fmt.Sprintf(" You represent the product: %s.", cfg.ProductName) } if cfg.Instructions != "" { prompt += fmt.Sprintf("\nInstructions: %s", cfg.Instructions) } if len(assistant.ResponseGuidelines) > 0 && string(assistant.ResponseGuidelines) != "null" { prompt += fmt.Sprintf("\nResponse Guidelines: %s", string(assistant.ResponseGuidelines)) } if len(assistant.Guardrails) > 0 && string(assistant.Guardrails) != "null" { prompt += fmt.Sprintf("\nGuardrails: %s", string(assistant.Guardrails)) } return prompt }