test: Phase 6 全功能测试 — 19个测试全部通过 + 开发路线文档
- host: 沙箱执行/命令拦截/超时/文件读写/系统信息/路径验证 (6 tests) - rag: 文本分块/余弦相似度/关键词匹配/文档索引+搜索 (4 tests) - tools: host_exec/host_file/host_system/knowledge_search/knowledge_ingest (5 tests) - vision: 图片编码/错误处理/定义验证/执行流程 (4 tests) - Embedder 重构为接口,支持 API 和 Simple 两种实现 - 添加 ROADMAP.md 未来开发路线 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
+104
@@ -0,0 +1,104 @@
|
||||
# Cyrene AI 未来开发路线
|
||||
|
||||
> 当前版本: Phase 0-6 完成 | 日期: 2026-05-24 | 分支: dev
|
||||
|
||||
## 已完成阶段
|
||||
|
||||
| 阶段 | 内容 | 状态 |
|
||||
|------|------|------|
|
||||
| Phase 1 | 连续思考链 + 主动消息决策 | ✅ |
|
||||
| Phase 2 | 情感状态机 + 离线自主思考 | ✅ |
|
||||
| Phase 3 | 插件与工具系统 (SDK + 13内置插件) | ✅ |
|
||||
| Phase 4 | 多平台接入 (6平台适配器) | ✅ |
|
||||
| Phase 5 | STT 语音识别 (DashScope Gummy + Whisper) | ✅ |
|
||||
| Phase 6.1 | 多模型目的路由 (5 purpose 全部接入) | ✅ |
|
||||
| Phase 6.2 | 宿主机安全操控 (沙箱执行 + 文件隔离) | ✅ |
|
||||
| Phase 6.3 | 视觉理解 (多模态图片 + OCR) | ✅ |
|
||||
| Phase 6.6 | 知识库 RAG (文档索引 + 语义检索) | ✅ |
|
||||
|
||||
## 待开发阶段
|
||||
|
||||
### Phase 5 补齐: TTS 语音合成
|
||||
- DashScope CosyVoice / Edge-TTS 作为 TTS 引擎
|
||||
- 流式音频输出 + SSML 标记支持
|
||||
- 情感语音参数联动 (语速/音调随情绪变化)
|
||||
|
||||
### Phase 7: 实时语音对话
|
||||
- 全双工 WebSocket 语音通道 (复用现有 STT WebSocket)
|
||||
- VAD 语音活动检测 + 打断检测
|
||||
- 语音端点间低延迟无缝切换
|
||||
- 通话状态管理 + 多人会话支持
|
||||
|
||||
### Phase 8: 前端/移动端增强
|
||||
- 语音消息录制 + 发送 (前端已预留 voice_msg mode)
|
||||
- PWA 离线支持 + 推送通知
|
||||
- 移动端原生 Shell (WebView + 原生语音/通知桥接)
|
||||
- 响应式 UI 优化
|
||||
|
||||
### Phase 9: 多 Agent 协作
|
||||
- Agent 间消息总线 + 任务分配协议
|
||||
- 专用 Agent 角色 (代码审查、安全审计、运维)
|
||||
- 多 Agent 并行处理 + 结果合并
|
||||
- Agent 间记忆共享
|
||||
|
||||
### Phase 10: 部署与运维
|
||||
- Docker Compose 一键部署
|
||||
- 健康监控 + 自动重启 + 日志聚合
|
||||
- 配置热更新 (models.json 变更无需重启)
|
||||
- 数据库迁移工具 + 备份策略
|
||||
- CI/CD 流水线
|
||||
|
||||
### 性能优化
|
||||
- LLM 响应缓存 (Redis) — 减少重复 API 调用
|
||||
- 流式输出优化 — 更快的首字节时间 (TTFB)
|
||||
- 向量数据库接入 (Qdrant) — 替换内存向量索引
|
||||
- 上下文窗口管理 — 长对话自动摘要压缩
|
||||
- 数据库连接池优化
|
||||
|
||||
### 安全增强
|
||||
- API 速率限制
|
||||
- 敏感信息过滤
|
||||
- 审计日志
|
||||
- 沙箱权限细粒度控制 (per-tool, per-user)
|
||||
|
||||
---
|
||||
|
||||
## 架构总览
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ Frontend (React) │
|
||||
│ Web + Mobile (PWA) │
|
||||
└─────────────────┬───────────────────────────────┘
|
||||
│ WebSocket / SSE
|
||||
┌─────────────────▼───────────────────────────────┐
|
||||
│ Gateway (:8080) │
|
||||
│ Auth / WS Hub / Session / Admin API │
|
||||
└──────┬──────────┬──────────┬────────────────────┘
|
||||
│ │ │
|
||||
┌──────▼──┐ ┌─────▼───┐ ┌───▼──────────────┐
|
||||
│ AI-Core │ │ Memory │ │ Tool-Engine │
|
||||
│ (:8081) │ │ (:8091) │ │ (:8092) │
|
||||
│ Orch. │ │ Vector │ │ Plugin Executor │
|
||||
│ Thinker │ │ Store │ │ Sandbox │
|
||||
└──┬───┬──┘ └─────────┘ └──────────────────┘
|
||||
│ │
|
||||
│ └── RAG / Host / Vision / Purpose Routing
|
||||
│
|
||||
└── Voice (:8093) / IoT (:8083) / Platform (:8095)
|
||||
```
|
||||
|
||||
## 当前服务端口
|
||||
|
||||
| 服务 | 端口 | 状态 |
|
||||
|------|------|------|
|
||||
| Gateway | 8080 | 运行中 |
|
||||
| AI-Core | 8081 | 运行中 |
|
||||
| IoT Debug | 8083 | 按需启动 |
|
||||
| DevTools | 9090 | 运行中 |
|
||||
| Memory Service | 8091 | 运行中 |
|
||||
| Tool Engine | 8092 | 运行中 |
|
||||
| Voice Service | 8093 | 运行中 |
|
||||
| Plugin Manager | 8094 | 按需启动 |
|
||||
| Platform Bridge | 8095 | 按需启动 |
|
||||
| Frontend | 5173 | 按需启动 |
|
||||
@@ -0,0 +1,133 @@
|
||||
package host
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSandboxExec(t *testing.T) {
|
||||
cfg := DefaultSandboxConfig()
|
||||
cfg.AllowedDirs = []string{os.TempDir()}
|
||||
sandbox := NewSandbox(cfg)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
result, err := sandbox.Exec(ctx, "echo hello cyrene", os.TempDir(), 5*time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("exec failed: %v", err)
|
||||
}
|
||||
if result.ExitCode != 0 {
|
||||
t.Fatalf("unexpected exit code: %d, stderr=%s", result.ExitCode, result.Stderr)
|
||||
}
|
||||
if result.Stdout == "" {
|
||||
t.Fatal("expected output, got empty")
|
||||
}
|
||||
t.Logf("exec OK: stdout=%q, duration=%s", result.Stdout, result.Duration)
|
||||
}
|
||||
|
||||
func TestSandboxBlockedCommand(t *testing.T) {
|
||||
cfg := DefaultSandboxConfig()
|
||||
sandbox := NewSandbox(cfg)
|
||||
|
||||
ctx := context.Background()
|
||||
_, err := sandbox.Exec(ctx, "rm -rf /", os.TempDir(), 5*time.Second)
|
||||
if err == nil {
|
||||
t.Fatal("expected 'rm' to be blocked")
|
||||
}
|
||||
t.Logf("blocked command OK: %v", err)
|
||||
}
|
||||
|
||||
func TestSandboxTimeout(t *testing.T) {
|
||||
cfg := DefaultSandboxConfig()
|
||||
cfg.AllowedCommands = append(cfg.AllowedCommands, "sleep")
|
||||
sandbox := NewSandbox(cfg)
|
||||
|
||||
ctx := context.Background()
|
||||
result, err := sandbox.Exec(ctx, "sleep 10", os.TempDir(), 1*time.Second)
|
||||
if err == nil {
|
||||
t.Fatal("expected timeout error")
|
||||
}
|
||||
if !result.TimedOut {
|
||||
t.Fatal("expected TimedOut=true")
|
||||
}
|
||||
t.Logf("timeout OK: exit=%d, timed_out=%v", result.ExitCode, result.TimedOut)
|
||||
}
|
||||
|
||||
func TestManagerFileOps(t *testing.T) {
|
||||
cfg := DefaultSandboxConfig()
|
||||
tmpDir := os.TempDir()
|
||||
cfg.AllowedDirs = []string{tmpDir}
|
||||
sandbox := NewSandbox(cfg)
|
||||
mgr := NewManager(sandbox)
|
||||
mgr.SetAllowedDirs([]string{tmpDir})
|
||||
|
||||
testPath := filepath.Join(tmpDir, "cyrene-test-file.txt")
|
||||
|
||||
err := mgr.WriteFile(testPath, "Hello from Cyrene host manager!", 1024*1024)
|
||||
if err != nil {
|
||||
t.Fatalf("write failed: %v", err)
|
||||
}
|
||||
defer os.Remove(testPath)
|
||||
|
||||
content, err := mgr.ReadFile(testPath, 1024*1024)
|
||||
if err != nil {
|
||||
t.Fatalf("read failed: %v", err)
|
||||
}
|
||||
if content != "Hello from Cyrene host manager!" {
|
||||
t.Fatalf("content mismatch: %q", content)
|
||||
}
|
||||
t.Logf("file read/write OK: %q", content)
|
||||
|
||||
entries, err := mgr.ListDir(tmpDir)
|
||||
if err != nil {
|
||||
t.Fatalf("listdir failed: %v", err)
|
||||
}
|
||||
found := false
|
||||
for _, e := range entries {
|
||||
if e.Name == "cyrene-test-file.txt" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("expected test file in directory listing")
|
||||
}
|
||||
t.Logf("listdir OK: %d entries", len(entries))
|
||||
}
|
||||
|
||||
func TestManagerSystemInfo(t *testing.T) {
|
||||
cfg := DefaultSandboxConfig()
|
||||
sandbox := NewSandbox(cfg)
|
||||
mgr := NewManager(sandbox)
|
||||
|
||||
info := mgr.SystemInfo()
|
||||
if info["hostname"] == nil || info["hostname"] == "" {
|
||||
t.Fatal("expected hostname in system info")
|
||||
}
|
||||
if info["os"] == nil || info["os"] == "" {
|
||||
t.Fatal("expected os in system info")
|
||||
}
|
||||
if info["arch"] == nil || info["arch"] == "" {
|
||||
t.Fatal("expected arch in system info")
|
||||
}
|
||||
t.Logf("system info OK: os=%v arch=%v num_cpu=%v", info["os"], info["arch"], info["num_cpu"])
|
||||
}
|
||||
|
||||
func TestPathValidation(t *testing.T) {
|
||||
cfg := DefaultSandboxConfig()
|
||||
cfg.AllowedDirs = []string{os.TempDir()}
|
||||
sandbox := NewSandbox(cfg)
|
||||
mgr := NewManager(sandbox)
|
||||
mgr.SetAllowedDirs([]string{os.TempDir()})
|
||||
|
||||
// Should fail: access outside allowed dirs
|
||||
_, err := mgr.ReadFile("/etc/passwd", 1024)
|
||||
if err == nil {
|
||||
t.Fatal("expected path validation to block /etc/passwd")
|
||||
}
|
||||
t.Logf("path validation OK: blocked access to /etc/passwd")
|
||||
}
|
||||
@@ -10,8 +10,15 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Embedder creates text embeddings using an LLM API.
|
||||
type Embedder struct {
|
||||
// Embedder is the interface for text embedding.
|
||||
type Embedder interface {
|
||||
Embed(ctx context.Context, text string) ([]float64, error)
|
||||
EmbedBatch(ctx context.Context, texts []string) ([]float64, error)
|
||||
IsAvailable() bool
|
||||
}
|
||||
|
||||
// APIEmbedder creates text embeddings using an LLM API.
|
||||
type APIEmbedder struct {
|
||||
baseURL string
|
||||
apiKey string
|
||||
model string
|
||||
@@ -19,8 +26,8 @@ type Embedder struct {
|
||||
}
|
||||
|
||||
// NewEmbedder creates a new embedding service.
|
||||
func NewEmbedder(baseURL, apiKey, model string) *Embedder {
|
||||
return &Embedder{
|
||||
func NewEmbedder(baseURL, apiKey, model string) *APIEmbedder {
|
||||
return &APIEmbedder{
|
||||
baseURL: baseURL,
|
||||
apiKey: apiKey,
|
||||
model: model,
|
||||
@@ -58,12 +65,12 @@ type embeddingError struct {
|
||||
}
|
||||
|
||||
// Embed generates an embedding vector for the given text.
|
||||
func (e *Embedder) Embed(ctx context.Context, text string) ([]float64, error) {
|
||||
func (e *APIEmbedder) Embed(ctx context.Context, text string) ([]float64, error) {
|
||||
return e.EmbedBatch(ctx, []string{text})
|
||||
}
|
||||
|
||||
// EmbedBatch generates embeddings for multiple texts.
|
||||
func (e *Embedder) EmbedBatch(ctx context.Context, texts []string) ([]float64, error) {
|
||||
func (e *APIEmbedder) EmbedBatch(ctx context.Context, texts []string) ([]float64, error) {
|
||||
if !e.IsAvailable() {
|
||||
return nil, fmt.Errorf("embedding service not available: no API key configured")
|
||||
}
|
||||
@@ -113,6 +120,6 @@ func (e *Embedder) EmbedBatch(ctx context.Context, texts []string) ([]float64, e
|
||||
}
|
||||
|
||||
// IsAvailable checks if the embedding service is configured.
|
||||
func (e *Embedder) IsAvailable() bool {
|
||||
func (e *APIEmbedder) IsAvailable() bool {
|
||||
return e.apiKey != "" && e.baseURL != ""
|
||||
}
|
||||
|
||||
@@ -34,12 +34,12 @@ type SearchResult struct {
|
||||
type KnowledgeStore struct {
|
||||
mu sync.RWMutex
|
||||
chunks []Chunk
|
||||
embedder *Embedder
|
||||
embedder Embedder
|
||||
knowledgeDir string
|
||||
}
|
||||
|
||||
// NewKnowledgeStore creates a new knowledge store.
|
||||
func NewKnowledgeStore(embedder *Embedder, knowledgeDir string) *KnowledgeStore {
|
||||
func NewKnowledgeStore(embedder Embedder, knowledgeDir string) *KnowledgeStore {
|
||||
if knowledgeDir == "" {
|
||||
knowledgeDir = "./data/knowledge"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
package rag
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestChunkText(t *testing.T) {
|
||||
text := "Hello World! This is a test document for chunking. "
|
||||
// Make it longer to trigger chunking
|
||||
longText := ""
|
||||
for i := 0; i < 100; i++ {
|
||||
longText += text
|
||||
}
|
||||
|
||||
chunks := chunkText(longText, 512, 128)
|
||||
if len(chunks) < 2 {
|
||||
t.Fatalf("expected at least 2 chunks, got %d (len=%d)", len(chunks), len(longText))
|
||||
}
|
||||
t.Logf("chunking OK: %d chunks from %d chars", len(chunks), len(longText))
|
||||
|
||||
// Verify overlap: each chunk should have some overlap with next
|
||||
for i := 1; i < len(chunks); i++ {
|
||||
prev := chunks[i-1]
|
||||
if len(prev) == 0 {
|
||||
t.Fatalf("empty chunk at index %d", i-1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCosineSimilarity(t *testing.T) {
|
||||
a := []float64{0.5, 0.3, 0.8, 0.1}
|
||||
b := []float64{0.5, 0.3, 0.8, 0.1}
|
||||
sim := cosineSimilarity(a, b)
|
||||
if sim < 0.99 {
|
||||
t.Fatalf("expected similarity ~1.0 for identical vectors, got %f", sim)
|
||||
}
|
||||
|
||||
c := []float64{-0.5, -0.3, -0.8, -0.1}
|
||||
sim2 := cosineSimilarity(a, c)
|
||||
if sim2 > -0.99 {
|
||||
t.Fatalf("expected similarity ~-1.0 for opposite vectors, got %f", sim2)
|
||||
}
|
||||
|
||||
d := []float64{0.0, 0.0, 0.0, 0.0}
|
||||
sim3 := cosineSimilarity(a, d)
|
||||
if sim3 != 0.0 {
|
||||
t.Fatalf("expected 0.0 for zero vector, got %f", sim3)
|
||||
}
|
||||
|
||||
// Different lengths
|
||||
sim4 := cosineSimilarity(a, []float64{0.5})
|
||||
if sim4 != 0.0 {
|
||||
t.Fatalf("expected 0.0 for different length vectors, got %f", sim4)
|
||||
}
|
||||
t.Logf("cosine similarity OK")
|
||||
}
|
||||
|
||||
func TestKeywordMatchScore(t *testing.T) {
|
||||
score := keywordMatchScore("hello world", "hello cyrene world of AI")
|
||||
if score < 0.0 || score > 1.0 {
|
||||
t.Fatalf("score out of range: %f", score)
|
||||
}
|
||||
t.Logf("keyword match OK: score=%f", score)
|
||||
}
|
||||
|
||||
func TestKnowledgeStoreIngestAndSearch(t *testing.T) {
|
||||
// Create temp dir
|
||||
tmpDir, err := os.MkdirTemp("", "cyrene-rag-test")
|
||||
if err != nil {
|
||||
t.Fatalf("create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
// Write a test document
|
||||
docPath := filepath.Join(tmpDir, "test.md")
|
||||
content := `# Cyrene AI 测试文档
|
||||
|
||||
Cyrene 是一个智能 AI 助手,支持语音识别、视觉理解、知识检索等功能。
|
||||
|
||||
## 主要功能
|
||||
|
||||
1. 多模型目的路由
|
||||
2. 宿主机安全操控
|
||||
3. 视觉理解与 OCR
|
||||
4. 知识库 RAG 检索
|
||||
|
||||
## 技术栈
|
||||
|
||||
Go 语言编写的后端服务,React 前端。支持多种 LLM 提供商。`
|
||||
if err := os.WriteFile(docPath, []byte(content), 0644); err != nil {
|
||||
t.Fatalf("write test doc: %v", err)
|
||||
}
|
||||
|
||||
// Use SimpleEmbedder for testing (no API key needed)
|
||||
embedder := &SimpleEmbedder{}
|
||||
store := NewKnowledgeStore(embedder, tmpDir)
|
||||
|
||||
ctx := context.Background()
|
||||
n, err := store.IngestFile(ctx, docPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ingest failed: %v", err)
|
||||
}
|
||||
if n == 0 {
|
||||
t.Fatal("expected at least 1 chunk")
|
||||
}
|
||||
t.Logf("ingest OK: %d chunks indexed from %s", n, docPath)
|
||||
|
||||
// Search
|
||||
results, err := store.Search(ctx, "视觉理解 OCR", 3)
|
||||
if err != nil {
|
||||
t.Fatalf("search failed: %v", err)
|
||||
}
|
||||
t.Logf("search OK: %d results for '视觉理解 OCR'", len(results))
|
||||
for _, r := range results {
|
||||
t.Logf(" - %s (score=%.4f): %.50s...", r.Chunk.DocTitle, r.Score, r.Chunk.Content)
|
||||
}
|
||||
|
||||
// Test stats
|
||||
stats := store.Stats()
|
||||
if stats["total_chunks"].(int) != n {
|
||||
t.Fatalf("stats mismatch: expected %d chunks, got %v", n, stats["total_chunks"])
|
||||
}
|
||||
t.Logf("stats OK: %v", stats)
|
||||
}
|
||||
|
||||
// SimpleEmbedder for testing without API calls.
|
||||
type SimpleEmbedder struct{}
|
||||
|
||||
func (e *SimpleEmbedder) Embed(ctx context.Context, text string) ([]float64, error) {
|
||||
vec := make([]float64, 128)
|
||||
runes := []rune(text)
|
||||
for i, r := range runes {
|
||||
idx := int(r) % 128
|
||||
vec[idx] += 1.0 / float64(len(runes))
|
||||
posIdx := (int(r) + i) % 128
|
||||
vec[posIdx] += 0.5 / float64(len(runes))
|
||||
}
|
||||
return vec, nil
|
||||
}
|
||||
|
||||
func (e *SimpleEmbedder) EmbedBatch(ctx context.Context, texts []string) ([]float64, error) {
|
||||
// For batch, embed the concatenation
|
||||
combined := ""
|
||||
for _, t := range texts {
|
||||
combined += t
|
||||
}
|
||||
return e.Embed(ctx, combined)
|
||||
}
|
||||
|
||||
func (e *SimpleEmbedder) IsAvailable() bool {
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/yourname/cyrene-ai/ai-core/internal/host"
|
||||
"github.com/yourname/cyrene-ai/ai-core/internal/rag"
|
||||
)
|
||||
|
||||
func TestHostExecToolDefinition(t *testing.T) {
|
||||
cfg := host.DefaultSandboxConfig()
|
||||
cfg.AllowedDirs = []string{os.TempDir()}
|
||||
sandbox := host.NewSandbox(cfg)
|
||||
mgr := host.NewManager(sandbox)
|
||||
|
||||
tool := NewHostExecTool(mgr)
|
||||
def := tool.Definition()
|
||||
if def.Name != "host_exec" {
|
||||
t.Fatalf("unexpected name: %s", def.Name)
|
||||
}
|
||||
t.Logf("host_exec definition OK")
|
||||
|
||||
// Test execute with echo
|
||||
result, err := tool.Execute(context.Background(), map[string]interface{}{
|
||||
"command": "echo test-ok",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("execute error: %v", err)
|
||||
}
|
||||
if !result.Success {
|
||||
t.Fatalf("execute failed: %s", result.Error)
|
||||
}
|
||||
t.Logf("host_exec execute OK: data=%s", result.Data[:50])
|
||||
}
|
||||
|
||||
func TestHostFileToolDefinition(t *testing.T) {
|
||||
cfg := host.DefaultSandboxConfig()
|
||||
tmpDir := os.TempDir()
|
||||
cfg.AllowedDirs = []string{tmpDir}
|
||||
sandbox := host.NewSandbox(cfg)
|
||||
mgr := host.NewManager(sandbox)
|
||||
mgr.SetAllowedDirs([]string{tmpDir})
|
||||
|
||||
tool := NewHostFileTool(mgr)
|
||||
def := tool.Definition()
|
||||
if def.Name != "host_file" {
|
||||
t.Fatalf("unexpected name: %s", def.Name)
|
||||
}
|
||||
t.Logf("host_file definition OK")
|
||||
|
||||
// Test list
|
||||
result, err := tool.Execute(context.Background(), map[string]interface{}{
|
||||
"action": "list",
|
||||
"path": tmpDir,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("list execute error: %v", err)
|
||||
}
|
||||
if !result.Success {
|
||||
t.Fatalf("list failed: %s", result.Error)
|
||||
}
|
||||
t.Logf("host_file list OK: data len=%d", len(result.Data))
|
||||
}
|
||||
|
||||
func TestHostSystemToolDefinition(t *testing.T) {
|
||||
cfg := host.DefaultSandboxConfig()
|
||||
sandbox := host.NewSandbox(cfg)
|
||||
mgr := host.NewManager(sandbox)
|
||||
|
||||
tool := NewHostSystemTool(mgr)
|
||||
def := tool.Definition()
|
||||
if def.Name != "host_system" {
|
||||
t.Fatalf("unexpected name: %s", def.Name)
|
||||
}
|
||||
t.Logf("host_system definition OK")
|
||||
|
||||
result, err := tool.Execute(context.Background(), map[string]interface{}{})
|
||||
if err != nil {
|
||||
t.Fatalf("execute error: %v", err)
|
||||
}
|
||||
if !result.Success {
|
||||
t.Fatalf("execute failed: %s", result.Error)
|
||||
}
|
||||
t.Logf("host_system execute OK: data len=%d", len(result.Data))
|
||||
}
|
||||
|
||||
type testEmbedder struct{}
|
||||
|
||||
func (e *testEmbedder) Embed(ctx context.Context, text string) ([]float64, error) {
|
||||
n := float64(len([]rune(text)))
|
||||
v := make([]float64, 128)
|
||||
for _, r := range text {
|
||||
v[int(r)%128] += 1.0 / n
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func (e *testEmbedder) EmbedBatch(ctx context.Context, texts []string) ([]float64, error) {
|
||||
combined := ""
|
||||
for _, t := range texts {
|
||||
combined += t
|
||||
}
|
||||
return e.Embed(ctx, combined)
|
||||
}
|
||||
|
||||
func (e *testEmbedder) IsAvailable() bool { return true }
|
||||
|
||||
func TestKnowledgeSearchToolDefinition(t *testing.T) {
|
||||
store := rag.NewKnowledgeStore(&testEmbedder{}, os.TempDir())
|
||||
retriever := rag.NewRetriever(store)
|
||||
|
||||
tool := NewKnowledgeSearchTool(retriever)
|
||||
def := tool.Definition()
|
||||
if def.Name != "knowledge_search" {
|
||||
t.Fatalf("unexpected name: %s", def.Name)
|
||||
}
|
||||
t.Logf("knowledge_search definition OK")
|
||||
|
||||
result, err := tool.Execute(context.Background(), map[string]interface{}{
|
||||
"query": "test query",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("execute error: %v", err)
|
||||
}
|
||||
if !result.Success {
|
||||
t.Fatalf("execute failed: %s", result.Error)
|
||||
}
|
||||
t.Logf("knowledge_search execute OK: data=%s", result.Data[:80])
|
||||
}
|
||||
|
||||
func TestKnowledgeIngestToolDefinition(t *testing.T) {
|
||||
store := rag.NewKnowledgeStore(&testEmbedder{}, os.TempDir())
|
||||
|
||||
tool := NewKnowledgeIngestTool(store)
|
||||
def := tool.Definition()
|
||||
if def.Name != "knowledge_ingest" {
|
||||
t.Fatalf("unexpected name: %s", def.Name)
|
||||
}
|
||||
t.Logf("knowledge_ingest definition OK")
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEncodeImageToDataURL(t *testing.T) {
|
||||
// Create a minimal 1x1 PNG
|
||||
pngBytes, _ := base64.StdEncoding.DecodeString("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==")
|
||||
tmpPath := filepath.Join(os.TempDir(), "cyrene-test-vision.png")
|
||||
if err := os.WriteFile(tmpPath, pngBytes, 0644); err != nil {
|
||||
t.Fatalf("write test image: %v", err)
|
||||
}
|
||||
defer os.Remove(tmpPath)
|
||||
|
||||
dataURL, mimeType, err := encodeImageToDataURL(tmpPath)
|
||||
if err != nil {
|
||||
t.Fatalf("encode: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(dataURL, "data:image/png;base64,") {
|
||||
t.Fatalf("unexpected data URL: %s...", dataURL[:50])
|
||||
}
|
||||
if mimeType != "image/png" {
|
||||
t.Fatalf("unexpected mime type: %s", mimeType)
|
||||
}
|
||||
t.Logf("encode OK: mime=%s, len=%d", mimeType, len(dataURL))
|
||||
}
|
||||
|
||||
func TestEncodeImageToDataURL_InvalidPath(t *testing.T) {
|
||||
_, _, err := encodeImageToDataURL("/nonexistent/image.png")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for nonexistent file")
|
||||
}
|
||||
t.Logf("error handling OK: %v", err)
|
||||
}
|
||||
|
||||
func TestVisionToolDefinition(t *testing.T) {
|
||||
tool := NewVisionTool()
|
||||
def := tool.Definition()
|
||||
if def.Name != "vision_analyze" {
|
||||
t.Fatalf("unexpected tool name: %s", def.Name)
|
||||
}
|
||||
params := def.Parameters
|
||||
props, ok := params["properties"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatal("missing properties")
|
||||
}
|
||||
if props["image_path"] == nil {
|
||||
t.Fatal("missing image_path parameter")
|
||||
}
|
||||
if props["task"] == nil {
|
||||
t.Fatal("missing task parameter")
|
||||
}
|
||||
t.Logf("definition OK: name=%s, params=%v", def.Name, def.Parameters)
|
||||
}
|
||||
|
||||
func TestVisionToolExecute(t *testing.T) {
|
||||
// Create test image
|
||||
pngBytes, _ := base64.StdEncoding.DecodeString("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==")
|
||||
tmpPath := filepath.Join(os.TempDir(), "cyrene-test-vision-exec.png")
|
||||
if err := os.WriteFile(tmpPath, pngBytes, 0644); err != nil {
|
||||
t.Fatalf("write test image: %v", err)
|
||||
}
|
||||
defer os.Remove(tmpPath)
|
||||
|
||||
tool := NewVisionTool()
|
||||
ctx := context.Background()
|
||||
result, err := tool.Execute(ctx, map[string]interface{}{
|
||||
"image_path": tmpPath,
|
||||
"task": "ocr",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
if !result.Success {
|
||||
t.Fatalf("execute failed: %s", result.Error)
|
||||
}
|
||||
if !strings.Contains(result.Data, "data:image/png;base64,") {
|
||||
t.Fatal("result missing data URL")
|
||||
}
|
||||
if !strings.Contains(result.Data, "ocr") {
|
||||
t.Fatal("result missing task info")
|
||||
}
|
||||
t.Logf("execute OK: data len=%d", len(result.Data))
|
||||
}
|
||||
Reference in New Issue
Block a user