Files
gochat/internal/service/captain_assistant_service.go
T

461 lines
16 KiB
Go

package service
import (
"context"
"encoding/json"
"fmt"
"strings"
"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"`
}
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"`
}
// --- 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
}
// --- 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) {
if _, err := s.assistantRepo.GetByAccountAndID(ctx, accountID, assistantID); 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)
}
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 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")
}
if err := s.inboxRepo.DeleteByAccount(ctx, accountID, assistantID, inboxID); err != nil {
applogger.L().Errorf("DissociateInbox: %v", err)
return fmt.Errorf("dissociate inbox: %w", err)
}
return 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)
}
// 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
}
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) {
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()
messages := []llm.ChatMessage{{Role: "system", Content: buildSystemPrompt(assistant, cfg)}}
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 {
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
}
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})
}
// 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
}