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.
285 lines
9.5 KiB
Go
285 lines
9.5 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/gochat/gochat/internal/llm"
|
|
)
|
|
|
|
// ========== Streaming Tests (M12) ==========
|
|
|
|
func TestCaptainTaskService_ReplySuggestionStream_成功(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.streamChunks = []llm.StreamChunk{
|
|
{Choices: []llm.StreamChoice{{Delta: llm.StreamDelta{Content: "建议一"}, FinishReason: ""}}},
|
|
{Choices: []llm.StreamChoice{{Delta: llm.StreamDelta{Content: "建议二"}, FinishReason: "stop"}}},
|
|
}
|
|
|
|
var collected []string
|
|
err := svc.ReplySuggestionStream(context.Background(), account.ID, &TaskReplySuggestionRequest{
|
|
AssistantID: assistant.ID,
|
|
ConversationID: conv.ID,
|
|
Tone: "friendly",
|
|
}, func(chunk llm.StreamChunk) error {
|
|
if len(chunk.Choices) > 0 && chunk.Choices[0].Delta.Content != "" {
|
|
collected = append(collected, chunk.Choices[0].Delta.Content)
|
|
}
|
|
return nil
|
|
})
|
|
|
|
require.NoError(t, err)
|
|
assert.Len(t, collected, 2)
|
|
assert.Equal(t, "建议一", collected[0])
|
|
assert.Equal(t, "建议二", collected[1])
|
|
}
|
|
|
|
func TestCaptainTaskService_ReplySuggestionStream_助手不存在(t *testing.T) {
|
|
_, _, svc := setupCaptainTaskService(t)
|
|
|
|
err := svc.ReplySuggestionStream(context.Background(), 1, &TaskReplySuggestionRequest{
|
|
AssistantID: 99999,
|
|
ConversationID: 1,
|
|
}, func(chunk llm.StreamChunk) error {
|
|
return nil
|
|
})
|
|
|
|
assert.Error(t, err)
|
|
assert.Contains(t, err.Error(), "assistant not found")
|
|
}
|
|
|
|
func TestCaptainTaskService_ReplySuggestionStream_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.streamError = fmt.Errorf("LLM stream 调用超时")
|
|
|
|
err := svc.ReplySuggestionStream(context.Background(), account.ID, &TaskReplySuggestionRequest{
|
|
AssistantID: assistant.ID,
|
|
ConversationID: conv.ID,
|
|
}, func(chunk llm.StreamChunk) error {
|
|
return nil
|
|
})
|
|
|
|
assert.Error(t, err)
|
|
assert.Contains(t, err.Error(), "LLM stream")
|
|
}
|
|
|
|
func TestCaptainTaskService_SummarizeStream_成功(t *testing.T) {
|
|
db, mockProvider, svc := setupCaptainTaskService(t)
|
|
account := createTestAccount(t, db)
|
|
conv := createTestConversationWithMessages(t, db, account.ID, []struct{ sender, content string }{
|
|
{"contact", "你好,我的订单还没收到"},
|
|
{"agent", "我来帮您查询一下"},
|
|
})
|
|
|
|
mockProvider.streamChunks = []llm.StreamChunk{
|
|
{Choices: []llm.StreamChoice{{Delta: llm.StreamDelta{Content: "客户咨询订单"}, FinishReason: ""}}},
|
|
{Choices: []llm.StreamChoice{{Delta: llm.StreamDelta{Content: "状态查询"}, FinishReason: "stop"}}},
|
|
}
|
|
|
|
var collected []string
|
|
err := svc.SummarizeStream(context.Background(), account.ID, &TaskSummarizeRequest{
|
|
ConversationID: conv.ID,
|
|
Language: "zh-CN",
|
|
}, func(chunk llm.StreamChunk) error {
|
|
if len(chunk.Choices) > 0 && chunk.Choices[0].Delta.Content != "" {
|
|
collected = append(collected, chunk.Choices[0].Delta.Content)
|
|
}
|
|
return nil
|
|
})
|
|
|
|
require.NoError(t, err)
|
|
assert.Len(t, collected, 2)
|
|
}
|
|
|
|
func TestCaptainTaskService_SummarizeStream_无消息(t *testing.T) {
|
|
db, _, svc := setupCaptainTaskService(t)
|
|
account := createTestAccount(t, db)
|
|
conv := createTestConversationWithMessages(t, db, account.ID, nil)
|
|
|
|
err := svc.SummarizeStream(context.Background(), account.ID, &TaskSummarizeRequest{
|
|
ConversationID: conv.ID,
|
|
}, func(chunk llm.StreamChunk) error {
|
|
return nil
|
|
})
|
|
|
|
assert.Error(t, err)
|
|
status, message, ok := CaptainTaskErrorStatus(err)
|
|
require.True(t, ok)
|
|
assert.Equal(t, 422, status)
|
|
assert.Contains(t, message, "no messages found")
|
|
}
|
|
|
|
func TestCaptainTaskService_SummarizeStream_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.streamError = fmt.Errorf("LLM stream 服务不可用")
|
|
|
|
err := svc.SummarizeStream(context.Background(), account.ID, &TaskSummarizeRequest{
|
|
ConversationID: conv.ID,
|
|
}, func(chunk llm.StreamChunk) error {
|
|
return nil
|
|
})
|
|
|
|
assert.Error(t, err)
|
|
}
|
|
|
|
func TestCaptainTaskService_RewriteStream_成功(t *testing.T) {
|
|
_, mockProvider, svc := setupCaptainTaskService(t)
|
|
|
|
mockProvider.streamChunks = []llm.StreamChunk{
|
|
{Choices: []llm.StreamChoice{{Delta: llm.StreamDelta{Content: "感谢您的"}, FinishReason: ""}}},
|
|
{Choices: []llm.StreamChoice{{Delta: llm.StreamDelta{Content: "来信"}, FinishReason: "stop"}}},
|
|
}
|
|
|
|
var collected []string
|
|
err := svc.RewriteStream(context.Background(), 1, &TaskRewriteRequest{
|
|
Content: "来信收到了",
|
|
Tone: "professional",
|
|
Language: "zh-CN",
|
|
}, func(chunk llm.StreamChunk) error {
|
|
if len(chunk.Choices) > 0 && chunk.Choices[0].Delta.Content != "" {
|
|
collected = append(collected, chunk.Choices[0].Delta.Content)
|
|
}
|
|
return nil
|
|
})
|
|
|
|
require.NoError(t, err)
|
|
assert.Len(t, collected, 2)
|
|
assert.Equal(t, "感谢您的", collected[0])
|
|
}
|
|
|
|
func TestCaptainTaskService_RewriteStream_默认参数(t *testing.T) {
|
|
_, mockProvider, svc := setupCaptainTaskService(t)
|
|
|
|
mockProvider.streamChunks = []llm.StreamChunk{
|
|
{Choices: []llm.StreamChoice{{Delta: llm.StreamDelta{Content: "重写文本"}, FinishReason: "stop"}}},
|
|
}
|
|
|
|
err := svc.RewriteStream(context.Background(), 1, &TaskRewriteRequest{
|
|
Content: "原始内容",
|
|
}, func(chunk llm.StreamChunk) error {
|
|
return nil
|
|
})
|
|
|
|
require.NoError(t, err)
|
|
require.NotNil(t, mockProvider.lastStreamReq)
|
|
// 默认 tone 为 professional, language 为 zh-CN
|
|
assert.Contains(t, mockProvider.lastStreamReq.Messages[0].Content, "zh-CN")
|
|
}
|
|
|
|
func TestCaptainTaskService_RewriteStream_LLM流错误(t *testing.T) {
|
|
_, mockProvider, svc := setupCaptainTaskService(t)
|
|
|
|
mockProvider.streamError = fmt.Errorf("LLM stream 不可用")
|
|
|
|
err := svc.RewriteStream(context.Background(), 1, &TaskRewriteRequest{
|
|
Content: "test",
|
|
}, func(chunk llm.StreamChunk) error {
|
|
return nil
|
|
})
|
|
|
|
assert.Error(t, err)
|
|
}
|
|
|
|
func TestCaptainTaskService_Stream_NoProviderDisabled(t *testing.T) {
|
|
_, _, svc := setupCaptainTaskService(t)
|
|
svc.llmProvider = nil
|
|
|
|
err := svc.RewriteStream(context.Background(), 1, &TaskRewriteRequest{Content: "test", Operation: "professional"}, func(chunk llm.StreamChunk) error {
|
|
return nil
|
|
})
|
|
|
|
status, message, ok := CaptainTaskErrorStatus(err)
|
|
require.True(t, ok)
|
|
assert.Equal(t, 422, status)
|
|
assert.Equal(t, "Captain is disabled", message)
|
|
}
|
|
|
|
func TestCaptainTaskService_RewriteStream_InvalidOperation(t *testing.T) {
|
|
_, _, svc := setupCaptainTaskService(t)
|
|
|
|
err := svc.RewriteStream(context.Background(), 1, &TaskRewriteRequest{Content: "test", Operation: "pirate"}, func(chunk llm.StreamChunk) error {
|
|
return nil
|
|
})
|
|
|
|
status, message, ok := CaptainTaskErrorStatus(err)
|
|
require.True(t, ok)
|
|
assert.Equal(t, 422, status)
|
|
assert.Equal(t, "Invalid operation: pirate", message)
|
|
}
|
|
|
|
func TestCaptainTaskService_SummarizeStream_ChatwootDisplayID(t *testing.T) {
|
|
db, mockProvider, svc := setupCaptainTaskService(t)
|
|
account := createTestAccount(t, db)
|
|
conv := createTestConversationWithMessages(t, db, account.ID, []struct{ sender, content string }{{"contact", "hello"}})
|
|
displayID := uint(321)
|
|
require.NoError(t, db.Model(conv).Update("display_id", displayID).Error)
|
|
mockProvider.streamChunks = []llm.StreamChunk{{Choices: []llm.StreamChoice{{Delta: llm.StreamDelta{Content: "summary"}, FinishReason: "stop"}}}}
|
|
|
|
var collected []string
|
|
err := svc.SummarizeStream(context.Background(), account.ID, &TaskSummarizeRequest{ConversationDisplayID: displayID}, func(chunk llm.StreamChunk) error {
|
|
if len(chunk.Choices) > 0 && chunk.Choices[0].Delta.Content != "" {
|
|
collected = append(collected, chunk.Choices[0].Delta.Content)
|
|
}
|
|
return nil
|
|
})
|
|
|
|
require.NoError(t, err)
|
|
assert.Equal(t, []string{"summary"}, collected)
|
|
}
|
|
|
|
func TestCaptainTaskService_ReplySuggestionStream_回调中断(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.streamChunks = []llm.StreamChunk{
|
|
{Choices: []llm.StreamChoice{{Delta: llm.StreamDelta{Content: "chunk1"}, FinishReason: ""}}},
|
|
{Choices: []llm.StreamChoice{{Delta: llm.StreamDelta{Content: "chunk2"}, FinishReason: ""}}},
|
|
{Choices: []llm.StreamChoice{{Delta: llm.StreamDelta{Content: "chunk3"}, FinishReason: "stop"}}},
|
|
}
|
|
|
|
callCount := 0
|
|
err := svc.ReplySuggestionStream(context.Background(), account.ID, &TaskReplySuggestionRequest{
|
|
AssistantID: assistant.ID,
|
|
ConversationID: conv.ID,
|
|
}, func(chunk llm.StreamChunk) error {
|
|
callCount++
|
|
if callCount == 2 {
|
|
return fmt.Errorf("callback interrupted")
|
|
}
|
|
return nil
|
|
})
|
|
|
|
assert.Error(t, err)
|
|
assert.Contains(t, err.Error(), "callback interrupted")
|
|
assert.Equal(t, 2, callCount)
|
|
}
|