Files
pulse/internal/ai/service_coverage_imp_test.go
T
rcourtman 39f988b220 Remove AI Discovery
AI Discovery ran a separate model-backed service scanner beside Patrol
and the Assistant: its own store, scheduler, model route, API, tool and
drawer UI. No telemetry counter ever measured its use (the telemetry
discovery_enabled flag OR'd it with network subnet discovery), the
maintainer judged it a flop, and the context it produced is already
available to the models through pulse_query, pulse_read and the
unified-resource context sections.

This deletes internal/servicediscovery, internal/ai/infradiscovery, the
pulse_discovery tool, the /api/discovery routes, the mock Discovery
fixtures, the Discovery tabs in the resource, guest, node and Docker host
drawers, the identified-service cards, readiness badges, provenance
markers, suggested web-interface URLs, the Workloads AI Context column,
the chat "Discovery is off" hint and the AI Service Context settings page.
The old settings route redirects to Assistant settings. The model-bound
sanitizer allow-list existed only to pass Discovery context through, so it
goes too, along with the CopyableCodeRow primitive that only the
Discovery tab used.

Network subnet discovery (/api/discover, the Infrastructure discovery
settings) and the discoveryTarget agent coordinates are separate features
and stay. Stored discovery_enabled, discovery_interval_hours and
discovery_model AI settings are ignored on load; the settings API decoder
is lenient, so older frontends saving those fields still succeed.
2026-10-11 22:20:25 +01:00

258 lines
7.9 KiB
Go

package ai
import (
"context"
"strings"
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/ai/memory"
"github.com/rcourtman/pulse-go-rewrite/internal/ai/providers"
"github.com/rcourtman/pulse-go-rewrite/internal/alerts"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
unifiedresources "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
)
func TestService_QuickAnalysis(t *testing.T) {
svc := NewService(nil, nil)
// Case 1: No provider configured
_, err := svc.QuickAnalysis(context.Background(), QuickAnalysisRequest{Prompt: "test"})
if err == nil || !strings.Contains(err.Error(), "not enabled") {
t.Errorf("Expected error about provider not enabled, got: %v", err)
}
// Case 2: Configured
mockProv := &mockProvider{
chatFunc: func(ctx context.Context, req providers.ChatRequest) (*providers.ChatResponse, error) {
if req.Model != "fast-model" {
return nil, nil // Should use patrol model
}
if req.ExecutionID != "patrol-run-123" {
t.Fatalf("execution_id=%q want patrol-run-123", req.ExecutionID)
}
return &providers.ChatResponse{
Content: "Analysis Result",
Model: "fast-model-response",
InputTokens: 12,
OutputTokens: 5,
}, nil
},
nameFunc: func() string { return "mock-provider" },
}
svc.provider = mockProv
svc.cfg = &config.AIConfig{
Enabled: true,
PatrolModel: "fast-model",
}
res, err := svc.QuickAnalysis(context.Background(), QuickAnalysisRequest{
Prompt: "Analysis prompt",
ExecutionID: "patrol-run-123",
UseCase: "patrol",
})
if err != nil {
t.Fatalf("QuickAnalysis failed: %v", err)
}
if res != "Analysis Result" {
t.Errorf("Unexpected result: %s", res)
}
events := svc.ListCostEvents(1)
if len(events) != 1 {
t.Fatalf("expected QuickAnalysis usage event, got %d", len(events))
}
if events[0].Provider == "" || events[0].UseCase != "patrol" || events[0].InputTokens != 12 || events[0].OutputTokens != 5 {
t.Fatalf("unexpected QuickAnalysis usage event: %+v", events[0])
}
// Case 3: Empty response
mockProv.chatFunc = func(ctx context.Context, req providers.ChatRequest) (*providers.ChatResponse, error) {
return &providers.ChatResponse{Content: ""}, nil
}
_, err = svc.QuickAnalysis(context.Background(), QuickAnalysisRequest{Prompt: "test"})
if err == nil {
t.Error("Expected error for empty response")
}
}
func TestService_QuickAnalysisSanitizesExternalModelRequest(t *testing.T) {
svc := NewService(nil, nil)
resource := unifiedresources.Resource{
ID: "agent/pve-secret",
Type: unifiedresources.ResourceTypeAgent,
Name: "pve-secret",
Tags: []string{"restricted"},
Identity: unifiedresources.ResourceIdentity{
Hostnames: []string{"pve-secret.lan"},
IPAddresses: []string{"10.0.0.5"},
},
}
svc.SetUnifiedResourceProvider(&mockUnifiedResourceProvider{
getAllFunc: func() []unifiedresources.Resource {
return []unifiedresources.Resource{resource}
},
})
var seen providers.ChatRequest
svc.provider = &mockProvider{
chatFunc: func(ctx context.Context, req providers.ChatRequest) (*providers.ChatResponse, error) {
seen = req
return &providers.ChatResponse{Content: "ok"}, nil
},
}
svc.cfg = &config.AIConfig{
Enabled: true,
PatrolModel: "openai:gpt-4o",
}
if _, err := svc.QuickAnalysis(context.Background(), QuickAnalysisRequest{
Prompt: "check pve-secret at pve-secret.lan and 10.0.0.5",
}); err != nil {
t.Fatalf("QuickAnalysis failed: %v", err)
}
combined := seen.Messages[0].Content + "\n" + seen.Messages[1].Content
for _, raw := range []string{"pve-secret", "pve-secret.lan", "10.0.0.5"} {
if strings.Contains(combined, raw) {
t.Fatalf("provider request still contains %q: %s", raw, combined)
}
}
}
func TestService_RecordIncidentRunbook(t *testing.T) {
svc := NewService(nil, nil)
canonicalStore := unifiedresources.NewMemoryStore()
svc.resourceExportStore = canonicalStore
svc.resourceExportStoreOrgID = svc.orgID
// Case 1: No store -> should safe return
svc.RecordIncidentRunbook("vm-1", "alert1", "rb1", "title", memory.OutcomeResolved, true, "msg")
// Case 2: Invalid inputs -> should safe return
svc.incidentStore = memory.NewIncidentStore(memory.IncidentStoreConfig{})
svc.RecordIncidentRunbook("vm-1", "", "rb1", "title", memory.OutcomeResolved, true, "msg")
svc.RecordIncidentRunbook("vm-1", "alert1", "", "title", memory.OutcomeResolved, true, "msg")
// Case 3: Valid
svc.RecordIncidentRunbook("vm-1", "alert1", "rb1", "title", memory.OutcomeResolved, true, "msg")
timeline := svc.incidentStore.GetTimelineByAlertIdentifier("alert1")
if timeline == nil {
t.Fatal("expected incident timeline to be created")
}
found := false
for _, ev := range timeline.Events {
if ev.Type != memory.IncidentEventRunbook {
continue
}
if ev.Details == nil {
continue
}
if ev.Details["runbook_id"] == "rb1" {
found = true
break
}
}
if !found {
t.Fatalf("expected runbook event to be recorded, got %d events", len(timeline.Events))
}
changes, err := canonicalStore.GetRecentChanges("vm-1", time.Time{}, 10)
if err != nil {
t.Fatalf("GetRecentChanges: %v", err)
}
if len(changes) == 0 {
t.Fatal("expected canonical runbook change")
}
if changes[0].Kind != unifiedresources.ChangeRunbookExecuted {
t.Fatalf("Kind = %q, want %q", changes[0].Kind, unifiedresources.ChangeRunbookExecuted)
}
}
func TestService_SetIncidentStore_AttachesCanonicalTimelineProjection(t *testing.T) {
svc := NewService(nil, nil)
canonicalStore := unifiedresources.NewMemoryStore()
svc.resourceExportStore = canonicalStore
svc.resourceExportStoreOrgID = svc.orgID
incidentStore := memory.NewIncidentStore(memory.IncidentStoreConfig{})
svc.SetIncidentStore(incidentStore)
alertStartedAt := time.Now().UTC().Add(-15 * time.Minute).Truncate(time.Second)
alert := &alerts.Alert{
ID: "alert-service-projected",
Type: "cpu",
Level: alerts.AlertLevelCritical,
ResourceID: "vm-service-projected",
ResourceName: "vm-service-projected",
StartTime: alertStartedAt,
Value: 94,
Threshold: 80,
}
incidentStore.RecordAlertFired(alert)
incidentStore.RecordAnalysis(alert.ID, "analysis", nil)
for _, change := range []*unifiedresources.ResourceChange{
unifiedresources.BuildAlertTimelineChange(alert.ResourceID, unifiedresources.ChangeAlertFired, alertStartedAt, "", unifiedresources.AlertTimelineChange{
AlertIdentifier: alert.ID,
AlertType: alert.Type,
AlertLevel: string(alert.Level),
AlertValue: alert.Value,
AlertThreshold: alert.Threshold,
}),
unifiedresources.BuildRunbookExecutionChange(alert.ResourceID, alert.ID, "agent:pulse-patrol", "rb-service", "Restart service", "resolved", true, "ok", nil),
} {
if err := canonicalStore.RecordChange(*change); err != nil {
t.Fatalf("RecordChange(%s): %v", change.ID, err)
}
}
timeline := incidentStore.GetTimelineByAlertIdentifier(alert.ID)
if timeline == nil {
t.Fatal("expected projected timeline")
}
foundRunbook := false
for _, event := range timeline.Events {
if event.Type == memory.IncidentEventRunbook {
foundRunbook = true
break
}
}
if !foundRunbook {
t.Fatal("expected canonical runbook event in projected timeline")
}
}
func TestAbsFloat(t *testing.T) {
tests := []struct {
input float64
expected float64
}{
{1.0, 1.0},
{-1.0, 1.0},
{0.0, 0.0},
{-0.0001, 0.0001},
}
for _, tt := range tests {
if got := absFloat(tt.input); got != tt.expected {
t.Errorf("absFloat(%f) = %f, want %f", tt.input, got, tt.expected)
}
}
}
func TestService_GetKnowledgeStore(t *testing.T) {
svc := NewService(nil, nil)
if svc.GetKnowledgeStore() != nil {
t.Error("Expected nil store initially")
}
// Set it indirectly? Or just set field via reflection/if exported
// knowledgeStore is unexported.
// But it is initialized in NewService? No, it's nil in tests usually.
// Creating a knowledge store is complex due to dependencies.
// But we can check the getter safely handles nil.
}