Files
pulse/internal/ai/tools/tools_summarize_test.go
T
rcourtman 03463c1bfe Thread per-tenant AI narrators into pulse_summarize via chat session
v1 of pulse_summarize (1fe5d6853) shipped with heuristic narrative
only. The follow-up wiring promised in that commit now lands: the
chat session carries optional report-narration providers that the
tool's handler reads when building requests, so AI-narrated synthesis
flows into chat using the same provider, sanitizer, model selection,
cost ledger, and budget gate the report PDF endpoint already uses.

Pipeline:
- pkg/reporting Narrator / FleetNarrator / FindingsProvider interfaces
  are already implemented by internal/ai.Service. No new
  implementations.
- tools.ExecutorConfig + PulseToolExecutor gain three optional fields
  (ReportNarrator, ReportFleetNarrator, ReportFindingsProvider).
  Clone() copies them so per-session executors inherit the wiring.
- chat.Config gains the same three fields; NewService threads them
  into ExecutorConfig.
- tools_summarize.go reads e.reportNarrator/FleetNarrator/
  FindingsProvider and populates MetricReportRequest /
  MultiReportRequest. The engine already accepts these on the request
  and falls back to heuristic when they are nil — no engine changes
  needed.
- AIHandler gains SetReportNarratorResolver(ctx -> narrators); both
  per-tenant and default chat.Config construction sites invoke the
  resolver. Router wires the resolver to AISettingsHandler.GetAIService
  with the same Enabled-gate the reporting handler uses.

Unconfigured tenants are unchanged: the resolver returns nil, the
tool returns heuristic narrative — identical to today. Configured
tenants get AI synthesis in chat that matches what their report PDF
already carries, billed and budget-gated the same way.
2026-05-10 22:50:17 +01:00

401 lines
12 KiB
Go

package tools
import (
"context"
"encoding/json"
"path/filepath"
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/pkg/metrics"
"github.com/rcourtman/pulse-go-rewrite/pkg/reporting"
)
func newSummarizeTestEnvironment(t *testing.T) (*PulseToolExecutor, func()) {
t.Helper()
dir := t.TempDir()
store, err := metrics.NewStore(metrics.StoreConfig{
DBPath: filepath.Join(dir, "metrics.db"),
WriteBufferSize: 10,
FlushInterval: 50 * time.Millisecond,
RetentionRaw: 24 * time.Hour,
RetentionMinute: 7 * 24 * time.Hour,
RetentionHourly: 30 * 24 * time.Hour,
RetentionDaily: 90 * 24 * time.Hour,
})
if err != nil {
t.Fatalf("metrics store: %v", err)
}
engine := reporting.NewReportEngine(reporting.EngineConfig{MetricsStore: store})
prev := reporting.GetEngine()
reporting.SetEngine(engine)
exec := NewPulseToolExecutor(ExecutorConfig{})
cleanup := func() {
reporting.SetEngine(prev)
store.Close()
}
return exec, cleanup
}
func writeMetricSamples(t *testing.T, dir string, store *metrics.Store, resourceID string, value float64, count int) {
t.Helper()
now := time.Now()
for i := 0; i < count; i++ {
ts := now.Add(time.Duration(-30+i*2) * time.Minute)
store.Write("node", resourceID, "cpu", value, ts)
store.Write("node", resourceID, "memory", value-10, ts)
}
store.Flush()
_ = dir
}
func TestSummarizeTool_RegisteredAndDiscoverable(t *testing.T) {
exec, cleanup := newSummarizeTestEnvironment(t)
defer cleanup()
tools := exec.registry.ListTools("")
var found bool
for _, tool := range tools {
if tool.Name == "pulse_summarize" {
found = true
if _, ok := tool.InputSchema.Properties["action"]; !ok {
t.Error("pulse_summarize should declare 'action' property")
}
if _, ok := tool.InputSchema.Properties["resource_type"]; !ok {
t.Error("pulse_summarize should declare 'resource_type' property")
}
break
}
}
if !found {
t.Fatal("pulse_summarize not registered")
}
}
func TestSummarizeTool_ResourceActionRequiresFields(t *testing.T) {
exec, cleanup := newSummarizeTestEnvironment(t)
defer cleanup()
res, err := exec.executeSummarize(context.Background(), map[string]interface{}{
"action": "resource",
})
if err != nil {
t.Fatalf("executeSummarize: %v", err)
}
if !res.IsError {
t.Error("expected error for missing resource_type")
}
}
func TestSummarizeTool_FleetActionRequiresFields(t *testing.T) {
exec, cleanup := newSummarizeTestEnvironment(t)
defer cleanup()
res, err := exec.executeSummarize(context.Background(), map[string]interface{}{
"action": "fleet",
"resource_type": "node",
})
if err != nil {
t.Fatalf("executeSummarize: %v", err)
}
if !res.IsError {
t.Error("expected error for missing resource_ids")
}
}
func TestSummarizeTool_RejectsUnknownAction(t *testing.T) {
exec, cleanup := newSummarizeTestEnvironment(t)
defer cleanup()
res, err := exec.executeSummarize(context.Background(), map[string]interface{}{
"action": "interplanetary",
})
if err != nil {
t.Fatalf("executeSummarize: %v", err)
}
if !res.IsError {
t.Error("expected error for unknown action")
}
}
func TestSummarizeTool_ResourceReturnsHeuristicNarrative(t *testing.T) {
exec, cleanup := newSummarizeTestEnvironment(t)
defer cleanup()
// Need a metrics store with data. Re-fetch the engine's store via the
// global since we set it in newSummarizeTestEnvironment.
engine, ok := reporting.GetEngine().(*reporting.ReportEngine)
if !ok {
t.Fatal("expected *ReportEngine")
}
_ = engine
// Write metrics via the same store. The engine was constructed with
// MetricsStore so we need to reach back into the store; instead, rely
// on writing via package-level access through engine internals.
// Simpler: skip data and accept that the heuristic narrator returns
// "insufficient data" — which is itself a valid narrative we can assert.
res, err := exec.executeSummarize(context.Background(), map[string]interface{}{
"action": "resource",
"resource_type": "node",
"resource_id": "missing-node",
})
if err != nil {
t.Fatalf("executeSummarize: %v", err)
}
if res.IsError {
t.Fatalf("unexpected error result: %+v", res.Content)
}
if len(res.Content) == 0 || res.Content[0].Type != "text" {
t.Fatalf("expected text content, got %+v", res.Content)
}
var parsed summarizeResourceResponse
if err := json.Unmarshal([]byte(res.Content[0].Text), &parsed); err != nil {
t.Fatalf("decode response: %v\nbody: %s", err, res.Content[0].Text)
}
if !parsed.OK {
t.Error("expected OK=true")
}
if parsed.Action != "resource" {
t.Errorf("Action = %q, want resource", parsed.Action)
}
if parsed.NarrativeSource != reporting.NarrativeSourceHeuristic {
t.Errorf("NarrativeSource = %q, want heuristic (v1 always heuristic)", parsed.NarrativeSource)
}
if parsed.HealthStatus == "" {
t.Error("expected HealthStatus populated")
}
if len(parsed.Observations) == 0 {
t.Error("expected at least one observation from the heuristic narrator")
}
}
func TestSummarizeTool_FleetParsesCommaSeparatedIDs(t *testing.T) {
exec, cleanup := newSummarizeTestEnvironment(t)
defer cleanup()
res, err := exec.executeSummarize(context.Background(), map[string]interface{}{
"action": "fleet",
"resource_type": "node",
"resource_ids": "node-a, node-b , node-c ",
})
if err != nil {
t.Fatalf("executeSummarize: %v", err)
}
if res.IsError {
t.Fatalf("unexpected error: %+v", res.Content)
}
var parsed summarizeFleetResponse
if err := json.Unmarshal([]byte(res.Content[0].Text), &parsed); err != nil {
t.Fatalf("decode response: %v", err)
}
if len(parsed.ResourceIDs) != 3 {
t.Fatalf("expected 3 deduped/trimmed IDs, got %v", parsed.ResourceIDs)
}
wanted := []string{"node-a", "node-b", "node-c"}
for i, w := range wanted {
if parsed.ResourceIDs[i] != w {
t.Errorf("ResourceIDs[%d] = %q, want %q", i, parsed.ResourceIDs[i], w)
}
}
if parsed.NarrativeSource != reporting.NarrativeSourceHeuristic {
t.Errorf("NarrativeSource = %q, want heuristic", parsed.NarrativeSource)
}
}
func TestSummarizeTool_FleetEnforcesMaxResources(t *testing.T) {
exec, cleanup := newSummarizeTestEnvironment(t)
defer cleanup()
// Build a comma-separated list well over the cap.
var ids string
for i := 0; i < summarizeFleetMaxResources+5; i++ {
if i > 0 {
ids += ","
}
ids += "node-x"
}
res, err := exec.executeSummarize(context.Background(), map[string]interface{}{
"action": "fleet",
"resource_type": "node",
"resource_ids": ids,
})
if err != nil {
t.Fatalf("executeSummarize: %v", err)
}
if !res.IsError {
t.Error("expected error for over-limit fleet size")
}
}
// stubReportNarrator implements reporting.Narrator with a recorded
// invocation so the test can assert the tool delegates to it.
type stubReportNarrator struct {
called bool
seen reporting.NarrativeInput
response reporting.Narrative
}
func (s *stubReportNarrator) Narrate(_ context.Context, in reporting.NarrativeInput) (reporting.Narrative, error) {
s.called = true
s.seen = in
return s.response, nil
}
type stubFleetReportNarrator struct {
called bool
seen reporting.FleetNarrativeInput
response reporting.FleetNarrative
}
func (s *stubFleetReportNarrator) NarrateFleet(_ context.Context, in reporting.FleetNarrativeInput) (reporting.FleetNarrative, error) {
s.called = true
s.seen = in
return s.response, nil
}
func TestSummarizeTool_UsesReportNarratorWhenConfigured(t *testing.T) {
dir := t.TempDir()
store, err := metrics.NewStore(metrics.StoreConfig{
DBPath: filepath.Join(dir, "metrics.db"),
WriteBufferSize: 10,
FlushInterval: 50 * time.Millisecond,
RetentionRaw: 24 * time.Hour,
RetentionMinute: 7 * 24 * time.Hour,
RetentionHourly: 30 * 24 * time.Hour,
RetentionDaily: 90 * 24 * time.Hour,
})
if err != nil {
t.Fatalf("metrics store: %v", err)
}
defer store.Close()
engine := reporting.NewReportEngine(reporting.EngineConfig{MetricsStore: store})
prev := reporting.GetEngine()
reporting.SetEngine(engine)
defer reporting.SetEngine(prev)
narrator := &stubReportNarrator{
response: reporting.Narrative{
Source: reporting.NarrativeSourceAI,
HealthStatus: "HEALTHY",
HealthMessage: "AI says fine",
Observations: []reporting.NarrativeBullet{
{Text: "AI bullet", Severity: reporting.NarrativeSeverityOK},
},
Recommendations: []string{"Continue monitoring"},
},
}
exec := NewPulseToolExecutor(ExecutorConfig{
ReportNarrator: narrator,
})
res, err := exec.executeSummarize(context.Background(), map[string]interface{}{
"action": "resource",
"resource_type": "node",
"resource_id": "node-with-narrator",
})
if err != nil {
t.Fatalf("executeSummarize: %v", err)
}
if res.IsError {
t.Fatalf("unexpected error: %+v", res.Content)
}
if !narrator.called {
t.Fatal("expected narrator to be invoked")
}
var parsed summarizeResourceResponse
if err := json.Unmarshal([]byte(res.Content[0].Text), &parsed); err != nil {
t.Fatalf("decode response: %v", err)
}
if parsed.NarrativeSource != reporting.NarrativeSourceAI {
t.Errorf("NarrativeSource = %q, want ai", parsed.NarrativeSource)
}
if parsed.HealthMessage != "AI says fine" {
t.Errorf("HealthMessage = %q, want AI says fine", parsed.HealthMessage)
}
if len(parsed.Observations) != 1 || parsed.Observations[0].Text != "AI bullet" {
t.Errorf("Observations = %#v", parsed.Observations)
}
}
func TestSummarizeTool_FleetUsesFleetNarratorWhenConfigured(t *testing.T) {
dir := t.TempDir()
store, err := metrics.NewStore(metrics.StoreConfig{
DBPath: filepath.Join(dir, "metrics.db"),
WriteBufferSize: 10,
FlushInterval: 50 * time.Millisecond,
RetentionRaw: 24 * time.Hour,
RetentionMinute: 7 * 24 * time.Hour,
RetentionHourly: 30 * 24 * time.Hour,
RetentionDaily: 90 * 24 * time.Hour,
})
if err != nil {
t.Fatalf("metrics store: %v", err)
}
defer store.Close()
engine := reporting.NewReportEngine(reporting.EngineConfig{MetricsStore: store})
prev := reporting.GetEngine()
reporting.SetEngine(engine)
defer reporting.SetEngine(prev)
fleet := &stubFleetReportNarrator{
response: reporting.FleetNarrative{
Source: reporting.NarrativeSourceAI,
HealthStatus: "WARNING",
HealthMessage: "Memory creeping up",
ExecutiveSummary: "AI fleet summary text",
Outliers: []reporting.FleetOutlier{
{ResourceID: "node-a", ResourceName: "alpha", Reason: "Memory at 92%", Severity: reporting.NarrativeSeverityWarning},
},
Recommendations: []string{"Review memory allocation"},
},
}
exec := NewPulseToolExecutor(ExecutorConfig{
ReportFleetNarrator: fleet,
})
res, err := exec.executeSummarize(context.Background(), map[string]interface{}{
"action": "fleet",
"resource_type": "node",
"resource_ids": "node-a,node-b",
})
if err != nil {
t.Fatalf("executeSummarize: %v", err)
}
if res.IsError {
t.Fatalf("unexpected error: %+v", res.Content)
}
if !fleet.called {
t.Fatal("expected fleet narrator to be invoked")
}
var parsed summarizeFleetResponse
if err := json.Unmarshal([]byte(res.Content[0].Text), &parsed); err != nil {
t.Fatalf("decode response: %v", err)
}
if parsed.NarrativeSource != reporting.NarrativeSourceAI {
t.Errorf("NarrativeSource = %q, want ai", parsed.NarrativeSource)
}
if len(parsed.Outliers) != 1 || parsed.Outliers[0].ResourceName != "alpha" {
t.Errorf("Outliers = %#v", parsed.Outliers)
}
}
func TestSummarizeRangeWindow(t *testing.T) {
cases := map[string]time.Duration{
"24h": 24 * time.Hour,
"7d": 7 * 24 * time.Hour,
"30d": 30 * 24 * time.Hour,
"": 7 * 24 * time.Hour,
"banana": reporting.DescribePerformanceReport().DefaultRangeDuration(),
" 7d ": 7 * 24 * time.Hour,
"7D": 7 * 24 * time.Hour,
}
for input, want := range cases {
if got := summarizeRangeWindow(input); got != want {
t.Errorf("summarizeRangeWindow(%q) = %v, want %v", input, got, want)
}
}
}