Files
nofx/decision/prompt_reload_integration_test.go
Lawrence Liu 6efe733127 feat(decision): auto-reload prompt templates when starting trader (#833)
* feat: 启动交易员时自动重新加载系统提示词模板
## 改动内容
- 在 handleStartTrader 中调用 decision.ReloadPromptTemplates()
- 每次启动交易员时从硬盘重新加载 prompts/ 目录下的所有 .txt 模板文件
- 添加完整的单元测试和端到端集成测试
## 测试覆盖
- 单元测试:模板加载、获取、重新加载功能
- 集成测试:文件修改 → 重新加载 → 决策引擎使用新内容的完整流程
- 并发测试:验证多 goroutine 场景下的线程安全性
- Race detector 测试通过
## 用户体验改进
- 修改 prompt 文件后无需重启服务
- 只需停止交易员再启动即可应用新的 prompt
- 控制台会输出重新加载成功的日志提示
* feat: 在重新加载日志中显示当前使用的模板名称
* feat: fallback 到 default 模板时明确显示原因
* fix: correct GetTraderConfig return type to get SystemPromptTemplate
* refactor: extract reloadPromptTemplatesWithLog as reusable method
2025-11-10 21:37:46 -05:00

244 lines
7.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package decision
import (
"os"
"path/filepath"
"strings"
"testing"
)
// TestPromptReloadEndToEnd 端到端测试:验证从文件修改到决策引擎使用的完整流程
func TestPromptReloadEndToEnd(t *testing.T) {
// 保存原始的 promptsDir
originalDir := promptsDir
defer func() {
promptsDir = originalDir
// 恢复原始模板
globalPromptManager.ReloadTemplates(originalDir)
}()
// 创建临时目录模拟 prompts/ 目录
tempDir := t.TempDir()
promptsDir = tempDir
// 步骤1: 创建初始 prompt 文件
initialContent := "# 初始交易策略\n你是一个保守的交易AI。"
if err := os.WriteFile(filepath.Join(tempDir, "test_strategy.txt"), []byte(initialContent), 0644); err != nil {
t.Fatalf("创建初始文件失败: %v", err)
}
// 步骤2: 首次加载(模拟系统启动)
if err := ReloadPromptTemplates(); err != nil {
t.Fatalf("首次加载失败: %v", err)
}
// 步骤3: 验证初始内容
template, err := GetPromptTemplate("test_strategy")
if err != nil {
t.Fatalf("获取初始模板失败: %v", err)
}
if template.Content != initialContent {
t.Errorf("初始内容不匹配\n期望: %s\n实际: %s", initialContent, template.Content)
}
// 步骤4: 使用 buildSystemPrompt 验证模板被正确使用
systemPrompt := buildSystemPrompt(10000.0, 10, 5, "test_strategy")
if !strings.Contains(systemPrompt, initialContent) {
t.Errorf("buildSystemPrompt 未包含模板内容\n生成的 prompt:\n%s", systemPrompt)
}
// 步骤5: 模拟用户修改文件(这是用户在硬盘上修改 prompt
updatedContent := "# 更新的交易策略\n你是一个激进的交易AI追求高风险高收益。"
if err := os.WriteFile(filepath.Join(tempDir, "test_strategy.txt"), []byte(updatedContent), 0644); err != nil {
t.Fatalf("更新文件失败: %v", err)
}
// 步骤6: 模拟交易员启动时调用 ReloadPromptTemplates()
t.Log("模拟交易员启动,调用 ReloadPromptTemplates()...")
if err := ReloadPromptTemplates(); err != nil {
t.Fatalf("重新加载失败: %v", err)
}
// 步骤7: 验证新内容已生效
reloadedTemplate, err := GetPromptTemplate("test_strategy")
if err != nil {
t.Fatalf("获取重新加载的模板失败: %v", err)
}
if reloadedTemplate.Content != updatedContent {
t.Errorf("重新加载后内容不匹配\n期望: %s\n实际: %s", updatedContent, reloadedTemplate.Content)
}
// 步骤8: 验证 buildSystemPrompt 使用了新内容
newSystemPrompt := buildSystemPrompt(10000.0, 10, 5, "test_strategy")
if !strings.Contains(newSystemPrompt, updatedContent) {
t.Errorf("buildSystemPrompt 未包含更新后的模板内容\n生成的 prompt:\n%s", newSystemPrompt)
}
// 步骤9: 验证旧内容不再存在
if strings.Contains(newSystemPrompt, "保守的交易AI") {
t.Errorf("buildSystemPrompt 仍包含旧的模板内容")
}
t.Log("✅ 端到端测试通过:文件修改 -> 重新加载 -> 决策引擎使用新内容")
}
// TestPromptReloadWithCustomPrompt 测试自定义 prompt 与模板重新加载的交互
func TestPromptReloadWithCustomPrompt(t *testing.T) {
// 保存原始的 promptsDir
originalDir := promptsDir
defer func() {
promptsDir = originalDir
globalPromptManager.ReloadTemplates(originalDir)
}()
// 创建临时目录
tempDir := t.TempDir()
promptsDir = tempDir
// 创建基础模板
baseContent := "基础策略:稳健交易"
if err := os.WriteFile(filepath.Join(tempDir, "base.txt"), []byte(baseContent), 0644); err != nil {
t.Fatalf("创建文件失败: %v", err)
}
// 加载模板
if err := ReloadPromptTemplates(); err != nil {
t.Fatalf("加载失败: %v", err)
}
// 测试1: 基础模板 + 自定义 prompt不覆盖
customPrompt := "个性化规则:只交易 BTC"
result := buildSystemPromptWithCustom(10000.0, 10, 5, customPrompt, false, "base")
if !strings.Contains(result, baseContent) {
t.Errorf("未包含基础模板内容")
}
if !strings.Contains(result, customPrompt) {
t.Errorf("未包含自定义 prompt")
}
// 测试2: 覆盖基础 prompt
result = buildSystemPromptWithCustom(10000.0, 10, 5, customPrompt, true, "base")
if strings.Contains(result, baseContent) {
t.Errorf("覆盖模式下仍包含基础模板内容")
}
if !strings.Contains(result, customPrompt) {
t.Errorf("覆盖模式下未包含自定义 prompt")
}
// 测试3: 重新加载后效果
updatedBase := "更新的基础策略:激进交易"
if err := os.WriteFile(filepath.Join(tempDir, "base.txt"), []byte(updatedBase), 0644); err != nil {
t.Fatalf("更新文件失败: %v", err)
}
if err := ReloadPromptTemplates(); err != nil {
t.Fatalf("重新加载失败: %v", err)
}
result = buildSystemPromptWithCustom(10000.0, 10, 5, customPrompt, false, "base")
if !strings.Contains(result, updatedBase) {
t.Errorf("重新加载后未包含更新的基础模板内容")
}
if strings.Contains(result, baseContent) {
t.Errorf("重新加载后仍包含旧的基础模板内容")
}
}
// TestPromptReloadFallback 测试模板不存在时的降级机制
func TestPromptReloadFallback(t *testing.T) {
// 保存原始的 promptsDir
originalDir := promptsDir
defer func() {
promptsDir = originalDir
globalPromptManager.ReloadTemplates(originalDir)
}()
// 创建临时目录
tempDir := t.TempDir()
promptsDir = tempDir
// 只创建 default 模板
defaultContent := "默认策略"
if err := os.WriteFile(filepath.Join(tempDir, "default.txt"), []byte(defaultContent), 0644); err != nil {
t.Fatalf("创建文件失败: %v", err)
}
if err := ReloadPromptTemplates(); err != nil {
t.Fatalf("加载失败: %v", err)
}
// 测试1: 请求不存在的模板,应该降级到 default
result := buildSystemPrompt(10000.0, 10, 5, "nonexistent")
if !strings.Contains(result, defaultContent) {
t.Errorf("请求不存在的模板时,未降级到 default")
}
// 测试2: 空模板名,应该使用 default
result = buildSystemPrompt(10000.0, 10, 5, "")
if !strings.Contains(result, defaultContent) {
t.Errorf("空模板名时,未使用 default")
}
}
// TestConcurrentPromptReload 测试并发场景下的 prompt 重新加载
func TestConcurrentPromptReload(t *testing.T) {
// 保存原始的 promptsDir
originalDir := promptsDir
defer func() {
promptsDir = originalDir
globalPromptManager.ReloadTemplates(originalDir)
}()
// 创建临时目录
tempDir := t.TempDir()
promptsDir = tempDir
// 创建测试文件
if err := os.WriteFile(filepath.Join(tempDir, "test.txt"), []byte("测试内容"), 0644); err != nil {
t.Fatalf("创建文件失败: %v", err)
}
if err := ReloadPromptTemplates(); err != nil {
t.Fatalf("初始加载失败: %v", err)
}
// 并发测试:同时读取和重新加载
done := make(chan bool)
// 启动多个读取 goroutine
for i := 0; i < 10; i++ {
go func() {
for j := 0; j < 100; j++ {
_, _ = GetPromptTemplate("test")
}
done <- true
}()
}
// 启动多个重新加载 goroutine
for i := 0; i < 3; i++ {
go func() {
for j := 0; j < 10; j++ {
_ = ReloadPromptTemplates()
}
done <- true
}()
}
// 等待所有 goroutine 完成
for i := 0; i < 13; i++ {
<-done
}
// 验证最终状态正确
template, err := GetPromptTemplate("test")
if err != nil {
t.Errorf("并发测试后获取模板失败: %v", err)
}
if template.Content != "测试内容" {
t.Errorf("并发测试后模板内容错误: %s", template.Content)
}
t.Log("✅ 并发测试通过:多个 goroutine 同时读取和重新加载模板,无数据竞争")
}