mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-10 00:14:38 +00:00
Found by exercising pulse_summarize in a real chat session. The
chat-tool response surfaced:
"observations": [{"Text": "...", "Severity": "info"}]
The AI narrator's system prompt (report_narrator.go) tells the
model to emit lowercase keys:
{"text": "...", "severity": "..."}
The model was being taught one schema and shown a different one
in the tool response for the same shape. NarrativeBullet,
FleetOutlier, Narrative, and FleetNarrative had no JSON tags, so
embedded struct fields serialised with their Go names.
Add struct tags so the wire shape matches the prompt schema. Pure
marshaling change — JSON tags don't affect Go field access, so
PDF rendering (which reads fields directly) is unchanged. Tests
in narrative_json_test.go pin the shape so the inconsistency
can't reappear silently.
Process note: this is a class of bug that only appears when an
LLM actually consumes the output. No unit test caught it; no
review of the code showed it; the model running through the chat
path is what surfaced it. Another argument for "exercise the
artifact" — even the tool surface that looks correct in
isolation has hidden inconsistencies you only see when something
external reads it.
417 lines
13 KiB
Go
417 lines
13 KiB
Go
package reporting
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"time"
|
|
)
|
|
|
|
// NarrativeSource identifies who produced a Narrative.
|
|
const (
|
|
NarrativeSourceHeuristic = "heuristic"
|
|
NarrativeSourceAI = "ai"
|
|
)
|
|
|
|
// NarrativeBulletSeverity classifies a single observation bullet.
|
|
const (
|
|
NarrativeSeverityOK = "ok"
|
|
NarrativeSeverityInfo = "info"
|
|
NarrativeSeverityWarning = "warning"
|
|
NarrativeSeverityCritical = "critical"
|
|
)
|
|
|
|
// Narrative is the textual interpretation layer of a performance report.
|
|
// It is rendered into the executive summary section of the PDF and is
|
|
// produced either by the heuristic narrator (deterministic thresholds) or
|
|
// by an AI narrator that reads the same NarrativeInput.
|
|
type Narrative struct {
|
|
Source string `json:"source"` // NarrativeSourceHeuristic or NarrativeSourceAI
|
|
HealthStatus string `json:"health_status"` // HEALTHY / WARNING / CRITICAL
|
|
HealthMessage string `json:"health_message"` // one-line summary shown next to HealthStatus
|
|
ExecutiveSummary string `json:"executive_summary,omitempty"` // optional prose (AI populates, heuristic leaves empty)
|
|
Observations []NarrativeBullet `json:"observations,omitempty"` // ordered list rendered as bullets
|
|
Recommendations []string `json:"recommendations,omitempty"` // ordered list rendered as numbered actions
|
|
PeriodComparison string `json:"period_comparison,omitempty"` // optional prose summarising deltas vs PriorPeriod
|
|
Disclaimer string `json:"disclaimer,omitempty"` // optional footer (e.g. AI provenance note)
|
|
}
|
|
|
|
// NarrativeBullet is a single observation entry with a severity classification
|
|
// the renderer maps to a colour swatch. JSON tags use lowercase keys to
|
|
// match the shape the AI narrator's system prompt asks for and the chat
|
|
// tool consumers expect — without them the embedded type serialises with
|
|
// capital field names, which is inconsistent with the rest of the JSON
|
|
// surface and confuses downstream models that get back a different shape
|
|
// than the schema they were taught.
|
|
type NarrativeBullet struct {
|
|
Text string `json:"text"`
|
|
Severity string `json:"severity"`
|
|
}
|
|
|
|
// NarrativeInput is the data passed to a Narrator. It is denormalised from
|
|
// ReportData so the AI implementation does not need to traverse internal
|
|
// structures.
|
|
type NarrativeInput struct {
|
|
Title string
|
|
ResourceType string
|
|
ResourceID string
|
|
GeneratedAt time.Time
|
|
Period TimeRange
|
|
PriorPeriod *PriorPeriodInput
|
|
Resource *ResourceInfo
|
|
MetricStats map[string]MetricStats
|
|
Alerts []AlertInfo
|
|
Storage []StorageInfo
|
|
Disks []DiskInfo
|
|
Backups []BackupInfo
|
|
Findings []FindingSummary
|
|
}
|
|
|
|
// TimeRange is a half-open interval [Start, End).
|
|
type TimeRange struct {
|
|
Start time.Time
|
|
End time.Time
|
|
}
|
|
|
|
// PriorPeriodInput captures the comparable prior-window aggregates the
|
|
// narrator can use to describe deltas. Empty MetricStats means no prior data
|
|
// was available.
|
|
type PriorPeriodInput struct {
|
|
Period TimeRange
|
|
MetricStats map[string]MetricStats
|
|
AlertCount int
|
|
}
|
|
|
|
// FindingSummary is the report-facing projection of a Patrol finding. It is
|
|
// defined here (rather than imported from internal/ai) so the reporting
|
|
// package stays free of AI-internal dependencies.
|
|
type FindingSummary struct {
|
|
ID string
|
|
Severity string // critical, high, medium, low, info
|
|
Category string
|
|
Title string
|
|
Description string
|
|
Recommendation string
|
|
DetectedAt time.Time
|
|
Resolved bool
|
|
}
|
|
|
|
// Narrator produces a Narrative from a NarrativeInput. Implementations must
|
|
// be safe for concurrent use and must respect ctx cancellation.
|
|
type Narrator interface {
|
|
Narrate(ctx context.Context, in NarrativeInput) (Narrative, error)
|
|
}
|
|
|
|
// FindingsProvider returns Patrol findings overlapping the given window for
|
|
// a single resource. An empty slice means no findings in scope.
|
|
type FindingsProvider interface {
|
|
FindingsForReport(ctx context.Context, resourceID string, start, end time.Time) []FindingSummary
|
|
}
|
|
|
|
// narrate is the helper used by the engine: it tries the supplied narrator
|
|
// (typically AI), and on nil/error falls back to the heuristic narrator so a
|
|
// report is always produced. The returned Narrative.Source records which
|
|
// path actually ran.
|
|
func narrate(ctx context.Context, n Narrator, in NarrativeInput) Narrative {
|
|
if n != nil {
|
|
out, err := n.Narrate(ctx, in)
|
|
if err == nil {
|
|
if out.Source == "" {
|
|
out.Source = NarrativeSourceAI
|
|
}
|
|
return out
|
|
}
|
|
}
|
|
heuristic := HeuristicNarrator{}
|
|
out, _ := heuristic.Narrate(ctx, in)
|
|
out.Source = NarrativeSourceHeuristic
|
|
return out
|
|
}
|
|
|
|
// HeuristicNarrator is the deterministic fallback narrator. It encodes the
|
|
// same observation and recommendation rules as the original PDF generator,
|
|
// lifted here so both renderers and the AI fallback path share one source of
|
|
// truth.
|
|
type HeuristicNarrator struct{}
|
|
|
|
// Narrate implements Narrator. It never returns an error.
|
|
func (HeuristicNarrator) Narrate(_ context.Context, in NarrativeInput) (Narrative, error) {
|
|
return Narrative{
|
|
Source: NarrativeSourceHeuristic,
|
|
HealthStatus: heuristicHealthStatus(in),
|
|
HealthMessage: heuristicHealthMessage(in),
|
|
Observations: heuristicObservations(in),
|
|
Recommendations: heuristicRecommendations(in),
|
|
}, nil
|
|
}
|
|
|
|
func heuristicHealthStatus(in NarrativeInput) string {
|
|
criticalAlerts := 0
|
|
warningAlerts := 0
|
|
for _, alert := range in.Alerts {
|
|
if alert.ResolvedTime != nil {
|
|
continue
|
|
}
|
|
if alert.Level == "critical" {
|
|
criticalAlerts++
|
|
} else {
|
|
warningAlerts++
|
|
}
|
|
}
|
|
switch {
|
|
case criticalAlerts > 0:
|
|
return "CRITICAL"
|
|
case warningAlerts > 0:
|
|
return "WARNING"
|
|
default:
|
|
return "HEALTHY"
|
|
}
|
|
}
|
|
|
|
func heuristicHealthMessage(in NarrativeInput) string {
|
|
criticalAlerts := 0
|
|
warningAlerts := 0
|
|
for _, alert := range in.Alerts {
|
|
if alert.ResolvedTime != nil {
|
|
continue
|
|
}
|
|
if alert.Level == "critical" {
|
|
criticalAlerts++
|
|
} else {
|
|
warningAlerts++
|
|
}
|
|
}
|
|
switch {
|
|
case criticalAlerts == 1:
|
|
return "1 critical issue requires immediate attention"
|
|
case criticalAlerts > 1:
|
|
return fmt.Sprintf("%d critical issues require immediate attention", criticalAlerts)
|
|
case warningAlerts == 1:
|
|
return "1 warning detected - review recommended"
|
|
case warningAlerts > 1:
|
|
return fmt.Sprintf("%d warnings detected - review recommended", warningAlerts)
|
|
default:
|
|
return "All systems operating normally"
|
|
}
|
|
}
|
|
|
|
// heuristicObservations mirrors the original generateObservations rules.
|
|
func heuristicObservations(in NarrativeInput) []NarrativeBullet {
|
|
if len(in.MetricStats) == 0 {
|
|
return []NarrativeBullet{{
|
|
Text: "Insufficient data for analysis",
|
|
Severity: NarrativeSeverityInfo,
|
|
}}
|
|
}
|
|
|
|
var obs []NarrativeBullet
|
|
|
|
if stats, ok := in.MetricStats["cpu"]; ok {
|
|
switch {
|
|
case stats.Max > 90:
|
|
obs = append(obs, NarrativeBullet{
|
|
Text: fmt.Sprintf("CPU peaked at %.1f%% - potential capacity constraint", stats.Max),
|
|
Severity: NarrativeSeverityCritical,
|
|
})
|
|
case stats.Avg < 20:
|
|
obs = append(obs, NarrativeBullet{
|
|
Text: fmt.Sprintf("CPU averaging %.1f%% - resource is underutilized", stats.Avg),
|
|
Severity: NarrativeSeverityOK,
|
|
})
|
|
default:
|
|
obs = append(obs, NarrativeBullet{
|
|
Text: fmt.Sprintf("CPU usage normal (avg %.1f%%, max %.1f%%)", stats.Avg, stats.Max),
|
|
Severity: NarrativeSeverityOK,
|
|
})
|
|
}
|
|
}
|
|
|
|
if stats, ok := in.MetricStats["memory"]; ok {
|
|
switch {
|
|
case stats.Avg > 85:
|
|
obs = append(obs, NarrativeBullet{
|
|
Text: fmt.Sprintf("Memory consistently high at %.1f%% avg - consider scaling", stats.Avg),
|
|
Severity: NarrativeSeverityCritical,
|
|
})
|
|
case stats.Max > 95:
|
|
obs = append(obs, NarrativeBullet{
|
|
Text: fmt.Sprintf("Memory peaked at %.1f%% - near capacity", stats.Max),
|
|
Severity: NarrativeSeverityWarning,
|
|
})
|
|
default:
|
|
obs = append(obs, NarrativeBullet{
|
|
Text: fmt.Sprintf("Memory usage healthy (avg %.1f%%)", stats.Avg),
|
|
Severity: NarrativeSeverityOK,
|
|
})
|
|
}
|
|
}
|
|
|
|
diskKey := "disk"
|
|
if _, hasDisk := in.MetricStats["disk"]; !hasDisk {
|
|
if _, hasUsage := in.MetricStats["usage"]; hasUsage {
|
|
diskKey = "usage"
|
|
}
|
|
}
|
|
if stats, ok := in.MetricStats[diskKey]; ok {
|
|
switch {
|
|
case stats.Avg > 85:
|
|
obs = append(obs, NarrativeBullet{
|
|
Text: fmt.Sprintf("Disk at %.1f%% - plan capacity expansion", stats.Avg),
|
|
Severity: NarrativeSeverityCritical,
|
|
})
|
|
case stats.Avg > 70:
|
|
obs = append(obs, NarrativeBullet{
|
|
Text: fmt.Sprintf("Disk at %.1f%% - monitor growth trend", stats.Avg),
|
|
Severity: NarrativeSeverityWarning,
|
|
})
|
|
default:
|
|
obs = append(obs, NarrativeBullet{
|
|
Text: fmt.Sprintf("Disk usage acceptable at %.1f%%", stats.Avg),
|
|
Severity: NarrativeSeverityOK,
|
|
})
|
|
}
|
|
}
|
|
|
|
resolved := 0
|
|
for _, alert := range in.Alerts {
|
|
if alert.ResolvedTime != nil {
|
|
resolved++
|
|
}
|
|
}
|
|
if resolved > 0 {
|
|
obs = append(obs, NarrativeBullet{
|
|
Text: fmt.Sprintf("%d alerts were triggered and resolved during this period", resolved),
|
|
Severity: NarrativeSeverityInfo,
|
|
})
|
|
}
|
|
|
|
for _, disk := range in.Disks {
|
|
if disk.WearLevel > 0 && disk.WearLevel <= 10 {
|
|
obs = append(obs, NarrativeBullet{
|
|
Text: fmt.Sprintf("CRITICAL: Disk %s at %d%% life remaining", disk.Device, disk.WearLevel),
|
|
Severity: NarrativeSeverityCritical,
|
|
})
|
|
}
|
|
if disk.Health == "FAILED" {
|
|
obs = append(obs, NarrativeBullet{
|
|
Text: fmt.Sprintf("CRITICAL: Disk %s SMART health check failed", disk.Device),
|
|
Severity: NarrativeSeverityCritical,
|
|
})
|
|
}
|
|
}
|
|
|
|
if in.Resource != nil && in.Resource.Uptime > 0 {
|
|
uptimeDays := in.Resource.Uptime / 86400
|
|
if uptimeDays > 90 {
|
|
obs = append(obs, NarrativeBullet{
|
|
Text: fmt.Sprintf("System uptime is %d days - schedule maintenance window", uptimeDays),
|
|
Severity: NarrativeSeverityWarning,
|
|
})
|
|
}
|
|
}
|
|
|
|
return obs
|
|
}
|
|
|
|
// heuristicRecommendations mirrors the original generateRecommendations rules.
|
|
func heuristicRecommendations(in NarrativeInput) []string {
|
|
criticalAlerts := 0
|
|
warningAlerts := 0
|
|
for _, alert := range in.Alerts {
|
|
if alert.ResolvedTime != nil {
|
|
continue
|
|
}
|
|
if alert.Level == "critical" {
|
|
criticalAlerts++
|
|
} else {
|
|
warningAlerts++
|
|
}
|
|
}
|
|
_ = warningAlerts // retained for future shaping; matches original signature
|
|
|
|
var recs []string
|
|
|
|
for _, disk := range in.Disks {
|
|
if disk.WearLevel > 0 && disk.WearLevel <= 10 {
|
|
recs = append(recs, fmt.Sprintf("Replace disk %s immediately (only %d%% life remaining)", disk.Device, disk.WearLevel))
|
|
} else if disk.WearLevel > 0 && disk.WearLevel <= 30 {
|
|
recs = append(recs, fmt.Sprintf("Schedule replacement for disk %s within 3-6 months (%d%% life remaining)", disk.Device, disk.WearLevel))
|
|
}
|
|
if disk.Health == "FAILED" {
|
|
recs = append(recs, fmt.Sprintf("Investigate and replace disk %s - SMART health check failed", disk.Device))
|
|
}
|
|
}
|
|
|
|
if criticalAlerts > 0 {
|
|
recs = append(recs, "Investigate and resolve critical alerts immediately")
|
|
}
|
|
|
|
if stats, ok := in.MetricStats["memory"]; ok {
|
|
if stats.Avg > 85 {
|
|
recs = append(recs, "Consider adding memory or optimizing memory-intensive workloads")
|
|
}
|
|
}
|
|
if stats, ok := in.MetricStats["cpu"]; ok {
|
|
if stats.Max > 90 {
|
|
recs = append(recs, "Review CPU-intensive processes during peak usage periods")
|
|
}
|
|
}
|
|
|
|
diskKey := "disk"
|
|
if _, ok := in.MetricStats["disk"]; !ok {
|
|
diskKey = "usage"
|
|
}
|
|
if stats, ok := in.MetricStats[diskKey]; ok {
|
|
if stats.Avg > 85 {
|
|
recs = append(recs, "Clean up disk space or expand storage capacity")
|
|
}
|
|
}
|
|
|
|
for _, storage := range in.Storage {
|
|
if storage.UsagePerc >= 90 {
|
|
recs = append(recs, fmt.Sprintf("Expand storage pool '%s' (currently at %.0f%% capacity)", storage.Name, storage.UsagePerc))
|
|
}
|
|
}
|
|
|
|
if in.Resource != nil && in.Resource.Uptime > 0 {
|
|
uptimeDays := in.Resource.Uptime / 86400
|
|
if uptimeDays > 90 {
|
|
recs = append(recs, "Schedule maintenance window to apply pending updates and reboot")
|
|
}
|
|
}
|
|
|
|
if stats, ok := in.MetricStats["cpu"]; ok {
|
|
if stats.Avg < 10 && len(recs) == 0 {
|
|
recs = append(recs, "System is underutilized - consider consolidating workloads")
|
|
}
|
|
}
|
|
|
|
if len(recs) == 0 {
|
|
recs = append(recs, "No immediate action required - continue monitoring")
|
|
}
|
|
|
|
return recs
|
|
}
|
|
|
|
// narrativeInputFromReport builds a NarrativeInput from a fully-populated
|
|
// ReportData plus the optional prior-period summary. Findings are passed in
|
|
// separately because they are not part of the metric snapshot.
|
|
func narrativeInputFromReport(data *ReportData, prior *PriorPeriodInput, findings []FindingSummary) NarrativeInput {
|
|
if data == nil {
|
|
return NarrativeInput{}
|
|
}
|
|
return NarrativeInput{
|
|
Title: data.Title,
|
|
ResourceType: data.ResourceType,
|
|
ResourceID: data.ResourceID,
|
|
GeneratedAt: data.GeneratedAt,
|
|
Period: TimeRange{Start: data.Start, End: data.End},
|
|
PriorPeriod: prior,
|
|
Resource: data.Resource,
|
|
MetricStats: data.Summary.ByMetric,
|
|
Alerts: data.Alerts,
|
|
Storage: data.Storage,
|
|
Disks: data.Disks,
|
|
Backups: data.Backups,
|
|
Findings: findings,
|
|
}
|
|
}
|