419 lines
12 KiB
Go
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)
|
|
} |