Record cost events for AI report narration

Service.Narrate consumed provider tokens without recording a
cost.UsageEvent, so AI-narrated reports were invisible in the operator
cost ledger. Every other Service call site in the AI runtime records
cost; the narrator omitted it.

Mirror the QuickAnalysis/chat pattern: capture the cost store under
the read lock alongside the provider/cfg snapshot, and after
provider.Chat returns, record a UsageEvent labelled
report_narrative with the resource type/id as the target. Recording
happens before parsing so a failed-but-billed call (e.g. provider
returned malformed JSON) still appears in the ledger — the operator
was billed regardless of whether we could use the response.

The use_case string lifts to a package-level constant so the budget
gate (enforceBudget), the cost label, and the dashboard taxonomy all
reference one identifier.
This commit is contained in:
rcourtman 2026-05-10 21:08:51 +01:00
parent 9905383f70
commit b84b87d8d8
2 changed files with 124 additions and 1 deletions

View file

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

View file

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