package workbench import ( "context" "os" "strings" "testing" "time" "aigateway.local/core/internal/platform/config" "aigateway.local/core/internal/platform/database" ) // TestKnowledgeVectorLifecycle 跑真实 PostgreSQL(pgvector)+ Ollama: // vector 模式知识库导入文档 → embedding 非空 → SemanticRetriever 语义命中; // 并验证 embedder 失败时文档照常入库且发出 embedding_failed 事件。 // 需要 WORKBENCH_TEST_DATABASE_URL 与 WORKBENCH_TEST_OLLAMA_URL。 func TestKnowledgeVectorLifecycle(t *testing.T) { databaseURL := os.Getenv("WORKBENCH_TEST_DATABASE_URL") ollamaURL := os.Getenv("WORKBENCH_TEST_OLLAMA_URL") if databaseURL == "" || ollamaURL == "" { t.Skip("WORKBENCH_TEST_DATABASE_URL and WORKBENCH_TEST_OLLAMA_URL are not set") } ctx := context.Background() pool, err := database.Open(ctx, config.Database{URL: databaseURL, MaxConns: 8, MinConns: 0}) if err != nil { t.Fatal(err) } defer pool.Close() actorID := "55555555-5555-4555-8555-555555555555" _, err = pool.Exec(ctx, `INSERT INTO gateway.admin_accounts(id,username,password_hash,role) VALUES($1,'m8-vector-test','test','superadmin') ON CONFLICT(id) DO NOTHING`, actorID) if err != nil { t.Fatal(err) } name := "m8-vector-integration-kb" cleanup := func() { _, _ = pool.Exec(ctx, `DELETE FROM gateway.knowledge_bases WHERE name=$1 OR name=$2`, name, name+"-fts") } cleanup() defer cleanup() assets := NewService(pool) assets.SetEmbedder(NewOllamaEmbedder(OllamaEmbedderConfig{BaseURL: ollamaURL, Model: "bge-m3", Dim: 1024, BatchSize: 8, Timeout: 60 * time.Second})) kb, err := assets.SaveKnowledgeBase(ctx, KnowledgeBase{Name: name, Description: "vector itest", RetrievalMode: "vector", ChunkSize: 300, ChunkOverlap: 40, DepartmentIDs: []string{}, Enabled: true}, actorID, true) if err != nil { t.Fatal(err) } doc, err := assets.AddKnowledgeDocument(ctx, kb.ID, "向量检索测试", "text", "", "pgvector 语义检索依赖 bge-m3 向量。Ollama 本地生成嵌入。", actorID) if err != nil { t.Fatal(err) } var embedded int if err = pool.QueryRow(ctx, `SELECT count(*) FROM gateway.knowledge_chunks WHERE document_id=$1 AND embedding IS NOT NULL`, doc.ID).Scan(&embedded); err != nil { t.Fatal(err) } if embedded == 0 || embedded != doc.ChunkCount { t.Fatalf("expected all chunks embedded, got %d/%d", embedded, doc.ChunkCount) } // 语义检索:查询词与正文无字面重合也应命中(余弦相似度)。 hitChunks, err := (&SemanticRetriever{pool: pool, embedder: assets.Embedder()}).Search(ctx, kb.ID, "语义相似度匹配", 4) if err != nil { t.Fatalf("semantic search: %v", err) } if len(hitChunks) == 0 { t.Fatal("expected semantic hit") } if !strings.Contains(hitChunks[0].Content, "pgvector") { t.Fatalf("expected pgvector content in top hit, got %q", hitChunks[0].Content) } // 降级路径:embedder 失败 → 文档照常入库 + embedding_failed 事件。 assets.SetEmbedder(failingEmbedder{}) kbFts, err := assets.SaveKnowledgeBase(ctx, KnowledgeBase{Name: name + "-fts", Description: "degraded", RetrievalMode: "vector", ChunkSize: 300, ChunkOverlap: 40, DepartmentIDs: []string{}, Enabled: true}, actorID, true) if err != nil { t.Fatal(err) } degradedDoc, err := assets.AddKnowledgeDocument(ctx, kbFts.ID, "降级测试", "text", "", "没有向量也能入库。", actorID) if err != nil { t.Fatalf("degraded add should succeed, got %v", err) } var failed bool if err = pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM gateway.outbox_events WHERE event_type='knowledge_document.embedding_failed' AND aggregate_id=$1)`, degradedDoc.ID).Scan(&failed); err != nil { t.Fatal(err) } if !failed { t.Fatal("expected embedding_failed event when embedder is down") } }