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\n" + transcript + "\n\n\n请草拟一条回复建议:" return systemPrompt, userPrompt case i18nx.LocaleRuRU: systemPrompt := "Вы — эксперт и ассистент оператора службы поддержки клиентов. На основе истории диалога составьте полезный, профессиональный, вежливый и краткий вариант ответа на последний запрос клиента. Ответ должен быть на том языке, на котором пишет клиент." userPrompt := "История диалога:\n\n" + transcript + "\n\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\n" + transcript + "\n\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\n" + transcript + "\n\n\n请总结此对话:" return systemPrompt, userPrompt case i18nx.LocaleRuRU: systemPrompt := "Вы — ассистент по подведению итогов диалогов службы поддержки. Предоставьте краткую сводку диалога, включая: 1. Основная проблема / вопрос, 2. Принятые меры / предоставленные ответы, 3. Текущий статус / следующие шаги." userPrompt := "История диалога:\n\n" + transcript + "\n\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\n" + transcript + "\n\n\nSummarize this conversation:" return systemPrompt, userPrompt } }