package knowledge import ( "context" "encoding/json" "fmt" "os" "path/filepath" "strings" "testing" "time" "github.com/example/glpi-ai-agent/internal/model" ) func TestLoadSearchesOnlyAllowedSources(t *testing.T) { dir := t.TempDir() data := filepath.Join(dir, "data") if err := os.MkdirAll(data, 0o755); err != nil { t.Fatal(err) } internal := `{"id":"I1","title":"VPN intern","text":"gateway vpn","answer":"x","source":"internal-kb","language":"de-DE","communication_style":"formal"}` vendor := `{"id":"V1","title":"VPN vendor","text":"gateway vpn","answer":"x","source":"vendor-docs","language":"de-DE","communication_style":"formal"}` if err := os.WriteFile(filepath.Join(dir, "internal.json"), []byte(internal), 0o644); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(dir, "vendor.json"), []byte(vendor), 0o644); err != nil { t.Fatal(err) } s, err := Load(context.Background(), dir, data, nil, false, []string{"internal-kb"}) if err != nil { t.Fatal(err) } if s.Count() != 1 { t.Fatalf("count=%d", s.Count()) } hits, err := s.Search(context.Background(), "vpn gateway", 10) if err != nil { t.Fatal(err) } if len(hits) != 1 || hits[0].Doc.Source != "internal-kb" { t.Fatalf("unexpected hits: %+v", hits) } } func TestLoadRequiresSourceMetadata(t *testing.T) { dir := t.TempDir() data := filepath.Join(dir, "data") if err := os.MkdirAll(data, 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(dir, "bad.json"), []byte(`{"id":"I1","title":"Missing source"}`), 0o644); err != nil { t.Fatal(err) } if _, err := Load(context.Background(), dir, data, nil, false, []string{"internal-kb"}); err == nil { t.Fatal("expected missing source to fail") } } func TestLoadMissingDirectoryReturnsHelpfulError(t *testing.T) { missing := filepath.Join(t.TempDir(), "does-not-exist") _, err := Load(context.Background(), missing, t.TempDir(), nil, false, []string{"internal-kb"}) if err == nil { t.Fatal("expected missing knowledge directory to fail") } if got := err.Error(); !strings.Contains(got, "read knowledge directory") || !strings.Contains(got, missing) { t.Fatalf("unexpected error: %v", err) } } func TestNilStoreHelpersDoNotPanic(t *testing.T) { var s *Store if got := s.Count(); got != 0 { t.Fatalf("Count()=%d, want 0", got) } if _, ok := s.ByID("KB1"); ok { t.Fatal("nil store unexpectedly returned a document") } if _, err := s.Search(context.Background(), "vpn", 1); err == nil { t.Fatal("expected Search on nil store to return an error") } } func TestRAGRequiresEmbedderWhenDocumentsExist(t *testing.T) { dir := t.TempDir() doc := `{"id":"I1","title":"VPN intern","text":"gateway vpn","answer":"x","source":"internal-kb","language":"de-DE","communication_style":"formal"}` if err := os.WriteFile(filepath.Join(dir, "internal.json"), []byte(doc), 0o644); err != nil { t.Fatal(err) } if _, err := Load(context.Background(), dir, t.TempDir(), nil, true, []string{"internal-kb"}); err == nil { t.Fatal("expected RAG without embedder to fail") } } func TestUpsertDelete(t *testing.T) { dir := t.TempDir() data := t.TempDir() s, err := Load(context.Background(), dir, data, nil, false, []string{"internal-kb"}) if err != nil { t.Fatal(err) } d := model.KnowledgeDoc{ID: "KB-1", Title: "Test", Text: "Wissen", Source: "internal-kb", Language: "de-DE", CommunicationStyle: "formal", MinScore: 0.8} if err := s.Upsert(context.Background(), d); err != nil { t.Fatal(err) } if s.Count() != 1 { t.Fatalf("count=%d", s.Count()) } if _, ok := s.ByID("KB-1"); !ok { t.Fatal("missing") } if err := s.Delete("KB-1"); err != nil { t.Fatal(err) } if s.Count() != 0 { t.Fatalf("count=%d", s.Count()) } } func TestExternalKnowledgeIsReadOnly(t *testing.T) { dir := t.TempDir() data := t.TempDir() s, err := Load(context.Background(), dir, data, nil, false, []string{"internal-kb", "glpi-kb"}) if err != nil { t.Fatal(err) } d := model.KnowledgeDoc{ID: "GLPI-KB-1", Title: "Extern", Text: "Wissen", Source: "glpi-kb", Language: "de-DE", CommunicationStyle: "formal"} if err := s.ReplaceExternalSource(context.Background(), "glpi-kb", []model.KnowledgeDoc{d}); err != nil { t.Fatal(err) } if s.Count() != 1 || s.Origin("GLPI-KB-1") != "glpi-kb" { t.Fatalf("unexpected external store state") } if err := s.Upsert(context.Background(), d); err == nil { t.Fatal("expected external document to be read-only") } } type semanticTestEmbedder struct{} func (semanticTestEmbedder) Embed(_ context.Context, texts []string) ([][]float64, error) { out := make([][]float64, len(texts)) for i, text := range texts { s := strings.ToLower(text) v := []float64{0, 0, 0, 0} if strings.Contains(s, "benutzerkonto") || strings.Contains(s, "konto gesperrt") || strings.Contains(s, "gesperrt") { v[0] = 1 } if strings.Contains(s, "anmeld") || strings.Contains(s, "login") || strings.Contains(s, "authent") { v[1] = 1 } if strings.Contains(s, "drucker") { v[2] = 1 } if strings.Contains(s, "allgemein") || strings.Contains(s, "hinweis") { v[3] = 1 } if v[0]+v[1]+v[2]+v[3] == 0 { v[3] = .1 } out[i] = v } return out, nil } func TestHybridScoringUsesChunksTitleKeywordsAndCategoryHints(t *testing.T) { dir := t.TempDir() data := t.TempDir() body := strings.Repeat("Allgemeine technische Hinweise ohne Bezug zum Benutzer. ", 80) + " Wenn ein Benutzerkonto gesperrt ist und die Anmeldung nicht möglich ist, muss die Kontosperre geprüft werden. " + strings.Repeat("Weitere allgemeine Hinweise. ", 80) doc := model.KnowledgeDoc{ID: "KB-AD-1", Title: "Benutzerkonto gesperrt", Text: body, Answer: "x", Source: "internal-kb", Language: "de-DE", CommunicationStyle: "formal", Categories: []int64{2}, Keywords: []string{"Konto gesperrt", "Anmeldung", "Login"}} b, _ := json.Marshal(doc) if err := os.WriteFile(filepath.Join(dir, "ad.json"), b, 0o644); err != nil { t.Fatal(err) } s, err := Load(context.Background(), dir, data, semanticTestEmbedder{}, true, []string{"internal-kb"}, ScoringConfig{SemanticWeight: .5, TitleWeight: .25, KeywordWeight: .15, CategoryWeight: .10, ChunkWords: 40, ChunkOverlap: 10, MaxChunksPerDoc: 24}) if err != nil { t.Fatal(err) } cats := []model.Category{{ID: 2, Name: "Active Directory", Hints: []string{"Benutzerkonto gesperrt", "Anmeldung Login Authentifizierung"}, Examples: []string{"Mein Benutzerkonto ist gesperrt und ich kann mich nicht anmelden"}}} hits, err := s.Search(context.Background(), "Benutzerkonto gesperrt, Anmeldung nicht möglich", 1, cats) if err != nil { t.Fatal(err) } if len(hits) != 1 { t.Fatalf("hits=%d", len(hits)) } h := hits[0] if h.Score < .75 { t.Fatalf("hybrid score too low: %+v", h) } if h.SemanticScore < .8 { t.Fatalf("expected strong best-chunk semantic score: %+v", h) } if h.TitleScore < .7 { t.Fatalf("expected strong title score: %+v", h) } if h.KeywordScore <= 0 || h.CategoryScore <= 0 { t.Fatalf("expected keyword/category contributions: %+v", h) } if !strings.Contains(strings.ToLower(h.BestChunkExcerpt), "benutzerkonto") { t.Fatalf("wrong best chunk: %q", h.BestChunkExcerpt) } } type hashTestEmbedder struct{} func (hashTestEmbedder) Embed(_ context.Context, texts []string) ([][]float64, error) { out := make([][]float64, len(texts)) for i, text := range texts { // Deterministic fixed-width vector: identical text -> identical vector; // unrelated text is unlikely to point in the same direction. v := make([]float64, 128) for pos, r := range []byte(strings.ToLower(strings.Join(strings.Fields(text), " "))) { idx := (int(r) + pos*31) % len(v) if (int(r)+pos)%2 == 0 { v[idx] += 1 } else { v[idx] -= 1 } } out[i] = v } return out, nil } func TestLongQueryIsChunkedAndCanMatchIdenticalKnowledgeSection(t *testing.T) { dir := t.TempDir() data := t.TempDir() parts := make([]string, 0, 400) for i := 0; i < 400; i++ { parts = append(parts, "Benutzerkonto Anmeldung Sperrung Active Directory Diagnose Schritt") } body := strings.Join(parts, " ") doc := model.KnowledgeDoc{ID: "KB-LONG", Title: "Benutzerkonto gesperrt", Text: body, Source: "internal-kb", Language: "de-DE", CommunicationStyle: "formal"} b, _ := json.Marshal(doc) if err := os.WriteFile(filepath.Join(dir, "long.json"), b, 0o644); err != nil { t.Fatal(err) } s, err := Load(context.Background(), dir, data, hashTestEmbedder{}, true, []string{"internal-kb"}, ScoringConfig{SemanticWeight: 1, ChunkWords: 80, ChunkOverlap: 20, MaxChunksPerDoc: 24}) if err != nil { t.Fatal(err) } hits, err := s.Search(context.Background(), "Benutzerkonto gesperrt\n"+body, 1) if err != nil { t.Fatal(err) } if len(hits) != 1 { t.Fatalf("hits=%d", len(hits)) } h := hits[0] if h.QueryChunkCount <= 1 || h.DocumentChunkCount <= 1 { t.Fatalf("expected both sides to be chunked: %+v", h) } if h.SemanticScore < 0.999999 { t.Fatalf("identical long body should contain an exact chunk match, got semantic=%f", h.SemanticScore) } if h.Score < 0.999999 { t.Fatalf("semantic-only hybrid should be ~1, got %f", h.Score) } } func TestRegressionAnmeldeproblemZeroMetadataDoesNotDragScoreBelowThreshold(t *testing.T) { cfg := DefaultScoringConfig() title := titleSimilarity("Anmeldeproblem", "Benutzeranmeldung, Anmeldeprobleme, Passwort vergessen") if title < .90 { t.Fatalf("expected strong fuzzy/asymmetric title match, got %f", title) } lex := title // Raw cosine copied from a real-world regression case. Keyword/category // metadata had zero overlap and must therefore not count as negative evidence. total := weightedScore(cfg, scorePart{.5025152998747112, cfg.SemanticWeight, true}, scorePart{title, cfg.TitleWeight, true}, scorePart{lex, cfg.LexicalWeight, true}, scorePart{0, cfg.KeywordWeight, false}, scorePart{0, cfg.CategoryWeight, false}, ) if total < .70 { t.Fatalf("obvious login KB regression should clear 0.70 evidence threshold, got %f (title=%f)", total, title) } } func TestEmbeddingGemmaRetrievalPrompts(t *testing.T) { q := formatQueryEmbeddings([]string{"Seit heute funktioniert die Anmeldung nicht"}, "embeddinggemma") if len(q) != 1 || !strings.HasPrefix(q[0], "task: search result | query: ") { t.Fatalf("unexpected query prompt: %#v", q) } d := formatDocumentEmbedding("Benutzeranmeldung", "Bei unbekanntem Benutzer LDAP prüfen", "embeddinggemma") if !strings.HasPrefix(d, "title: Benutzeranmeldung | text: ") { t.Fatalf("unexpected document prompt: %q", d) } if got := ResolveEmbeddingProfile("auto", "embeddinggemma:latest"); got != "embeddinggemma" { t.Fatalf("resolved profile=%q", got) } if got := ResolveEmbeddingProfile("auto", "qwen3-embedding:0.6b"); got != "plain" { t.Fatalf("resolved non-gemma profile=%q", got) } } func TestRegressionShortGermanLoginTicketGetsStrongLexicalEvidence(t *testing.T) { d := model.KnowledgeDoc{ Title: "Benutzeranmeldung, Anmeldeprobleme, Passwort vergessen", Text: "Bei Problemen mit der Benutzeranmeldung und Domänenkonten prüfen Sie Active Directory. Ein unbekannter Benutzer kann auf ein Anmelde- oder Synchronisationsproblem hinweisen.", } got := lexicalSimilarity("Problem mit Nutzerkonto\nKann mich nicht anmelden", d) if got < .55 { t.Fatalf("short German login request should have useful lexical evidence, got %f", got) } if sim := tokenSimilarity("anmelden", "Benutzeranmeldung"); sim < .85 { t.Fatalf("anmelden/Benutzeranmeldung should match through a German support stem, got %f", sim) } } func TestExternalStringCategoryLoadsUnscoped(t *testing.T) { dir := t.TempDir() data := t.TempDir() body := `{"id":"KB-EXT-1","title":"Docker Test","text":"Docker Fehler","answer":"Pruefen","auto_reply":false,"min_score":0.7,"categories":["Docker","Security"],"keywords":["docker"],"source":"internal-kb","language":"de-DE","communication_style":"formal"}` if err := os.WriteFile(filepath.Join(dir, "ext.json"), []byte(body), 0o644); err != nil { t.Fatal(err) } s, err := Load(context.Background(), dir, data, nil, false, []string{"internal-kb"}, ScoringConfig{CategoryMode: "unscoped"}) if err != nil { t.Fatal(err) } doc, ok := s.ByID("KB-EXT-1") if !ok { t.Fatal("document not loaded") } if len(doc.Categories) != 0 { t.Fatalf("expected no GLPI ids, got %v", doc.Categories) } if len(doc.ExternalCategories) != 2 { t.Fatalf("external categories=%v", doc.ExternalCategories) } stats := s.LoadStats() if stats.UnmappedCategoryFiles != 1 { t.Fatalf("stats=%+v", stats) } } func TestExternalStringCategoryMapsToGLPI(t *testing.T) { dir := t.TempDir() data := t.TempDir() mapPath := filepath.Join(data, "category-map.json") if err := os.WriteFile(mapPath, []byte(`{"Docker":[12,13],"Security":7}`), 0o644); err != nil { t.Fatal(err) } body := `{"id":"KB-EXT-2","title":"Docker Test","text":"Docker Fehler","answer":"Pruefen","auto_reply":false,"min_score":0.7,"categories":["Docker","Security"],"keywords":["docker"],"source":"internal-kb","language":"de-DE","communication_style":"formal"}` if err := os.WriteFile(filepath.Join(dir, "ext.json"), []byte(body), 0o644); err != nil { t.Fatal(err) } s, err := Load(context.Background(), dir, data, nil, false, []string{"internal-kb"}, ScoringConfig{CategoryMode: "unscoped", CategoryMapFile: mapPath}) if err != nil { t.Fatal(err) } doc, _ := s.ByID("KB-EXT-2") want := []int64{12, 13, 7} if len(doc.Categories) != len(want) { t.Fatalf("categories=%v", doc.Categories) } for _, id := range want { found := false for _, got := range doc.Categories { if got == id { found = true } } if !found { t.Fatalf("missing id %d in %v", id, doc.Categories) } } if s.LoadStats().UnmappedCategoryFiles != 0 { t.Fatalf("unexpected unmapped stats: %+v", s.LoadStats()) } } func TestKnowledgeIgnoreGlobs(t *testing.T) { dir := t.TempDir() data := t.TempDir() bad := `{"id":"KB-BAD","title":"Foreign","text":"x","answer":"x","auto_reply":false,"min_score":0.7,"categories":[{"unsupported":true}],"source":"internal-kb","language":"de-DE","communication_style":"formal"}` if err := os.WriteFile(filepath.Join(dir, "KB-SEC-ATTCK-AN-0001.json"), []byte(bad), 0o644); err != nil { t.Fatal(err) } s, err := Load(context.Background(), dir, data, nil, false, []string{"internal-kb"}, ScoringConfig{CategoryMode: "strict", IgnoreGlobs: []string{"KB-SEC-ATTCK-*.json"}}) if err != nil { t.Fatal(err) } if s.Count() != 0 { t.Fatalf("count=%d", s.Count()) } if s.LoadStats().IgnoredFiles != 1 { t.Fatalf("stats=%+v", s.LoadStats()) } } func TestNewStoreSupportsBackgroundInitialization(t *testing.T) { dir := t.TempDir() data := t.TempDir() for i := 0; i < 250; i++ { doc := model.KnowledgeDoc{ID: fmt.Sprintf("KB-%04d", i), Title: fmt.Sprintf("Artikel %d", i), Text: "Testwissen Anmeldung", Source: "internal-kb", Language: "de-DE", CommunicationStyle: "formal"} b, _ := json.Marshal(doc) if err := os.WriteFile(filepath.Join(dir, fmt.Sprintf("kb-%04d.json", i)), b, 0o644); err != nil { t.Fatal(err) } } s, err := NewStore(dir, data, nil, false, []string{"internal-kb"}) if err != nil { t.Fatal(err) } if s.Ready() { t.Fatal("new store must not be ready before Initialize") } if st := s.InitStatus(); st.State != "waiting" { t.Fatalf("state=%q want waiting", st.State) } if err := s.Initialize(context.Background()); err != nil { t.Fatal(err) } st := s.InitStatus() if !s.Ready() || st.State != "ready" || st.ProcessedFiles != 250 || st.LoadedDocs != 250 || s.Count() != 250 { t.Fatalf("unexpected init status: %+v count=%d", st, s.Count()) } } type countingEmbedder struct{ calls int } func (e *countingEmbedder) Embed(_ context.Context, texts []string) ([][]float64, error) { e.calls++ out := make([][]float64, len(texts)) for i, text := range texts { v := make([]float64, 8) for j, b := range []byte(strings.ToLower(text)) { v[(int(b)+j)%len(v)] += 1 } out[i] = v } return out, nil } func TestPersistentIndexLoadsWithoutReembeddingAndSyncsDelta(t *testing.T) { dir := t.TempDir() data := t.TempDir() path := filepath.Join(dir, "kb.json") writeDoc := func(title, text string) { t.Helper() d := model.KnowledgeDoc{ID: "KB-1", Title: title, Text: text, Answer: "Antwort", Source: "internal-kb", Language: "de-DE", CommunicationStyle: "formal"} b, _ := json.Marshal(d) if err := os.WriteFile(path, b, 0o644); err != nil { t.Fatal(err) } } writeDoc("Anmeldung", "Benutzer kann sich nicht anmelden") cfg := ScoringConfig{EmbeddingIdentity: "test-embed", EmbeddingProfile: "plain", ChunkWords: 40, ChunkOverlap: 10, MaxChunksPerDoc: 8, MaxQueryChunks: 8, IndexMode: "incremental", EmbedBatchSize: 8} firstEmbed := &countingEmbedder{} first, err := Load(context.Background(), dir, data, firstEmbed, true, []string{"internal-kb"}, cfg) if err != nil { t.Fatal(err) } if firstEmbed.calls == 0 { t.Fatal("expected initial embedding calls") } if _, err := os.Stat(filepath.Join(data, "knowledge-index", "snapshot.gob")); err != nil { t.Fatalf("snapshot missing: %v", err) } if !first.Ready() { t.Fatal("first store not ready") } secondEmbed := &countingEmbedder{} second, err := Load(context.Background(), dir, data, secondEmbed, true, []string{"internal-kb"}, cfg) if err != nil { t.Fatal(err) } if secondEmbed.calls != 0 { t.Fatalf("snapshot startup unexpectedly re-embedded: calls=%d", secondEmbed.calls) } if !second.InitStatus().SnapshotLoaded { t.Fatal("expected persistent snapshot to be loaded") } if second.Count() != 1 { t.Fatalf("count=%d", second.Count()) } // Ensure mtime changes even on filesystems with coarse timestamp resolution. time.Sleep(20 * time.Millisecond) writeDoc("Anmeldung geändert", "Benutzer kann sich weiterhin nicht anmelden") if err := second.SyncLocal(context.Background()); err != nil { t.Fatal(err) } if secondEmbed.calls == 0 { t.Fatal("expected changed document to be re-embedded") } got, ok := second.ByID("KB-1") if !ok || got.Title != "Anmeldung geändert" { t.Fatalf("delta update not applied: %+v", got) } callsAfterChange := secondEmbed.calls if err := second.SyncLocal(context.Background()); err != nil { t.Fatal(err) } if secondEmbed.calls != callsAfterChange { t.Fatalf("unchanged delta scan re-embedded document: before=%d after=%d", callsAfterChange, secondEmbed.calls) } } func TestReadonlyIndexRequiresSnapshot(t *testing.T) { dir := t.TempDir() data := t.TempDir() s, err := NewStore(dir, data, nil, false, []string{"internal-kb"}, ScoringConfig{IndexMode: "readonly"}) if err != nil { t.Fatal(err) } if err := s.Initialize(context.Background()); err == nil { t.Fatal("expected readonly mode without snapshot to fail") } } func TestCategoryMappingEditorReappliesWithoutReembedding(t *testing.T) { dir := t.TempDir() data := t.TempDir() mapPath := filepath.Join(data, "knowledge-category-map.json") doc := `{ "id":"KB-MAP-1", "title":"Outlook Signatur", "text":"Signatur kann nicht angelegt werden", "answer":"Bitte Einstellungen prüfen.", "auto_reply":true, "min_score":0.7, "categories":["Outlook","Signatur"], "keywords":["Outlook","Signatur"], "source":"internal-kb", "language":"de-DE", "communication_style":"formal" }` if err := os.WriteFile(filepath.Join(dir, "kb.json"), []byte(doc), 0o644); err != nil { t.Fatal(err) } cfg := ScoringConfig{ EmbeddingIdentity: "test-embed", EmbeddingProfile: "plain", ChunkWords: 40, ChunkOverlap: 10, MaxChunksPerDoc: 8, MaxQueryChunks: 8, IndexMode: "incremental", EmbedBatchSize: 8, CategoryMode: "unscoped", CategoryMapFile: mapPath, } embed := &countingEmbedder{} s, err := Load(context.Background(), dir, data, embed, true, []string{"internal-kb"}, cfg) if err != nil { t.Fatal(err) } if embed.calls == 0 { t.Fatal("expected initial embeddings") } beforeCalls := embed.calls before, ok := s.ByID("KB-MAP-1") if !ok { t.Fatal("knowledge document missing") } if before.AutoReply { t.Fatal("unmapped external categories should make auto_reply fail closed") } if len(before.UnmappedExternalCategories) != 2 { t.Fatalf("unmapped=%v", before.UnmappedExternalCategories) } state, err := s.CategoryMappings() if err != nil { t.Fatal(err) } if state.ObservedCount != 2 || state.UnmappedObserved != 2 { t.Fatalf("unexpected mapping state before save: %+v", state) } if err := s.SaveCategoryMappings(context.Background(), map[string][]int64{ "Outlook": {12}, "Signatur": {12, 18}, }); err != nil { t.Fatal(err) } if embed.calls != beforeCalls { t.Fatalf("category remap unexpectedly re-embedded: before=%d after=%d", beforeCalls, embed.calls) } after, ok := s.ByID("KB-MAP-1") if !ok { t.Fatal("knowledge document missing after remap") } if !after.AutoReply { t.Fatal("fully mapped document should restore auto_reply from source JSON") } if len(after.UnmappedExternalCategories) != 0 { t.Fatalf("still unmapped: %v", after.UnmappedExternalCategories) } if got := fmt.Sprint(after.Categories); got != "[12 18]" { t.Fatalf("mapped categories=%s", got) } b, err := os.ReadFile(mapPath) if err != nil { t.Fatal(err) } if !strings.Contains(string(b), `"Outlook": 12`) || !strings.Contains(string(b), `"Signatur": [12,18]`) { t.Fatalf("unexpected mapping file:\n%s", b) } state, err = s.CategoryMappings() if err != nil { t.Fatal(err) } if state.MappedObserved != 2 || state.UnmappedObserved != 0 { t.Fatalf("unexpected mapping state after save: %+v", state) } } func TestFilterHitsBySourcesSeparatesCategoryOnlyKnowledge(t *testing.T) { hits := []model.KnowledgeHit{ {Doc: model.KnowledgeDoc{ID: "CAT", Source: "internal-category"}, Score: .9}, {Doc: model.KnowledgeDoc{ID: "REPLY", Source: "internal-kb"}, Score: .8}, } category := FilterHitsBySources(hits, []string{"internal-category"}, 0) reply := FilterHitsBySources(hits, []string{"internal-kb"}, 0) if len(category) != 1 || category[0].Doc.ID != "CAT" { t.Fatalf("category hits=%+v", category) } if len(reply) != 1 || reply[0].Doc.ID != "REPLY" { t.Fatalf("reply hits=%+v", reply) } }