Skip to content

Commit eafab0b

Browse files
committed
fix: average prompt always 0 in WAKATIME_AI_STATS
1 parent 089bb13 commit eafab0b

6 files changed

Lines changed: 129 additions & 90 deletions

File tree

pkg/container/calculator.go

Lines changed: 17 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,10 @@ type LanguageStats struct {
2525
Languages map[string][2]interface{}
2626
}
2727

28-
// AIStats stores AI vs human attribution aggregated across WakaTime projects.
28+
// AIStats stores AI vs human attribution from WakaTime's top-level totals.
29+
// AvgPromptLength comes from doc-defined ai_average_prompt_length when WakaTime
30+
// returns it; PromptLength is the raw total chars typed (ai_prompt_length) used
31+
// as fallback since the average field is currently missing in API responses.
2932
// HasData is false when nothing meaningful was reported, signalling writers to
3033
// hide the block entirely (avoids rendering a section full of zeros).
3134
type AIStats struct {
@@ -36,39 +39,28 @@ type AIStats struct {
3639
AIInputTokens int64
3740
AIOutputTokens int64
3841
AvgPromptLength float64
42+
PromptLength int64
3943
HasData bool
4044
}
4145

42-
// CalculateAIStats aggregates AI attribution fields across all WakaTime projects.
43-
// Avg prompt length is weighted by ai_input_tokens — projects without AI prompts
44-
// (zero tokens) contribute nothing, preventing them from skewing the average.
46+
// CalculateAIStats reads AI attribution from the top-level WakaTime stats.
47+
// We trust the API's own aggregation rather than re-summing per-project items.
4548
func (d *DataContainer) CalculateAIStats() *AIStats {
4649
if d.Data.WakaTime == nil {
4750
return &AIStats{}
4851
}
4952

50-
var s AIStats
51-
var weightedPromptSum float64
52-
var promptWeight int64
53-
54-
for _, p := range d.Data.WakaTime.Data.Projects {
55-
s.AIAdditions += p.AIAdditions
56-
s.AIDeletions += p.AIDeletions
57-
s.HumanAdditions += p.HumanAdditions
58-
s.HumanDeletions += p.HumanDeletions
59-
s.AIInputTokens += p.AIInputTokens
60-
s.AIOutputTokens += p.AIOutputTokens
61-
62-
if p.AIInputTokens > 0 && p.AIAveragePromptLength > 0 {
63-
weightedPromptSum += p.AIAveragePromptLength * float64(p.AIInputTokens)
64-
promptWeight += p.AIInputTokens
65-
}
66-
}
67-
68-
if promptWeight > 0 {
69-
s.AvgPromptLength = weightedPromptSum / float64(promptWeight)
53+
src := d.Data.WakaTime.Data
54+
s := AIStats{
55+
AIAdditions: src.AIAdditions,
56+
AIDeletions: src.AIDeletions,
57+
HumanAdditions: src.HumanAdditions,
58+
HumanDeletions: src.HumanDeletions,
59+
AIInputTokens: src.AIInputTokens,
60+
AIOutputTokens: src.AIOutputTokens,
61+
AvgPromptLength: src.AIAvgPromptLength,
62+
PromptLength: src.AIPromptLength,
7063
}
71-
7264
s.HasData = s.AIAdditions > 0 || s.AIInputTokens > 0
7365
return &s
7466
}

pkg/container/calculator_test.go

Lines changed: 44 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -104,65 +104,73 @@ func TestCalculateStreaks(t *testing.T) {
104104
}
105105
}
106106

107-
func newAIStatsContainer(projects []wakatime.StatsItem) *DataContainer {
107+
func newAIStatsContainer(stats *wakatime.Stats) *DataContainer {
108108
d := NewDataContainer(log.Default(), &ClientManager{}, &config.Config{})
109-
if projects != nil {
110-
d.Data.WakaTime = &wakatime.Stats{}
111-
d.Data.WakaTime.Data.Projects = projects
109+
if stats != nil {
110+
d.Data.WakaTime = stats
112111
}
113112
return d
114113
}
115114

115+
func aiStats(setup func(s *wakatime.Stats)) *wakatime.Stats {
116+
s := &wakatime.Stats{}
117+
setup(s)
118+
return s
119+
}
120+
116121
func TestCalculateAIStats(t *testing.T) {
117122
tests := []struct {
118-
name string
119-
projects []wakatime.StatsItem
120-
want AIStats
123+
name string
124+
stats *wakatime.Stats
125+
want AIStats
121126
}{
122127
{
123-
name: "no wakatime data",
124-
projects: nil,
125-
want: AIStats{},
128+
name: "no wakatime data",
129+
stats: nil,
130+
want: AIStats{},
126131
},
127132
{
128-
name: "wakatime present but no AI activity",
129-
projects: []wakatime.StatsItem{{Name: "proj"}},
130-
want: AIStats{},
133+
name: "wakatime present but no AI activity",
134+
stats: aiStats(func(s *wakatime.Stats) {}),
135+
want: AIStats{},
131136
},
132137
{
133-
name: "aggregates additions and tokens across projects",
134-
projects: []wakatime.StatsItem{
135-
{Name: "alpha", AIAdditions: 100, HumanAdditions: 50, AIInputTokens: 1000, AIOutputTokens: 2000, AIAveragePromptLength: 100},
136-
{Name: "beta", AIAdditions: 200, HumanAdditions: 150, AIInputTokens: 3000, AIOutputTokens: 4000, AIAveragePromptLength: 200},
137-
},
138+
name: "reads top-level totals including doc-compliant avg prompt",
139+
stats: aiStats(func(s *wakatime.Stats) {
140+
s.Data.AIAdditions = 300
141+
s.Data.HumanAdditions = 200
142+
s.Data.AIInputTokens = 4000
143+
s.Data.AIOutputTokens = 6000
144+
s.Data.AIAvgPromptLength = 175
145+
}),
138146
want: AIStats{
139147
AIAdditions: 300,
140148
HumanAdditions: 200,
141149
AIInputTokens: 4000,
142150
AIOutputTokens: 6000,
143-
AvgPromptLength: (100*1000 + 200*3000) / 4000.0, // 175
151+
AvgPromptLength: 175,
144152
HasData: true,
145153
},
146154
},
147155
{
148-
name: "skips projects with zero ai_input_tokens for prompt-length avg",
149-
projects: []wakatime.StatsItem{
150-
{Name: "with-ai", AIAdditions: 10, AIInputTokens: 500, AIAveragePromptLength: 80},
151-
{Name: "no-ai", HumanAdditions: 100, AIAveragePromptLength: 9999}, // bogus, should be ignored
152-
},
156+
name: "exposes raw ai_prompt_length when avg field is missing",
157+
stats: aiStats(func(s *wakatime.Stats) {
158+
s.Data.AIAdditions = 10
159+
s.Data.AIInputTokens = 500
160+
s.Data.AIPromptLength = 484803
161+
}),
153162
want: AIStats{
154-
AIAdditions: 10,
155-
HumanAdditions: 100,
156-
AIInputTokens: 500,
157-
AvgPromptLength: 80,
158-
HasData: true,
163+
AIAdditions: 10,
164+
AIInputTokens: 500,
165+
PromptLength: 484803,
166+
HasData: true,
159167
},
160168
},
161169
{
162170
name: "AI additions without tokens still triggers HasData",
163-
projects: []wakatime.StatsItem{
164-
{Name: "p", AIAdditions: 5},
165-
},
171+
stats: aiStats(func(s *wakatime.Stats) {
172+
s.Data.AIAdditions = 5
173+
}),
166174
want: AIStats{
167175
AIAdditions: 5,
168176
HasData: true,
@@ -172,7 +180,7 @@ func TestCalculateAIStats(t *testing.T) {
172180

173181
for _, tt := range tests {
174182
t.Run(tt.name, func(t *testing.T) {
175-
d := newAIStatsContainer(tt.projects)
183+
d := newAIStatsContainer(tt.stats)
176184
got := d.CalculateAIStats()
177185

178186
if got.AIAdditions != tt.want.AIAdditions {
@@ -190,6 +198,9 @@ func TestCalculateAIStats(t *testing.T) {
190198
if math.Abs(got.AvgPromptLength-tt.want.AvgPromptLength) > 0.001 {
191199
t.Errorf("AvgPromptLength: got %v, want %v", got.AvgPromptLength, tt.want.AvgPromptLength)
192200
}
201+
if got.PromptLength != tt.want.PromptLength {
202+
t.Errorf("PromptLength: got %d, want %d", got.PromptLength, tt.want.PromptLength)
203+
}
193204
if got.HasData != tt.want.HasData {
194205
t.Errorf("HasData: got %v, want %v", got.HasData, tt.want.HasData)
195206
}

pkg/container/container.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ func (d *DataContainer) metrics(com *CommitStats, lang *LanguageStats, ai *AISta
3838
version := d.Config.ProgressBarVersion
3939
aiBlock := ""
4040
if ai != nil && ai.HasData {
41-
aiBlock = writer.MakeAIStatsList(ai.AIAdditions, ai.HumanAdditions, ai.AIInputTokens, ai.AIOutputTokens, ai.AvgPromptLength)
41+
aiBlock = writer.MakeAIStatsList(ai.AIAdditions, ai.HumanAdditions, ai.AIInputTokens, ai.AIOutputTokens, ai.PromptLength, ai.AvgPromptLength)
4242
}
4343
return map[string]string{
4444
"LANGUAGE_PER_REPO": writer.MakeLanguagePerRepoList(d.Data.Repositories, version),

pkg/wakatime/stats.go

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -21,15 +21,6 @@ type StatsItem struct {
2121
Hours int `json:"hours"`
2222
Minutes int `json:"minutes"`
2323
Seconds int `json:"seconds"`
24-
25-
// AI attribution fields (populated on projects when the user has GenAI tooling integrated)
26-
AIAdditions int64 `json:"ai_additions"`
27-
AIDeletions int64 `json:"ai_deletions"`
28-
HumanAdditions int64 `json:"human_additions"`
29-
HumanDeletions int64 `json:"human_deletions"`
30-
AIInputTokens int64 `json:"ai_input_tokens"`
31-
AIOutputTokens int64 `json:"ai_output_tokens"`
32-
AIAveragePromptLength float64 `json:"ai_average_prompt_length"`
3324
}
3425

3526
type Stats struct {
@@ -40,6 +31,24 @@ type Stats struct {
4031
Editors []StatsItem `json:"editors"`
4132
Projects []StatsItem `json:"projects"`
4233
OperatingSystems []StatsItem `json:"operating_systems"`
34+
35+
// AI attribution aggregates (top-level totals across the user's activity in this range).
36+
AIAdditions int64 `json:"ai_additions"`
37+
AIDeletions int64 `json:"ai_deletions"`
38+
HumanAdditions int64 `json:"human_additions"`
39+
HumanDeletions int64 `json:"human_deletions"`
40+
AIInputTokens int64 `json:"ai_input_tokens"`
41+
AIOutputTokens int64 `json:"ai_output_tokens"`
42+
43+
// AIAvgPromptLength is doc-defined (avg chars per prompt). Preferred when populated.
44+
AIAvgPromptLength float64 `json:"ai_average_prompt_length"`
45+
// TODO(wakatime-api): remove AIPromptLength once WakaTime's /stats endpoint
46+
// actually returns ai_average_prompt_length at top-level (currently doc-only).
47+
// AIPromptLength is the raw total chars typed to AI tools and is semantically
48+
// NOT an average — we render it under the "Average Prompt" label as a stop-gap
49+
// per user decision. Drop this field + the writer fallback when the API is fixed.
50+
// See: https://wakatime.com/developers#stats
51+
AIPromptLength int64 `json:"ai_prompt_length"`
4352
} `json:"data"`
4453
}
4554

pkg/writer/writer.go

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,11 @@ func MakeCodingStreakList(s *wakatime.AllTimeSinceTodayStats, currentStreak, lon
178178

179179
// MakeAIStatsList returns a summary of AI vs human coding attribution from WakaTime.
180180
// Returns an empty string when there is no AI activity to display, so the block is hidden entirely.
181-
func MakeAIStatsList(aiAdd, humanAdd, inTokens, outTokens int64, avgPrompt float64) string {
181+
//
182+
// The "Average Prompt" row prefers avgPrompt (doc-defined ai_average_prompt_length) and
183+
// falls back to promptLength (ai_prompt_length) while the avg field is missing in API
184+
// responses. The row is omitted when neither value is populated.
185+
func MakeAIStatsList(aiAdd, humanAdd, inTokens, outTokens, promptLength int64, avgPrompt float64) string {
182186
if aiAdd == 0 && inTokens == 0 {
183187
return ""
184188
}
@@ -189,14 +193,29 @@ func MakeAIStatsList(aiAdd, humanAdd, inTokens, outTokens int64, avgPrompt float
189193
aiContribution = float64(aiAdd) / float64(totalAdd) * 100
190194
}
191195

196+
// TODO(wakatime-api): drop the promptLength fallback once WakaTime ships
197+
// ai_average_prompt_length at top-level. Today's API returns only ai_prompt_length
198+
// (a raw total, not an average), so we display the total under the "Average Prompt"
199+
// label as a stop-gap. When avgPrompt becomes non-zero, this branch becomes unreachable
200+
// in practice and the whole switch can collapse to a single avgPrompt assignment.
201+
var promptValue int64
202+
switch {
203+
case avgPrompt > 0:
204+
promptValue = int64(math.Round(avgPrompt))
205+
case promptLength > 0:
206+
promptValue = promptLength
207+
}
208+
192209
var b strings.Builder
193210
b.WriteString("**🤖 My AI Footprint**\n\n")
194211
b.WriteString("```text\n")
195212
fmt.Fprintf(&b, "🤖 Generated by AI: %s lines\n", addCommas(int(aiAdd)))
196213
fmt.Fprintf(&b, "👤 Written by Hand: %s lines\n", addCommas(int(humanAdd)))
197214
fmt.Fprintf(&b, "📊 AI Contribution: %.1f%%\n", aiContribution)
198215
fmt.Fprintf(&b, "🔤 Tokens In / Out: %s / %s\n", humanizeCount(inTokens), humanizeCount(outTokens))
199-
fmt.Fprintf(&b, "💬 Average Prompt: %d chars\n", int(math.Round(avgPrompt)))
216+
if promptValue > 0 {
217+
fmt.Fprintf(&b, "💬 Average Prompt: %s chars\n", humanizeCount(promptValue))
218+
}
200219
b.WriteString("```\n\n")
201220

202221
return b.String()

pkg/writer/writer_test.go

Lines changed: 28 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -122,60 +122,63 @@ func TestMakeCodingStreakList(t *testing.T) {
122122

123123
func TestMakeAIStatsList(t *testing.T) {
124124
tests := []struct {
125-
name string
126-
aiAdd int64
127-
humanAdd int64
128-
inTokens int64
129-
outTokens int64
130-
avgPrompt float64
131-
empty bool
132-
contains []string
125+
name string
126+
aiAdd int64
127+
humanAdd int64
128+
inTokens int64
129+
outTokens int64
130+
promptLength int64
131+
avgPrompt float64
132+
empty bool
133+
contains []string
134+
notContains []string
133135
}{
134136
{
135137
name: "no activity returns empty",
136138
empty: true,
137139
},
138140
{
139-
name: "only AI activity reported",
141+
name: "Average Prompt uses ai_average_prompt_length when populated",
140142
aiAdd: 1500,
141143
inTokens: 120_000,
142144
outTokens: 350_000,
143-
avgPrompt: 142,
145+
avgPrompt: 7200,
144146
contains: []string{
145147
"🤖 My AI Footprint",
146148
"Generated by AI: 1,500 lines",
147149
"Written by Hand: 0 lines",
148150
"AI Contribution: 100.0%",
149151
"Tokens In / Out: 120.0K / 350.0K",
150-
"Average Prompt: 142 chars",
152+
"Average Prompt: 7.2K chars",
151153
},
152154
},
153155
{
154-
name: "mixed AI and human contribution",
155-
aiAdd: 12340,
156-
humanAdd: 8721,
157-
inTokens: 1_200_000,
158-
outTokens: 3_400_000,
159-
avgPrompt: 142.4,
156+
name: "Average Prompt falls back to ai_prompt_length when avg is zero",
157+
aiAdd: 12340,
158+
humanAdd: 8721,
159+
inTokens: 1_200_000,
160+
outTokens: 3_400_000,
161+
promptLength: 484803,
160162
contains: []string{
161163
"AI Contribution: 58.6%",
162164
"Tokens In / Out: 1.2M / 3.4M",
163-
"Average Prompt: 142 chars",
165+
"Average Prompt: 484.8K chars",
164166
},
165167
},
166168
{
167-
name: "small token counts use addCommas",
169+
name: "small token counts use addCommas; prompt row hidden when both zero",
168170
aiAdd: 20,
169171
inTokens: 999,
170172
contains: []string{
171173
"Tokens In / Out: 999 / 0",
172174
},
175+
notContains: []string{"Average Prompt:"},
173176
},
174177
}
175178

176179
for _, tt := range tests {
177180
t.Run(tt.name, func(t *testing.T) {
178-
got := MakeAIStatsList(tt.aiAdd, tt.humanAdd, tt.inTokens, tt.outTokens, tt.avgPrompt)
181+
got := MakeAIStatsList(tt.aiAdd, tt.humanAdd, tt.inTokens, tt.outTokens, tt.promptLength, tt.avgPrompt)
179182

180183
if tt.empty {
181184
if got != "" {
@@ -189,6 +192,11 @@ func TestMakeAIStatsList(t *testing.T) {
189192
t.Errorf("expected output to contain %q, got:\n%s", want, got)
190193
}
191194
}
195+
for _, banned := range tt.notContains {
196+
if strings.Contains(got, banned) {
197+
t.Errorf("expected output to NOT contain %q, got:\n%s", banned, got)
198+
}
199+
}
192200
})
193201
}
194202
}

0 commit comments

Comments
 (0)