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

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
}