313 lines
11 KiB
Go
313 lines
11 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"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"
|
|
pkgvalidator "github.com/gochat/gochat/pkg/validator"
|
|
)
|
|
|
|
// CaptainAssistantResponseService processes assistant responses for conversations.
|
|
// It generates AI-powered responses and optionally stores them as messages.
|
|
// Reference: Chatwoot Captain::AssistantResponsesController + M12 PRD
|
|
type CaptainAssistantResponseService struct {
|
|
assistantRepo *repository.CaptainAssistantRepo
|
|
responseRepo *repository.CaptainAssistantResponseRepo
|
|
conversationRepo *repository.ConversationRepo
|
|
messageRepo *repository.MessageRepo
|
|
preferenceRepo *repository.CaptainPreferenceRepo
|
|
llmProvider llm.Provider
|
|
}
|
|
|
|
func NewCaptainAssistantResponseService(
|
|
assistantRepo *repository.CaptainAssistantRepo,
|
|
responseRepo *repository.CaptainAssistantResponseRepo,
|
|
conversationRepo *repository.ConversationRepo,
|
|
messageRepo *repository.MessageRepo,
|
|
preferenceRepo *repository.CaptainPreferenceRepo,
|
|
llmProvider llm.Provider,
|
|
) *CaptainAssistantResponseService {
|
|
return &CaptainAssistantResponseService{
|
|
assistantRepo: assistantRepo,
|
|
responseRepo: responseRepo,
|
|
conversationRepo: conversationRepo,
|
|
messageRepo: messageRepo,
|
|
preferenceRepo: preferenceRepo,
|
|
llmProvider: llmProvider,
|
|
}
|
|
}
|
|
|
|
// --- Request/Response DTOs ---
|
|
|
|
// ProcessResponseRequest is the input for generating and storing an assistant response.
|
|
type ProcessResponseRequest struct {
|
|
ConversationID uint `json:"conversation_id" validate:"required"`
|
|
AssistantID uint `json:"assistant_id" validate:"required"`
|
|
SendMessage bool `json:"send_message"` // if true, store response as outgoing message
|
|
ContentHint string `json:"content_hint,omitempty"` // optional context for response generation
|
|
}
|
|
|
|
// ProcessResponseResult holds the generated response and its storage status.
|
|
type ProcessResponseResult struct {
|
|
Content string `json:"content"`
|
|
MessageID uint `json:"message_id,omitempty"` // set if send_message was true
|
|
AssistantID uint `json:"assistant_id"`
|
|
ConversationID uint `json:"conversation_id"`
|
|
Sent bool `json:"sent"` // true if the message was stored
|
|
}
|
|
|
|
// --- Business logic ---
|
|
|
|
// ProcessResponse generates an AI-powered response for a conversation and optionally stores it as a message.
|
|
func (s *CaptainAssistantResponseService) ProcessResponse(ctx context.Context, accountID uint, req *ProcessResponseRequest) (*ProcessResponseResult, error) {
|
|
// Validate request
|
|
if err := pkgvalidator.ValidateStruct(req); err != nil {
|
|
return nil, fmt.Errorf("validation error: %w", err)
|
|
}
|
|
|
|
// Fetch assistant config for guidelines
|
|
assistant, err := s.assistantRepo.GetByID(ctx, req.AssistantID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("assistant not found: %w", err)
|
|
}
|
|
|
|
// Verify assistant belongs to this account
|
|
if assistant.AccountID != accountID {
|
|
return nil, fmt.Errorf("assistant %d does not belong to account %d", req.AssistantID, accountID)
|
|
}
|
|
|
|
cfg, _ := assistant.GetConfig()
|
|
|
|
// Fetch conversation to get InboxID for message creation
|
|
conversation, err := s.conversationRepo.FindByID(ctx, req.ConversationID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("conversation not found: %w", err)
|
|
}
|
|
|
|
// Verify conversation belongs to this account
|
|
if conversation.AccountID != accountID {
|
|
return nil, fmt.Errorf("conversation %d does not belong to account %d", req.ConversationID, accountID)
|
|
}
|
|
|
|
// Fetch conversation messages for context
|
|
messages, _, err := s.messageRepo.FindByConversation(ctx, req.ConversationID, 0, 50)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("fetch messages: %w", err)
|
|
}
|
|
|
|
var msgParts []string
|
|
for _, m := range messages {
|
|
role := "contact"
|
|
if m.SenderType == "user" || m.SenderType == "bot" {
|
|
role = "agent"
|
|
}
|
|
msgParts = append(msgParts, fmt.Sprintf("[%s]: %s", role, m.Content))
|
|
}
|
|
conversationContext := strings.Join(msgParts, "\n")
|
|
|
|
// Build system prompt from assistant config + preference
|
|
var instructions string
|
|
if cfg != nil && cfg.Instructions != "" {
|
|
instructions = cfg.Instructions
|
|
}
|
|
|
|
pref, _ := s.preferenceRepo.GetByAccountID(ctx, accountID)
|
|
maxLen := 500
|
|
tone := "professional"
|
|
promptSuffix := ""
|
|
if pref != nil {
|
|
maxLen = pref.MaxResponseLength
|
|
tone = pref.Tone
|
|
promptSuffix = pref.CustomPromptSuffix
|
|
}
|
|
|
|
systemPrompt := fmt.Sprintf(`You are a customer support AI assistant named "%s". Respond to the customer in a %s tone.
|
|
Keep your response concise (max ~%d characters).
|
|
%s
|
|
%s`, assistant.Name, tone, maxLen, instructions, promptSuffix)
|
|
|
|
if req.ContentHint != "" {
|
|
conversationContext += fmt.Sprintf("\n\nAgent note: %s", req.ContentHint)
|
|
}
|
|
|
|
llmResp, err := s.llmProvider.ChatCompletion(ctx, llm.ChatRequest{
|
|
Model: "",
|
|
Messages: []llm.ChatMessage{
|
|
{Role: "system", Content: systemPrompt},
|
|
{Role: "user", Content: conversationContext},
|
|
},
|
|
Temperature: 0.7,
|
|
MaxTokens: maxLen,
|
|
})
|
|
if err != nil {
|
|
applogger.L().Errorf("LLM assistant response generation failed: %v", err)
|
|
return nil, fmt.Errorf("generate response: %w", err)
|
|
}
|
|
|
|
if len(llmResp.Choices) == 0 {
|
|
return nil, fmt.Errorf("no LLM response")
|
|
}
|
|
|
|
content := llmResp.Choices[0].Message.Content
|
|
|
|
result := &ProcessResponseResult{
|
|
Content: content,
|
|
AssistantID: req.AssistantID,
|
|
ConversationID: req.ConversationID,
|
|
Sent: false,
|
|
}
|
|
|
|
// Optionally send as a message in the conversation
|
|
if req.SendMessage && conversation != nil {
|
|
msg := &model.Message{
|
|
ConversationID: req.ConversationID,
|
|
AccountID: accountID,
|
|
InboxID: conversation.InboxID,
|
|
SenderType: "bot",
|
|
Content: content,
|
|
ContentType: "text",
|
|
MessageType: "outgoing",
|
|
}
|
|
if err := s.messageRepo.Create(ctx, msg); err != nil {
|
|
applogger.L().Warnf("Send assistant response as message: %v", err)
|
|
// Don't fail the whole operation, just skip the message
|
|
} else {
|
|
result.MessageID = msg.ID
|
|
result.Sent = true
|
|
}
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// --- CRUD methods for Chatwoot resources :assistant_responses (flat namespace) ---
|
|
|
|
// List returns paginated assistant responses for an account.
|
|
// Reference: Chatwoot Captain::AssistantResponsesController#index
|
|
func (s *CaptainAssistantResponseService) List(ctx context.Context, accountID uint, assistantID uint, documentID uint, status string, search string, page, pageSize int) ([]model.CaptainAssistantResponse, int64, error) {
|
|
db := s.responseRepo.DB().WithContext(ctx).Model(&model.CaptainAssistantResponse{}).
|
|
Where("account_id = ?", accountID)
|
|
|
|
// Apply filters — 1:1 matching Chatwoot apply_filters
|
|
if assistantID > 0 {
|
|
db = db.Where("assistant_id = ?", assistantID)
|
|
}
|
|
if documentID > 0 {
|
|
db = db.Where("documentable_id = ? AND documentable_type = ?", documentID, "Captain::Document")
|
|
}
|
|
if status != "" {
|
|
db = db.Where("status = ?", status)
|
|
}
|
|
if search != "" {
|
|
searchTerm := "%" + search + "%"
|
|
db = db.Where("question LIKE ? OR answer LIKE ?", searchTerm, searchTerm)
|
|
}
|
|
|
|
// Eager load assistant and documentable — matches Chatwoot includes(:assistant, :documentable)
|
|
var total int64
|
|
if err := db.Count(&total).Error; err != nil {
|
|
return nil, 0, fmt.Errorf("count responses: %w", err)
|
|
}
|
|
|
|
var responses []model.CaptainAssistantResponse
|
|
// Ordered by created_at DESC — matches Chatwoot scope :ordered
|
|
if err := db.Preload("Assistant").Order("created_at DESC").
|
|
Offset((page - 1) * pageSize).Limit(pageSize).
|
|
Find(&responses).Error; err != nil {
|
|
return nil, 0, fmt.Errorf("list responses: %w", err)
|
|
}
|
|
return responses, total, nil
|
|
}
|
|
|
|
// Get returns a single assistant response by ID.
|
|
// Reference: Chatwoot Captain::AssistantResponsesController#show
|
|
func (s *CaptainAssistantResponseService) Get(ctx context.Context, accountID uint, responseID uint) (*model.CaptainAssistantResponse, error) {
|
|
resp, err := s.responseRepo.GetByAccountAndID(ctx, accountID, responseID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get response: %w", err)
|
|
}
|
|
return resp, nil
|
|
}
|
|
|
|
// Create creates a new assistant response.
|
|
// Reference: Chatwoot Captain::AssistantResponsesController#create
|
|
// documentable = Current.user (polymorphic User)
|
|
func (s *CaptainAssistantResponseService) Create(ctx context.Context, accountID uint, userID uint, assistantID uint, question, answer, status string) (*model.CaptainAssistantResponse, error) {
|
|
// Validate assistant exists and belongs to account
|
|
assistant, err := s.assistantRepo.GetByID(ctx, assistantID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("assistant not found: %w", err)
|
|
}
|
|
if assistant.AccountID != accountID {
|
|
return nil, fmt.Errorf("assistant does not belong to account")
|
|
}
|
|
|
|
resp := &model.CaptainAssistantResponse{
|
|
AccountID: accountID,
|
|
AssistantID: assistantID,
|
|
Question: question,
|
|
Answer: answer,
|
|
DocumentableID: &userID,
|
|
DocumentableType: "User", // Chatwoot: documentable = Current.user
|
|
}
|
|
// ensure_status — default approved
|
|
if status != "" {
|
|
resp.Status = model.ResponseStatus(status)
|
|
} else {
|
|
resp.Status = model.ResponseStatusApproved
|
|
}
|
|
// ensure_account — derived from assistant.account (already validated above)
|
|
|
|
if err := s.responseRepo.Create(ctx, resp); err != nil {
|
|
return nil, fmt.Errorf("create response: %w", err)
|
|
}
|
|
if created, err := s.responseRepo.GetByAccountAndID(ctx, accountID, resp.ID); err == nil {
|
|
return created, nil
|
|
}
|
|
return resp, nil
|
|
}
|
|
|
|
// Update modifies an existing assistant response.
|
|
// Reference: Chatwoot Captain::AssistantResponsesController#update
|
|
// mark_as_edited: only when question or answer actually changed
|
|
func (s *CaptainAssistantResponseService) Update(ctx context.Context, accountID uint, responseID uint, question, answer string, status string) (*model.CaptainAssistantResponse, error) {
|
|
resp, err := s.Get(ctx, accountID, responseID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// 1:1 Chatwoot mark_as_edited: only set edited=true if question or answer actually changed
|
|
questionChanged := question != "" && question != resp.Question
|
|
answerChanged := answer != "" && answer != resp.Answer
|
|
if questionChanged || answerChanged {
|
|
resp.Edited = true
|
|
}
|
|
if questionChanged {
|
|
resp.Question = question
|
|
}
|
|
if answerChanged {
|
|
resp.Answer = answer
|
|
}
|
|
if status != "" {
|
|
resp.Status = model.ResponseStatus(status)
|
|
}
|
|
if err := s.responseRepo.Update(ctx, resp); err != nil {
|
|
return nil, fmt.Errorf("update response: %w", err)
|
|
}
|
|
return resp, nil
|
|
}
|
|
|
|
// Delete removes an assistant response.
|
|
// Reference: Chatwoot Captain::AssistantResponsesController#destroy
|
|
func (s *CaptainAssistantResponseService) Delete(ctx context.Context, accountID uint, responseID uint) error {
|
|
resp, err := s.Get(ctx, accountID, responseID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return s.responseRepo.DeleteByAccount(ctx, accountID, resp.ID)
|
|
}
|