461 lines
16 KiB
Go
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
|
|
}
|