mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-09 16:00:59 +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:
parent
9905383f70
commit
b84b87d8d8
2 changed files with 124 additions and 1 deletions
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue