package agent import ( "context" "os" "strings" "testing" "time" "github.com/example/glpi-ai-agent/internal/config" "github.com/example/glpi-ai-agent/internal/knowledge" "github.com/example/glpi-ai-agent/internal/metrics" "github.com/example/glpi-ai-agent/internal/model" "github.com/example/glpi-ai-agent/internal/queue" "github.com/example/glpi-ai-agent/internal/state" ) type fakeGLPI struct { ticket model.Ticket followups []model.Followup cats []model.Category setCategory int addReply int ticketReads int followupReads int injectFollowupOnSecondCheck bool } func (f *fakeGLPI) Ping(context.Context) error { return nil } func (f *fakeGLPI) ValidateContract(context.Context) error { return nil } func (f *fakeGLPI) ListRecentTickets(context.Context, int, string) ([]model.Ticket, error) { return nil, nil } func (f *fakeGLPI) GetTicket(context.Context, int64) (model.Ticket, error) { f.ticketReads++ return f.ticket, nil } func (f *fakeGLPI) GetFollowups(context.Context, int64) ([]model.Followup, error) { f.followupReads++ if f.injectFollowupOnSecondCheck && f.followupReads >= 2 { return []model.Followup{{ID: 99}}, nil } return f.followups, nil } func (f *fakeGLPI) SetCategory(_ context.Context, _ int64, id int64) error { f.setCategory++ f.ticket.CategoryID = id f.ticket.DateMod = "v2" return nil } func (f *fakeGLPI) AddFollowup(context.Context, int64, string, bool) error { f.addReply++ f.ticket.DateMod = "v3" return nil } func (f *fakeGLPI) GetCategories(context.Context) ([]model.Category, error) { return f.cats, nil } type fakeAI struct { d model.Decision replyHitCount *int } func (f fakeAI) Ping(context.Context) error { return nil } func (f fakeAI) Analyse(_ context.Context, _ model.Ticket, _ []model.Category, _ []model.KnowledgeHit, replyHits []model.KnowledgeHit, _ model.ContextSnapshot) (model.Decision, error) { if f.replyHitCount != nil { *f.replyHitCount = len(replyHits) } return f.d, nil } func newTestService(t *testing.T, g *fakeGLPI, d model.Decision, autoReply bool) *Service { t.Helper() dir := t.TempDir() kDir := dir + "/k" if err := os.MkdirAll(kDir, 0o755); err != nil { t.Fatal(err) } doc := `{"id":"KB1","title":"Known","text":"vpn gateway","answer":"Bitte starten Sie den VPN-Client neu.","auto_reply":true,"min_score":0,"categories":[2],"keywords":["vpn","gateway"],"source":"internal-kb","language":"de-DE","communication_style":"formal"}` if err := os.WriteFile(kDir+"/kb.json", []byte(doc), 0o644); err != nil { t.Fatal(err) } k, err := knowledge.Load(context.Background(), kDir, dir, nil, false, []string{"internal-kb"}) if err != nil { t.Fatal(err) } st, err := state.Open(dir, 100) if err != nil { t.Fatal(err) } cfg := config.Config{DryRun: false, AutoCategory: true, AutoReply: autoReply, CategoryConfidence: .9, ReplyConfidence: .9, KnowledgeMinScore: 0, KnowledgeTopK: 1, CategoryPromptLimit: 20, Workers: 1, GLPIAllowedStatusIDs: []int64{1}, KnowledgeAllowedSources: []string{"internal-kb"}, KnowledgeCategorySources: []string{"internal-kb"}, KnowledgeAutoReplySources: []string{"internal-kb"}, CommunicationLanguage: "de-DE", CommunicationStyle: "formal", CommunicationSalutation: "Guten Tag,", CommunicationClosing: "Mit freundlichen Grüßen", CommunicationSignature: "IT-Service", AIContentLabelEnabled: true} return New(cfg, g, fakeAI{d: d}, k, nil, st, queue.New(8), metrics.New(), nil) } func TestExistingFollowupBlocksReplyButNotCategory(t *testing.T) { g := &fakeGLPI{ticket: model.Ticket{ID: 1, Name: "vpn", Content: "gateway", DateMod: "v1", StatusID: 1, CategoryID: 1}, followups: []model.Followup{{ID: 5}}, cats: []model.Category{{ID: 1}, {ID: 2}}} var d model.Decision d.Category.ID, d.Category.Confidence = 2, 1 d.Reply.Allowed, d.Reply.Confidence, d.Reply.KnowledgeID = true, 1, "KB1" svc := newTestService(t, g, d, true) replyHitCount := -1 svc.ai = fakeAI{d: d, replyHitCount: &replyHitCount} if err := svc.Process(context.Background(), 1); err != nil { t.Fatal(err) } if g.setCategory != 1 { t.Fatalf("category writes=%d", g.setCategory) } if g.addReply != 0 { t.Fatalf("reply writes=%d", g.addReply) } if replyHitCount != 0 { t.Fatalf("reply candidates sent to AI=%d, want 0", replyHitCount) } runs := svc.state.Recent(1) if len(runs) != 1 || runs[0].ReplyDecision != "reply_existing_followup" || runs[0].Outcome != "processed" { t.Fatalf("unexpected run audit: %+v", runs) } } func TestRaceFollowupBlocksReply(t *testing.T) { g := &fakeGLPI{ticket: model.Ticket{ID: 1, Name: "vpn", Content: "gateway", DateMod: "v1", StatusID: 1, CategoryID: 2}, cats: []model.Category{{ID: 2}}, injectFollowupOnSecondCheck: true} var d model.Decision d.Reply.Allowed, d.Reply.Confidence, d.Reply.KnowledgeID = true, 1, "KB1" svc := newTestService(t, g, d, true) ctx, cancel := context.WithTimeout(context.Background(), time.Second) defer cancel() if err := svc.Process(ctx, 1); err != nil { t.Fatal(err) } if g.addReply != 0 { t.Fatalf("reply writes=%d", g.addReply) } } func TestDisallowedStatusSkipsWithoutWrites(t *testing.T) { g := &fakeGLPI{ticket: model.Ticket{ID: 1, Name: "vpn", Content: "gateway", DateMod: "v1", StatusID: 6, CategoryID: 1}, cats: []model.Category{{ID: 1}, {ID: 2}}} var d model.Decision d.Category.ID, d.Category.Confidence = 2, 1 svc := newTestService(t, g, d, true) if err := svc.Process(context.Background(), 1); err != nil { t.Fatal(err) } if g.setCategory != 0 || g.addReply != 0 { t.Fatalf("unexpected writes: category=%d reply=%d", g.setCategory, g.addReply) } } func TestRunAuditExplainsCategoryBelowThreshold(t *testing.T) { g := &fakeGLPI{ticket: model.Ticket{ID: 1, Name: "konto", Content: "anmeldung", DateMod: "v1", StatusID: 1, CategoryID: 1}, cats: []model.Category{{ID: 1, Name: "Allgemein"}, {ID: 2, Name: "Active Directory"}}} var d model.Decision d.Category.ID, d.Category.Confidence = 2, .82 d.Reason = "Das Problem deutet auf Active Directory hin." svc := newTestService(t, g, d, false) if err := svc.Process(context.Background(), 1); err != nil { t.Fatal(err) } if g.setCategory != 0 { t.Fatalf("unexpected category write: %d", g.setCategory) } runs := svc.state.Recent(1) if len(runs) != 1 { t.Fatalf("runs=%d", len(runs)) } r := runs[0] if r.AIRecommendedCategoryID != 2 || r.AIRecommendedCategoryName != "Active Directory" || r.AICategoryConfidence != .82 || r.CategoryThreshold != .9 { t.Fatalf("missing AI category audit: %+v", r) } if r.CategoryDecision != "category_confidence_below_threshold" || r.CategoryWouldChange || r.CategoryChanged { t.Fatalf("unexpected category decision audit: %+v", r) } if r.AIReason == "" || r.PolicyReason == "" { t.Fatalf("missing reason audit: %+v", r) } } func TestSemanticHintsImproveActiveDirectoryCategory(t *testing.T) { h := strings.Join(semanticCategoryHints(model.Category{ID: 2, Name: "Active Directory"}), " ") if !strings.Contains(strings.ToLower(h), "konto gesperrt") || !strings.Contains(strings.ToLower(h), "anmeldung") { t.Fatalf("expected identity hints, got %q", h) } } func TestRunStoresStructuredPolicyChecks(t *testing.T) { g := &fakeGLPI{ticket: model.Ticket{ID: 1, Name: "konto", Content: "anmeldung", DateMod: "v1", StatusID: 1, CategoryID: 1}, cats: []model.Category{{ID: 1, Name: "Allgemein"}, {ID: 2, Name: "Active Directory"}}} var d model.Decision d.Category.ID, d.Category.Confidence = 2, .82 d.Reply.Allowed = false svc := newTestService(t, g, d, false) if err := svc.Process(context.Background(), 1); err != nil { t.Fatal(err) } r := svc.state.Recent(1)[0] if len(r.CategoryChecks) < 4 { t.Fatalf("category checks=%d", len(r.CategoryChecks)) } if len(r.ReplyChecks) < 8 { t.Fatalf("reply checks=%d", len(r.ReplyChecks)) } found := false for _, c := range r.CategoryChecks { if c.Code == "category_confidence" && c.Status == "fail" && c.Blocking { found = true } } if !found { t.Fatalf("missing blocking confidence rule: %+v", r.CategoryChecks) } } func TestDiagnoseKnowledgeExplainsCandidate(t *testing.T) { g := &fakeGLPI{ticket: model.Ticket{ID: 1, Name: "VPN gateway", Content: "gateway nicht erreichbar", DateMod: "v1", StatusID: 1, CategoryID: 2}, cats: []model.Category{{ID: 2, Name: "VPN"}}} var d model.Decision d.Category.ID, d.Category.Confidence = 2, 1 d.Reply.Allowed, d.Reply.Confidence, d.Reply.KnowledgeID = true, 1, "KB1" svc := newTestService(t, g, d, true) if err := svc.Process(context.Background(), 1); err != nil { t.Fatal(err) } r := svc.state.Recent(1)[0] diag, err := svc.DiagnoseKnowledge(context.Background(), r.RunID, "KB1") if err != nil { t.Fatal(err) } if diag.KnowledgeID != "KB1" || diag.RetrievalRank != 1 { t.Fatalf("unexpected diagnostic: %+v", diag) } if len(diag.Checks) == 0 { t.Fatal("expected diagnostic checks") } if diag.CurrentTicketChanged { t.Fatal("ticket should not be marked changed") } } func TestCategoryHintsUseOnlyConfiguredCategorySources(t *testing.T) { dir := t.TempDir() kDir := dir + "/knowledge" if err := os.MkdirAll(kDir, 0o755); err != nil { t.Fatal(err) } categoryDoc := `{"id":"CAT","title":"Category selector","text":"category evidence","categories":[2],"keywords":["category-only-hint"],"source":"internal-category","language":"de-DE","communication_style":"formal"}` replyDoc := `{"id":"REPLY","title":"Reply article","text":"reply evidence","answer":"answer","categories":[1],"keywords":["reply-only-hint"],"source":"internal-kb","language":"de-DE","communication_style":"formal"}` if err := os.WriteFile(kDir+"/cat.json", []byte(categoryDoc), 0o644); err != nil { t.Fatal(err) } if err := os.WriteFile(kDir+"/reply.json", []byte(replyDoc), 0o644); err != nil { t.Fatal(err) } k, err := knowledge.Load(context.Background(), kDir, dir, nil, false, []string{"internal-category", "internal-kb"}) if err != nil { t.Fatal(err) } svc := &Service{cfg: config.Config{KnowledgeCategorySources: []string{"internal-category"}}, knowledge: k} cats := svc.enrichCategories([]model.Category{{ID: 1, Name: "One"}, {ID: 2, Name: "Two"}}) if strings.Contains(strings.Join(cats[0].Hints, " "), "reply-only-hint") { t.Fatalf("normal reply source influenced category hints: %+v", cats[0].Hints) } if !strings.Contains(strings.Join(cats[1].Hints, " "), "category-only-hint") { t.Fatalf("category source hint missing: %+v", cats[1].Hints) } }