mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-23 03:33:53 +00:00
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:
@@ -6,12 +6,20 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"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/ai/providers"
|
||||||
|
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
||||||
"github.com/rcourtman/pulse-go-rewrite/pkg/reporting"
|
"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.
|
// reportNarratorMaxTokens caps the response budget for narrative generation.
|
||||||
// The output is short prose plus a small JSON envelope, so a tight ceiling
|
// The output is short prose plus a small JSON envelope, so a tight ceiling
|
||||||
// keeps cost predictable and discourages padding.
|
// keeps cost predictable and discourages padding.
|
||||||
@@ -150,6 +158,7 @@ func (s *Service) Narrate(ctx context.Context, in reporting.NarrativeInput) (rep
|
|||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
provider := s.provider
|
provider := s.provider
|
||||||
cfg := s.cfg
|
cfg := s.cfg
|
||||||
|
costStore := s.costStore
|
||||||
s.mu.RUnlock()
|
s.mu.RUnlock()
|
||||||
|
|
||||||
if provider == nil {
|
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
|
return reporting.Narrative{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,6 +201,29 @@ func (s *Service) Narrate(ctx context.Context, in reporting.NarrativeInput) (rep
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return reporting.Narrative{}, fmt.Errorf("provider chat: %w", err)
|
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)
|
content := strings.TrimSpace(resp.Content)
|
||||||
if content == "" {
|
if content == "" {
|
||||||
return reporting.Narrative{}, errors.New("provider returned empty narrative")
|
return reporting.Narrative{}, errors.New("provider returned empty narrative")
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
package ai
|
package ai
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"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"
|
"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) {
|
func TestBuildReportNarratorPayload_PeriodFormatting(t *testing.T) {
|
||||||
start := time.Date(2026, 1, 15, 10, 0, 0, 0, time.UTC)
|
start := time.Date(2026, 1, 15, 10, 0, 0, 0, time.UTC)
|
||||||
end := start.Add(24 * time.Hour)
|
end := start.Add(24 * time.Hour)
|
||||||
|
|||||||
Reference in New Issue
Block a user