Add fleet-level AI narrative for multi-resource reports

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.
This commit is contained in:
rcourtman 2026-05-10 21:23:12 +01:00
parent f9289b7c81
commit d4463a615c
9 changed files with 1372 additions and 21 deletions

View file

@ -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": "<one short sentence>",
"executive_summary": "<2-4 sentence paragraph framing the fleet's week>",
"outliers": [
{ "resource_id": "<id>", "resource_name": "<name>", "reason": "<one sentence>", "severity": "ok" | "info" | "warning" | "critical" }
],
"patterns": [
{ "text": "<one sentence cross-cutting pattern>", "severity": "ok" | "info" | "warning" | "critical" }
],
"recommendations": [ "<one sentence fleet-scoped imperative>" ],
"period_comparison": "<optional paragraph; empty string if no prior data>"
}`
// 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
}

View file

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

View file

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

View file

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

View file

@ -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) {

View file

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

View file

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

View file

@ -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

View file

@ -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.