chore: import clean Agent Desk snapshot (2/7)
This commit is contained in:
210
internal/services/ai_copilot_service.go
Normal file
210
internal/services/ai_copilot_service.go
Normal file
@@ -0,0 +1,210 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"agent-desk/internal/ai"
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/pkg/enums"
|
||||
"agent-desk/internal/pkg/errorsx"
|
||||
"agent-desk/internal/pkg/i18nx"
|
||||
"agent-desk/internal/pkg/utils"
|
||||
"agent-desk/internal/repositories"
|
||||
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
)
|
||||
|
||||
var AICopilotService = newAICopilotService()
|
||||
|
||||
func newAICopilotService() *aiCopilotService {
|
||||
return &aiCopilotService{
|
||||
chatFn: ai.LLM.Chat,
|
||||
}
|
||||
}
|
||||
|
||||
type aiCopilotService struct {
|
||||
chatFn func(ctx context.Context, systemPrompt, userPrompt string) (*ai.ChatCompletionResult, error)
|
||||
}
|
||||
|
||||
// SetChatFnForTest allows tests outside package services to override LLM chat logic.
|
||||
func (s *aiCopilotService) SetChatFnForTest(fn func(ctx context.Context, systemPrompt, userPrompt string) (*ai.ChatCompletionResult, error)) func() {
|
||||
prev := s.chatFn
|
||||
s.chatFn = fn
|
||||
return func() {
|
||||
s.chatFn = prev
|
||||
}
|
||||
}
|
||||
|
||||
// SuggestReply analyzes recent conversation messages and produces a suggested draft reply for human operators.
|
||||
func (s *aiCopilotService) SuggestReply(ctx context.Context, conversationID int64, locale string) (string, error) {
|
||||
if conversationID <= 0 {
|
||||
return "", errorsx.InvalidParamI18n("error.e0064")
|
||||
}
|
||||
conversation := ConversationService.Get(conversationID)
|
||||
if conversation == nil {
|
||||
return "", errorsx.NotFoundI18n("error.e0116")
|
||||
}
|
||||
|
||||
messages := repositories.MessageRepository.Find(
|
||||
sqls.DB(),
|
||||
sqls.NewCnd().Eq("conversation_id", conversationID).Desc("id").Limit(12),
|
||||
)
|
||||
if len(messages) == 0 {
|
||||
return "", errorsx.InvalidParamI18n("error.e0245")
|
||||
}
|
||||
slices.Reverse(messages)
|
||||
|
||||
transcript := s.buildConversationTranscript(messages)
|
||||
if strings.TrimSpace(transcript) == "" {
|
||||
return "", errorsx.InvalidParamI18n("error.e0245")
|
||||
}
|
||||
|
||||
systemPrompt, userPrompt := s.buildSuggestReplyPrompts(transcript, locale)
|
||||
result, err := s.chat(ctx, systemPrompt, userPrompt)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if result == nil || strings.TrimSpace(result.Content) == "" {
|
||||
return "", fmt.Errorf("empty reply suggestion from model")
|
||||
}
|
||||
|
||||
return result.Content, nil
|
||||
}
|
||||
|
||||
// SummarizeConversation produces a structured summary of the conversation for handoffs or resolution records.
|
||||
func (s *aiCopilotService) SummarizeConversation(ctx context.Context, conversationID int64, locale string) (string, error) {
|
||||
if conversationID <= 0 {
|
||||
return "", errorsx.InvalidParamI18n("error.e0064")
|
||||
}
|
||||
conversation := ConversationService.Get(conversationID)
|
||||
if conversation == nil {
|
||||
return "", errorsx.NotFoundI18n("error.e0116")
|
||||
}
|
||||
|
||||
messages := repositories.MessageRepository.Find(
|
||||
sqls.DB(),
|
||||
sqls.NewCnd().Eq("conversation_id", conversationID).Desc("id").Limit(20),
|
||||
)
|
||||
if len(messages) == 0 {
|
||||
return "", errorsx.InvalidParamI18n("error.e0245")
|
||||
}
|
||||
slices.Reverse(messages)
|
||||
|
||||
transcript := s.buildConversationTranscript(messages)
|
||||
if strings.TrimSpace(transcript) == "" {
|
||||
return "", errorsx.InvalidParamI18n("error.e0245")
|
||||
}
|
||||
|
||||
systemPrompt, userPrompt := s.buildSummarizePrompts(transcript, locale)
|
||||
result, err := s.chat(ctx, systemPrompt, userPrompt)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if result == nil || strings.TrimSpace(result.Content) == "" {
|
||||
return "", fmt.Errorf("empty summary from model")
|
||||
}
|
||||
|
||||
return result.Content, nil
|
||||
}
|
||||
|
||||
func (s *aiCopilotService) chat(ctx context.Context, systemPrompt, userPrompt string) (*ai.ChatCompletionResult, error) {
|
||||
if s.chatFn != nil {
|
||||
return s.chatFn(ctx, systemPrompt, userPrompt)
|
||||
}
|
||||
return ai.LLM.Chat(ctx, systemPrompt, userPrompt)
|
||||
}
|
||||
|
||||
func (s *aiCopilotService) buildConversationTranscript(messages []models.Message) string {
|
||||
var lines []string
|
||||
for _, msg := range messages {
|
||||
if msg.RecalledAt != nil || msg.SendStatus == enums.IMMessageStatusRecalled {
|
||||
continue
|
||||
}
|
||||
text := s.extractMessageText(msg)
|
||||
if text == "" {
|
||||
continue
|
||||
}
|
||||
switch msg.SenderType {
|
||||
case enums.IMSenderTypeCustomer:
|
||||
lines = append(lines, "Customer: "+text)
|
||||
case enums.IMSenderTypeAgent, enums.IMSenderTypeAI:
|
||||
lines = append(lines, "Agent: "+text)
|
||||
case enums.IMSenderTypeSystem:
|
||||
// System notifications are intentionally excluded from operator transcript
|
||||
}
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func (s *aiCopilotService) extractMessageText(msg models.Message) string {
|
||||
content := strings.TrimSpace(msg.Content)
|
||||
var text string
|
||||
switch msg.MessageType {
|
||||
case enums.IMMessageTypeHTML:
|
||||
summary := utils.BuildHTMLSummary(content)
|
||||
if summary != "" {
|
||||
text = summary
|
||||
} else {
|
||||
text = content
|
||||
}
|
||||
case enums.IMMessageTypeImage:
|
||||
if content != "" {
|
||||
text = fmt.Sprintf("[Image: %s]", content)
|
||||
} else {
|
||||
text = "[Image]"
|
||||
}
|
||||
case enums.IMMessageTypeAttachment:
|
||||
if content != "" {
|
||||
text = fmt.Sprintf("[Attachment: %s]", content)
|
||||
} else {
|
||||
text = "[Attachment]"
|
||||
}
|
||||
default:
|
||||
text = content
|
||||
}
|
||||
|
||||
runes := []rune(text)
|
||||
if len(runes) > 2000 {
|
||||
return string(runes[:2000])
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
func (s *aiCopilotService) buildSuggestReplyPrompts(transcript, locale string) (string, string) {
|
||||
normalized := i18nx.NormalizeLocale(locale)
|
||||
switch normalized {
|
||||
case i18nx.LocaleZhCN:
|
||||
systemPrompt := "你是一位专业的智能客服助手。请根据对话历史,为客服人员草拟一条针对客户最新诉求的专业、礼貌、准确且简洁的回复建议。回复语言必须与客户在对话中使用的语言保持一致。"
|
||||
userPrompt := "对话记录:\n<conversation_history>\n" + transcript + "\n</conversation_history>\n\n请草拟一条回复建议:"
|
||||
return systemPrompt, userPrompt
|
||||
case i18nx.LocaleRuRU:
|
||||
systemPrompt := "Вы — эксперт и ассистент оператора службы поддержки клиентов. На основе истории диалога составьте полезный, профессиональный, вежливый и краткий вариант ответа на последний запрос клиента. Ответ должен быть на том языке, на котором пишет клиент."
|
||||
userPrompt := "История диалога:\n<conversation_history>\n" + transcript + "\n</conversation_history>\n\nПредложите вариант ответа:"
|
||||
return systemPrompt, userPrompt
|
||||
default:
|
||||
systemPrompt := "You are an expert customer support co-pilot. Based on the conversation history, draft a helpful, professional, polite, and concise reply to the customer's latest inquiry. Reply in the language used by the customer in the conversation."
|
||||
userPrompt := "Conversation history:\n<conversation_history>\n" + transcript + "\n</conversation_history>\n\nDraft a suggested reply:"
|
||||
return systemPrompt, userPrompt
|
||||
}
|
||||
}
|
||||
|
||||
func (s *aiCopilotService) buildSummarizePrompts(transcript, locale string) (string, string) {
|
||||
normalized := i18nx.NormalizeLocale(locale)
|
||||
switch normalized {
|
||||
case i18nx.LocaleZhCN:
|
||||
systemPrompt := "你是一位专业的客服对话总结助手。请提供此客服对话的简明扼要总结,包含:1. 核心问题/诉求,2. 已采取的措施/已提供的解答,3. 当前状态/后续跟进事项。"
|
||||
userPrompt := "对话记录:\n<conversation_history>\n" + transcript + "\n</conversation_history>\n\n请总结此对话:"
|
||||
return systemPrompt, userPrompt
|
||||
case i18nx.LocaleRuRU:
|
||||
systemPrompt := "Вы — ассистент по подведению итогов диалогов службы поддержки. Предоставьте краткую сводку диалога, включая: 1. Основная проблема / вопрос, 2. Принятые меры / предоставленные ответы, 3. Текущий статус / следующие шаги."
|
||||
userPrompt := "История диалога:\n<conversation_history>\n" + transcript + "\n</conversation_history>\n\nПодведите итог диалога:"
|
||||
return systemPrompt, userPrompt
|
||||
default:
|
||||
systemPrompt := "You are a customer support summarizer. Provide a concise summary of this customer support conversation including: 1. Main Issue / Inquiry, 2. Actions taken / Answers provided, 3. Current Status / Next Steps."
|
||||
userPrompt := "Conversation history:\n<conversation_history>\n" + transcript + "\n</conversation_history>\n\nSummarize this conversation:"
|
||||
return systemPrompt, userPrompt
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user