Files
gochat/internal/service/copilot_service.go
T
2026-06-04 15:44:48 +08:00

430 lines
14 KiB
Go

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"
)
// CopilotService implements business logic for Copilot (conversation assistance) operations.
// Reference: Chatwoot enterprise/app/controllers/api/v1/copilot_threads_controller.rb
type CopilotService struct {
threadRepo *repository.CopilotThreadRepo
messageRepo *repository.CopilotMessageRepo
suggestionRepo *repository.CopilotSuggestionRepo
llmProvider llm.Provider
}
// NewCopilotService creates a new CopilotService.
func NewCopilotService(
threadRepo *repository.CopilotThreadRepo,
messageRepo *repository.CopilotMessageRepo,
suggestionRepo *repository.CopilotSuggestionRepo,
llmProvider llm.Provider,
) *CopilotService {
return &CopilotService{
threadRepo: threadRepo,
messageRepo: messageRepo,
suggestionRepo: suggestionRepo,
llmProvider: llmProvider,
}
}
// --- Thread CRUD ---
// CreateThreadRequest is the DTO for creating a copilot thread.
type CreateThreadRequest struct {
Title string `json:"title" validate:"required"`
AssistantID *uint `json:"assistant_id,omitempty"`
}
// CreateThread creates a new CopilotThread for a user.
func (s *CopilotService) CreateThread(ctx context.Context, accountID, userID uint, req *CreateThreadRequest) (*model.CopilotThread, error) {
thread := &model.CopilotThread{
AccountID: accountID,
UserID: userID,
AssistantID: req.AssistantID,
Title: req.Title,
}
if err := s.threadRepo.Create(ctx, thread); err != nil {
applogger.L().Errorf("Create copilot thread: %v", err)
return nil, fmt.Errorf("create thread: %w", err)
}
return thread, nil
}
// GetThread retrieves a thread by ID, including its messages.
func (s *CopilotService) GetThread(ctx context.Context, id uint) (*model.CopilotThread, error) {
thread, err := s.threadRepo.GetByID(ctx, id)
if err != nil {
applogger.L().Errorf("Get copilot thread: %v", err)
return nil, fmt.Errorf("get thread: %w", err)
}
return thread, nil
}
// ListThreads retrieves threads for a user with pagination.
func (s *CopilotService) ListThreads(ctx context.Context, accountID, userID uint, offset, limit int) ([]model.CopilotThread, int64, error) {
threads, count, err := s.threadRepo.ListByUser(ctx, accountID, userID, offset, limit)
if err != nil {
applogger.L().Errorf("List copilot threads: %v", err)
return nil, 0, fmt.Errorf("list threads: %w", err)
}
return threads, count, nil
}
// --- Message Operations ---
// SendMessageRequest is the DTO for sending a message in a copilot thread.
type SendMessageRequest struct {
Content string `json:"content" validate:"required"`
}
// SendMessageResult holds the result of sending a message (includes the assistant reply).
type SendMessageResult struct {
UserMessage *model.CopilotMessage `json:"user_message"`
AssistantMessage *model.CopilotMessage `json:"assistant_message"`
}
// SendMessage sends a user message and generates an assistant reply via LLM.
// Reference: Chatwoot CopilotThread#send_message
func (s *CopilotService) SendMessage(ctx context.Context, threadID, accountID uint, req *SendMessageRequest) (*SendMessageResult, error) {
thread, err := s.threadRepo.GetByID(ctx, threadID)
if err != nil {
return nil, fmt.Errorf("thread not found: %w", err)
}
// Save the user message
userMsg := &model.CopilotMessage{
AccountID: accountID,
CopilotThreadID: threadID,
MessageType: model.CopilotMessageTypeUser,
Message: json.RawMessage(`{"content": "` + jsonEscape(req.Content) + `"}`),
}
if err := s.messageRepo.Create(ctx, userMsg); err != nil {
applogger.L().Errorf("SendMessage create user message: %v", err)
return nil, fmt.Errorf("save user message: %w", err)
}
// Build conversation history for LLM
// Convert model.ChatMessage (from PreviousHistory) to llm.ChatMessage
modelHistory := thread.PreviousHistory(thread.Messages)
var llmHistory []llm.ChatMessage
for _, m := range modelHistory {
llmHistory = append(llmHistory, llm.ChatMessage{
Role: m.Role,
Content: m.Content,
})
}
llmHistory = append(llmHistory, llm.ChatMessage{
Role: "user",
Content: req.Content,
})
// Build system prompt
systemPrompt := "You are an AI assistant helping a customer support agent."
if thread.AssistantID != nil {
systemPrompt += " Use the context provided by the captain assistant."
}
messages := []llm.ChatMessage{
{Role: "system", Content: systemPrompt},
}
messages = append(messages, llmHistory...)
// Call LLM for assistant response
llmResp, err := s.llmProvider.ChatCompletion(ctx, llm.ChatRequest{
Model: "gpt-4",
Messages: messages,
Temperature: 0.7,
MaxTokens: 1024,
})
if err != nil {
applogger.L().Errorf("SendMessage LLM call: %v", err)
return &SendMessageResult{UserMessage: userMsg}, fmt.Errorf("llm generation failed: %w", err)
}
assistantContent := ""
if len(llmResp.Choices) > 0 {
assistantContent = llmResp.Choices[0].Message.Content
}
// Save the assistant message
assistantMsg := &model.CopilotMessage{
AccountID: accountID,
CopilotThreadID: threadID,
MessageType: model.CopilotMessageTypeAssistant,
Message: json.RawMessage(`{"content": "` + jsonEscape(assistantContent) + `"}`),
}
if err := s.messageRepo.Create(ctx, assistantMsg); err != nil {
applogger.L().Errorf("SendMessage create assistant message: %v", err)
// Return user message even if assistant message save fails
return &SendMessageResult{UserMessage: userMsg}, fmt.Errorf("save assistant message: %w", err)
}
return &SendMessageResult{
UserMessage: userMsg,
AssistantMessage: assistantMsg,
}, nil
}
// --- Suggested Replies & Summary ---
// SuggestedRepliesResult holds suggested reply options for a conversation.
type SuggestedRepliesResult struct {
Replies []string `json:"replies"`
}
// GetSuggestedReplies generates reply suggestions for a conversation.
// Reference: Chatwoot Captain::AssistanceDriver#suggested_replies
func (s *CopilotService) GetSuggestedReplies(ctx context.Context, accountID uint, conversationContext string) (*SuggestedRepliesResult, error) {
systemPrompt := "You are an AI assistant helping a customer support agent. Based on the conversation context, suggest 3 concise reply options. Return them as a JSON array of strings."
messages := []llm.ChatMessage{
{Role: "system", Content: systemPrompt},
{Role: "user", Content: "Conversation context:\n" + conversationContext + "\n\nSuggest 3 reply options."},
}
llmResp, err := s.llmProvider.ChatCompletion(ctx, llm.ChatRequest{
Model: "gpt-4",
Messages: messages,
Temperature: 0.7,
MaxTokens: 512,
})
if err != nil {
applogger.L().Errorf("GetSuggestedReplies LLM call: %v", err)
return nil, fmt.Errorf("llm generation failed: %w", err)
}
if len(llmResp.Choices) == 0 {
return &SuggestedRepliesResult{Replies: []string{}}, nil
}
// Try to parse the response as a JSON array of strings
var replies []string
content := llmResp.Choices[0].Message.Content
if err := json.Unmarshal([]byte(content), &replies); err != nil {
// Fallback: split by newline if not valid JSON array
replies = splitReplies(content)
}
return &SuggestedRepliesResult{Replies: replies}, nil
}
// SummarizeConversationResult holds the conversation summary.
type SummarizeConversationResult struct {
Summary string `json:"summary"`
}
// SummarizeConversation generates a summary of the conversation.
// Reference: Chatwoot Captain::AssistanceDriver#summarize
func (s *CopilotService) SummarizeConversation(ctx context.Context, accountID uint, conversationContext string) (*SummarizeConversationResult, error) {
systemPrompt := "You are an AI assistant. Summarize the following customer support conversation concisely, highlighting key issues, resolution status, and any action items."
messages := []llm.ChatMessage{
{Role: "system", Content: systemPrompt},
{Role: "user", Content: "Conversation:\n" + conversationContext},
}
llmResp, err := s.llmProvider.ChatCompletion(ctx, llm.ChatRequest{
Model: "gpt-4",
Messages: messages,
Temperature: 0.3,
MaxTokens: 256,
})
if err != nil {
applogger.L().Errorf("SummarizeConversation LLM call: %v", err)
return nil, fmt.Errorf("llm generation failed: %w", err)
}
if len(llmResp.Choices) == 0 {
return &SummarizeConversationResult{Summary: ""}, nil
}
return &SummarizeConversationResult{
Summary: llmResp.Choices[0].Message.Content,
}, nil
}
// jsonEscape escapes a string for safe embedding in JSON content.
func jsonEscape(s string) string {
b, _ := json.Marshal(s)
// Strip surrounding quotes from json.Marshal output
if len(b) >= 2 {
return string(b[1 : len(b)-1])
}
return s
}
// splitReplies tries to split raw LLM text into individual reply suggestions.
func splitReplies(content string) []string {
var replies []string
lines := splitLines(content)
for _, line := range lines {
trimmed := trimReplyPrefix(line)
if trimmed != "" {
replies = append(replies, trimmed)
}
}
if len(replies) == 0 && content != "" {
replies = []string{content}
}
return replies
}
func splitLines(s string) []string {
var lines []string
start := 0
for i := 0; i < len(s); i++ {
if s[i] == '\n' {
line := s[start:i]
if line != "" {
lines = append(lines, line)
}
start = i + 1
}
}
if start < len(s) {
lines = append(lines, s[start:])
}
return lines
}
func trimReplyPrefix(s string) string {
// Remove common numbered prefixes like "1.", "1) ", "- "
for i := 0; i < len(s); i++ {
if s[i] == '.' || s[i] == ')' || s[i] == '-' {
return s[i+1:]
}
if s[i] == ' ' {
continue
}
break
}
return s
}
// --- Thread deletion & translation ---
// DeleteThread deletes a copilot thread by ID.
func (s *CopilotService) DeleteThread(ctx context.Context, id uint) error {
if err := s.threadRepo.Delete(ctx, id); err != nil {
applogger.L().Errorf("Delete copilot thread: %v", err)
return fmt.Errorf("delete thread: %w", err)
}
return nil
}
// TranslateRequest is the DTO for translating a message.
type TranslateRequest struct {
Content string `json:"content" validate:"required"`
TargetLanguage string `json:"target_language" validate:"required"`
}
// TranslateResult holds the translated message.
type TranslateResult struct {
TranslatedContent string `json:"translated_content"`
SourceLanguage string `json:"source_language"`
TargetLanguage string `json:"target_language"`
}
// TranslateMessage translates a message to the target language using LLM.
func (s *CopilotService) TranslateMessage(ctx context.Context, accountID uint, req *TranslateRequest) (*TranslateResult, error) {
llmResp, err := s.llmProvider.ChatCompletion(ctx, llm.ChatRequest{
Messages: []llm.ChatMessage{
{Role: "system", Content: fmt.Sprintf("You are a translator. Translate the user's message to %s. Return only the translated text, nothing else.", req.TargetLanguage)},
{Role: "user", Content: req.Content},
},
})
if err != nil {
applogger.L().Errorf("TranslateMessage LLM: %v", err)
return nil, fmt.Errorf("translate message: %w", err)
}
translated := ""
if len(llmResp.Choices) > 0 {
translated = llmResp.Choices[0].Message.Content
}
return &TranslateResult{
TranslatedContent: translated,
TargetLanguage: req.TargetLanguage,
}, nil
}
// --- Copilot Suggestion Messages ---
// Reference: Chatwoot enterprise/app/controllers/api/v1/copilot_messages_controller.rb
// Conversation-level suggestion/reply/summary messages (distinct from thread-based CopilotMessage)
// CreateSuggestionRequest is the DTO for creating a copilot suggestion message.
type CreateSuggestionRequest struct {
ConversationID uint `json:"conversation_id" validate:"required"`
Content string `json:"content" validate:"required"`
SuggestionType model.CopilotSuggestionType `json:"suggestion_type"`
}
// ListSuggestionResult holds paginated suggestion messages.
type ListSuggestionResult struct {
Messages []model.CopilotSuggestionMessage `json:"messages"`
TotalCount int64 `json:"total_count"`
Page int `json:"page"`
PerPage int `json:"per_page"`
}
// GetCopilotSuggestions retrieves suggestion messages for a conversation.
func (s *CopilotService) GetCopilotSuggestions(ctx context.Context, accountID, conversationID uint, page, perPage int) (*ListSuggestionResult, error) {
offset := (page - 1) * perPage
msgs, count, err := s.suggestionRepo.FindByConversationID(ctx, accountID, conversationID, offset, perPage)
if err != nil {
applogger.L().Errorf("GetCopilotSuggestions: %v", err)
return nil, fmt.Errorf("get copilot suggestions: %w", err)
}
return &ListSuggestionResult{
Messages: msgs,
TotalCount: count,
Page: page,
PerPage: perPage,
}, nil
}
// CreateCopilotSuggestion creates a new copilot suggestion message.
func (s *CopilotService) CreateCopilotSuggestion(ctx context.Context, accountID uint, req *CreateSuggestionRequest) (*model.CopilotSuggestionMessage, error) {
suggestionType := req.SuggestionType
if suggestionType == "" {
suggestionType = model.CopilotSuggestionTypeSuggestion
}
msg := &model.CopilotSuggestionMessage{
AccountID: accountID,
ConversationID: req.ConversationID,
Content: req.Content,
SuggestionType: suggestionType,
Status: model.CopilotSuggestionStatusPending,
}
if err := s.suggestionRepo.Create(ctx, msg); err != nil {
applogger.L().Errorf("CreateCopilotSuggestion: %v", err)
return nil, fmt.Errorf("create copilot suggestion: %w", err)
}
return msg, nil
}
// UpdateSuggestionStatus updates the status of a copilot suggestion message.
func (s *CopilotService) UpdateSuggestionStatus(ctx context.Context, id uint, status model.CopilotSuggestionStatus) (*model.CopilotSuggestionMessage, error) {
msg, err := s.suggestionRepo.GetByID(ctx, id)
if err != nil {
return nil, fmt.Errorf("suggestion not found: %w", err)
}
if err := s.suggestionRepo.UpdateStatus(ctx, id, status); err != nil {
applogger.L().Errorf("UpdateSuggestionStatus: %v", err)
return nil, fmt.Errorf("update suggestion status: %w", err)
}
msg.Status = status
return msg, nil
}