package service import ( "context" "fmt" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gorm.io/driver/sqlite" "gorm.io/gorm" "gorm.io/gorm/logger" "github.com/gochat/gochat/internal/llm" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/repository" ) // ========== Mock LLM Provider for Insight tests ========== type mockInsightLLMProvider struct { chatResponse *llm.ChatResponse chatError error lastChatRequest *llm.ChatRequest streamChunks []llm.StreamChunk streamError error } func (m *mockInsightLLMProvider) ChatCompletion(ctx context.Context, req llm.ChatRequest) (*llm.ChatResponse, error) { m.lastChatRequest = &req return m.chatResponse, m.chatError } func (m *mockInsightLLMProvider) CreateEmbedding(ctx context.Context, req llm.EmbeddingRequest) (*llm.EmbeddingResponse, error) { return nil, fmt.Errorf("not implemented") } func (m *mockInsightLLMProvider) ChatCompletionStream(ctx context.Context, req llm.ChatRequest, onChunk func(llm.StreamChunk) error) error { for _, chunk := range m.streamChunks { if err := onChunk(chunk); err != nil { return err } } return m.streamError } // ========== Setup ========== func setupConversationInsightService(t *testing.T) (*gorm.DB, *mockInsightLLMProvider, *ConversationInsightService) { t.Helper() db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{ Logger: logger.Default.LogMode(logger.Silent), }) if err != nil { t.Fatalf("无法打开 SQLite 测试数据库: %v", err) } if err := db.AutoMigrate( &model.Account{}, &model.Conversation{}, &model.Message{}, &model.Contact{}, &model.CaptainAssistant{}, &model.CaptainAssistantResponse{}, &model.CaptainCustomTool{}, &model.CaptainDocument{}, ); err != nil { t.Fatalf("无法自动迁移模型: %v", err) } t.Cleanup(func() { sqlDB, _ := db.DB() sqlDB.Close() }) mockProvider := &mockInsightLLMProvider{} conversationRepo := repository.NewConversationRepo(db) messageRepo := repository.NewMessageRepo(db) assistantRepo := repository.NewCaptainAssistantRepo(db) svc := NewConversationInsightService(conversationRepo, messageRepo, assistantRepo, mockProvider) return db, mockProvider, svc } // createInsightTestAccount creates a test Account for insight tests. func createInsightTestAccount(t *testing.T, db *gorm.DB) *model.Account { t.Helper() account := &model.Account{Name: "test-account"} if err := db.Create(account).Error; err != nil { t.Fatalf("无法创建测试账户: %v", err) } return account } // createInsightTestConversation creates a test conversation with messages. func createInsightTestConversation(t *testing.T, db *gorm.DB, accountID uint, msgs []struct{ sender, content string }) *model.Conversation { t.Helper() conv := &model.Conversation{ AccountID: accountID, Status: "open", } if err := db.Create(conv).Error; err != nil { t.Fatalf("无法创建测试会话: %v", err) } for _, m := range msgs { senderType := "contact" msgType := "incoming" if m.sender == "agent" { senderType = "agent" msgType = "outgoing" } msg := &model.Message{ ConversationID: conv.ID, AccountID: accountID, Content: m.content, SenderType: senderType, MessageType: msgType, } if err := db.Create(msg).Error; err != nil { t.Fatalf("无法创建测试消息: %v", err) } } return conv } // ========== AnalyzeParticipants Tests ========== func TestConversationInsightService_AnalyzeParticipants_成功(t *testing.T) { db, mockProvider, svc := setupConversationInsightService(t) account := createInsightTestAccount(t, db) conv := createInsightTestConversation(t, db, account.ID, []struct{ sender, content string }{ {"contact", "我想退款"}, {"agent", "我来帮您处理退款申请"}, }) // LLM returns valid JSON for participant analysis participantJSON := `{ "participants": [ {"name": "Customer", "role": "customer", "sentiment": "negative", "topics": ["退款"], "engagement": 0.8}, {"name": "Agent", "role": "agent", "sentiment": "neutral", "topics": ["退款处理"], "engagement": 0.6} ], "summary": "客户要求退款,客服正在处理" }` mockProvider.chatResponse = &llm.ChatResponse{ Choices: []llm.ChatChoice{ {Message: llm.ChatMessage{Role: "assistant", Content: participantJSON}}, }, } result, err := svc.AnalyzeParticipants(context.Background(), account.ID, &ParticipantAnalysisRequest{ ConversationID: conv.ID, }) require.NoError(t, err) assert.Len(t, result.Participants, 2) assert.Equal(t, "Customer", result.Participants[0].Name) assert.Equal(t, "customer", result.Participants[0].Role) assert.NotEmpty(t, result.Summary) } func TestConversationInsightService_AnalyzeParticipants_无消息(t *testing.T) { db, _, svc := setupConversationInsightService(t) account := createInsightTestAccount(t, db) conv := createInsightTestConversation(t, db, account.ID, nil) result, err := svc.AnalyzeParticipants(context.Background(), account.ID, &ParticipantAnalysisRequest{ ConversationID: conv.ID, }) assert.Error(t, err) assert.Nil(t, result) assert.Contains(t, err.Error(), "no messages") } func TestConversationInsightService_AnalyzeParticipants_LLM失败(t *testing.T) { db, mockProvider, svc := setupConversationInsightService(t) account := createInsightTestAccount(t, db) conv := createInsightTestConversation(t, db, account.ID, []struct{ sender, content string }{ {"contact", "test message"}, }) mockProvider.chatError = fmt.Errorf("LLM API 不可用") result, err := svc.AnalyzeParticipants(context.Background(), account.ID, &ParticipantAnalysisRequest{ ConversationID: conv.ID, }) assert.Error(t, err) assert.Nil(t, result) assert.Contains(t, err.Error(), "participant analysis") } func TestConversationInsightService_AnalyzeParticipants_纯文本回退(t *testing.T) { db, mockProvider, svc := setupConversationInsightService(t) account := createInsightTestAccount(t, db) conv := createInsightTestConversation(t, db, account.ID, []struct{ sender, content string }{ {"contact", "你好"}, }) // LLM returns plain text instead of JSON — fallback parser kicks in mockProvider.chatResponse = &llm.ChatResponse{ Choices: []llm.ChatChoice{ {Message: llm.ChatMessage{Role: "assistant", Content: "The customer seems frustrated. The agent is helping."}}, }, } result, err := svc.AnalyzeParticipants(context.Background(), account.ID, &ParticipantAnalysisRequest{ ConversationID: conv.ID, }) require.NoError(t, err) // Fallback should produce at least one participant or summary assert.NotNil(t, result) } // ========== ExtractActionItems Tests ========== func TestConversationInsightService_ExtractActionItems_成功(t *testing.T) { db, mockProvider, svc := setupConversationInsightService(t) account := createInsightTestAccount(t, db) conv := createInsightTestConversation(t, db, account.ID, []struct{ sender, content string }{ {"contact", "我需要在周五前收到退款"}, {"agent", "我会在周五前为您处理退款"}, }) actionItemsJSON := `{ "items": [ {"description": "处理退款", "owner": "Agent", "deadline": "周五", "priority": "high", "status": "pending"} ] }` mockProvider.chatResponse = &llm.ChatResponse{ Choices: []llm.ChatChoice{ {Message: llm.ChatMessage{Role: "assistant", Content: actionItemsJSON}}, }, } result, err := svc.ExtractActionItems(context.Background(), account.ID, &ActionItemsRequest{ ConversationID: conv.ID, }) require.NoError(t, err) assert.Len(t, result.Items, 1) assert.Equal(t, "处理退款", result.Items[0].Description) assert.Equal(t, "high", result.Items[0].Priority) } func TestConversationInsightService_ExtractActionItems_无消息(t *testing.T) { db, _, svc := setupConversationInsightService(t) account := createInsightTestAccount(t, db) conv := createInsightTestConversation(t, db, account.ID, nil) result, err := svc.ExtractActionItems(context.Background(), account.ID, &ActionItemsRequest{ ConversationID: conv.ID, }) assert.Error(t, err) assert.Nil(t, result) assert.Contains(t, err.Error(), "no messages") } func TestConversationInsightService_ExtractActionItems_LLM失败(t *testing.T) { db, mockProvider, svc := setupConversationInsightService(t) account := createInsightTestAccount(t, db) conv := createInsightTestConversation(t, db, account.ID, []struct{ sender, content string }{ {"contact", "test"}, }) mockProvider.chatError = fmt.Errorf("LLM 服务不可用") result, err := svc.ExtractActionItems(context.Background(), account.ID, &ActionItemsRequest{ ConversationID: conv.ID, }) assert.Error(t, err) assert.Nil(t, result) assert.Contains(t, err.Error(), "action item extraction") } // ========== SuggestLabels Tests ========== func TestConversationInsightService_SuggestLabels_成功(t *testing.T) { db, mockProvider, svc := setupConversationInsightService(t) account := createInsightTestAccount(t, db) conv := createInsightTestConversation(t, db, account.ID, []struct{ sender, content string }{ {"contact", "我的快递丢了"}, {"agent", "我来帮您处理快递丢失问题"}, }) labelJSON := `{ "suggestions": [{ "labels": ["快递", "丢失", "投诉"], "priority": "high", "reason": "客户快递丢失,需要紧急处理" }] }` mockProvider.chatResponse = &llm.ChatResponse{ Choices: []llm.ChatChoice{ {Message: llm.ChatMessage{Role: "assistant", Content: labelJSON}}, }, } result, err := svc.SuggestLabels(context.Background(), account.ID, &LabelSuggestionRequest{ ConversationID: conv.ID, }) require.NoError(t, err) assert.NotEmpty(t, result.Suggestions) if len(result.Suggestions) > 0 { s := result.Suggestions[0] assert.NotEmpty(t, s.Labels) assert.NotEmpty(t, s.Priority) } } func TestConversationInsightService_SuggestLabels_带助手风格(t *testing.T) { db, mockProvider, svc := setupConversationInsightService(t) account := createInsightTestAccount(t, db) assistant := &model.CaptainAssistant{ AccountID: account.ID, Name: "客服助手", Status: model.AssistantStatusActive, Config: marshalAssistantConfig(map[string]interface{}{"instructions": "关注物流问题"}), } if err := db.Create(assistant).Error; err != nil { t.Fatalf("无法创建助手: %v", err) } conv := createInsightTestConversation(t, db, account.ID, []struct{ sender, content string }{ {"contact", "快递没到"}, }) labelJSON := `{ "labels": ["物流", "延迟"], "priority": "medium", "reason": "物流延迟" }` mockProvider.chatResponse = &llm.ChatResponse{ Choices: []llm.ChatChoice{ {Message: llm.ChatMessage{Role: "assistant", Content: labelJSON}}, }, } result, err := svc.SuggestLabels(context.Background(), account.ID, &LabelSuggestionRequest{ ConversationID: conv.ID, AssistantID: assistant.ID, }) require.NoError(t, err) assert.NotNil(t, result) // The system prompt should include assistant guidelines require.NotNil(t, mockProvider.lastChatRequest) assert.Contains(t, mockProvider.lastChatRequest.Messages[0].Content, "物流") } func TestConversationInsightService_SuggestLabels_无消息(t *testing.T) { db, _, svc := setupConversationInsightService(t) account := createInsightTestAccount(t, db) conv := createInsightTestConversation(t, db, account.ID, nil) result, err := svc.SuggestLabels(context.Background(), account.ID, &LabelSuggestionRequest{ ConversationID: conv.ID, }) assert.Error(t, err) assert.Nil(t, result) assert.Contains(t, err.Error(), "no messages") } func TestConversationInsightService_SuggestLabels_LLM失败(t *testing.T) { db, mockProvider, svc := setupConversationInsightService(t) account := createInsightTestAccount(t, db) conv := createInsightTestConversation(t, db, account.ID, []struct{ sender, content string }{ {"contact", "test"}, }) mockProvider.chatError = fmt.Errorf("LLM 调用超时") result, err := svc.SuggestLabels(context.Background(), account.ID, &LabelSuggestionRequest{ ConversationID: conv.ID, }) assert.Error(t, err) assert.Nil(t, result) assert.Contains(t, err.Error(), "label suggestion") }