Restructure the monorepo into clear top-level directories: - backend/: Go module root (cmd, internal, pkg, configs, migrations, docs/swagger, scripts, tests, go.mod, Makefile, .air.toml) - deploy/: Docker (Dockerfile, docker-compose*), quickstart, fluentd - docs/: project documentation + reports/ (moved from repo root) - AGENTS.md: new AI coding-agent guide at repo root Update all references to the new layout: - Dockerfile: COPY backend/go.mod, COPY backend/ (context = repo root) - docker-compose files: context ../.., dockerfile deploy/docker/Dockerfile, env_file ../../.env, volume mounts ../../backend:/app - deploy/quickstart/compose.yaml: dockerfile deploy/docker/Dockerfile - CI: working-directory: backend for go commands, file deploy/docker/Dockerfile, coverage path backend/coverage.out, health_check backend/scripts/ - backend/Makefile: docker target uses -f ../deploy/docker/Dockerfile ../ - README: architecture tree, quickstart, config paths updated Move root stray scripts (rename_models.*, run_m11_tests.sh, verify_build.sh, gorm_bool_main.go) to backend/scripts/legacy/. All moves via git mv to preserve history. Build, vet, SQLite tests, and docker compose config verified.
524 lines
15 KiB
Go
524 lines
15 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"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 CopilotContext tests ==========
|
|
|
|
type mockContextLLMProvider struct {
|
|
chatResponse *llm.ChatResponse
|
|
chatError error
|
|
embeddingResp *llm.EmbeddingResponse
|
|
embeddingError error
|
|
streamChunks []llm.StreamChunk
|
|
streamError error
|
|
lastChatRequest *llm.ChatRequest
|
|
lastEmbeddingReq *llm.EmbeddingRequest
|
|
chatCalls int
|
|
embeddingCalls int
|
|
}
|
|
|
|
func (m *mockContextLLMProvider) ChatCompletion(ctx context.Context, req llm.ChatRequest) (*llm.ChatResponse, error) {
|
|
m.lastChatRequest = &req
|
|
m.chatCalls++
|
|
return m.chatResponse, m.chatError
|
|
}
|
|
|
|
func (m *mockContextLLMProvider) CreateEmbedding(ctx context.Context, req llm.EmbeddingRequest) (*llm.EmbeddingResponse, error) {
|
|
m.lastEmbeddingReq = &req
|
|
m.embeddingCalls++
|
|
return m.embeddingResp, m.embeddingError
|
|
}
|
|
|
|
func (m *mockContextLLMProvider) 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 setupCopilotContextTestDB(t *testing.T) *gorm.DB {
|
|
t.Helper()
|
|
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{
|
|
Logger: logger.Default.LogMode(logger.Silent),
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
err = db.AutoMigrate(
|
|
&model.Account{},
|
|
&model.Contact{},
|
|
&model.Conversation{},
|
|
&model.Message{},
|
|
&model.CaptainAssistant{},
|
|
&model.Inbox{},
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
t.Cleanup(func() {
|
|
sqlDB, _ := db.DB()
|
|
sqlDB.Close()
|
|
})
|
|
return db
|
|
}
|
|
|
|
func setupCopilotContextService(t *testing.T) (*CopilotContextService, *mockContextLLMProvider, *gorm.DB) {
|
|
t.Helper()
|
|
db := setupCopilotContextTestDB(t)
|
|
mockProvider := &mockContextLLMProvider{
|
|
chatResponse: &llm.ChatResponse{
|
|
Choices: []llm.ChatChoice{
|
|
{Message: llm.ChatMessage{Content: "test summary"}, FinishReason: "stop"},
|
|
},
|
|
},
|
|
embeddingResp: &llm.EmbeddingResponse{
|
|
Data: []llm.EmbeddingData{
|
|
{Embedding: []float64{0.1, 0.2, 0.3}},
|
|
},
|
|
},
|
|
}
|
|
messageRepo := repository.NewMessageRepo(db)
|
|
conversationRepo := repository.NewConversationRepo(db)
|
|
contactRepo := repository.NewContactRepo(db)
|
|
|
|
svc := NewCopilotContextService(messageRepo, conversationRepo, contactRepo, mockProvider)
|
|
require.NotNil(t, svc)
|
|
return svc, mockProvider, db
|
|
}
|
|
|
|
// createContextTestConversation creates a conversation with messages for context tests.
|
|
// (Separate from captain_task_service_test.go's createTestConversationWithMessages to avoid signature conflicts.)
|
|
func createContextTestConversation(t *testing.T, db *gorm.DB, accountID uint) (*model.Conversation, []*model.Message) {
|
|
t.Helper()
|
|
contact := &model.Contact{
|
|
Name: "Test Contact",
|
|
Email: "test@example.com",
|
|
}
|
|
require.NoError(t, db.Create(contact).Error)
|
|
|
|
inbox := &model.Inbox{
|
|
Name: "Test Inbox",
|
|
AccountID: accountID,
|
|
}
|
|
require.NoError(t, db.Create(inbox).Error)
|
|
|
|
conversation := &model.Conversation{
|
|
AccountID: accountID,
|
|
InboxID: inbox.ID,
|
|
ContactID: contact.ID,
|
|
Status: "open",
|
|
}
|
|
require.NoError(t, db.Create(conversation).Error)
|
|
|
|
var messages []*model.Message
|
|
for i, content := range []string{
|
|
"Hello, I need help with my order",
|
|
"Sure, let me look into that",
|
|
"Order #12345 hasn't arrived",
|
|
"I'll check the tracking info",
|
|
} {
|
|
msgType := "incoming"
|
|
if i%2 == 1 {
|
|
msgType = "outgoing"
|
|
}
|
|
msg := &model.Message{
|
|
ConversationID: conversation.ID,
|
|
Content: content,
|
|
MessageType: msgType,
|
|
ContentType: "text",
|
|
AccountID: accountID,
|
|
}
|
|
require.NoError(t, db.Create(msg).Error)
|
|
messages = append(messages, msg)
|
|
}
|
|
|
|
return conversation, messages
|
|
}
|
|
|
|
// helper to create stream chunks matching OpenAI SSE format
|
|
func makeStreamChunks(contents []string) []llm.StreamChunk {
|
|
var chunks []llm.StreamChunk
|
|
for i, content := range contents {
|
|
finishReason := ""
|
|
if i == len(contents)-1 {
|
|
finishReason = "stop"
|
|
}
|
|
chunks = append(chunks, llm.StreamChunk{
|
|
Choices: []llm.StreamChoice{
|
|
{
|
|
Index: 0,
|
|
Delta: llm.StreamDelta{Content: content},
|
|
FinishReason: finishReason,
|
|
},
|
|
},
|
|
})
|
|
}
|
|
return chunks
|
|
}
|
|
|
|
// ========== Context Enrichment Tests ==========
|
|
|
|
func TestCopilotContextService_GetCurrentViewingContext(t *testing.T) {
|
|
svc, _, db := setupCopilotContextService(t)
|
|
|
|
account := &model.Account{Name: "Test Account"}
|
|
require.NoError(t, db.Create(account).Error)
|
|
accountID := account.ID
|
|
|
|
conversation, _ := createContextTestConversation(t, db, accountID)
|
|
|
|
ctx := context.Background()
|
|
result, err := svc.GetCurrentViewingContext(ctx, accountID, conversation.ID)
|
|
|
|
require.NoError(t, err)
|
|
assert.NotNil(t, result)
|
|
assert.Equal(t, conversation.ID, result.ConversationID)
|
|
assert.Equal(t, "Test Contact", result.ContactName)
|
|
assert.Equal(t, "test@example.com", result.ContactEmail)
|
|
assert.NotEmpty(t, result.Messages)
|
|
}
|
|
|
|
func TestCopilotContextService_GetCurrentViewingContext_NoContact(t *testing.T) {
|
|
svc, _, db := setupCopilotContextService(t)
|
|
|
|
account := &model.Account{Name: "Test Account"}
|
|
require.NoError(t, db.Create(account).Error)
|
|
accountID := account.ID
|
|
|
|
inbox := &model.Inbox{Name: "Test Inbox", AccountID: accountID}
|
|
require.NoError(t, db.Create(inbox).Error)
|
|
|
|
conversation := &model.Conversation{
|
|
AccountID: accountID,
|
|
InboxID: inbox.ID,
|
|
ContactID: 0,
|
|
Status: "open",
|
|
}
|
|
require.NoError(t, db.Create(conversation).Error)
|
|
|
|
ctx := context.Background()
|
|
result, err := svc.GetCurrentViewingContext(ctx, accountID, conversation.ID)
|
|
|
|
require.NoError(t, err)
|
|
assert.NotNil(t, result)
|
|
assert.Empty(t, result.ContactName)
|
|
}
|
|
|
|
func TestCopilotContextService_GetCurrentViewingContext_NonexistentConversation(t *testing.T) {
|
|
svc, _, _ := setupCopilotContextService(t)
|
|
|
|
ctx := context.Background()
|
|
result, err := svc.GetCurrentViewingContext(ctx, 999, 99999)
|
|
|
|
assert.Error(t, err)
|
|
assert.Nil(t, result)
|
|
}
|
|
|
|
func TestCopilotContextService_ContextMessageFormatting(t *testing.T) {
|
|
svc, _, db := setupCopilotContextService(t)
|
|
|
|
account := &model.Account{Name: "Test Account"}
|
|
require.NoError(t, db.Create(account).Error)
|
|
accountID := account.ID
|
|
|
|
conversation, _ := createContextTestConversation(t, db, accountID)
|
|
|
|
ctx := context.Background()
|
|
result, err := svc.GetCurrentViewingContext(ctx, accountID, conversation.ID)
|
|
|
|
require.NoError(t, err)
|
|
require.NotNil(t, result)
|
|
|
|
// Verify customer/agent role assignment
|
|
for _, m := range result.Messages {
|
|
if m.Role == "customer" {
|
|
assert.Contains(t, []string{"Hello, I need help with my order", "Order #12345 hasn't arrived"}, m.Content)
|
|
} else if m.Role == "agent" {
|
|
assert.Contains(t, []string{"Sure, let me look into that", "I'll check the tracking info"}, m.Content)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCopilotContextService_EnrichmentIntegration(t *testing.T) {
|
|
db := setupCopilotContextTestDB(t)
|
|
|
|
account := &model.Account{Name: "Test Account"}
|
|
require.NoError(t, db.Create(account).Error)
|
|
accountID := account.ID
|
|
|
|
mockLLM := &mockContextLLMProvider{
|
|
chatResponse: &llm.ChatResponse{
|
|
Choices: []llm.ChatChoice{
|
|
{Message: llm.ChatMessage{Content: "I can help with that order"}, FinishReason: "stop"},
|
|
},
|
|
},
|
|
streamChunks: makeStreamChunks([]string{"I can", " help", " with that"}),
|
|
}
|
|
|
|
// Create test data
|
|
contact := &model.Contact{Name: "Alice", Email: "alice@example.com"}
|
|
require.NoError(t, db.Create(contact).Error)
|
|
inbox := &model.Inbox{Name: "Support", AccountID: accountID}
|
|
require.NoError(t, db.Create(inbox).Error)
|
|
conversation := &model.Conversation{
|
|
AccountID: accountID, InboxID: inbox.ID, ContactID: contact.ID,
|
|
Status: "open",
|
|
}
|
|
require.NoError(t, db.Create(conversation).Error)
|
|
msg := &model.Message{
|
|
ConversationID: conversation.ID, Content: "Help me!", MessageType: "incoming",
|
|
ContentType: "text", AccountID: accountID,
|
|
}
|
|
require.NoError(t, db.Create(msg).Error)
|
|
|
|
assistantRepo := repository.NewCaptainAssistantRepo(db)
|
|
responseRepo := repository.NewCaptainAssistantResponseRepo(db)
|
|
customToolRepo := repository.NewCaptainCustomToolRepo(db)
|
|
conversationRepo := repository.NewConversationRepo(db)
|
|
messageRepo := repository.NewMessageRepo(db)
|
|
contactRepo := repository.NewContactRepo(db)
|
|
|
|
copilotContextSvc := NewCopilotContextService(messageRepo, conversationRepo, contactRepo, mockLLM)
|
|
|
|
captainTaskSvc := NewCaptainTaskService(
|
|
assistantRepo, responseRepo, customToolRepo,
|
|
conversationRepo, messageRepo,
|
|
mockLLM, copilotContextSvc,
|
|
)
|
|
|
|
require.NotNil(t, captainTaskSvc)
|
|
assert.NotNil(t, captainTaskSvc.copilotContextSvc)
|
|
}
|
|
|
|
// ========== Streaming Tests ==========
|
|
|
|
func TestCaptainTaskService_ReplySuggestionStream(t *testing.T) {
|
|
db := setupCopilotContextTestDB(t)
|
|
|
|
account := &model.Account{Name: "Test Account"}
|
|
require.NoError(t, db.Create(account).Error)
|
|
accountID := account.ID
|
|
|
|
streamChunks := makeStreamChunks([]string{"Hello!", " I can", " help you."})
|
|
|
|
mockLLM := &mockLLMProvider{
|
|
streamChunks: streamChunks,
|
|
}
|
|
|
|
assistantRepo := repository.NewCaptainAssistantRepo(db)
|
|
responseRepo := repository.NewCaptainAssistantResponseRepo(db)
|
|
customToolRepo := repository.NewCaptainCustomToolRepo(db)
|
|
conversationRepo := repository.NewConversationRepo(db)
|
|
messageRepo := repository.NewMessageRepo(db)
|
|
contactRepo := repository.NewContactRepo(db)
|
|
|
|
copilotContextSvc := NewCopilotContextService(messageRepo, conversationRepo, contactRepo, mockLLM)
|
|
captainTaskSvc := NewCaptainTaskService(
|
|
assistantRepo, responseRepo, customToolRepo,
|
|
conversationRepo, messageRepo,
|
|
mockLLM, copilotContextSvc,
|
|
)
|
|
|
|
// Create assistant and conversation
|
|
assistant := &model.CaptainAssistant{
|
|
AccountID: accountID,
|
|
Name: "Test Assistant",
|
|
Status: model.AssistantStatusActive,
|
|
Config: marshalAssistantConfig(model.DefaultAssistantConfig()),
|
|
}
|
|
require.NoError(t, db.Create(assistant).Error)
|
|
|
|
conversation := &model.Conversation{
|
|
AccountID: accountID,
|
|
Status: "open",
|
|
}
|
|
require.NoError(t, db.Create(conversation).Error)
|
|
|
|
req := &TaskReplySuggestionRequest{
|
|
ConversationID: conversation.ID,
|
|
AssistantID: assistant.ID,
|
|
}
|
|
|
|
var collectedContents []string
|
|
err := captainTaskSvc.ReplySuggestionStream(context.Background(), accountID, req, func(chunk llm.StreamChunk) error {
|
|
if len(chunk.Choices) > 0 {
|
|
content := chunk.Choices[0].Delta.Content
|
|
if content != "" {
|
|
collectedContents = append(collectedContents, content)
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
|
|
require.NoError(t, err)
|
|
assert.Equal(t, []string{"Hello!", " I can", " help you."}, collectedContents)
|
|
assert.NotNil(t, mockLLM.lastStreamReq)
|
|
}
|
|
|
|
func TestCaptainTaskService_SummarizeStream(t *testing.T) {
|
|
db := setupCopilotContextTestDB(t)
|
|
|
|
account := &model.Account{Name: "Test Account"}
|
|
require.NoError(t, db.Create(account).Error)
|
|
accountID := account.ID
|
|
|
|
streamChunks := makeStreamChunks([]string{"Customer", " asked about", " order status."})
|
|
|
|
mockLLM := &mockLLMProvider{
|
|
streamChunks: streamChunks,
|
|
}
|
|
|
|
assistantRepo := repository.NewCaptainAssistantRepo(db)
|
|
responseRepo := repository.NewCaptainAssistantResponseRepo(db)
|
|
customToolRepo := repository.NewCaptainCustomToolRepo(db)
|
|
conversationRepo := repository.NewConversationRepo(db)
|
|
messageRepo := repository.NewMessageRepo(db)
|
|
contactRepo := repository.NewContactRepo(db)
|
|
|
|
copilotContextSvc := NewCopilotContextService(messageRepo, conversationRepo, contactRepo, mockLLM)
|
|
captainTaskSvc := NewCaptainTaskService(
|
|
assistantRepo, responseRepo, customToolRepo,
|
|
conversationRepo, messageRepo,
|
|
mockLLM, copilotContextSvc,
|
|
)
|
|
|
|
conversation := &model.Conversation{
|
|
AccountID: accountID,
|
|
Status: "open",
|
|
}
|
|
require.NoError(t, db.Create(conversation).Error)
|
|
|
|
// Add messages to the conversation
|
|
for _, content := range []string{"I need help", "Let me check"} {
|
|
msg := &model.Message{
|
|
ConversationID: conversation.ID,
|
|
Content: content,
|
|
MessageType: "incoming",
|
|
ContentType: "text",
|
|
AccountID: accountID,
|
|
}
|
|
require.NoError(t, db.Create(msg).Error)
|
|
}
|
|
|
|
req := &TaskSummarizeRequest{
|
|
ConversationID: conversation.ID,
|
|
}
|
|
|
|
var collectedContents []string
|
|
err := captainTaskSvc.SummarizeStream(context.Background(), accountID, req, func(chunk llm.StreamChunk) error {
|
|
if len(chunk.Choices) > 0 {
|
|
content := chunk.Choices[0].Delta.Content
|
|
if content != "" {
|
|
collectedContents = append(collectedContents, content)
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
|
|
require.NoError(t, err)
|
|
assert.Equal(t, []string{"Customer", " asked about", " order status."}, collectedContents)
|
|
}
|
|
|
|
func TestCaptainTaskService_RewriteStream(t *testing.T) {
|
|
db := setupCopilotContextTestDB(t)
|
|
|
|
account := &model.Account{Name: "Test Account"}
|
|
require.NoError(t, db.Create(account).Error)
|
|
accountID := account.ID
|
|
|
|
streamChunks := makeStreamChunks([]string{"Dear", " customer,", " we apologize."})
|
|
|
|
mockLLM := &mockLLMProvider{
|
|
streamChunks: streamChunks,
|
|
}
|
|
|
|
assistantRepo := repository.NewCaptainAssistantRepo(db)
|
|
responseRepo := repository.NewCaptainAssistantResponseRepo(db)
|
|
customToolRepo := repository.NewCaptainCustomToolRepo(db)
|
|
conversationRepo := repository.NewConversationRepo(db)
|
|
messageRepo := repository.NewMessageRepo(db)
|
|
contactRepo := repository.NewContactRepo(db)
|
|
|
|
copilotContextSvc := NewCopilotContextService(messageRepo, conversationRepo, contactRepo, mockLLM)
|
|
captainTaskSvc := NewCaptainTaskService(
|
|
assistantRepo, responseRepo, customToolRepo,
|
|
conversationRepo, messageRepo,
|
|
mockLLM, copilotContextSvc,
|
|
)
|
|
|
|
req := &TaskRewriteRequest{
|
|
Content: "sorry for delay",
|
|
Tone: "professional",
|
|
}
|
|
|
|
var collectedContents []string
|
|
err := captainTaskSvc.RewriteStream(context.Background(), accountID, req, func(chunk llm.StreamChunk) error {
|
|
if len(chunk.Choices) > 0 {
|
|
content := chunk.Choices[0].Delta.Content
|
|
if content != "" {
|
|
collectedContents = append(collectedContents, content)
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
|
|
require.NoError(t, err)
|
|
assert.Equal(t, []string{"Dear", " customer,", " we apologize."}, collectedContents)
|
|
}
|
|
|
|
func TestCaptainTaskService_StreamErrorHandling(t *testing.T) {
|
|
db := setupCopilotContextTestDB(t)
|
|
|
|
account := &model.Account{Name: "Test Account"}
|
|
require.NoError(t, db.Create(account).Error)
|
|
accountID := account.ID
|
|
|
|
mockLLM := &mockLLMProvider{
|
|
streamError: context.DeadlineExceeded,
|
|
}
|
|
|
|
assistantRepo := repository.NewCaptainAssistantRepo(db)
|
|
responseRepo := repository.NewCaptainAssistantResponseRepo(db)
|
|
customToolRepo := repository.NewCaptainCustomToolRepo(db)
|
|
conversationRepo := repository.NewConversationRepo(db)
|
|
messageRepo := repository.NewMessageRepo(db)
|
|
contactRepo := repository.NewContactRepo(db)
|
|
|
|
copilotContextSvc := NewCopilotContextService(messageRepo, conversationRepo, contactRepo, mockLLM)
|
|
captainTaskSvc := NewCaptainTaskService(
|
|
assistantRepo, responseRepo, customToolRepo,
|
|
conversationRepo, messageRepo,
|
|
mockLLM, copilotContextSvc,
|
|
)
|
|
|
|
conversation := &model.Conversation{
|
|
AccountID: accountID,
|
|
Status: "open",
|
|
}
|
|
require.NoError(t, db.Create(conversation).Error)
|
|
|
|
req := &TaskReplySuggestionRequest{
|
|
ConversationID: conversation.ID,
|
|
}
|
|
|
|
err := captainTaskSvc.ReplySuggestionStream(context.Background(), accountID, req, func(chunk llm.StreamChunk) error {
|
|
return nil
|
|
})
|
|
|
|
assert.Error(t, err)
|
|
} |