diff --git a/internal/ai/report_narrator.go b/internal/ai/report_narrator.go index 403fd01b9..135e0fb1a 100644 --- a/internal/ai/report_narrator.go +++ b/internal/ai/report_narrator.go @@ -6,12 +6,20 @@ import ( "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" ) +// reportNarratorUseCase is the cost-ledger label for AI-narrated report +// generations. Operators see this value in the AI usage dashboard so +// report-narrative spend is distinguishable from chat or patrol. +const reportNarratorUseCase = "report_narrative" + // reportNarratorMaxTokens caps the response budget for narrative generation. // The output is short prose plus a small JSON envelope, so a tight ceiling // keeps cost predictable and discourages padding. @@ -150,6 +158,7 @@ func (s *Service) Narrate(ctx context.Context, in reporting.NarrativeInput) (rep s.mu.RLock() provider := s.provider cfg := s.cfg + costStore := s.costStore s.mu.RUnlock() if provider == nil { @@ -165,7 +174,7 @@ func (s *Service) Narrate(ctx context.Context, in reporting.NarrativeInput) (rep } } - if err := s.enforceBudget("report_narrative"); err != nil { + if err := s.enforceBudget(reportNarratorUseCase); err != nil { return reporting.Narrative{}, err } @@ -192,6 +201,29 @@ func (s *Service) Narrate(ctx context.Context, in reporting.NarrativeInput) (rep if err != nil { return reporting.Narrative{}, fmt.Errorf("provider chat: %w", err) } + + // Record token usage in the operator-facing cost ledger so AI-narrated + // report generation shows up in the AI usage dashboard alongside chat + // and patrol spend. Recording happens regardless of whether the + // response parses, so failed-but-billed calls are still visible. + 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: reportNarratorUseCase, + InputTokens: resp.InputTokens, + OutputTokens: resp.OutputTokens, + TargetType: in.ResourceType, + TargetID: in.ResourceID, + }) + } + content := strings.TrimSpace(resp.Content) if content == "" { return reporting.Narrative{}, errors.New("provider returned empty narrative") diff --git a/internal/ai/report_narrator_test.go b/internal/ai/report_narrator_test.go index def345c54..cd233f386 100644 --- a/internal/ai/report_narrator_test.go +++ b/internal/ai/report_narrator_test.go @@ -1,10 +1,13 @@ 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" ) @@ -168,6 +171,94 @@ func TestBuildReportNarratorPayload_OmitsEmptySections(t *testing.T) { } } +func TestNarrate_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": "HEALTHY", + "health_message": "Quiet", + "executive_summary": "All clear.", + "observations": [{"text": "CPU steady at 30%", "severity": "ok"}], + "recommendations": ["Carry on"], + "period_comparison": "" + }`, + Model: "anthropic:claude-test", + InputTokens: 120, + OutputTokens: 45, + }, nil + }, + } + + in := reporting.NarrativeInput{ + ResourceType: "node", + ResourceID: "pve1", + Period: reporting.TimeRange{ + Start: time.Now().Add(-time.Hour), + End: time.Now(), + }, + MetricStats: map[string]reporting.MetricStats{ + "cpu": {Avg: 30, Max: 40}, + }, + } + + if _, err := svc.Narrate(context.Background(), in); err != nil { + t.Fatalf("Narrate: %v", err) + } + + events := svc.ListCostEvents(1) + if len(events) != 1 { + t.Fatalf("expected 1 cost event, got %d", len(events)) + } + ev := events[0] + if ev.UseCase != reportNarratorUseCase { + t.Errorf("UseCase = %q, want %q", ev.UseCase, reportNarratorUseCase) + } + if ev.InputTokens != 120 || ev.OutputTokens != 45 { + t.Errorf("tokens = (%d, %d), want (120, 45)", ev.InputTokens, ev.OutputTokens) + } + if ev.TargetType != "node" || ev.TargetID != "pve1" { + t.Errorf("target = (%q, %q), want (node, pve1)", ev.TargetType, ev.TargetID) + } + if ev.Provider != "anthropic" { + t.Errorf("Provider = %q, want anthropic", ev.Provider) + } +} + +func TestNarrate_RecordsCostEvenOnParseFailure(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: "not json", + Model: "anthropic:claude-test", + InputTokens: 100, + OutputTokens: 10, + }, nil + }, + } + + in := reporting.NarrativeInput{ResourceType: "node", ResourceID: "pve1"} + if _, err := svc.Narrate(context.Background(), in); err == nil { + t.Fatal("expected parse error, got nil") + } + + events := svc.ListCostEvents(1) + if len(events) != 1 { + t.Fatalf("failed parse should still record cost (provider was billed); got %d events", len(events)) + } + if events[0].InputTokens != 100 || events[0].OutputTokens != 10 { + t.Errorf("tokens = (%d, %d), want (100, 10)", events[0].InputTokens, events[0].OutputTokens) + } +} + func TestBuildReportNarratorPayload_PeriodFormatting(t *testing.T) { start := time.Date(2026, 1, 15, 10, 0, 0, 0, time.UTC) end := start.Add(24 * time.Hour)