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

419 lines
12 KiB
Go

package service
import (
"context"
"encoding/json"
"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 ==========
// mockLLMProvider is a mock implementation of llm.Provider for testing.
type mockLLMProvider struct {
chatResponse *llm.ChatResponse
chatError error
embeddingResponse *llm.EmbeddingResponse
embeddingError error
lastChatRequest *llm.ChatRequest
lastEmbeddingReq *llm.EmbeddingRequest
streamChunks []llm.StreamChunk
streamError error
lastStreamReq *llm.ChatRequest
}
func (m *mockLLMProvider) ChatCompletion(ctx context.Context, req llm.ChatRequest) (*llm.ChatResponse, error) {
m.lastChatRequest = &req
return m.chatResponse, m.chatError
}
func (m *mockLLMProvider) CreateEmbedding(ctx context.Context, req llm.EmbeddingRequest) (*llm.EmbeddingResponse, error) {
m.lastEmbeddingReq = &req
return m.embeddingResponse, m.embeddingError
}
func (m *mockLLMProvider) ChatCompletionStream(ctx context.Context, req llm.ChatRequest, onChunk func(llm.StreamChunk) error) error {
m.lastStreamReq = &req
if m.streamError != nil {
return m.streamError
}
for _, chunk := range m.streamChunks {
if err := onChunk(chunk); err != nil {
return err
}
}
return nil
}
// ========== Test Setup ==========
// setupCaptainTaskService 创建测试所需的数据库、mockLLMProvider 和 CaptainTaskService 实例。
func setupCaptainTaskService(t *testing.T) (*gorm.DB, *mockLLMProvider, *CaptainTaskService) {
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.CaptainAssistant{},
&model.CaptainAssistantResponse{},
&model.CaptainCustomTool{},
&model.CaptainDocument{},
); err != nil {
t.Fatalf("无法自动迁移模型: %v", err)
}
t.Cleanup(func() {
sqlDB, _ := db.DB()
sqlDB.Close()
})
mockProvider := &mockLLMProvider{}
assistantRepo := repository.NewCaptainAssistantRepo(db)
responseRepo := repository.NewCaptainAssistantResponseRepo(db)
customToolRepo := repository.NewCaptainCustomToolRepo(db)
conversationRepo := repository.NewConversationRepo(db)
messageRepo := repository.NewMessageRepo(db)
contactRepo := repository.NewContactRepo(db)
// M12: CopilotContextService for context enrichment in streaming
copilotContextSvc := NewCopilotContextService(messageRepo, conversationRepo, contactRepo, mockProvider)
svc := NewCaptainTaskService(assistantRepo, responseRepo, customToolRepo, conversationRepo, messageRepo, mockProvider, copilotContextSvc)
return db, mockProvider, svc
}
// createTestAssistant 创建一个测试 CaptainAssistant。
func createTestAssistant(t *testing.T, db *gorm.DB, accountID uint, name string) *model.CaptainAssistant {
t.Helper()
assistant := &model.CaptainAssistant{
AccountID: accountID,
Name: name,
Status: model.AssistantStatusActive,
Config: marshalAssistantConfig(model.DefaultAssistantConfig()),
}
if err := db.Create(assistant).Error; err != nil {
t.Fatalf("无法创建测试助手: %v", err)
}
return assistant
}
// createTestConversationWithMessages 创建一个测试会话并添加消息。
func createTestConversationWithMessages(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
}
// makeChatResponse 创建一个简单的 LLM ChatResponse。
func makeChatResponse(content string) *llm.ChatResponse {
return &llm.ChatResponse{
Choices: []llm.ChatChoice{
{
Message: llm.ChatMessage{
Role: "assistant",
Content: content,
},
},
},
}
}
// makeEmbeddingResponse 创建一个模拟的 EmbeddingResponse。
func makeEmbeddingResponse() *llm.EmbeddingResponse {
// 8维向量,足以通过 SQLite 测试
dims := make([]float64, 8)
return &llm.EmbeddingResponse{
Data: []llm.EmbeddingData{
{
Embedding: dims,
},
},
}
}
// ========== Summarize Tests ==========
func TestCaptainTaskService_Summarize_成功(t *testing.T) {
db, mockProvider, svc := setupCaptainTaskService(t)
account := createTestAccount(t, db)
conv := createTestConversationWithMessages(t, db, account.ID, []struct{ sender, content string }{
{"contact", "你好,我的订单还没收到"},
{"agent", "我来帮您查询一下"},
{"contact", "订单号是 12345"},
})
mockProvider.chatResponse = makeChatResponse("客户咨询订单状态,订单号12345,代理正在查询")
result, err := svc.Summarize(context.Background(), account.ID, &TaskSummarizeRequest{
ConversationID: conv.ID,
Language: "zh-CN",
})
require.NoError(t, err)
assert.NotEmpty(t, result.Summary)
}
func TestCaptainTaskService_Summarize_无消息(t *testing.T) {
db, _, svc := setupCaptainTaskService(t)
account := createTestAccount(t, db)
conv := createTestConversationWithMessages(t, db, account.ID, nil)
result, err := svc.Summarize(context.Background(), account.ID, &TaskSummarizeRequest{
ConversationID: conv.ID,
})
assert.Error(t, err)
assert.Nil(t, result)
assert.Contains(t, err.Error(), "no messages")
}
func TestCaptainTaskService_Summarize_LLM失败(t *testing.T) {
db, mockProvider, svc := setupCaptainTaskService(t)
account := createTestAccount(t, db)
conv := createTestConversationWithMessages(t, db, account.ID, []struct{ sender, content string }{
{"contact", "test message"},
})
mockProvider.chatError = fmt.Errorf("LLM API 调用失败")
result, err := svc.Summarize(context.Background(), account.ID, &TaskSummarizeRequest{
ConversationID: conv.ID,
})
assert.Error(t, err)
assert.Nil(t, result)
assert.Contains(t, err.Error(), "llm")
}
func TestCaptainTaskService_Summarize_默认语言(t *testing.T) {
db, mockProvider, svc := setupCaptainTaskService(t)
account := createTestAccount(t, db)
conv := createTestConversationWithMessages(t, db, account.ID, []struct{ sender, content string }{
{"contact", "hello"},
})
mockProvider.chatResponse = makeChatResponse("摘要内容")
_, err := svc.Summarize(context.Background(), account.ID, &TaskSummarizeRequest{
ConversationID: conv.ID,
})
require.NoError(t, err)
require.NotNil(t, mockProvider.lastChatRequest)
// 默认语言 zh-CN 应出现在 system prompt 中
assert.Contains(t, mockProvider.lastChatRequest.Messages[0].Content, "zh-CN")
}
// ========== Rewrite Tests ==========
func TestCaptainTaskService_Rewrite_成功(t *testing.T) {
_, mockProvider, svc := setupCaptainTaskService(t)
mockProvider.chatResponse = makeChatResponse("感谢您的来信,我会尽快处理。")
result, err := svc.Rewrite(context.Background(), 1, &TaskRewriteRequest{
Content: "来信收到了",
Tone: "professional",
Language: "zh-CN",
})
require.NoError(t, err)
assert.NotEmpty(t, result.RewrittenContent)
assert.Equal(t, "professional", result.Tone)
}
func TestCaptainTaskService_Rewrite_默认参数(t *testing.T) {
_, mockProvider, svc := setupCaptainTaskService(t)
mockProvider.chatResponse = makeChatResponse("rewritten text")
result, err := svc.Rewrite(context.Background(), 1, &TaskRewriteRequest{
Content: "原始内容",
})
require.NoError(t, err)
assert.Equal(t, "professional", result.Tone) // 默认 professional
require.NotNil(t, mockProvider.lastChatRequest)
assert.Contains(t, mockProvider.lastChatRequest.Messages[0].Content, "zh-CN")
}
func TestCaptainTaskService_Rewrite_带助手风格(t *testing.T) {
db, mockProvider, svc := setupCaptainTaskService(t)
account := createTestAccount(t, db)
assistant := createTestAssistant(t, db, account.ID, "客服助手")
// 更新助手 config 添加 instructions
cfg := &model.AssistantConfig{
Temperature: 0.7,
Instructions: "始终以礼貌的语气回复",
}
configJSON, _ := json.Marshal(cfg)
db.Model(assistant).Update("config", configJSON)
mockProvider.chatResponse = makeChatResponse("礼貌回复内容")
result, err := svc.Rewrite(context.Background(), account.ID, &TaskRewriteRequest{
Content: "好的知道了",
Tone: "friendly",
Language: "zh-CN",
AssistantID: assistant.ID,
})
require.NoError(t, err)
assert.NotEmpty(t, result.RewrittenContent)
require.NotNil(t, mockProvider.lastChatRequest)
assert.Contains(t, mockProvider.lastChatRequest.Messages[0].Content, "礼貌")
}
func TestCaptainTaskService_Rewrite_LLM失败(t *testing.T) {
_, mockProvider, svc := setupCaptainTaskService(t)
mockProvider.chatError = fmt.Errorf("LLM 服务不可用")
result, err := svc.Rewrite(context.Background(), 1, &TaskRewriteRequest{
Content: "test",
})
assert.Error(t, err)
assert.Nil(t, result)
}
// ========== ReplySuggestion Tests ==========
func TestCaptainTaskService_ReplySuggestion_成功(t *testing.T) {
db, mockProvider, svc := setupCaptainTaskService(t)
account := createTestAccount(t, db)
assistant := createTestAssistant(t, db, account.ID, "客服助手")
conv := createTestConversationWithMessages(t, db, account.ID, []struct{ sender, content string }{
{"contact", "你们的产品怎么使用?"},
{"agent", "请问是哪个产品?"},
})
mockProvider.embeddingResponse = makeEmbeddingResponse()
mockProvider.chatResponse = makeChatResponse("1. 您可以参考产品手册\n2. 请访问我们的帮助中心\n3. 我来为您演示使用方法")
result, err := svc.ReplySuggestion(context.Background(), account.ID, &TaskReplySuggestionRequest{
AssistantID: assistant.ID,
ConversationID: conv.ID,
Tone: "friendly",
})
require.NoError(t, err)
assert.NotEmpty(t, result.Suggestions)
assert.Equal(t, "llm", result.Source) // no RAG docs in SQLite, so source is "llm"
}
func TestCaptainTaskService_ReplySuggestion_助手不存在(t *testing.T) {
_, _, svc := setupCaptainTaskService(t)
result, err := svc.ReplySuggestion(context.Background(), 1, &TaskReplySuggestionRequest{
AssistantID: 99999,
ConversationID: 1,
})
assert.Error(t, err)
assert.Nil(t, result)
assert.Contains(t, err.Error(), "assistant not found")
}
func TestCaptainTaskService_ReplySuggestion_LLM失败(t *testing.T) {
db, mockProvider, svc := setupCaptainTaskService(t)
account := createTestAccount(t, db)
assistant := createTestAssistant(t, db, account.ID, "助手")
conv := createTestConversationWithMessages(t, db, account.ID, []struct{ sender, content string }{
{"contact", "hello"},
})
mockProvider.embeddingResponse = makeEmbeddingResponse()
mockProvider.chatError = fmt.Errorf("LLM 调用超时")
result, err := svc.ReplySuggestion(context.Background(), account.ID, &TaskReplySuggestionRequest{
AssistantID: assistant.ID,
ConversationID: conv.ID,
})
assert.Error(t, err)
assert.Nil(t, result)
}
// ========== parseSuggestions Tests ==========
func TestParseSuggestions_编号列表(t *testing.T) {
content := "1. 建议一\n2. 建议二\n3. 建议三"
result := parseSuggestions(content)
assert.Len(t, result, 3)
assert.Equal(t, "建议一", result[0])
assert.Equal(t, "建议二", result[1])
}
func TestParseSuggestions_子弹列表(t *testing.T) {
content := "- 建议A\n* 建议B\n- 建议C"
result := parseSuggestions(content)
assert.Len(t, result, 3)
assert.Equal(t, "建议A", result[0])
}
func TestParseSuggestions_逗号分隔(t *testing.T) {
content := "建议X,建议Y,建议Z"
result := parseSuggestions(content)
assert.Len(t, result, 3)
}
func TestParseSuggestions_单条文本(t *testing.T) {
content := "这是一个建议"
result := parseSuggestions(content)
assert.Len(t, result, 1)
assert.Equal(t, "这是一个建议", result[0])
}
func TestParseSuggestions_空内容(t *testing.T) {
content := ""
result := parseSuggestions(content)
assert.Len(t, result, 1)
}