From d4463a615c4e2302cd470a3c74db1d5f33b093e0 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sun, 10 May 2026 21:23:12 +0100 Subject: [PATCH] Add fleet-level AI narrative for multi-resource reports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The single-resource AI narrative landed in b2bd9d114 but multi-resource fleet reports stayed heuristic-only. That left a gap on the exact axis where AI helps most: a 50-resource fleet PDF is where synthesis is the difference between useful and unread. Introduce FleetNarrator as a separate interface from Narrator. The input shapes are different — single-resource takes one set of metric stats with a prior window, fleet takes a denormalised cross-resource view with per-resource summaries plus a fleet aggregate. HeuristicFleetNarrator owns the deterministic fallback: ranks resources by severity (critical alerts > unhealthy disks > storage pressure > memory > CPU > non-critical alerts), picks up to 5 outliers, derives cross-cutting patterns by counting how many of N resources share a hot signal, and emits fleet-scoped recommendations. internal/ai.Service implements FleetNarrator through report_fleet_narrator.go. Distinct use-case label (report_narrative_fleet) so fleet vs single-resource spend is separable in the cost ledger and budget gate. The fleet payload is denormalised through buildReportFleetPayload so prompt cost scales linearly with fleet size. Same fail-closed invariant — nil provider, parse failure, or context cancellation falls through to the heuristic. Single-resource Narrator is intentionally NOT propagated through engine.GenerateMulti: a 50-resource fleet report performs one AI call (fleet narrator), not 51. The router resolver returns the AI service for all three roles (Narrator, FleetNarrator, FindingsProvider). The fleet PDF renders the FleetNarrative in the fleet summary cover when present: executive prose, named outliers with severity-coloured bullets, cross-cutting patterns, recommendations, optional period comparison, and an AI provenance footer. The deterministic resource summary table is preserved above so every named outlier is verifiable against the table immediately below it. Legacy "Highest CPU / Most alerts" bullets remain as the fallback when no FleetNarrative is attached. --- internal/ai/report_fleet_narrator.go | 331 ++++++++++++++ internal/ai/report_fleet_narrator_test.go | 138 ++++++ internal/api/metrics_reporting_handlers.go | 33 +- internal/api/router.go | 18 +- pkg/reporting/engine.go | 45 +- pkg/reporting/fleet_narrative.go | 488 +++++++++++++++++++++ pkg/reporting/fleet_narrative_test.go | 216 +++++++++ pkg/reporting/pdf.go | 108 ++++- pkg/reporting/reporting.go | 16 + 9 files changed, 1372 insertions(+), 21 deletions(-) create mode 100644 internal/ai/report_fleet_narrator.go create mode 100644 internal/ai/report_fleet_narrator_test.go create mode 100644 pkg/reporting/fleet_narrative.go create mode 100644 pkg/reporting/fleet_narrative_test.go 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.