Skip to content

Commit 50b75b9

Browse files
authored
Merge pull request #20 from comet-ml/jacques/sankey-breakdown-improvements
feat: bucket thinking by effort level, group file attachments by extension
2 parents 01232ca + 3f52d8f commit 50b75b9

3 files changed

Lines changed: 235 additions & 68 deletions

File tree

docs/metadata-schema.md

Lines changed: 20 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -254,19 +254,28 @@ CLAUDE.md / MEMORY.md / `.agents/` files loaded at session start.
254254

255255
## `cc.thinking` schema
256256

257-
Per-turn aggregate of thinking blocks, grouped by model. (`effort` isn't
258-
in the transcript — left out until we add a SessionStart capture.)
257+
Per-turn aggregate of thinking tokens bucketed by effort level. Level is
258+
inferred from actual tokens per LLM call — the transcript does not expose
259+
the requested `budget_tokens`.
260+
261+
| Level | Thinking tokens per call |
262+
|-------|--------------------------|
263+
| `minimal` | ≤ 500 |
264+
| `light` | 501 – 3 000 |
265+
| `medium` | 3 001 – 10 000 |
266+
| `heavy` | > 10 000 |
259267

260268
```jsonc
261269
"cc": {
262270
"thinking": {
263271
"summary": {
264272
"total_tokens": 9230,
265-
"block_count": 18
273+
"call_count": 5
266274
},
267-
"by_model": [
268-
{ "model": "claude-opus-4-7", "tokens": 8100, "block_count": 14 },
269-
{ "model": "claude-haiku-4-5", "tokens": 1130, "block_count": 4 }
275+
"by_level": [
276+
{ "level": "minimal", "tokens": 130, "call_count": 2 },
277+
{ "level": "light", "tokens": 1000, "call_count": 1 },
278+
{ "level": "heavy", "tokens": 8100, "call_count": 2 }
270279
]
271280
}
272281
}
@@ -326,7 +335,7 @@ turn-level lane total.
326335

327336
## `cc.file_attachments` schema
328337

329-
@-mentioned files and system-injected attachments (excluding skill bodies — those go under `cc.skills.loaded`).
338+
@-mentioned files and system-injected attachments (excluding skill bodies — those go under `cc.skills.loaded`), grouped by file extension.
330339

331340
```jsonc
332341
"cc": {
@@ -335,14 +344,10 @@ turn-level lane total.
335344
"total_tokens": 12400,
336345
"file_count": 4
337346
},
338-
"files": [
339-
{
340-
"path": "/Users/collinc/code/opik/apps/opik-frontend/src/v2/router.tsx",
341-
"sha256": "1234abcd…",
342-
"body_tokens": 8200,
343-
"content_type": "source" // source | log | image | pdf | csv | other
344-
}
345-
//
347+
"by_type": [
348+
{ "ext": ".tsx", "tokens": 8200, "file_count": 1 },
349+
{ "ext": ".md", "tokens": 3100, "file_count": 2 },
350+
{ "ext": "other", "tokens": 1100, "file_count": 1 } // no extension
346351
]
347352
}
348353
}

src/extractors.go

Lines changed: 99 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -290,62 +290,89 @@ func readInstalledPluginPaths(home string) map[string]string {
290290
return out
291291
}
292292

293-
// extractThinkingSnapshot aggregates thinking-block tokens per model,
294-
// driven off the SAME per-block attribution that lands on each span's
295-
// cc.llm_call.attributed_output_tokens. This guarantees that
296-
// Σ attributed[thinking] over the trace == cc.thinking.summary.total_tokens.
297-
// `cc.thinking.{summary, by_model}`.
293+
// extractThinkingSnapshot aggregates thinking-block tokens bucketed by effort
294+
// level. Level is derived from actual thinking tokens per LLM call (the
295+
// transcript does not expose the requested budget_tokens).
298296
//
299-
// `parsed` should be the dedup-applied output of ParseAssistantMessages +
300-
// DeduplicateUsage on the turn's entries. Pass nil to reparse (for
301-
// callers that don't already have a cached slice).
297+
// Buckets: minimal ≤500, light 501–3 000, medium 3 001–10 000, heavy >10 000.
298+
//
299+
// `cc.thinking.{summary, by_level}`.
302300
func extractThinkingSnapshot(entries []TranscriptEntry, parsed []ParsedEntry) map[string]interface{} {
303301
if parsed == nil {
304302
parsed = ParseAssistantMessages(entries)
305303
DeduplicateUsage(parsed)
306304
}
307305

308-
type group struct {
309-
tokens, blockCount int
310-
}
311-
byModel := map[string]*group{}
312-
totalTokens, totalBlocks := 0, 0
313-
306+
// Sum thinking tokens per LLM call (MessageID).
307+
callThinking := map[string]int{}
308+
anonTokens := 0
314309
for _, p := range parsed {
315310
if p.ContentType != "thinking" {
316311
continue
317312
}
318-
g, ok := byModel[p.Model]
319-
if !ok {
320-
g = &group{}
321-
byModel[p.Model] = g
313+
if p.MessageID == "" {
314+
anonTokens += p.AttributedOutputTokens
315+
continue
322316
}
323-
g.tokens += p.AttributedOutputTokens
324-
g.blockCount++
325-
totalTokens += p.AttributedOutputTokens
326-
totalBlocks++
317+
callThinking[p.MessageID] += p.AttributedOutputTokens
318+
}
319+
if anonTokens > 0 {
320+
callThinking["__anon"] = anonTokens
327321
}
328-
if totalBlocks == 0 {
322+
if len(callThinking) == 0 {
329323
return nil
330324
}
331325

332-
byModelOut := make([]map[string]interface{}, 0, len(byModel))
333-
for m, g := range byModel {
334-
byModelOut = append(byModelOut, map[string]interface{}{
335-
"model": m,
336-
"tokens": g.tokens,
337-
"block_count": g.blockCount,
326+
type levelGroup struct{ calls, tokens int }
327+
byLevel := map[string]*levelGroup{}
328+
totalTokens, totalCalls := 0, 0
329+
330+
for _, tok := range callThinking {
331+
l := thinkingLevel(tok)
332+
g, ok := byLevel[l]
333+
if !ok {
334+
g = &levelGroup{}
335+
byLevel[l] = g
336+
}
337+
g.calls++
338+
g.tokens += tok
339+
totalTokens += tok
340+
totalCalls++
341+
}
342+
343+
order := []string{"minimal", "light", "medium", "heavy"}
344+
byLevelOut := make([]map[string]interface{}, 0, len(byLevel))
345+
for _, l := range order {
346+
g, ok := byLevel[l]
347+
if !ok {
348+
continue
349+
}
350+
byLevelOut = append(byLevelOut, map[string]interface{}{
351+
"level": l,
352+
"tokens": g.tokens,
353+
"call_count": g.calls,
338354
})
339355
}
340-
sort.Slice(byModelOut, func(i, j int) bool {
341-
return byModelOut[i]["tokens"].(int) > byModelOut[j]["tokens"].(int)
342-
})
356+
343357
return map[string]interface{}{
344358
"summary": map[string]interface{}{
345359
"total_tokens": totalTokens,
346-
"block_count": totalBlocks,
360+
"call_count": totalCalls,
347361
},
348-
"by_model": byModelOut,
362+
"by_level": byLevelOut,
363+
}
364+
}
365+
366+
func thinkingLevel(tokens int) string {
367+
switch {
368+
case tokens > 10000:
369+
return "heavy"
370+
case tokens > 3000:
371+
return "medium"
372+
case tokens > 500:
373+
return "light"
374+
default:
375+
return "minimal"
349376
}
350377
}
351378

@@ -597,21 +624,20 @@ func promptBucket(tokens int) string {
597624
}
598625

599626
// extractFileAttachmentsSnapshot returns @-mentioned + system-injected file
600-
// attachments this turn. Skill bodies are NOT here — they go under
601-
// cc.skills.loaded. `cc.file_attachments.{summary, files}`.
627+
// attachments this turn grouped by file extension. Skill bodies are NOT here —
628+
// they go under cc.skills.loaded. `cc.file_attachments.{summary, by_type}`.
602629
func extractFileAttachmentsSnapshot(entries []TranscriptEntry) map[string]interface{} {
603-
files := []map[string]interface{}{}
604-
total := 0
630+
type group struct{ tokens, count int }
631+
byExt := map[string]*group{}
632+
total, fileCount := 0, 0
633+
605634
for _, e := range entries {
606635
if e.Type != "attachment" || e.Attachment == nil {
607636
continue
608637
}
609638
if e.Attachment.Type != "file" {
610639
continue
611640
}
612-
// File attachment shape: attachment.content is a JSON object with
613-
// a nested file.content string. The struct treats Content as
614-
// RawMessage so we decode lazily here.
615641
var wrapper struct {
616642
File struct {
617643
Path string `json:"path,omitempty"`
@@ -621,26 +647,46 @@ func extractFileAttachmentsSnapshot(entries []TranscriptEntry) map[string]interf
621647
if err := json.Unmarshal(e.Attachment.Content, &wrapper); err != nil {
622648
continue
623649
}
624-
body := wrapper.File.Content
625-
// Auto-detect — file attachments vary (source code, markdown, JSON, …).
626-
tokens := tokEstimate(body)
627-
files = append(files, map[string]interface{}{
628-
"path": wrapper.File.Path,
629-
"sha256": sha256hex(body),
630-
"body_tokens": tokens,
631-
"content_type": "source", // bucket classification deferred — single bucket for now
632-
})
650+
tokens := tokEstimate(wrapper.File.Content)
651+
652+
ext := strings.ToLower(filepath.Ext(wrapper.File.Path))
653+
if ext == "" {
654+
ext = "other"
655+
}
656+
657+
g, ok := byExt[ext]
658+
if !ok {
659+
g = &group{}
660+
byExt[ext] = g
661+
}
662+
g.tokens += tokens
663+
g.count++
633664
total += tokens
665+
fileCount++
634666
}
635-
if len(files) == 0 {
667+
668+
if fileCount == 0 {
636669
return nil
637670
}
671+
672+
byTypeOut := make([]map[string]interface{}, 0, len(byExt))
673+
for ext, g := range byExt {
674+
byTypeOut = append(byTypeOut, map[string]interface{}{
675+
"ext": ext,
676+
"tokens": g.tokens,
677+
"file_count": g.count,
678+
})
679+
}
680+
sort.Slice(byTypeOut, func(i, j int) bool {
681+
return byTypeOut[i]["tokens"].(int) > byTypeOut[j]["tokens"].(int)
682+
})
683+
638684
return map[string]interface{}{
639685
"summary": map[string]interface{}{
640686
"total_tokens": total,
641-
"file_count": len(files),
687+
"file_count": fileCount,
642688
},
643-
"files": files,
689+
"by_type": byTypeOut,
644690
}
645691
}
646692

src/extractors_test.go

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,122 @@ func TestExtractOutputTokensSnapshotNilOnEmpty(t *testing.T) {
8181
}
8282
}
8383

84+
func TestExtractThinkingSnapshotByLevel(t *testing.T) {
85+
// Three LLM calls with different thinking budgets:
86+
// msg1 → 200 tokens (minimal)
87+
// msg2 → 2000 tokens (light)
88+
// msg3 → 15000 tokens (heavy)
89+
makeEntry := func(msgID string, thinkingTokens int) TranscriptEntry {
90+
return TranscriptEntry{
91+
Type: "assistant",
92+
Message: &Message{
93+
ID: msgID,
94+
Model: "claude-sonnet-4-6",
95+
Usage: &Usage{OutputTokens: thinkingTokens},
96+
Content: ContentSlice{
97+
{Type: "thinking", Thinking: "..."},
98+
},
99+
},
100+
}
101+
}
102+
entries := []TranscriptEntry{
103+
makeEntry("msg1", 200),
104+
makeEntry("msg2", 2000),
105+
makeEntry("msg3", 15000),
106+
}
107+
parsed := ParseAssistantMessages(entries)
108+
DeduplicateUsage(parsed)
109+
110+
snap := extractThinkingSnapshot(entries, parsed)
111+
if snap == nil {
112+
t.Fatal("expected non-nil snapshot")
113+
}
114+
115+
summary := snap["summary"].(map[string]interface{})
116+
if total := summary["total_tokens"].(int); total != 17200 {
117+
t.Errorf("total_tokens = %d, want 17200", total)
118+
}
119+
if calls := summary["call_count"].(int); calls != 3 {
120+
t.Errorf("call_count = %d, want 3", calls)
121+
}
122+
123+
byLevel := snap["by_level"].([]map[string]interface{})
124+
levels := map[string]map[string]interface{}{}
125+
for _, l := range byLevel {
126+
levels[l["level"].(string)] = l
127+
}
128+
129+
if _, ok := levels["minimal"]; !ok {
130+
t.Error("expected minimal level")
131+
}
132+
if _, ok := levels["light"]; !ok {
133+
t.Error("expected light level")
134+
}
135+
if _, ok := levels["heavy"]; !ok {
136+
t.Error("expected heavy level")
137+
}
138+
if _, ok := levels["medium"]; ok {
139+
t.Error("unexpected medium level")
140+
}
141+
142+
if levels["minimal"]["call_count"].(int) != 1 {
143+
t.Errorf("minimal call_count = %d, want 1", levels["minimal"]["call_count"])
144+
}
145+
if levels["heavy"]["tokens"].(int) != 15000 {
146+
t.Errorf("heavy tokens = %d, want 15000", levels["heavy"]["tokens"])
147+
}
148+
}
149+
150+
func TestExtractFileAttachmentsSnapshotByType(t *testing.T) {
151+
makeAttachment := func(path, content string) TranscriptEntry {
152+
raw, _ := json.Marshal(map[string]interface{}{
153+
"file": map[string]string{"path": path, "content": content},
154+
})
155+
return TranscriptEntry{
156+
Type: "attachment",
157+
Attachment: &Attachment{
158+
Type: "file",
159+
Content: raw,
160+
},
161+
}
162+
}
163+
entries := []TranscriptEntry{
164+
makeAttachment("/repo/main.go", "package main\nfunc main() {}"),
165+
makeAttachment("/repo/util.go", "package main"),
166+
makeAttachment("/repo/README.md", "# Hello"),
167+
makeAttachment("/repo/Makefile", "build:"), // no extension → "other"
168+
}
169+
170+
snap := extractFileAttachmentsSnapshot(entries)
171+
if snap == nil {
172+
t.Fatal("expected non-nil snapshot")
173+
}
174+
175+
summary := snap["summary"].(map[string]interface{})
176+
if fc := summary["file_count"].(int); fc != 4 {
177+
t.Errorf("file_count = %d, want 4", fc)
178+
}
179+
180+
byType := snap["by_type"].([]map[string]interface{})
181+
exts := map[string]map[string]interface{}{}
182+
for _, row := range byType {
183+
exts[row["ext"].(string)] = row
184+
}
185+
186+
if _, ok := exts[".go"]; !ok {
187+
t.Error("expected .go entry")
188+
}
189+
if _, ok := exts[".md"]; !ok {
190+
t.Error("expected .md entry")
191+
}
192+
if _, ok := exts["other"]; !ok {
193+
t.Error("expected other entry for Makefile")
194+
}
195+
if exts[".go"]["file_count"].(int) != 2 {
196+
t.Errorf(".go file_count = %d, want 2", exts[".go"]["file_count"])
197+
}
198+
}
199+
84200
func TestExtractAgentsSnapshotPrefersFrontmatterName(t *testing.T) {
85201
home := t.TempDir()
86202
cwd := t.TempDir()

0 commit comments

Comments
 (0)