Files
glpi-ai-agent/internal/knowledge/store_test.go

628 lines
22 KiB
Go

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)
}
}