378 lines
12 KiB
Plaintext
378 lines
12 KiB
Plaintext
package service
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/pgvector/pgvector-go"
|
|
|
|
"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"
|
|
)
|
|
|
|
// CaptainTaskService implements standalone AI task endpoints for Captain.
|
|
// Reference: Chatwoot enterprise/app/controllers/api/v1/accounts/captain/tasks_controller.rb
|
|
// These tasks (reply_suggestion, summarize, rewrite) are independent of
|
|
// CopilotThread flow — they provide one-shot AI suggestions for agents.
|
|
type CaptainTaskService struct {
|
|
assistantRepo *repository.CaptainAssistantRepo
|
|
responseRepo *repository.CaptainAssistantResponseRepo
|
|
customToolRepo *repository.CaptainCustomToolRepo
|
|
conversationRepo *repository.ConversationRepo
|
|
messageRepo *repository.MessageRepo
|
|
llmProvider llm.Provider
|
|
promptBuilder *SystemPromptBuilder
|
|
}
|
|
|
|
// NewCaptainTaskService creates a new CaptainTaskService.
|
|
func NewCaptainTaskService(
|
|
assistantRepo *repository.CaptainAssistantRepo,
|
|
responseRepo *repository.CaptainAssistantResponseRepo,
|
|
customToolRepo *repository.CaptainCustomToolRepo,
|
|
conversationRepo *repository.ConversationRepo,
|
|
messageRepo *repository.MessageRepo,
|
|
llmProvider llm.Provider,
|
|
) *CaptainTaskService {
|
|
return &CaptainTaskService{
|
|
assistantRepo: assistantRepo,
|
|
responseRepo: responseRepo,
|
|
customToolRepo: customToolRepo,
|
|
conversationRepo: conversationRepo,
|
|
messageRepo: messageRepo,
|
|
llmProvider: llmProvider,
|
|
promptBuilder: NewSystemPromptBuilder(),
|
|
}
|
|
}
|
|
|
|
// --- Task Request/Response DTOs ---
|
|
|
|
// TaskReplySuggestionRequest is the DTO for reply_suggestion task.
|
|
type TaskReplySuggestionRequest struct {
|
|
AssistantID uint `json:"assistant_id" validate:"required"`
|
|
ConversationID uint `json:"conversation_id" validate:"required"`
|
|
Tone string `json:"tone"` // e.g. "friendly", "formal", "professional"
|
|
}
|
|
|
|
// TaskSummarizeRequest is the DTO for summarize task.
|
|
type TaskSummarizeRequest struct {
|
|
ConversationID uint `json:"conversation_id" validate:"required"`
|
|
Language string `json:"language"` // summary output language, default "zh-CN"
|
|
}
|
|
|
|
// TaskRewriteRequest is the DTO for rewrite task.
|
|
type TaskRewriteRequest struct {
|
|
Content string `json:"content" validate:"required"`
|
|
Tone string `json:"tone"` // desired tone, e.g. "friendly", "formal"
|
|
Language string `json:"language"` // desired output language
|
|
AssistantID uint `json:"assistant_id"` // optional: use assistant style guidelines
|
|
}
|
|
|
|
// TaskReplySuggestionResult holds the reply suggestion result.
|
|
type TaskReplySuggestionResult struct {
|
|
Suggestions []string `json:"suggestions"`
|
|
Source string `json:"source"` // "llm", "rag+llm"
|
|
}
|
|
|
|
// TaskSummarizeResult holds the conversation summary result.
|
|
type TaskSummarizeResult struct {
|
|
Summary string `json:"summary"`
|
|
}
|
|
|
|
// TaskRewriteResult holds the rewritten content result.
|
|
type TaskRewriteResult struct {
|
|
RewrittenContent string `json:"rewritten_content"`
|
|
Tone string `json:"tone"`
|
|
}
|
|
|
|
// --- Reply Suggestion Task ---
|
|
// Reference: Chatwoot Captain::Llm::SearchReplyDocumentationService
|
|
|
|
// ReplySuggestion generates AI-powered reply suggestions for a conversation.
|
|
// It combines RAG (document-based similarity search) with LLM generation:
|
|
// 1. Fetch recent conversation messages as context
|
|
// 2. Generate embedding for the conversation context
|
|
// 3. Search similar FAQ responses from the assistant documents
|
|
// 4. Build prompt with conversation context + relevant FAQ answers
|
|
// 5. Call LLM to generate reply suggestions
|
|
func (s *CaptainTaskService) ReplySuggestion(ctx context.Context, accountID uint, req *TaskReplySuggestionRequest) (*TaskReplySuggestionResult, error) {
|
|
// Fetch assistant config for prompt building
|
|
assistant, err := s.assistantRepo.GetByID(ctx, req.AssistantID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("assistant not found: %w", err)
|
|
}
|
|
|
|
// Fetch conversation messages as context
|
|
messages, err := s.fetchConversationContext(ctx, req.ConversationID)
|
|
if err != nil {
|
|
applogger.L().Warnf("ReplySuggestion: conversation context fetch failed: %v, proceeding without context", err)
|
|
messages = ""
|
|
}
|
|
|
|
// Search relevant documents via embedding similarity (RAG)
|
|
ragContext, source := "", "llm"
|
|
docContext, err := s.searchDocumentation(ctx, req.AssistantID, messages)
|
|
if err != nil {
|
|
applogger.L().Warnf("ReplySuggestion: document search failed: %v, proceeding without RAG", err)
|
|
} else if docContext != "" {
|
|
ragContext = docContext
|
|
source = "rag+llm"
|
|
}
|
|
|
|
// Build system prompt using prompt builder
|
|
cfg, _ := assistant.GetConfig()
|
|
systemPrompt := s.promptBuilder.BuildReplySuggestionPrompt(assistant, cfg, req.Tone, ragContext)
|
|
|
|
// Build messages for LLM
|
|
chatMessages := []llm.ChatMessage{
|
|
{Role: "system", Content: systemPrompt},
|
|
{Role: "user", Content: messages},
|
|
}
|
|
|
|
llmReq := llm.ChatRequest{
|
|
Model: cfg.Model,
|
|
Messages: chatMessages,
|
|
Temperature: 0.7,
|
|
MaxTokens: 1024,
|
|
}
|
|
|
|
resp, err := s.llmProvider.ChatCompletion(ctx, llmReq)
|
|
if err != nil {
|
|
applogger.L().Errorf("ReplySuggestion LLM call: %v", err)
|
|
return nil, fmt.Errorf("llm generation failed: %w", err)
|
|
}
|
|
|
|
if len(resp.Choices) == 0 {
|
|
return nil, fmt.Errorf("no response from LLM")
|
|
}
|
|
|
|
// Parse suggestions from LLM response
|
|
suggestions := parseSuggestions(resp.Choices[0].Message.Content)
|
|
|
|
return &TaskReplySuggestionResult{
|
|
Suggestions: suggestions,
|
|
Source: source,
|
|
}, nil
|
|
}
|
|
|
|
// --- Summarize Task ---
|
|
// Reference: Chatwoot Captain::Copilot::ChatService summarize action
|
|
|
|
// Summarize generates a concise summary of a conversation.
|
|
func (s *CaptainTaskService) Summarize(ctx context.Context, accountID uint, req *TaskSummarizeRequest) (*TaskSummarizeResult, error) {
|
|
// Fetch conversation messages
|
|
messages, err := s.fetchConversationContext(ctx, req.ConversationID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("fetch conversation context: %w", err)
|
|
}
|
|
|
|
if messages == "" {
|
|
return nil, fmt.Errorf("no messages found for conversation %d", req.ConversationID)
|
|
}
|
|
|
|
// Determine output language
|
|
language := req.Language
|
|
if language == "" {
|
|
language = "zh-CN"
|
|
}
|
|
|
|
// Build summarization prompt using prompt builder
|
|
systemPrompt := s.promptBuilder.BuildSummarizePrompt(language)
|
|
|
|
chatMessages := []llm.ChatMessage{
|
|
{Role: "system", Content: systemPrompt},
|
|
{Role: "user", Content: messages},
|
|
}
|
|
|
|
llmReq := llm.ChatRequest{
|
|
Messages: chatMessages,
|
|
Temperature: 0.3,
|
|
MaxTokens: 512,
|
|
}
|
|
|
|
resp, err := s.llmProvider.ChatCompletion(ctx, llmReq)
|
|
if err != nil {
|
|
applogger.L().Errorf("Summarize LLM call: %v", err)
|
|
return nil, fmt.Errorf("llm summarization failed: %w", err)
|
|
}
|
|
|
|
if len(resp.Choices) == 0 {
|
|
return nil, fmt.Errorf("no response from LLM")
|
|
}
|
|
|
|
return &TaskSummarizeResult{
|
|
Summary: resp.Choices[0].Message.Content,
|
|
}, nil
|
|
}
|
|
|
|
// --- Rewrite Task ---
|
|
// Reference: Chatwoot Captain::TasksController rewrite action
|
|
|
|
// Rewrite rewrites a draft message to improve tone, clarity, or language.
|
|
func (s *CaptainTaskService) Rewrite(ctx context.Context, accountID uint, req *TaskRewriteRequest) (*TaskRewriteResult, error) {
|
|
tone := req.Tone
|
|
if tone == "" {
|
|
tone = "professional"
|
|
}
|
|
|
|
language := req.Language
|
|
if language == "" {
|
|
language = "zh-CN"
|
|
}
|
|
|
|
// Optionally use assistant guidelines for style
|
|
styleGuidelines := ""
|
|
if req.AssistantID > 0 {
|
|
assistant, err := s.assistantRepo.GetByID(ctx, req.AssistantID)
|
|
if err == nil {
|
|
cfg, _ := assistant.GetConfig()
|
|
if cfg.Instructions != "" {
|
|
styleGuidelines = fmt.Sprintf("\nStyle guidelines from your assistant config: %s", cfg.Instructions)
|
|
}
|
|
if len(assistant.ResponseGuidelines) > 0 && string(assistant.ResponseGuidelines) != "null" {
|
|
styleGuidelines += fmt.Sprintf("\nResponse guidelines: %s", string(assistant.ResponseGuidelines))
|
|
}
|
|
}
|
|
}
|
|
|
|
// Build rewrite prompt using prompt builder
|
|
systemPrompt := s.promptBuilder.BuildRewritePrompt(tone, language, styleGuidelines)
|
|
|
|
chatMessages := []llm.ChatMessage{
|
|
{Role: "system", Content: systemPrompt},
|
|
{Role: "user", Content: req.Content},
|
|
}
|
|
|
|
llmReq := llm.ChatRequest{
|
|
Messages: chatMessages,
|
|
Temperature: 0.5,
|
|
MaxTokens: 512,
|
|
}
|
|
|
|
resp, err := s.llmProvider.ChatCompletion(ctx, llmReq)
|
|
if err != nil {
|
|
applogger.L().Errorf("Rewrite LLM call: %v", err)
|
|
return nil, fmt.Errorf("llm rewrite failed: %w", err)
|
|
}
|
|
|
|
if len(resp.Choices) == 0 {
|
|
return nil, fmt.Errorf("no response from LLM")
|
|
}
|
|
|
|
return &TaskRewriteResult{
|
|
RewrittenContent: resp.Choices[0].Message.Content,
|
|
Tone: tone,
|
|
}, nil
|
|
}
|
|
|
|
// --- Helper Methods ---
|
|
|
|
// fetchConversationContext retrieves recent messages from a conversation and formats them.
|
|
func (s *CaptainTaskService) fetchConversationContext(ctx context.Context, conversationID uint) (string, error) {
|
|
msgs, _, err := s.messageRepo.FindByConversation(ctx, conversationID, 0, 20)
|
|
if err != nil {
|
|
return "", fmt.Errorf("fetch messages: %w", err)
|
|
}
|
|
|
|
if len(msgs) == 0 {
|
|
return "", nil
|
|
}
|
|
|
|
var builder strings.Builder
|
|
for _, msg := range msgs {
|
|
sender := "Customer"
|
|
if msg.SenderType == "agent" || msg.SenderType == "user" {
|
|
sender = "Agent"
|
|
}
|
|
builder.WriteString(fmt.Sprintf("[%s]: %s\n", sender, msg.Content))
|
|
}
|
|
|
|
return builder.String(), nil
|
|
}
|
|
|
|
// searchDocumentation performs embedding-based similarity search on assistant responses.
|
|
// Reference: Chatwoot Captain::Llm::SearchReplyDocumentationService
|
|
func (s *CaptainTaskService) searchDocumentation(ctx context.Context, assistantID uint, query string) (string, error) {
|
|
if query == "" {
|
|
return "", nil
|
|
}
|
|
|
|
// Generate embedding for the query
|
|
embResp, err := s.llmProvider.CreateEmbedding(ctx, llm.EmbeddingRequest{
|
|
Input: []string{query},
|
|
Model: "text-embedding-ada-002",
|
|
})
|
|
if err != nil {
|
|
return "", fmt.Errorf("create embedding: %w", err)
|
|
}
|
|
|
|
if len(embResp.Data) == 0 {
|
|
return "", fmt.Errorf("no embedding generated")
|
|
}
|
|
|
|
// Convert embedding float slice to pgvector
|
|
embedding := pgvector.NewVector(embResp.Data[0].Embedding)
|
|
|
|
// Search similar responses
|
|
results, err := s.responseRepo.SimilaritySearch(ctx, assistantID, embedding, 5)
|
|
if err != nil {
|
|
return "", fmt.Errorf("similarity search: %w", err)
|
|
}
|
|
|
|
if len(results) == 0 {
|
|
return "", nil
|
|
}
|
|
|
|
// Format relevant FAQ answers as context
|
|
var builder strings.Builder
|
|
builder.WriteString("Relevant FAQ answers from documentation:\n")
|
|
for i, r := range results {
|
|
builder.WriteString(fmt.Sprintf("%d. Q: %s\n A: %s\n", i+1, r.Question, r.Answer))
|
|
}
|
|
|
|
return builder.String(), nil
|
|
}
|
|
|
|
// parseSuggestions splits LLM output into individual suggestions.
|
|
// The LLM may return numbered lines, bullet points, or comma-separated items.
|
|
func parseSuggestions(content string) []string {
|
|
lines := strings.Split(content, "\n")
|
|
var suggestions []string
|
|
for _, line := range lines {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" {
|
|
continue
|
|
}
|
|
// Strip leading numbers/bullets
|
|
for _, prefix := range []string{"1.", "2.", "3.", "4.", "5.", "1)", "2)", "3)", "4)", "5)", "- ", "* "} {
|
|
if strings.HasPrefix(line, prefix) {
|
|
line = strings.TrimSpace(strings.TrimPrefix(line, prefix))
|
|
break
|
|
}
|
|
}
|
|
if line != "" {
|
|
suggestions = append(suggestions, line)
|
|
}
|
|
}
|
|
|
|
// If only one block, try comma separation
|
|
if len(suggestions) <= 1 {
|
|
parts := strings.Split(content, ",")
|
|
suggestions = nil
|
|
for _, p := range parts {
|
|
p = strings.TrimSpace(p)
|
|
if p != "" {
|
|
suggestions = append(suggestions, p)
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(suggestions) == 0 {
|
|
suggestions = []string{content}
|
|
}
|
|
|
|
return suggestions
|
|
}
|