Files
gochat/internal/service/captain_assistant_response_service.go
T

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)
}