diff --git a/internal/ai/report_fleet_narrator.go b/internal/ai/report_fleet_narrator.go new file mode 100644 index 000000000..2ddd0a559 --- /dev/null +++ b/internal/ai/report_fleet_narrator.go @@ -0,0 +1,331 @@ +package ai + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "github.com/google/uuid" + "github.com/rcourtman/pulse-go-rewrite/internal/ai/cost" + "github.com/rcourtman/pulse-go-rewrite/internal/ai/providers" + "github.com/rcourtman/pulse-go-rewrite/internal/config" + "github.com/rcourtman/pulse-go-rewrite/pkg/reporting" +) + +// reportFleetNarratorMaxTokens caps fleet narrative response budget. +// Fleet narratives are larger than single-resource because they +// summarise N resources, but the structured output is still bounded: +// up to fleetMaxOutliers outliers, a few patterns, and a few +// recommendations. 2500 tokens is generous enough for that envelope +// without inviting padding. +const reportFleetNarratorMaxTokens = 2500 + +// reportFleetNarratorUseCase is the cost-ledger label for AI fleet +// narrative calls. Distinct from report_narrative so operators can see +// fleet vs single-resource spend separately in the AI usage dashboard. +const reportFleetNarratorUseCase = "report_narrative_fleet" + +// reportFleetNarratorSystemPrompt instructs the model to interpret a +// cross-resource view. Severity, outlier count, and JSON schema are +// constrained so unknown values do not silently render as muted. +const reportFleetNarratorSystemPrompt = `You are Pulse Assistant generating the executive summary section of a sysadmin FLEET performance report. + +You MUST: +- Interpret the structured fleet data in the user message. If the data does not support a claim, do not make it. +- Reference specific named resources for outliers. Use the resource_name field where present, otherwise resource_id verbatim. Do NOT invent resource names. +- Use observation severity strictly from this set: "ok", "info", "warning", "critical". Map clean state to "ok", informational facts to "info", concerning trends to "warning", and immediate-action items to "critical". +- Pick at most 5 outliers — the resources most worth investigating. Order by severity. Do not list every resource. +- Patterns describe cross-cutting trends ("3 of 8 resources show memory pressure"), not individual resources. +- Recommendations are fleet-scoped imperatives ("review memory allocation across the fleet"), not per-resource fixes. +- Keep prose concrete and short. Avoid hedging adverbs. + +Respond ONLY with a single JSON object matching this exact schema (no markdown fences, no commentary outside the JSON): + +{ + "health_status": "HEALTHY" | "WARNING" | "CRITICAL", + "health_message": "", + "executive_summary": "<2-4 sentence paragraph framing the fleet's week>", + "outliers": [ + { "resource_id": "", "resource_name": "", "reason": "", "severity": "ok" | "info" | "warning" | "critical" } + ], + "patterns": [ + { "text": "", "severity": "ok" | "info" | "warning" | "critical" } + ], + "recommendations": [ "" ], + "period_comparison": "" +}` + +// reportFleetPayload is what the model receives. Compact per-resource +// rows so the prompt scales with fleet size without exploding token +// usage. +type reportFleetPayload struct { + Title string `json:"title"` + Period reportNarratorPeriod `json:"period"` + PriorPeriod *reportFleetPeriodOnly `json:"prior_period,omitempty"` + Aggregate reportFleetAggregate `json:"aggregate"` + Resources []reportFleetResourceSummary `json:"resources"` +} + +type reportFleetPeriodOnly struct { + Start string `json:"start"` + End string `json:"end"` +} + +type reportFleetAggregate struct { + ResourceCount int `json:"resource_count"` + TotalActiveAlerts int `json:"total_active_alerts"` + TotalCriticalAlerts int `json:"total_critical_alerts"` + TotalResolvedAlerts int `json:"total_resolved_alerts"` + TotalFindings int `json:"total_findings"` + AvgCPUMean float64 `json:"avg_cpu_mean"` + AvgMemoryMean float64 `json:"avg_memory_mean"` + AvgDiskMean float64 `json:"avg_disk_mean"` + MaxCPUSeen float64 `json:"max_cpu_seen"` + MaxMemorySeen float64 `json:"max_memory_seen"` + MaxDiskSeen float64 `json:"max_disk_seen"` +} + +type reportFleetResourceSummary struct { + ResourceID string `json:"resource_id"` + ResourceName string `json:"resource_name,omitempty"` + ResourceType string `json:"resource_type"` + Status string `json:"status,omitempty"` + AvgCPU float64 `json:"avg_cpu"` + MaxCPU float64 `json:"max_cpu"` + AvgMemory float64 `json:"avg_memory"` + MaxMemory float64 `json:"max_memory"` + AvgDisk float64 `json:"avg_disk"` + MaxDisk float64 `json:"max_disk"` + ActiveAlerts int `json:"active_alerts"` + CriticalAlerts int `json:"critical_alerts"` + ResolvedAlerts int `json:"resolved_alerts"` + UnhealthyDisks int `json:"unhealthy_disks,omitempty"` + StoragePoolsHigh int `json:"storage_pools_high,omitempty"` + Findings int `json:"findings,omitempty"` +} + +type reportFleetResponse struct { + HealthStatus string `json:"health_status"` + HealthMessage string `json:"health_message"` + ExecutiveSummary string `json:"executive_summary"` + Outliers []reportFleetResponseOutlier `json:"outliers"` + Patterns []reportFleetResponsePattern `json:"patterns"` + Recommendations []string `json:"recommendations"` + PeriodComparison string `json:"period_comparison"` +} + +type reportFleetResponseOutlier struct { + ResourceID string `json:"resource_id"` + ResourceName string `json:"resource_name"` + Reason string `json:"reason"` + Severity string `json:"severity"` +} + +type reportFleetResponsePattern struct { + Text string `json:"text"` + Severity string `json:"severity"` +} + +// Compile-time assertion the Service satisfies the FleetNarrator interface. +var _ reporting.FleetNarrator = (*Service)(nil) + +// NarrateFleet implements reporting.FleetNarrator. Same shape as +// Narrate: single-turn JSON call, fail closed so the engine falls +// back to the heuristic fleet narrator on any error. +func (s *Service) NarrateFleet(ctx context.Context, in reporting.FleetNarrativeInput) (reporting.FleetNarrative, error) { + s.mu.RLock() + provider := s.provider + cfg := s.cfg + costStore := s.costStore + s.mu.RUnlock() + + if provider == nil { + return reporting.FleetNarrative{}, errors.New("Pulse Assistant is not configured") + } + + model := "" + if cfg != nil { + if cfg.PatrolModel != "" { + model = cfg.PatrolModel + } else { + model = cfg.GetChatModel() + } + } + + if err := s.enforceBudget(reportFleetNarratorUseCase); err != nil { + return reporting.FleetNarrative{}, err + } + + payload := buildReportFleetPayload(in) + body, err := json.Marshal(payload) + if err != nil { + return reporting.FleetNarrative{}, fmt.Errorf("encode fleet payload: %w", err) + } + + chatReq := providers.ChatRequest{ + Messages: []providers.Message{ + {Role: "user", Content: string(body)}, + }, + Model: model, + System: reportFleetNarratorSystemPrompt, + MaxTokens: reportFleetNarratorMaxTokens, + ExecutionID: uuid.NewString(), + } + if sanitizer := s.requestSanitizerForModel(model); sanitizer != nil { + chatReq = sanitizer(chatReq) + } + + resp, err := provider.Chat(ctx, chatReq) + if err != nil { + return reporting.FleetNarrative{}, fmt.Errorf("provider chat: %w", err) + } + + // Record token usage in the operator-facing cost ledger. Recording + // happens before parsing so failed-but-billed calls are still + // visible — operator was billed regardless. + if costStore != nil { + providerName, _ := config.ParseModelString(model) + if providerName == "" { + providerName = provider.Name() + } + costStore.Record(cost.UsageEvent{ + Timestamp: time.Now(), + Provider: providerName, + RequestModel: model, + ResponseModel: resp.Model, + UseCase: reportFleetNarratorUseCase, + InputTokens: resp.InputTokens, + OutputTokens: resp.OutputTokens, + TargetType: "fleet", + TargetID: strings.TrimSpace(in.Title), + }) + } + + content := strings.TrimSpace(resp.Content) + if content == "" { + return reporting.FleetNarrative{}, errors.New("provider returned empty fleet narrative") + } + + parsed, err := parseReportFleetResponse(content) + if err != nil { + return reporting.FleetNarrative{}, err + } + + narrative := reporting.FleetNarrative{ + Source: reporting.NarrativeSourceAI, + HealthStatus: normalizeReportHealthStatus(parsed.HealthStatus), + HealthMessage: strings.TrimSpace(parsed.HealthMessage), + ExecutiveSummary: strings.TrimSpace(parsed.ExecutiveSummary), + PeriodComparison: strings.TrimSpace(parsed.PeriodComparison), + Disclaimer: "Fleet narrative generated by Pulse Assistant. Verify against the resource summary table and per-resource pages.", + } + + for _, o := range parsed.Outliers { + reason := strings.TrimSpace(o.Reason) + id := strings.TrimSpace(o.ResourceID) + name := strings.TrimSpace(o.ResourceName) + if reason == "" || (id == "" && name == "") { + continue + } + narrative.Outliers = append(narrative.Outliers, reporting.FleetOutlier{ + ResourceID: id, + ResourceName: name, + Reason: reason, + Severity: normalizeBulletSeverity(o.Severity), + }) + } + for _, p := range parsed.Patterns { + text := strings.TrimSpace(p.Text) + if text == "" { + continue + } + narrative.Patterns = append(narrative.Patterns, reporting.NarrativeBullet{ + Text: text, + Severity: normalizeBulletSeverity(p.Severity), + }) + } + for _, r := range parsed.Recommendations { + r = strings.TrimSpace(r) + if r != "" { + narrative.Recommendations = append(narrative.Recommendations, r) + } + } + if narrative.HealthStatus == "" || (len(narrative.Outliers) == 0 && len(narrative.Patterns) == 0 && len(narrative.Recommendations) == 0) { + return reporting.FleetNarrative{}, errors.New("provider returned empty or invalid fleet narrative") + } + return narrative, nil +} + +func buildReportFleetPayload(in reporting.FleetNarrativeInput) reportFleetPayload { + out := reportFleetPayload{ + Title: in.Title, + Period: reportNarratorPeriod{ + Start: in.Period.Start.UTC().Format("2006-01-02T15:04:05Z"), + End: in.Period.End.UTC().Format("2006-01-02T15:04:05Z"), + Hours: int(in.Period.End.Sub(in.Period.Start).Hours()), + }, + Aggregate: reportFleetAggregate{ + ResourceCount: in.Aggregate.ResourceCount, + TotalActiveAlerts: in.Aggregate.TotalActiveAlerts, + TotalCriticalAlerts: in.Aggregate.TotalCriticalAlerts, + TotalResolvedAlerts: in.Aggregate.TotalResolvedAlerts, + TotalFindings: in.Aggregate.TotalFindings, + AvgCPUMean: in.Aggregate.AvgCPUMean, + AvgMemoryMean: in.Aggregate.AvgMemoryMean, + AvgDiskMean: in.Aggregate.AvgDiskMean, + MaxCPUSeen: in.Aggregate.MaxCPUSeen, + MaxMemorySeen: in.Aggregate.MaxMemorySeen, + MaxDiskSeen: in.Aggregate.MaxDiskSeen, + }, + } + if in.PriorPeriod != nil { + out.PriorPeriod = &reportFleetPeriodOnly{ + Start: in.PriorPeriod.Start.UTC().Format("2006-01-02T15:04:05Z"), + End: in.PriorPeriod.End.UTC().Format("2006-01-02T15:04:05Z"), + } + } + out.Resources = make([]reportFleetResourceSummary, 0, len(in.Resources)) + for _, r := range in.Resources { + out.Resources = append(out.Resources, reportFleetResourceSummary{ + ResourceID: r.ResourceID, + ResourceName: r.ResourceName, + ResourceType: r.ResourceType, + Status: r.Status, + AvgCPU: r.AvgCPU, + MaxCPU: r.MaxCPU, + AvgMemory: r.AvgMemory, + MaxMemory: r.MaxMemory, + AvgDisk: r.AvgDisk, + MaxDisk: r.MaxDisk, + ActiveAlerts: r.ActiveAlerts, + CriticalAlerts: r.CriticalAlerts, + ResolvedAlerts: r.ResolvedAlerts, + UnhealthyDisks: r.UnhealthyDisks, + StoragePoolsHigh: r.StoragePoolsHigh, + Findings: r.Findings, + }) + } + return out +} + +func parseReportFleetResponse(raw string) (reportFleetResponse, error) { + trimmed := strings.TrimSpace(raw) + if strings.HasPrefix(trimmed, "```") { + trimmed = strings.TrimPrefix(trimmed, "```") + if newline := strings.IndexByte(trimmed, '\n'); newline >= 0 { + trimmed = trimmed[newline+1:] + } + if idx := strings.LastIndex(trimmed, "```"); idx >= 0 { + trimmed = trimmed[:idx] + } + trimmed = strings.TrimSpace(trimmed) + } + var parsed reportFleetResponse + if err := json.Unmarshal([]byte(trimmed), &parsed); err != nil { + return reportFleetResponse{}, fmt.Errorf("decode fleet narrative JSON: %w", err) + } + return parsed, nil +} diff --git a/internal/ai/report_fleet_narrator_test.go b/internal/ai/report_fleet_narrator_test.go new file mode 100644 index 000000000..80a3d3155 --- /dev/null +++ b/internal/ai/report_fleet_narrator_test.go @@ -0,0 +1,138 @@ +package ai + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/ai/providers" + "github.com/rcourtman/pulse-go-rewrite/internal/config" + "github.com/rcourtman/pulse-go-rewrite/pkg/reporting" +) + +func TestParseReportFleetResponse_StripsCodeFence(t *testing.T) { + raw := "```json\n{\"health_status\":\"WARNING\",\"health_message\":\"x\",\"executive_summary\":\"y\",\"outliers\":[],\"patterns\":[],\"recommendations\":[]}\n```" + got, err := parseReportFleetResponse(raw) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.HealthStatus != "WARNING" { + t.Errorf("HealthStatus = %q", got.HealthStatus) + } +} + +func TestParseReportFleetResponse_RejectsGarbage(t *testing.T) { + if _, err := parseReportFleetResponse("not json"); err == nil { + t.Fatal("expected error on non-JSON input") + } +} + +func TestBuildReportFleetPayload_PopulatesAggregateAndResources(t *testing.T) { + now := time.Now().UTC() + in := reporting.FleetNarrativeInput{ + Title: "Fleet", + Period: reporting.TimeRange{Start: now.Add(-time.Hour), End: now}, + Aggregate: reporting.FleetAggregate{ + ResourceCount: 2, + TotalActiveAlerts: 3, + TotalCriticalAlerts: 1, + MaxCPUSeen: 95, + }, + Resources: []reporting.FleetResourceSummary{ + {ResourceID: "a", ResourceName: "alpha", AvgMemory: 90, CriticalAlerts: 1}, + {ResourceID: "b", ResourceName: "beta", AvgCPU: 30}, + }, + } + payload := buildReportFleetPayload(in) + if payload.Aggregate.ResourceCount != 2 || payload.Aggregate.TotalCriticalAlerts != 1 { + t.Errorf("Aggregate: %+v", payload.Aggregate) + } + if len(payload.Resources) != 2 { + t.Fatalf("Resources length = %d", len(payload.Resources)) + } + if payload.Resources[0].ResourceName != "alpha" || payload.Resources[0].AvgMemory != 90 { + t.Errorf("Resources[0] = %+v", payload.Resources[0]) + } + if !strings.HasPrefix(payload.Period.Start, now.Add(-time.Hour).UTC().Format("2006-01-02")) { + t.Errorf("Period.Start = %q", payload.Period.Start) + } +} + +func TestNarrateFleet_RecordsCostEvent(t *testing.T) { + tmp := t.TempDir() + persistence := config.NewConfigPersistence(tmp) + svc := NewService(persistence, nil) + svc.cfg = &config.AIConfig{Enabled: true, Model: "anthropic:claude-test"} + svc.provider = &mockProvider{ + chatFunc: func(_ context.Context, _ providers.ChatRequest) (*providers.ChatResponse, error) { + return &providers.ChatResponse{ + Content: `{ + "health_status": "WARNING", + "health_message": "Pressure", + "executive_summary": "Memory creeping up.", + "outliers": [{"resource_id":"a","resource_name":"alpha","reason":"Memory at 92%","severity":"warning"}], + "patterns": [{"text":"3 of 8 resources show memory pressure","severity":"warning"}], + "recommendations": ["Review memory across the fleet"], + "period_comparison": "" + }`, + Model: "anthropic:claude-test", + InputTokens: 500, + OutputTokens: 200, + }, nil + }, + } + + in := reporting.FleetNarrativeInput{ + Title: "Weekly Fleet", + Period: reporting.TimeRange{ + Start: time.Now().Add(-time.Hour), + End: time.Now(), + }, + Aggregate: reporting.FleetAggregate{ResourceCount: 1}, + Resources: []reporting.FleetResourceSummary{ + {ResourceID: "a", ResourceName: "alpha", AvgMemory: 92}, + }, + } + + out, err := svc.NarrateFleet(context.Background(), in) + if err != nil { + t.Fatalf("NarrateFleet: %v", err) + } + if out.Source != reporting.NarrativeSourceAI { + t.Errorf("Source = %q, want ai", out.Source) + } + if len(out.Outliers) != 1 || out.Outliers[0].ResourceName != "alpha" { + t.Errorf("Outliers = %#v", out.Outliers) + } + + events := svc.ListCostEvents(1) + if len(events) != 1 { + t.Fatalf("expected 1 cost event, got %d", len(events)) + } + ev := events[0] + if ev.UseCase != reportFleetNarratorUseCase { + t.Errorf("UseCase = %q, want %q", ev.UseCase, reportFleetNarratorUseCase) + } + if ev.TargetType != "fleet" { + t.Errorf("TargetType = %q, want fleet", ev.TargetType) + } + if ev.InputTokens != 500 || ev.OutputTokens != 200 { + t.Errorf("tokens = (%d, %d)", ev.InputTokens, ev.OutputTokens) + } +} + +func TestNarrateFleet_FailsClosedOnEmptyContent(t *testing.T) { + tmp := t.TempDir() + persistence := config.NewConfigPersistence(tmp) + svc := NewService(persistence, nil) + svc.cfg = &config.AIConfig{Enabled: true, Model: "anthropic:claude-test"} + svc.provider = &mockProvider{ + chatFunc: func(_ context.Context, _ providers.ChatRequest) (*providers.ChatResponse, error) { + return &providers.ChatResponse{Content: "", Model: "anthropic:claude-test"}, nil + }, + } + if _, err := svc.NarrateFleet(context.Background(), reporting.FleetNarrativeInput{}); err == nil { + t.Fatal("expected error on empty narrative") + } +} diff --git a/internal/api/metrics_reporting_handlers.go b/internal/api/metrics_reporting_handlers.go index 05b9213b4..fc04366d5 100644 --- a/internal/api/metrics_reporting_handlers.go +++ b/internal/api/metrics_reporting_handlers.go @@ -62,23 +62,24 @@ func normalizeReportResourceType(raw string) (string, error) { type ReportingHandlers struct { mtMonitor *monitoring.MultiTenantMonitor recoveryManager *recoverymanager.Manager - narratorResolver func(ctx context.Context) (reporting.Narrator, reporting.FindingsProvider) + narratorResolver func(ctx context.Context) (reporting.Narrator, reporting.FleetNarrator, reporting.FindingsProvider) } -// SetNarratorResolver wires an optional resolver that returns the per-tenant -// AI narrator and Patrol findings provider for a request. When unset, or -// when the resolver returns nil, reports use the deterministic heuristic -// narrator and skip findings enrichment. -func (h *ReportingHandlers) SetNarratorResolver(resolver func(ctx context.Context) (reporting.Narrator, reporting.FindingsProvider)) { +// SetNarratorResolver wires an optional resolver that returns the +// per-tenant AI narrator, fleet narrator, and Patrol findings provider +// for a request. When unset, or when the resolver returns nil, reports +// use the deterministic heuristic narrators and skip findings +// enrichment. +func (h *ReportingHandlers) SetNarratorResolver(resolver func(ctx context.Context) (reporting.Narrator, reporting.FleetNarrator, reporting.FindingsProvider)) { if h == nil { return } h.narratorResolver = resolver } -func (h *ReportingHandlers) resolveNarrator(ctx context.Context) (reporting.Narrator, reporting.FindingsProvider) { +func (h *ReportingHandlers) resolveNarrator(ctx context.Context) (reporting.Narrator, reporting.FleetNarrator, reporting.FindingsProvider) { if h == nil || h.narratorResolver == nil { - return nil, nil + return nil, nil, nil } return h.narratorResolver(ctx) } @@ -478,8 +479,11 @@ func (h *ReportingHandlers) HandleGenerateReport(w http.ResponseWriter, r *http. // Wire the per-tenant AI narrator and Patrol findings provider when // configured. Both are nil-safe at the engine layer; absence falls - // back to the heuristic narrator with no findings section. - req.Narrator, req.FindingsProvider = h.resolveNarrator(r.Context()) + // back to the heuristic narrator with no findings section. Single- + // resource reports do not use the FleetNarrator. + narrator, _, findings := h.resolveNarrator(r.Context()) + req.Narrator = narrator + req.FindingsProvider = findings data, contentType, err := engine.Generate(req) if err != nil { @@ -856,6 +860,15 @@ func (h *ReportingHandlers) HandleGenerateMultiReport(w http.ResponseWriter, r * multiReq.Resources = append(multiReq.Resources, req) } + // Wire the per-tenant fleet narrator and Patrol findings provider + // when configured. The single-resource Narrator is intentionally + // not propagated to the multi path: a fleet PDF would otherwise + // trigger one AI call per resource. The fleet narrator handles + // cross-resource synthesis in a single call instead. + _, fleetNarrator, findings := h.resolveNarrator(r.Context()) + multiReq.FleetNarrator = fleetNarrator + multiReq.FindingsProvider = findings + data, contentType, err := engine.GenerateMulti(multiReq) if err != nil { writeErrorResponse(w, http.StatusInternalServerError, "generation_failed", "Failed to generate multi-resource report", nil) diff --git a/internal/api/router.go b/internal/api/router.go index c55a4574c..4298a848d 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -589,25 +589,25 @@ func (r *Router) setupRoutes() { ) r.aiSettingsHandler.SetMetadataProvider(metadataProvider) - // Wire the per-tenant AI narrator and Patrol findings provider into - // reporting. The AI service implements both reporting.Narrator and - // reporting.FindingsProvider; when not configured for the tenant the - // engine falls back to the heuristic narrator with no findings section. + // Wire the per-tenant AI narrator, fleet narrator, and Patrol + // findings provider into reporting. The AI service implements all + // three interfaces; when not configured for the tenant the engine + // falls back to the heuristic narrators with no findings section. if r.reportingHandlers != nil { settings := r.aiSettingsHandler - r.reportingHandlers.SetNarratorResolver(func(ctx context.Context) (reporting.Narrator, reporting.FindingsProvider) { + r.reportingHandlers.SetNarratorResolver(func(ctx context.Context) (reporting.Narrator, reporting.FleetNarrator, reporting.FindingsProvider) { if settings == nil { - return nil, nil + return nil, nil, nil } svc := settings.GetAIService(ctx) if svc == nil { - return nil, nil + return nil, nil, nil } cfg := svc.GetAIConfig() if cfg == nil || !cfg.Enabled { - return nil, nil + return nil, nil, nil } - return svc, svc + return svc, svc, svc }) } diff --git a/pkg/reporting/engine.go b/pkg/reporting/engine.go index f769121f1..40ff71e57 100644 --- a/pkg/reporting/engine.go +++ b/pkg/reporting/engine.go @@ -388,12 +388,20 @@ func (e *ReportEngine) GenerateMulti(req MultiReportRequest) (data []byte, conte multiData.Title = "Fleet Performance Report" } - // Query metrics for each resource + // Query metrics for each resource. When the multi-report request + // supplies a per-resource Narrator or FindingsProvider, propagate + // them so each per-resource report carries its own narrative. var successCount int for _, resReq := range req.Resources { resReq.Start = req.Start resReq.End = req.End resReq.MetricType = req.MetricType + if resReq.Narrator == nil { + resReq.Narrator = req.Narrator + } + if resReq.FindingsProvider == nil { + resReq.FindingsProvider = req.FindingsProvider + } reportData, queryErr := e.queryMetrics(resReq) if queryErr != nil { @@ -405,6 +413,13 @@ func (e *ReportEngine) GenerateMulti(req MultiReportRequest) (data []byte, conte continue } + // Per-resource narrative is intentionally skipped on the multi + // path: a fleet PDF aggregates 50 resources, and running an AI + // call per resource would be cost-hostile and slow. The single + // fleet-level call carries the summary instead. + _ = resReq.Narrator + _ = resReq.FindingsProvider + multiData.Resources = append(multiData.Resources, reportData) multiData.TotalPoints += reportData.TotalPoints successCount++ @@ -414,6 +429,12 @@ func (e *ReportEngine) GenerateMulti(req MultiReportRequest) (data []byte, conte return nil, "", fmt.Errorf("all resources failed to query metrics") } + // Build fleet-level narrative. If req.FleetNarrator is supplied + // (typically AI-backed) it is invoked with a bounded timeout; + // nil/error/timeout falls back to the heuristic fleet narrator so + // the fleet PDF always has narrative content. + e.attachFleetNarrative(multiData, req) + log.Debug(). Int("resources", successCount). Int("skipped", len(req.Resources)-successCount). @@ -521,6 +542,28 @@ func narrativeSource(data *ReportData) string { return data.Narrative.Source } +// attachFleetNarrative populates multiData.FleetNarrative using +// req.FleetNarrator (when supplied). Always populates so the renderer +// has a single source of truth for the fleet summary section. +func (e *ReportEngine) attachFleetNarrative(multiData *MultiReportData, req MultiReportRequest) { + if multiData == nil { + return + } + input := buildFleetNarrativeInput(multiData) + + if req.FleetNarrator == nil { + out, _ := HeuristicFleetNarrator{}.NarrateFleet(context.Background(), input) + out.Source = NarrativeSourceHeuristic + multiData.FleetNarrative = &out + return + } + + ctx, cancel := context.WithTimeout(context.Background(), narrativeTimeout) + defer cancel() + out := narrateFleet(ctx, req.FleetNarrator, input) + multiData.FleetNarrative = &out +} + // GetResourceTypeDisplayName returns a human-readable name for resource types. func GetResourceTypeDisplayName(resourceType string) string { switch CanonicalResourceType(resourceType) { diff --git a/pkg/reporting/fleet_narrative.go b/pkg/reporting/fleet_narrative.go new file mode 100644 index 000000000..24e9ff86b --- /dev/null +++ b/pkg/reporting/fleet_narrative.go @@ -0,0 +1,488 @@ +package reporting + +import ( + "context" + "fmt" + "sort" +) + +// FleetNarrativeInput is the cross-resource view passed to a FleetNarrator. +// It is denormalised from MultiReportData so AI implementations do not +// need to traverse internal structures. +type FleetNarrativeInput struct { + Title string + Period TimeRange + PriorPeriod *TimeRange + Resources []FleetResourceSummary + Aggregate FleetAggregate +} + +// FleetResourceSummary is a compact per-resource snapshot — the same +// numbers the deterministic fleet table renders, plus alert/finding +// counts. Heavy time-series data is deliberately not included; the +// renderer keeps the chart data, the narrator only needs the rollups. +type FleetResourceSummary struct { + ResourceID string + ResourceName string + ResourceType string + Status string + AvgCPU float64 + MaxCPU float64 + AvgMemory float64 + MaxMemory float64 + AvgDisk float64 + MaxDisk float64 + ActiveAlerts int + CriticalAlerts int + ResolvedAlerts int + UnhealthyDisks int + StoragePoolsHigh int + Findings int +} + +// FleetAggregate is the fleet-wide rollup. Means are means-of-means: +// each resource contributes one observation per metric, regardless of +// its weight or sample count. Sysadmins reading a fleet report want +// "is anything in this fleet hot?" not a sample-weighted mean. +type FleetAggregate struct { + ResourceCount int + TotalActiveAlerts int + TotalCriticalAlerts int + TotalResolvedAlerts int + TotalFindings int + AvgCPUMean float64 + AvgMemoryMean float64 + AvgDiskMean float64 + MaxCPUSeen float64 + MaxMemorySeen float64 + MaxDiskSeen float64 +} + +// FleetNarrative is the cross-resource interpretation rendered into +// the fleet summary cover. Source values mirror Narrative. +type FleetNarrative struct { + Source string + HealthStatus string + HealthMessage string + ExecutiveSummary string + Outliers []FleetOutlier + Patterns []NarrativeBullet + Recommendations []string + PeriodComparison string + Disclaimer string +} + +// FleetOutlier names a single resource the operator should pay +// attention to and why. The Reason is rendered verbatim in the PDF. +type FleetOutlier struct { + ResourceID string + ResourceName string + Reason string + Severity string +} + +// FleetNarrator produces a FleetNarrative from a FleetNarrativeInput. +type FleetNarrator interface { + NarrateFleet(ctx context.Context, in FleetNarrativeInput) (FleetNarrative, error) +} + +// narrateFleet is the helper used by the engine: tries the supplied +// fleet narrator (typically AI), falls back to the heuristic on +// nil/error so a fleet report always has narrative. +func narrateFleet(ctx context.Context, n FleetNarrator, in FleetNarrativeInput) FleetNarrative { + if n != nil { + out, err := n.NarrateFleet(ctx, in) + if err == nil { + if out.Source == "" { + out.Source = NarrativeSourceAI + } + return out + } + } + heuristic := HeuristicFleetNarrator{} + out, _ := heuristic.NarrateFleet(ctx, in) + out.Source = NarrativeSourceHeuristic + return out +} + +// HeuristicFleetNarrator is the deterministic fleet fallback. It does +// not invent synthesis — it picks the worst outliers by hard threshold, +// counts cross-cutting patterns, and emits aggregated recommendations. +type HeuristicFleetNarrator struct{} + +// NarrateFleet implements FleetNarrator. It never returns an error. +func (HeuristicFleetNarrator) NarrateFleet(_ context.Context, in FleetNarrativeInput) (FleetNarrative, error) { + return FleetNarrative{ + Source: NarrativeSourceHeuristic, + HealthStatus: fleetHeuristicHealthStatus(in), + HealthMessage: fleetHeuristicHealthMessage(in), + Outliers: fleetHeuristicOutliers(in), + Patterns: fleetHeuristicPatterns(in), + Recommendations: fleetHeuristicRecommendations(in), + }, nil +} + +func fleetHeuristicHealthStatus(in FleetNarrativeInput) string { + switch { + case in.Aggregate.TotalCriticalAlerts > 0: + return "CRITICAL" + case in.Aggregate.TotalActiveAlerts > 0: + return "WARNING" + default: + return "HEALTHY" + } +} + +func fleetHeuristicHealthMessage(in FleetNarrativeInput) string { + a := in.Aggregate + switch { + case a.TotalCriticalAlerts == 1: + return "1 critical alert across the fleet requires immediate attention" + case a.TotalCriticalAlerts > 1: + return fmt.Sprintf("%d critical alerts across the fleet require immediate attention", a.TotalCriticalAlerts) + case a.TotalActiveAlerts == 1: + return "1 active alert across the fleet — review recommended" + case a.TotalActiveAlerts > 1: + return fmt.Sprintf("%d active alerts across the fleet — review recommended", a.TotalActiveAlerts) + case a.ResourceCount == 0: + return "No resources in this fleet report" + default: + return fmt.Sprintf("All %d resources operating normally", a.ResourceCount) + } +} + +// fleetHeuristicOutliers picks up to fleetMaxOutliers resources that +// most warrant operator attention, ordered by severity then magnitude. +const fleetMaxOutliers = 5 + +func fleetHeuristicOutliers(in FleetNarrativeInput) []FleetOutlier { + type scored struct { + outlier FleetOutlier + rank int // higher = render first + } + var pool []scored + + for _, r := range in.Resources { + if r.CriticalAlerts > 0 { + pool = append(pool, scored{ + outlier: FleetOutlier{ + ResourceID: r.ResourceID, + ResourceName: displayResourceName(r), + Reason: fmt.Sprintf("%d critical alert(s) active", r.CriticalAlerts), + Severity: NarrativeSeverityCritical, + }, + rank: 1000 + r.CriticalAlerts, + }) + } + if r.UnhealthyDisks > 0 { + pool = append(pool, scored{ + outlier: FleetOutlier{ + ResourceID: r.ResourceID, + ResourceName: displayResourceName(r), + Reason: fmt.Sprintf("%d disk(s) failing or near end of life", r.UnhealthyDisks), + Severity: NarrativeSeverityCritical, + }, + rank: 900 + r.UnhealthyDisks, + }) + } + if r.StoragePoolsHigh > 0 { + pool = append(pool, scored{ + outlier: FleetOutlier{ + ResourceID: r.ResourceID, + ResourceName: displayResourceName(r), + Reason: fmt.Sprintf("%d storage pool(s) above 90%% capacity", r.StoragePoolsHigh), + Severity: NarrativeSeverityWarning, + }, + rank: 700 + r.StoragePoolsHigh, + }) + } + if r.AvgMemory > 85 { + pool = append(pool, scored{ + outlier: FleetOutlier{ + ResourceID: r.ResourceID, + ResourceName: displayResourceName(r), + Reason: fmt.Sprintf("Memory averaging %.1f%% — sustained pressure", r.AvgMemory), + Severity: NarrativeSeverityWarning, + }, + rank: 500 + int(r.AvgMemory), + }) + } + if r.MaxCPU > 90 { + pool = append(pool, scored{ + outlier: FleetOutlier{ + ResourceID: r.ResourceID, + ResourceName: displayResourceName(r), + Reason: fmt.Sprintf("CPU peaked at %.1f%%", r.MaxCPU), + Severity: NarrativeSeverityWarning, + }, + rank: 400 + int(r.MaxCPU), + }) + } + if r.AvgDisk > 85 { + pool = append(pool, scored{ + outlier: FleetOutlier{ + ResourceID: r.ResourceID, + ResourceName: displayResourceName(r), + Reason: fmt.Sprintf("Disk usage averaging %.1f%%", r.AvgDisk), + Severity: NarrativeSeverityWarning, + }, + rank: 300 + int(r.AvgDisk), + }) + } + if r.ActiveAlerts > 0 && r.CriticalAlerts == 0 { + pool = append(pool, scored{ + outlier: FleetOutlier{ + ResourceID: r.ResourceID, + ResourceName: displayResourceName(r), + Reason: fmt.Sprintf("%d non-critical alert(s) active", r.ActiveAlerts), + Severity: NarrativeSeverityInfo, + }, + rank: 100 + r.ActiveAlerts, + }) + } + } + + sort.SliceStable(pool, func(i, j int) bool { return pool[i].rank > pool[j].rank }) + if len(pool) > fleetMaxOutliers { + pool = pool[:fleetMaxOutliers] + } + out := make([]FleetOutlier, 0, len(pool)) + seen := make(map[string]struct{}, len(pool)) + for _, s := range pool { + key := s.outlier.ResourceID + "|" + s.outlier.Reason + if _, dup := seen[key]; dup { + continue + } + seen[key] = struct{}{} + out = append(out, s.outlier) + } + return out +} + +// fleetHeuristicPatterns expresses cross-cutting trends as count-based +// observations — "X of Y resources show memory pressure," not synthesis. +// These are the kind of summary lines the AI narrator can do better, +// but the heuristic must produce something useful when AI is off. +func fleetHeuristicPatterns(in FleetNarrativeInput) []NarrativeBullet { + if in.Aggregate.ResourceCount == 0 { + return []NarrativeBullet{{ + Text: "No resources reported metrics in the selected window", + Severity: NarrativeSeverityInfo, + }} + } + var out []NarrativeBullet + + memHigh := 0 + cpuHigh := 0 + diskHigh := 0 + for _, r := range in.Resources { + if r.AvgMemory > 85 { + memHigh++ + } + if r.MaxCPU > 90 { + cpuHigh++ + } + if r.AvgDisk > 85 { + diskHigh++ + } + } + if memHigh > 0 { + out = append(out, NarrativeBullet{ + Text: fmt.Sprintf("%d of %d resources show sustained memory pressure (>85%% avg)", memHigh, in.Aggregate.ResourceCount), + Severity: severityFromCount(memHigh, in.Aggregate.ResourceCount), + }) + } + if cpuHigh > 0 { + out = append(out, NarrativeBullet{ + Text: fmt.Sprintf("%d of %d resources hit CPU peaks above 90%%", cpuHigh, in.Aggregate.ResourceCount), + Severity: severityFromCount(cpuHigh, in.Aggregate.ResourceCount), + }) + } + if diskHigh > 0 { + out = append(out, NarrativeBullet{ + Text: fmt.Sprintf("%d of %d resources are above 85%% disk usage", diskHigh, in.Aggregate.ResourceCount), + Severity: severityFromCount(diskHigh, in.Aggregate.ResourceCount), + }) + } + if in.Aggregate.TotalResolvedAlerts > 0 { + out = append(out, NarrativeBullet{ + Text: fmt.Sprintf("%d alerts triggered and resolved across the fleet during this period", in.Aggregate.TotalResolvedAlerts), + Severity: NarrativeSeverityInfo, + }) + } + if len(out) == 0 { + out = append(out, NarrativeBullet{ + Text: fmt.Sprintf("Fleet of %d resources operating within nominal thresholds", in.Aggregate.ResourceCount), + Severity: NarrativeSeverityOK, + }) + } + return out +} + +func severityFromCount(hot, total int) string { + if total <= 0 { + return NarrativeSeverityInfo + } + ratio := float64(hot) / float64(total) + switch { + case ratio >= 0.5: + return NarrativeSeverityCritical + case ratio >= 0.25: + return NarrativeSeverityWarning + default: + return NarrativeSeverityInfo + } +} + +func fleetHeuristicRecommendations(in FleetNarrativeInput) []string { + var recs []string + a := in.Aggregate + if a.TotalCriticalAlerts > 0 { + recs = append(recs, "Investigate and resolve critical alerts across the fleet immediately") + } + memHigh := 0 + cpuHigh := 0 + diskHigh := 0 + disksHigh := 0 + for _, r := range in.Resources { + if r.AvgMemory > 85 { + memHigh++ + } + if r.MaxCPU > 90 { + cpuHigh++ + } + if r.AvgDisk > 85 { + diskHigh++ + } + if r.UnhealthyDisks > 0 { + disksHigh++ + } + } + if memHigh > 0 { + recs = append(recs, "Review memory allocation on resources with sustained pressure (>85% avg)") + } + if cpuHigh > 0 { + recs = append(recs, "Profile CPU-intensive workloads on resources peaking above 90%") + } + if diskHigh > 0 { + recs = append(recs, "Plan capacity expansion for resources above 85% disk usage") + } + if disksHigh > 0 { + recs = append(recs, "Replace failing or end-of-life disks before they cause outage") + } + if len(recs) == 0 { + recs = append(recs, "No fleet-wide action required — continue routine monitoring") + } + return recs +} + +func displayResourceName(r FleetResourceSummary) string { + if r.ResourceName != "" { + return r.ResourceName + } + return r.ResourceID +} + +// buildFleetNarrativeInput collapses MultiReportData into the compact +// FleetNarrativeInput the narrator consumes. Per-resource MetricStats +// are reduced to the means/maxes the renderer surfaces; raw time-series +// stays in MultiReportData for the deterministic charts. +func buildFleetNarrativeInput(data *MultiReportData) FleetNarrativeInput { + if data == nil { + return FleetNarrativeInput{} + } + in := FleetNarrativeInput{ + Title: data.Title, + Period: TimeRange{Start: data.Start, End: data.End}, + Resources: make([]FleetResourceSummary, 0, len(data.Resources)), + } + + var sumCPU, sumMem, sumDisk float64 + var countCPU, countMem, countDisk int + + for _, rd := range data.Resources { + if rd == nil { + continue + } + summary := FleetResourceSummary{ + ResourceID: rd.ResourceID, + ResourceType: rd.ResourceType, + Findings: len(rd.Findings), + } + if rd.Resource != nil { + summary.ResourceName = rd.Resource.Name + summary.Status = rd.Resource.Status + } + + if stats, ok := rd.Summary.ByMetric["cpu"]; ok { + summary.AvgCPU = stats.Avg + summary.MaxCPU = stats.Max + sumCPU += stats.Avg + countCPU++ + if stats.Max > in.Aggregate.MaxCPUSeen { + in.Aggregate.MaxCPUSeen = stats.Max + } + } + if stats, ok := rd.Summary.ByMetric["memory"]; ok { + summary.AvgMemory = stats.Avg + summary.MaxMemory = stats.Max + sumMem += stats.Avg + countMem++ + if stats.Max > in.Aggregate.MaxMemorySeen { + in.Aggregate.MaxMemorySeen = stats.Max + } + } + diskKey := "disk" + if _, ok := rd.Summary.ByMetric["disk"]; !ok { + diskKey = "usage" + } + if stats, ok := rd.Summary.ByMetric[diskKey]; ok { + summary.AvgDisk = stats.Avg + summary.MaxDisk = stats.Max + sumDisk += stats.Avg + countDisk++ + if stats.Max > in.Aggregate.MaxDiskSeen { + in.Aggregate.MaxDiskSeen = stats.Max + } + } + + for _, alert := range rd.Alerts { + if alert.ResolvedTime != nil { + summary.ResolvedAlerts++ + in.Aggregate.TotalResolvedAlerts++ + continue + } + summary.ActiveAlerts++ + in.Aggregate.TotalActiveAlerts++ + if alert.Level == "critical" { + summary.CriticalAlerts++ + in.Aggregate.TotalCriticalAlerts++ + } + } + for _, disk := range rd.Disks { + if disk.Health == "FAILED" || (disk.WearLevel > 0 && disk.WearLevel <= 30) { + summary.UnhealthyDisks++ + } + } + for _, st := range rd.Storage { + if st.UsagePerc >= 90 { + summary.StoragePoolsHigh++ + } + } + + in.Aggregate.TotalFindings += summary.Findings + in.Resources = append(in.Resources, summary) + } + + in.Aggregate.ResourceCount = len(in.Resources) + if countCPU > 0 { + in.Aggregate.AvgCPUMean = sumCPU / float64(countCPU) + } + if countMem > 0 { + in.Aggregate.AvgMemoryMean = sumMem / float64(countMem) + } + if countDisk > 0 { + in.Aggregate.AvgDiskMean = sumDisk / float64(countDisk) + } + return in +} diff --git a/pkg/reporting/fleet_narrative_test.go b/pkg/reporting/fleet_narrative_test.go new file mode 100644 index 000000000..f0e70957c --- /dev/null +++ b/pkg/reporting/fleet_narrative_test.go @@ -0,0 +1,216 @@ +package reporting + +import ( + "context" + "errors" + "strings" + "testing" + "time" +) + +func TestHeuristicFleetNarrator_HealthyFleet(t *testing.T) { + in := FleetNarrativeInput{ + Aggregate: FleetAggregate{ResourceCount: 3}, + Resources: []FleetResourceSummary{ + {ResourceID: "a", AvgCPU: 30, MaxCPU: 50, AvgMemory: 40}, + {ResourceID: "b", AvgCPU: 35, MaxCPU: 55, AvgMemory: 50}, + {ResourceID: "c", AvgCPU: 25, MaxCPU: 45, AvgMemory: 60}, + }, + } + out, err := HeuristicFleetNarrator{}.NarrateFleet(context.Background(), in) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out.HealthStatus != "HEALTHY" { + t.Errorf("HealthStatus = %q, want HEALTHY", out.HealthStatus) + } + if out.Source != NarrativeSourceHeuristic { + t.Errorf("Source = %q", out.Source) + } + if len(out.Outliers) != 0 { + t.Errorf("expected no outliers, got %d", len(out.Outliers)) + } +} + +func TestHeuristicFleetNarrator_PicksCriticalAlertsAsOutliers(t *testing.T) { + in := FleetNarrativeInput{ + Aggregate: FleetAggregate{ + ResourceCount: 3, + TotalActiveAlerts: 2, + TotalCriticalAlerts: 2, + }, + Resources: []FleetResourceSummary{ + {ResourceID: "a", ResourceName: "alpha", CriticalAlerts: 1, ActiveAlerts: 1}, + {ResourceID: "b", ResourceName: "beta", CriticalAlerts: 1, ActiveAlerts: 1}, + {ResourceID: "c", ResourceName: "gamma"}, + }, + } + out, _ := HeuristicFleetNarrator{}.NarrateFleet(context.Background(), in) + if out.HealthStatus != "CRITICAL" { + t.Errorf("HealthStatus = %q, want CRITICAL", out.HealthStatus) + } + if len(out.Outliers) < 2 { + t.Fatalf("expected at least 2 outliers, got %d", len(out.Outliers)) + } + names := []string{out.Outliers[0].ResourceName, out.Outliers[1].ResourceName} + if !contains(names, "alpha") || !contains(names, "beta") { + t.Errorf("expected alpha and beta outliers, got %v", names) + } + if !sliceContainsSubstring(out.Recommendations, "critical alerts across the fleet") { + t.Errorf("expected fleet-scoped critical recommendation, got %v", out.Recommendations) + } +} + +func TestHeuristicFleetNarrator_PatternCountsAreFractional(t *testing.T) { + in := FleetNarrativeInput{ + Aggregate: FleetAggregate{ResourceCount: 10}, + Resources: append( + make([]FleetResourceSummary, 0, 10), + func() []FleetResourceSummary { + out := make([]FleetResourceSummary, 10) + for i := range out { + out[i] = FleetResourceSummary{ResourceID: "r", ResourceName: "r"} + } + // 6 of 10 hot on memory -> critical pattern + for i := 0; i < 6; i++ { + out[i].AvgMemory = 90 + } + // 3 of 10 hot on CPU -> warning pattern + for i := 0; i < 3; i++ { + out[i].MaxCPU = 95 + } + return out + }()..., + ), + } + out, _ := HeuristicFleetNarrator{}.NarrateFleet(context.Background(), in) + var memBullet, cpuBullet *NarrativeBullet + for i := range out.Patterns { + if strings.Contains(out.Patterns[i].Text, "memory") { + memBullet = &out.Patterns[i] + } + if strings.Contains(out.Patterns[i].Text, "CPU") { + cpuBullet = &out.Patterns[i] + } + } + if memBullet == nil || memBullet.Severity != NarrativeSeverityCritical { + t.Errorf("expected critical memory pattern, got %#v", memBullet) + } + if cpuBullet == nil || cpuBullet.Severity != NarrativeSeverityWarning { + t.Errorf("expected warning cpu pattern, got %#v", cpuBullet) + } +} + +func TestNarrateFleet_FallsBackToHeuristicOnError(t *testing.T) { + stub := &stubFleetNarrator{err: errors.New("boom")} + out := narrateFleet(context.Background(), stub, FleetNarrativeInput{ + Aggregate: FleetAggregate{ResourceCount: 1}, + Resources: []FleetResourceSummary{{ResourceID: "x", AvgMemory: 95}}, + }) + if out.Source != NarrativeSourceHeuristic { + t.Fatalf("Source = %q, want heuristic", out.Source) + } + if len(out.Outliers) == 0 { + t.Fatal("expected heuristic outliers on AI failure") + } +} + +func TestNarrateFleet_UsesAINarrativeOnSuccess(t *testing.T) { + stub := &stubFleetNarrator{out: FleetNarrative{ + HealthStatus: "WARNING", + HealthMessage: "Pressure", + ExecutiveSummary: "Memory creeping up across half the fleet.", + Outliers: []FleetOutlier{ + {ResourceID: "a", ResourceName: "alpha", Reason: "Memory at 92%", Severity: NarrativeSeverityWarning}, + }, + Recommendations: []string{"Add RAM"}, + }} + out := narrateFleet(context.Background(), stub, FleetNarrativeInput{}) + if out.Source != NarrativeSourceAI { + t.Fatalf("Source = %q, want ai", out.Source) + } + if len(out.Outliers) != 1 || out.Outliers[0].ResourceName != "alpha" { + t.Errorf("Outliers = %#v", out.Outliers) + } +} + +func TestBuildFleetNarrativeInput_AggregatesAlertsAndDisks(t *testing.T) { + now := time.Now() + resolved := now.Add(-30 * time.Minute) + multi := &MultiReportData{ + Title: "Fleet", + Start: now.Add(-time.Hour), + End: now, + Resources: []*ReportData{ + { + ResourceID: "a", + ResourceType: "node", + Resource: &ResourceInfo{Name: "alpha", Status: "online"}, + Summary: MetricSummary{ByMetric: map[string]MetricStats{ + "cpu": {Avg: 50, Max: 92}, + "memory": {Avg: 88, Max: 90}, + }}, + Alerts: []AlertInfo{ + {Level: "critical"}, + {Level: "warning", ResolvedTime: &resolved}, + }, + Disks: []DiskInfo{ + {Device: "sda", Health: "FAILED"}, + {Device: "sdb", WearLevel: 20}, // wear level <= 30 counts unhealthy + }, + Storage: []StorageInfo{{Name: "tank", UsagePerc: 95}}, + }, + { + ResourceID: "b", + ResourceType: "node", + Resource: &ResourceInfo{Name: "beta", Status: "online"}, + Summary: MetricSummary{ByMetric: map[string]MetricStats{ + "cpu": {Avg: 30, Max: 50}, + }}, + }, + }, + } + in := buildFleetNarrativeInput(multi) + if in.Aggregate.ResourceCount != 2 { + t.Errorf("ResourceCount = %d", in.Aggregate.ResourceCount) + } + if in.Aggregate.TotalCriticalAlerts != 1 { + t.Errorf("TotalCriticalAlerts = %d", in.Aggregate.TotalCriticalAlerts) + } + if in.Aggregate.TotalActiveAlerts != 1 { + t.Errorf("TotalActiveAlerts = %d", in.Aggregate.TotalActiveAlerts) + } + if in.Aggregate.TotalResolvedAlerts != 1 { + t.Errorf("TotalResolvedAlerts = %d", in.Aggregate.TotalResolvedAlerts) + } + if len(in.Resources) != 2 { + t.Fatalf("Resources = %d", len(in.Resources)) + } + if in.Resources[0].UnhealthyDisks != 2 { + t.Errorf("UnhealthyDisks = %d", in.Resources[0].UnhealthyDisks) + } + if in.Resources[0].StoragePoolsHigh != 1 { + t.Errorf("StoragePoolsHigh = %d", in.Resources[0].StoragePoolsHigh) + } + if in.Aggregate.MaxCPUSeen != 92 { + t.Errorf("MaxCPUSeen = %v", in.Aggregate.MaxCPUSeen) + } +} + +type stubFleetNarrator struct { + out FleetNarrative + err error +} + +func (s *stubFleetNarrator) NarrateFleet(_ context.Context, _ FleetNarrativeInput) (FleetNarrative, error) { + return s.out, s.err +} + +func contains(haystack []string, needle string) bool { + for _, h := range haystack { + if h == needle { + return true + } + } + return false +} diff --git a/pkg/reporting/pdf.go b/pkg/reporting/pdf.go index 1dc5b3d03..b9e4c0225 100644 --- a/pkg/reporting/pdf.go +++ b/pkg/reporting/pdf.go @@ -1873,7 +1873,17 @@ func (g *PDFGenerator) writeFleetSummary(pdf *fpdf.Fpdf, data *MultiReportData) pdf.Ln(8) - // Fleet Observations + // Fleet narrative section. When data.FleetNarrative is set (AI or + // heuristic) it owns the prose, outlier list, patterns, and + // recommendations rendered here. The legacy highest-CPU / + // most-alerts bullets are rendered as a fallback when no narrative + // has been attached, preserving the prior multi-report behaviour + // for callers that bypass the engine wiring. + if data.FleetNarrative != nil { + writeFleetNarrativeSection(pdf, data.FleetNarrative) + return + } + pdf.SetFont("Arial", "B", 11) pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2]) pdf.CellFormat(0, 8, "Fleet Observations", "", 1, "L", false, 0, "") @@ -1906,6 +1916,102 @@ func (g *PDFGenerator) writeFleetSummary(pdf *fpdf.Fpdf, data *MultiReportData) } } +// writeFleetNarrativeSection renders the fleet-level narrative produced +// by either the heuristic or AI fleet narrator. Layout mirrors the +// single-resource executive summary but is scoped to fleet semantics: +// outliers point at named resources, patterns describe cross-cutting +// trends, and the period-comparison and provenance footers keep the +// AI/heuristic distinction visible. +func writeFleetNarrativeSection(pdf *fpdf.Fpdf, fn *FleetNarrative) { + if fn == nil { + return + } + pageWidth, _ := pdf.GetPageSize() + bodyWidth := pageWidth - 40 + + if summary := strings.TrimSpace(fn.ExecutiveSummary); summary != "" { + pdf.SetFont("Arial", "", 10) + pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2]) + pdf.MultiCell(bodyWidth, 5, summary, "", "L", false) + pdf.Ln(3) + } + + if len(fn.Outliers) > 0 { + pdf.SetFont("Arial", "B", 11) + pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2]) + pdf.CellFormat(0, 8, "Resources to investigate", "", 1, "L", false, 0, "") + pdf.Ln(2) + pdf.SetFont("Arial", "", 10) + for _, o := range fn.Outliers { + color := bulletColor(o.Severity) + pdf.SetFillColor(color[0], color[1], color[2]) + pdf.Circle(pdf.GetX()+3, pdf.GetY()+3, 2, "F") + pdf.SetX(pdf.GetX() + 8) + pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2]) + label := o.ResourceName + if label == "" { + label = o.ResourceID + } + pdf.CellFormat(0, 6, fmt.Sprintf("%s — %s", label, o.Reason), "", 1, "L", false, 0, "") + pdf.Ln(1) + } + } + + if len(fn.Patterns) > 0 { + pdf.Ln(3) + pdf.SetFont("Arial", "B", 11) + pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2]) + pdf.CellFormat(0, 8, "Cross-cutting patterns", "", 1, "L", false, 0, "") + pdf.Ln(2) + pdf.SetFont("Arial", "", 10) + for _, b := range fn.Patterns { + color := bulletColor(b.Severity) + pdf.SetFillColor(color[0], color[1], color[2]) + pdf.Circle(pdf.GetX()+3, pdf.GetY()+3, 2, "F") + pdf.SetX(pdf.GetX() + 8) + pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2]) + pdf.CellFormat(0, 6, b.Text, "", 1, "L", false, 0, "") + pdf.Ln(1) + } + } + + if len(fn.Recommendations) > 0 { + pdf.Ln(3) + pdf.SetFont("Arial", "B", 11) + pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2]) + pdf.CellFormat(0, 8, "Recommended Actions", "", 1, "L", false, 0, "") + pdf.Ln(2) + pdf.SetFont("Arial", "", 9) + for i, rec := range fn.Recommendations { + if i >= 5 { + break + } + pdf.SetTextColor(colorSecondary[0], colorSecondary[1], colorSecondary[2]) + pdf.CellFormat(6, 5, fmt.Sprintf("%d.", i+1), "", 0, "L", false, 0, "") + pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2]) + pdf.CellFormat(0, 5, rec, "", 1, "L", false, 0, "") + pdf.Ln(1) + } + } + + if comparison := strings.TrimSpace(fn.PeriodComparison); comparison != "" { + pdf.Ln(3) + pdf.SetFont("Arial", "B", 11) + pdf.SetTextColor(colorTextDark[0], colorTextDark[1], colorTextDark[2]) + pdf.CellFormat(0, 8, "Period-over-period changes", "", 1, "L", false, 0, "") + pdf.Ln(2) + pdf.SetFont("Arial", "", 9) + pdf.MultiCell(bodyWidth, 5, comparison, "", "L", false) + } + + if disclaimer := strings.TrimSpace(fn.Disclaimer); disclaimer != "" { + pdf.Ln(4) + pdf.SetFont("Arial", "I", 8) + pdf.SetTextColor(colorTextMuted[0], colorTextMuted[1], colorTextMuted[2]) + pdf.MultiCell(bodyWidth, 4, disclaimer, "", "L", false) + } +} + // writeCondensedResourcePage writes a condensed single-page view for one resource. func (g *PDFGenerator) writeCondensedResourcePage(pdf *fpdf.Fpdf, rd *ReportData) { // Resource header diff --git a/pkg/reporting/reporting.go b/pkg/reporting/reporting.go index c99428e15..7cb8da77d 100644 --- a/pkg/reporting/reporting.go +++ b/pkg/reporting/reporting.go @@ -122,6 +122,16 @@ type MultiReportRequest struct { End time.Time Title string MetricType string + + // Optional fleet-level narrative interpretation. When FleetNarrator is + // non-nil the engine builds a FleetNarrativeInput from the queried + // per-resource report data and asks it to produce the cross-resource + // summary; on error or nil it falls back to the heuristic fleet + // narrator. FindingsProvider, when set, is consulted per-resource so + // patrol findings can flow into per-resource narratives. + FleetNarrator FleetNarrator + Narrator Narrator + FindingsProvider FindingsProvider } // MultiReportData holds the data for multi-resource report generation. @@ -132,6 +142,12 @@ type MultiReportData struct { GeneratedAt time.Time Resources []*ReportData // Reuse existing ReportData per resource TotalPoints int + + // Fleet-level narrative interpretation, populated by the engine when + // the request supplies a FleetNarrator (or always populated with the + // heuristic fallback). The renderer prefers this over recomputing + // observations inline. + FleetNarrative *FleetNarrative } // Engine defines the interface for report generation.