waldiez 0.5.2__py3-none-any.whl → 0.5.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of waldiez might be problematic. Click here for more details.

@@ -0,0 +1,1248 @@
1
+ # SPDX-License-Identifier: Apache-2.0.
2
+ # Copyright (c) 2024 - 2025 Waldiez and contributors.
3
+ # pylint: skip-file
4
+ # pyright: reportArgumentType=false,reportUnknownVariableType=false
5
+ # pyright: reportUnknownMemberType=false,reportUnknownArgumentType=false
6
+ # flake8: noqa: C901
7
+ """
8
+ Timeline Analysis Data Processor.
9
+
10
+ Processes CSV files and outputs JSON structure for timeline visualization
11
+ """
12
+
13
+ import argparse
14
+ import json
15
+ import os
16
+ import re
17
+ from pathlib import Path
18
+ from typing import TYPE_CHECKING, Any
19
+
20
+ import pandas as pd
21
+
22
+ from waldiez.logger import WaldiezLogger
23
+
24
+ if TYPE_CHECKING:
25
+ Series = pd.Series[Any]
26
+ else:
27
+ Series = pd.Series
28
+
29
+ # Color palettes
30
+ AGENT_COLORS = [
31
+ "#FF6B35",
32
+ "#4A90E2",
33
+ "#7ED321",
34
+ "#9013FE",
35
+ "#FF9500",
36
+ "#FF3B30",
37
+ "#007AFF",
38
+ "#34C759",
39
+ "#AF52DE",
40
+ "#FF9F0A",
41
+ "#FF2D92",
42
+ "#5AC8FA",
43
+ "#30D158",
44
+ "#BF5AF2",
45
+ "#FFD60A",
46
+ "#FF453A",
47
+ "#64D2FF",
48
+ "#32D74B",
49
+ "#DA70D6",
50
+ "#FFD23F",
51
+ ]
52
+
53
+ ACTIVITY_COLORS = {
54
+ "human_input_waiting": "#FF8C00",
55
+ "user_thinking": "#87CEEB",
56
+ "agent_transition": "#FF7043",
57
+ "tool_call": "#4CAF50",
58
+ "function_call": "#9C27B0",
59
+ "processing": "#BDBDBD",
60
+ "session": "#8B5CF6",
61
+ }
62
+
63
+ DEFAULT_AGENT_COLOR = "#E5E7EB"
64
+
65
+ LOG = WaldiezLogger()
66
+
67
+
68
+ class TimelineProcessor:
69
+ """Class to process timeline data from CSV files."""
70
+
71
+ agents_data: pd.DataFrame | None
72
+ chat_data: pd.DataFrame | None
73
+ events_data: pd.DataFrame | None
74
+ functions_data: pd.DataFrame | None
75
+
76
+ def __init__(self) -> None:
77
+ """Initialize the TimelineProcessor with empty data attributes."""
78
+ self.agents_data = None
79
+ self.chat_data = None
80
+ self.events_data = None
81
+ self.functions_data = None
82
+
83
+ def is_missing_or_nan(self, value: Any) -> bool:
84
+ """Check if a value is missing, NaN, or empty.
85
+
86
+ Parameters
87
+ ----------
88
+ value : Any
89
+ The value to check.
90
+
91
+ Returns
92
+ -------
93
+ bool
94
+ True if the value is missing, NaN, or empty; False otherwise.
95
+ """
96
+ if pd.isna(value): # pyright: ignore
97
+ return True
98
+ if isinstance(value, str) and (
99
+ value.strip() == "" or value.lower() == "nan"
100
+ ):
101
+ return True
102
+ return False
103
+
104
+ def fill_missing_agent_names(
105
+ self, data: pd.DataFrame | None, name_column: str = "source_name"
106
+ ) -> pd.DataFrame | None:
107
+ """Fill missing agent names with the previous valid name.
108
+
109
+ Parameters
110
+ ----------
111
+ data : pd.DataFrame | None
112
+ DataFrame containing agent names.
113
+ name_column : str, optional
114
+ The column name containing agent names, by default "source_name".
115
+
116
+ Returns
117
+ -------
118
+ pd.DataFrame | None
119
+ DataFrame with missing agent names filled.
120
+ """
121
+ if data is None or data.empty:
122
+ return data
123
+
124
+ data = data.copy()
125
+ last_valid_name: str | None = None
126
+
127
+ for idx in range(len(data)):
128
+ current_name = data.iloc[idx][name_column]
129
+
130
+ if self.is_missing_or_nan(current_name):
131
+ if last_valid_name is not None:
132
+ column = data.columns.get_loc(name_column)
133
+ data.iloc[idx, column] = last_valid_name # type: ignore[index]
134
+ LOG.debug(
135
+ "Row %d: Replaced missing agent name with '%s'",
136
+ idx,
137
+ last_valid_name,
138
+ )
139
+ else:
140
+ # If no previous valid name, use a default
141
+ default_name = "unknown_agent"
142
+ column = data.columns.get_loc(name_column)
143
+ data.iloc[idx, column] = default_name # type: ignore[index]
144
+ last_valid_name = default_name
145
+ LOG.debug(
146
+ "Row %d: Used default agent name '%s'",
147
+ idx,
148
+ default_name,
149
+ )
150
+ else:
151
+ last_valid_name = current_name
152
+
153
+ return data
154
+
155
+ def fill_missing_agent_data(self) -> None:
156
+ """Fill missing agent names in agents_data."""
157
+ if self.agents_data is None:
158
+ return
159
+
160
+ self.agents_data = self.fill_missing_agent_names(
161
+ self.agents_data, "name"
162
+ )
163
+
164
+ def load_csv_files(
165
+ self,
166
+ agents_file: str | None = None,
167
+ chat_file: str | None = None,
168
+ events_file: str | None = None,
169
+ functions_file: str | None = None,
170
+ ) -> None:
171
+ """Load CSV files into pandas DataFrames.
172
+
173
+ Parameters
174
+ ----------
175
+ agents_file : str | None
176
+ Path to the agents CSV file.
177
+ chat_file : str | None
178
+ Path to the chat CSV file.
179
+ events_file : str | None
180
+ Path to the events CSV file.
181
+ functions_file : str | None
182
+ Path to the functions CSV file.
183
+ """
184
+ if agents_file:
185
+ self.agents_data = pd.read_csv(agents_file)
186
+ LOG.info("Loaded agents data: %d rows", len(self.agents_data))
187
+ # Fill missing agent names
188
+ self.fill_missing_agent_data()
189
+
190
+ if chat_file:
191
+ self.chat_data = pd.read_csv(chat_file)
192
+ LOG.info("Loaded chat data: %d rows", len(self.chat_data))
193
+ # Fill missing agent names in chat data
194
+ self.chat_data = self.fill_missing_agent_names(
195
+ self.chat_data, "source_name"
196
+ )
197
+
198
+ if events_file:
199
+ self.events_data = pd.read_csv(events_file)
200
+ LOG.info("Loaded events data: %d rows", len(self.events_data))
201
+
202
+ if functions_file:
203
+ self.functions_data = pd.read_csv(functions_file)
204
+ LOG.info("Loaded functions data: %d rows", len(self.functions_data))
205
+
206
+ def parse_date(self, date_str: str) -> pd.Timestamp:
207
+ """Parse date string to datetime.
208
+
209
+ Parameters
210
+ ----------
211
+ date_str : str
212
+ The date string to parse.
213
+
214
+ Returns
215
+ -------
216
+ pd.Timestamp
217
+ The parsed datetime.
218
+ """
219
+ try:
220
+ return pd.to_datetime(date_str)
221
+ except Exception:
222
+ coerced = pd.to_datetime(date_str, errors="coerce")
223
+ if isinstance(coerced, pd.Timestamp):
224
+ return coerced
225
+ return pd.Timestamp("1970-01-01")
226
+
227
+ def generate_agent_colors(self, agent_names: list[str]) -> dict[str, str]:
228
+ """Generate color mapping for agents.
229
+
230
+ Parameters
231
+ ----------
232
+ agent_names : list[str]
233
+ List of agent names.
234
+
235
+ Returns
236
+ -------
237
+ dict[str, str]
238
+ Mapping of agent names to their assigned colors.
239
+ """
240
+ colors = {}
241
+ for i, agent in enumerate(agent_names):
242
+ colors[agent] = AGENT_COLORS[i % len(AGENT_COLORS)]
243
+ return colors
244
+
245
+ def extract_token_info(
246
+ self,
247
+ request_str: Any,
248
+ response_str: Any,
249
+ ) -> dict[str, int]:
250
+ """Extract token information from request/response strings.
251
+
252
+ Parameters
253
+ ----------
254
+ request_str : Any
255
+ The request string containing token usage information.
256
+ response_str : Any
257
+ The response string containing token usage information.
258
+
259
+ Returns
260
+ -------
261
+ dict[str, int]
262
+ A dictionary containing the extracted token information.
263
+ """
264
+ prompt_tokens = 0
265
+ completion_tokens = 0
266
+ total_tokens = 0
267
+ try:
268
+ # Try to parse as JSON first
269
+ if (
270
+ request_str
271
+ and isinstance(request_str, str)
272
+ and request_str.strip().startswith("{")
273
+ ):
274
+ request_data = json.loads(request_str)
275
+ if "usage" in request_data:
276
+ prompt_tokens = request_data["usage"].get(
277
+ "prompt_tokens", 0
278
+ )
279
+ elif "prompt_tokens" in request_data:
280
+ prompt_tokens = request_data["prompt_tokens"]
281
+ elif "messages" in request_data:
282
+ # Estimate tokens from content length
283
+ content_length = sum(
284
+ len(msg.get("content", ""))
285
+ for msg in request_data["messages"]
286
+ if "content" in msg and msg["content"]
287
+ )
288
+ prompt_tokens = max(1, content_length // 4)
289
+
290
+ if (
291
+ response_str
292
+ and isinstance(response_str, str)
293
+ and response_str.strip().startswith("{")
294
+ ):
295
+ response_data = json.loads(response_str)
296
+ if "usage" in response_data:
297
+ prompt_tokens = response_data["usage"].get(
298
+ "prompt_tokens", prompt_tokens
299
+ )
300
+ completion_tokens = response_data["usage"].get(
301
+ "completion_tokens", 0
302
+ )
303
+ total_tokens = response_data["usage"].get(
304
+ "total_tokens", prompt_tokens + completion_tokens
305
+ )
306
+ except json.JSONDecodeError:
307
+ # Fallback to regex patterns if JSON parsing fails
308
+ pass
309
+
310
+ if total_tokens == 0 and (prompt_tokens > 0 or completion_tokens > 0):
311
+ total_tokens = prompt_tokens + completion_tokens
312
+
313
+ return {
314
+ "prompt_tokens": prompt_tokens,
315
+ "completion_tokens": completion_tokens,
316
+ "total_tokens": total_tokens,
317
+ }
318
+
319
+ def extract_llm_model(
320
+ self, agent_name: str, request_str: Any = None
321
+ ) -> str:
322
+ """Extract LLM model from agent data or request.
323
+
324
+ Parameters
325
+ ----------
326
+ agent_name : str
327
+ The name of the agent.
328
+ request_str : Any, optional
329
+ The request string containing token usage information.
330
+
331
+ Returns
332
+ -------
333
+ str
334
+ The extracted LLM model name.
335
+ """
336
+ # Handle missing/nan agent names
337
+ if self.is_missing_or_nan(agent_name):
338
+ agent_name = "unknown_agent"
339
+
340
+ # First try to extract from request_str (chat_completions.csv)
341
+ if request_str:
342
+ model = self._extract_model_from_text(str(request_str))
343
+ if model != "Unknown":
344
+ return model
345
+
346
+ # Then try to extract from agents data
347
+ if self.agents_data is not None:
348
+ agent_row = self.agents_data[self.agents_data["name"] == agent_name]
349
+ if not agent_row.empty and "init_args" in agent_row.columns:
350
+ init_args = str(agent_row.iloc[0]["init_args"])
351
+ model = self._extract_model_from_text(init_args)
352
+ if model != "Unknown":
353
+ return model
354
+
355
+ return "Unknown"
356
+
357
+ def _extract_model_from_text(self, text: Any) -> str:
358
+ """Extract model name from text using dynamic parsing.
359
+
360
+ Parameters
361
+ ----------
362
+ text : Any
363
+ The text to extract the model name from.
364
+
365
+ Returns
366
+ -------
367
+ str
368
+ The extracted model name.
369
+ """
370
+ if not text or not isinstance(text, str):
371
+ return "Unknown"
372
+
373
+ try:
374
+ # Try JSON parsing first
375
+ if text.strip().startswith("{"):
376
+ model = self._extract_model_from_json(text)
377
+ if model != "Unknown":
378
+ return model
379
+ except json.JSONDecodeError:
380
+ pass
381
+
382
+ # Use dynamic regex patterns to catch any model-like strings
383
+ model = self._extract_model_with_regex(text)
384
+ if model != "Unknown":
385
+ return model
386
+
387
+ return "Unknown"
388
+
389
+ def _extract_model_from_json(self, text: str) -> str:
390
+ """Extract model from JSON text using comprehensive key search.
391
+
392
+ Parameters
393
+ ----------
394
+ text : str
395
+ The JSON text to extract the model name from.
396
+
397
+ Returns
398
+ -------
399
+ str
400
+ The extracted model name.
401
+ """
402
+ try:
403
+ parsed = json.loads(text)
404
+
405
+ # Direct model keys
406
+ model_keys = [
407
+ "model",
408
+ "llm_model",
409
+ "engine",
410
+ "model_name",
411
+ "model_id",
412
+ ]
413
+ for key in model_keys:
414
+ if key in parsed and isinstance(parsed[key], str):
415
+ return parsed[key]
416
+
417
+ # Nested searches for different structures
418
+ # Structure 1: config_list array (from agents.csv)
419
+ if "config_list" in parsed and isinstance(
420
+ parsed["config_list"], list
421
+ ):
422
+ for config in parsed["config_list"]:
423
+ if isinstance(config, dict) and "model" in config:
424
+ return config["model"]
425
+
426
+ # Structure 2: llm_config._model.config_list (from agents.csv)
427
+ if "llm_config" in parsed:
428
+ llm_config = parsed["llm_config"]
429
+ if isinstance(llm_config, dict):
430
+ if "_model" in llm_config and isinstance(
431
+ llm_config["_model"], dict
432
+ ):
433
+ model_config = llm_config["_model"]
434
+ if "config_list" in model_config and isinstance(
435
+ model_config["config_list"], list
436
+ ):
437
+ for config in model_config["config_list"]:
438
+ if (
439
+ isinstance(config, dict)
440
+ and "model" in config
441
+ ):
442
+ return config["model"]
443
+ # Also check direct model keys in _model
444
+ for key in model_keys:
445
+ if key in model_config and isinstance(
446
+ model_config[key], str
447
+ ):
448
+ return model_config[key]
449
+
450
+ # Check llm_config level for model keys
451
+ for key in model_keys:
452
+ if key in llm_config and isinstance(
453
+ llm_config[key], str
454
+ ):
455
+ return llm_config[key]
456
+
457
+ # Structure 3: Recursive search for any model key in nested objects
458
+
459
+ model = recursive_search(parsed, model_keys)
460
+ if model != "Unknown":
461
+ return model
462
+
463
+ except (json.JSONDecodeError, AttributeError, TypeError):
464
+ pass
465
+
466
+ return "Unknown"
467
+
468
+ def _extract_model_with_regex(self, text: str) -> str:
469
+ """Extract model using flexible regex patterns.
470
+
471
+ Parameters
472
+ ----------
473
+ text : str
474
+ The input text from which to extract the model name.
475
+
476
+ Returns
477
+ -------
478
+ str
479
+ The extracted model name or "Unknown" if not found.
480
+ """
481
+ # Dynamic patterns that can catch various model names
482
+ patterns = [
483
+ # OpenAI models - flexible to catch versions like gpt-4.1, gpt-4o...
484
+ r"\bgpt-[0-9]+(?:\.[0-9]+)?[a-zA-Z]*(?:-[a-zA-Z0-9]+)*\b",
485
+ # Claude models - flexible for various versions
486
+ r"\bclaude-[0-9]+(?:\.[0-9]+)?(?:-[a-zA-Z0-9]+)*\b",
487
+ # Gemini models
488
+ r"\bgemini-[a-zA-Z0-9]+(?:-[a-zA-Z0-9]+)*\b",
489
+ # Generic model patterns
490
+ r"\b[a-zA-Z]+-[0-9]+(?:\.[0-9]+)?[a-zA-Z]*(?:-[a-zA-Z0-9]+)*\b",
491
+ # Anthropic models
492
+ r"\b(?:anthropic|claude)[/_-][a-zA-Z0-9]+(?:[._-][a-zA-Z0-9]+)*\b",
493
+ # Other common patterns
494
+ r"\b(?:llama|mistral|falcon|vicuna|alpaca)[/_-]?[0-9]+[a-zA-Z]*(?:[._-][a-zA-Z0-9]+)*\b",
495
+ # Cohere models
496
+ r"\bcommand[/_-]?[a-zA-Z0-9]*\b",
497
+ # Generic AI model patterns
498
+ r"\b[a-zA-Z]+(?:ai|ml|model)[/_-]?[0-9]+[a-zA-Z]*\b",
499
+ ]
500
+
501
+ for pattern in patterns:
502
+ matches = re.findall(pattern, text, re.IGNORECASE)
503
+ if matches:
504
+ # Return the first match, but prefer longer matches
505
+ best_match = max(matches, key=len)
506
+ return best_match
507
+
508
+ # Last resort: look for any word that might be a model name
509
+ # This catches custom or unknown models
510
+ model_indicators = [
511
+ r'model["\']?\s*[:=]\s*["\']?([a-zA-Z0-9._-]+)',
512
+ r'engine["\']?\s*[:=]\s*["\']?([a-zA-Z0-9._-]+)',
513
+ r'"model"\s*:\s*"([^"]+)"',
514
+ r"'model'\s*:\s*'([^']+)'",
515
+ ]
516
+
517
+ for pattern in model_indicators:
518
+ matches = re.findall(pattern, text, re.IGNORECASE)
519
+ if matches:
520
+ return matches[0]
521
+
522
+ return "Unknown"
523
+
524
+ def is_human_input_waiting_period(
525
+ self,
526
+ prev_session: Series,
527
+ current_session: Series,
528
+ gap_duration: float,
529
+ ) -> bool:
530
+ """Detect if gap represents human input waiting.
531
+
532
+ Parameters
533
+ ----------
534
+ prev_session : Series
535
+ The previous session data.
536
+ current_session : Series
537
+ The current session data.
538
+ gap_duration : float
539
+ The duration of the gap to analyze.
540
+
541
+ Returns
542
+ -------
543
+ bool
544
+ True if gap likely represents human input waiting, False otherwise.
545
+ """
546
+ if gap_duration < 1.0: # Reduced threshold for better detection
547
+ return False
548
+
549
+ if self.events_data is None:
550
+ return False
551
+
552
+ # Get events around the gap period
553
+ prev_end = self.parse_date(prev_session["end_time"])
554
+ current_start = self.parse_date(current_session["start_time"])
555
+
556
+ # Look for user message events right after the gap (within 1 second)
557
+ after_gap_window = current_start + pd.Timedelta(seconds=1)
558
+
559
+ user_events_after_gap = self.events_data[
560
+ (pd.to_datetime(self.events_data["timestamp"]) >= current_start)
561
+ & (
562
+ pd.to_datetime(self.events_data["timestamp"])
563
+ <= after_gap_window
564
+ )
565
+ & (self.events_data["event_name"] == "received_message")
566
+ ]
567
+
568
+ # Check if any of these events contain user messages
569
+ user_message_found = False
570
+ for _, event in user_events_after_gap.iterrows():
571
+ if self.is_user_message_event(event):
572
+ user_message_found = True
573
+ break
574
+
575
+ if user_message_found:
576
+ return True
577
+
578
+ # Alternative check: look for gaps that are longer and likely represent
579
+ # user thinking time
580
+ # This catches cases where user input detection might be missed
581
+ if gap_duration > 5.0: # Longer gaps are more likely to be user input
582
+ # Check if there are any user messages in the broader timeline
583
+ # around this gap
584
+ broader_window_start = prev_end - pd.Timedelta(seconds=2)
585
+ broader_window_end = current_start + pd.Timedelta(seconds=5)
586
+
587
+ broader_events = self.events_data[
588
+ (
589
+ pd.to_datetime(self.events_data["timestamp"])
590
+ >= broader_window_start
591
+ )
592
+ & (
593
+ pd.to_datetime(self.events_data["timestamp"])
594
+ <= broader_window_end
595
+ )
596
+ ]
597
+
598
+ # Look for user message patterns
599
+ for _, event in broader_events.iterrows():
600
+ if self.is_user_message_event(event):
601
+ return True
602
+
603
+ return False
604
+
605
+ def is_user_message_event(self, event: Series) -> bool:
606
+ """Check if an event represents a user message.
607
+
608
+ Parameters
609
+ ----------
610
+ event : Series
611
+ The event data to check.
612
+
613
+ Returns
614
+ -------
615
+ bool
616
+ True if the event represents a user message, False otherwise.
617
+ """
618
+ json_state = event.get("json_state", "")
619
+ if not json_state or not isinstance(json_state, str):
620
+ return False
621
+
622
+ try:
623
+ parsed = json.loads(json_state)
624
+ # Check for user role in message
625
+ if parsed.get("message", {}).get("role") == "user":
626
+ return True
627
+ # Check for customer sender (another indicator of user input)
628
+ if parsed.get("sender") == "customer":
629
+ return True
630
+ except (json.JSONDecodeError, AttributeError, TypeError):
631
+ pass
632
+
633
+ return False
634
+
635
+ def categorize_gap_activity(
636
+ self,
637
+ prev_session: Series,
638
+ current_session: Series,
639
+ gap_duration: float,
640
+ ) -> dict[str, Any]:
641
+ """Categorize what happened during a gap.
642
+
643
+ Parameters
644
+ ----------
645
+ prev_session : Series
646
+ The previous session data.
647
+ current_session : Series
648
+ The current session data.
649
+ gap_duration : float
650
+ The duration of the gap in seconds.
651
+
652
+ Returns
653
+ -------
654
+ dict[str, Any]
655
+ A dictionary categorizing the gap activity.
656
+ """
657
+ # First check for human input waiting period
658
+ if self.is_human_input_waiting_period(
659
+ prev_session, current_session, gap_duration
660
+ ):
661
+ return {
662
+ "type": "human_input_waiting",
663
+ "label": "👤 Human Input",
664
+ "detail": f"Waiting for user ({gap_duration:.1f}s)",
665
+ }
666
+
667
+ # Check for function calls during gap
668
+ if self.functions_data is not None:
669
+ prev_end = self.parse_date(prev_session["end_time"])
670
+ current_start = self.parse_date(current_session["start_time"])
671
+
672
+ gap_functions = self.functions_data[
673
+ (pd.to_datetime(self.functions_data["timestamp"]) >= prev_end)
674
+ & (
675
+ pd.to_datetime(self.functions_data["timestamp"])
676
+ <= current_start
677
+ )
678
+ ]
679
+
680
+ if not gap_functions.empty:
681
+ primary_function = gap_functions.iloc[0]["function_name"]
682
+
683
+ if (
684
+ "transfer" in primary_function.lower()
685
+ or "switch" in primary_function.lower()
686
+ ):
687
+ detail = (
688
+ f"{primary_function} → {current_session['source_name']}"
689
+ )
690
+ return {
691
+ "type": "agent_transition",
692
+ "label": "🔄 Transfer",
693
+ "detail": detail,
694
+ }
695
+ else:
696
+ return {
697
+ "type": "tool_call",
698
+ "label": f"🛠️ {primary_function.replace('_', ' ')}",
699
+ "detail": "Tool execution",
700
+ }
701
+
702
+ # Check if agent changed
703
+ if prev_session["source_name"] != current_session["source_name"]:
704
+ detail = (
705
+ f"{prev_session['source_name']} → "
706
+ f"{current_session['source_name']}"
707
+ )
708
+ return {
709
+ "type": "agent_transition",
710
+ "label": "🔄 Agent Switch",
711
+ "detail": detail,
712
+ }
713
+
714
+ # For longer gaps without clear indicators,
715
+ # they might still be user input
716
+ # This provides a fallback for cases
717
+ # where the user input detection might miss
718
+ if gap_duration > 8.0: # Longer gaps are more likely to be user input
719
+ return {
720
+ "type": "human_input_waiting",
721
+ "label": "👤 Likely User Input",
722
+ "detail": f"Probable user input ({gap_duration:.1f}s)",
723
+ }
724
+
725
+ return {
726
+ "type": "processing",
727
+ "label": "⚙️ Processing",
728
+ "detail": f"Processing ({gap_duration:.1f}s)",
729
+ }
730
+
731
+ def compress_timeline(
732
+ self,
733
+ ) -> tuple[list[dict[str, Any]], list[dict[str, Any]], float, float]:
734
+ """Create compressed timeline from chat data.
735
+
736
+ Processes chat data and generates a compressed timeline with gaps,
737
+ sessions, and cost information.
738
+
739
+ Returns
740
+ -------
741
+ tuple[list[dict[str, Any]], list[dict[str, Any]], float, float]
742
+ A tuple containing:
743
+ - Compressed timeline as a list of dictionaries.
744
+ - Cost timeline as a list of dictionaries.
745
+ - Total compressed time.
746
+ - Cumulative cost.
747
+
748
+ Raises
749
+ ------
750
+ ValueError
751
+ If chat data is not provided.
752
+ """
753
+ if self.chat_data is None:
754
+ raise ValueError("Chat data is required")
755
+
756
+ LOG.info("Starting timeline compression...")
757
+
758
+ # Sort by start time and calculate durations
759
+ chat_sorted = self.chat_data.copy()
760
+ chat_sorted["start_time"] = pd.to_datetime(chat_sorted["start_time"])
761
+ chat_sorted["end_time"] = pd.to_datetime(chat_sorted["end_time"])
762
+ chat_sorted = chat_sorted.sort_values("start_time")
763
+ chat_sorted["duration"] = (
764
+ chat_sorted["end_time"] - chat_sorted["start_time"]
765
+ ).dt.total_seconds()
766
+
767
+ LOG.info(
768
+ "Sorted chat data by start time. Total sessions: %d",
769
+ len(chat_sorted),
770
+ )
771
+
772
+ timeline: list[dict[str, Any]] = []
773
+ cost_timeline: list[dict[str, Any]] = []
774
+ current_compressed_time = 0.0
775
+ cumulative_cost = 0.0
776
+ session_id = 1
777
+
778
+ for _i, (_idx, row) in enumerate(chat_sorted.iterrows()):
779
+ try:
780
+ # Get agent name and handle missing values
781
+ agent_name = row["source_name"]
782
+ if self.is_missing_or_nan(agent_name):
783
+ agent_name = "unknown_agent"
784
+
785
+ LOG.debug(
786
+ "Processing session %d: %s",
787
+ session_id,
788
+ agent_name,
789
+ )
790
+ start_compressed = current_compressed_time
791
+ gap_before = 0
792
+ gap_activity = None
793
+
794
+ if session_id > 1: # Not the first session
795
+ prev_row = chat_sorted.iloc[session_id - 2] # Previous row
796
+ gap_duration = (
797
+ row["start_time"] - prev_row["end_time"]
798
+ ).total_seconds()
799
+
800
+ gap_activity = self.categorize_gap_activity(
801
+ prev_row, row, gap_duration
802
+ )
803
+
804
+ # Determine compressed gap duration
805
+ if gap_activity["type"] == "human_input_waiting":
806
+ compressed_gap = 1.0
807
+ gap_before = gap_duration
808
+ elif gap_duration > 2.0 and gap_activity["type"] in [
809
+ "processing",
810
+ "user_thinking",
811
+ ]:
812
+ compressed_gap = 2.0
813
+ gap_before = gap_duration
814
+ else:
815
+ compressed_gap = gap_duration
816
+ gap_before = gap_duration
817
+
818
+ # Add gap to timeline if significant
819
+ if gap_before > 0.1:
820
+ gap_start = current_compressed_time
821
+ gap_end = gap_start + compressed_gap
822
+
823
+ timeline.append(
824
+ {
825
+ "id": f"gap_{session_id - 1}",
826
+ "type": "gap",
827
+ "gap_type": gap_activity["type"],
828
+ "start": gap_start,
829
+ "end": gap_end,
830
+ "duration": compressed_gap,
831
+ "value": compressed_gap,
832
+ "real_duration": gap_before,
833
+ "compressed": gap_activity["type"]
834
+ == "human_input_waiting"
835
+ or (
836
+ gap_duration > 2.0
837
+ and gap_activity["type"]
838
+ in ["processing", "user_thinking"]
839
+ ),
840
+ "color": ACTIVITY_COLORS.get(
841
+ gap_activity["type"],
842
+ ACTIVITY_COLORS["processing"],
843
+ ),
844
+ "label": (
845
+ gap_activity["label"]
846
+ + f" ({gap_before:.1f}s)"
847
+ if gap_before != compressed_gap
848
+ else gap_activity["label"]
849
+ ),
850
+ "y_position": session_id - 0.5,
851
+ }
852
+ )
853
+
854
+ current_compressed_time += compressed_gap
855
+ start_compressed = current_compressed_time
856
+
857
+ end_compressed = start_compressed + row["duration"]
858
+
859
+ # Extract token info with error handling
860
+ try:
861
+ token_info = self.extract_token_info(
862
+ row.get("request", ""), row.get("response", "")
863
+ )
864
+ except Exception as e:
865
+ LOG.error(
866
+ "Error extracting token info for session %s: %s",
867
+ session_id,
868
+ e,
869
+ )
870
+ token_info = {
871
+ "prompt_tokens": 0,
872
+ "completion_tokens": 0,
873
+ "total_tokens": 0,
874
+ }
875
+
876
+ # Add session to timeline
877
+ timeline.append(
878
+ {
879
+ "id": f"session_{session_id}",
880
+ "type": "session",
881
+ "start": start_compressed,
882
+ "end": end_compressed,
883
+ "duration": row["duration"],
884
+ "value": row["duration"],
885
+ "agent": agent_name,
886
+ # Will be updated if agents data available
887
+ "agent_class": agent_name,
888
+ "cost": row.get("cost", 0),
889
+ "tokens": token_info["total_tokens"],
890
+ "prompt_tokens": token_info["prompt_tokens"],
891
+ "completion_tokens": token_info["completion_tokens"],
892
+ "events": 1, # Placeholder
893
+ "color": DEFAULT_AGENT_COLOR, # Will be updated later
894
+ "label": f"S{session_id}: {agent_name}",
895
+ "is_cached": bool(row.get("is_cached", False)),
896
+ "y_position": session_id,
897
+ "llm_model": self.extract_llm_model(
898
+ agent_name, row.get("request", "")
899
+ ),
900
+ "session_id": row.get(
901
+ "session_id", f"session_{session_id}"
902
+ ),
903
+ "real_start_time": row["start_time"].strftime(
904
+ "%H:%M:%S"
905
+ ),
906
+ "request": row.get("request", ""),
907
+ "response": row.get("response", ""),
908
+ }
909
+ )
910
+
911
+ # Add to cost timeline
912
+ cumulative_cost += row.get("cost", 0)
913
+ cost_timeline.append(
914
+ {
915
+ "time": start_compressed + row["duration"] / 2,
916
+ "cumulative_cost": cumulative_cost,
917
+ "session_cost": row.get("cost", 0),
918
+ "session_id": session_id,
919
+ }
920
+ )
921
+
922
+ current_compressed_time = end_compressed
923
+ session_id += 1
924
+
925
+ except Exception as e:
926
+ LOG.error(
927
+ "Error processing session %d: %s",
928
+ session_id,
929
+ e,
930
+ )
931
+ LOG.error("Row data: %s", dict(row))
932
+ raise
933
+
934
+ # Finalize timeline
935
+ if not timeline:
936
+ LOG.warning("No valid sessions found in chat data.")
937
+ return [], [], 0.0, 0.0
938
+ LOG.info(
939
+ "Timeline compression complete. Generated %d items.",
940
+ len(timeline),
941
+ )
942
+ return timeline, cost_timeline, current_compressed_time, cumulative_cost
943
+
944
+ def process_timeline(self) -> dict[str, Any]:
945
+ """Timeline processing function.
946
+
947
+ Processes chat data and generates a timeline with summary statistics.
948
+
949
+ Returns
950
+ -------
951
+ dict
952
+ A dictionary containing the processed timeline, cost timeline,
953
+ """
954
+ if self.chat_data is None:
955
+ raise ValueError("Chat data is required for processing")
956
+
957
+ timeline, cost_timeline, total_time, total_cost = (
958
+ self.compress_timeline()
959
+ )
960
+
961
+ # Get unique agents and assign colors
962
+ # (filter out any remaining NaN values)
963
+ agents_in_timeline = list(
964
+ {
965
+ item["agent"]
966
+ for item in timeline
967
+ if item["type"] == "session"
968
+ and not self.is_missing_or_nan(item["agent"])
969
+ }
970
+ )
971
+ agent_colors = self.generate_agent_colors(agents_in_timeline)
972
+
973
+ # Update timeline with colors and agent classes
974
+ for item in timeline:
975
+ if item["type"] == "session":
976
+ agent_name = item["agent"]
977
+ if self.is_missing_or_nan(agent_name):
978
+ agent_name = "unknown_agent"
979
+ item["agent"] = agent_name
980
+
981
+ item["color"] = agent_colors.get(
982
+ agent_name, DEFAULT_AGENT_COLOR
983
+ )
984
+
985
+ # Update agent class if agents data available
986
+ if self.agents_data is not None:
987
+ agent_row = self.agents_data[
988
+ self.agents_data["name"] == agent_name
989
+ ]
990
+ if not agent_row.empty and "class" in agent_row.columns:
991
+ agent_class = agent_row.iloc[0]["class"]
992
+ if not self.is_missing_or_nan(agent_class):
993
+ item["agent_class"] = agent_class
994
+
995
+ # Create agents list
996
+ agents: list[dict[str, Any]] = []
997
+ for agent_name in agents_in_timeline:
998
+ if self.is_missing_or_nan(agent_name):
999
+ continue
1000
+
1001
+ agent_class = agent_name # Default
1002
+ if self.agents_data is not None:
1003
+ agent_row = self.agents_data[
1004
+ self.agents_data["name"] == agent_name
1005
+ ]
1006
+ if not agent_row.empty and "class" in agent_row.columns:
1007
+ agent_class_value = agent_row.iloc[0]["class"]
1008
+ if not self.is_missing_or_nan(agent_class_value):
1009
+ agent_class = agent_class_value
1010
+
1011
+ agents.append(
1012
+ {
1013
+ "name": agent_name,
1014
+ "class": agent_class,
1015
+ "color": agent_colors.get(agent_name, DEFAULT_AGENT_COLOR),
1016
+ }
1017
+ )
1018
+
1019
+ # Calculate summary statistics
1020
+ sessions = [item for item in timeline if item["type"] == "session"]
1021
+ gaps = [item for item in timeline if item["type"] == "gap"]
1022
+
1023
+ total_tokens = sum(session["tokens"] for session in sessions)
1024
+ gaps_compressed = sum(1 for gap in gaps if gap["compressed"])
1025
+ time_saved = sum(
1026
+ gap["real_duration"] - gap["duration"]
1027
+ for gap in gaps
1028
+ if gap["compressed"]
1029
+ )
1030
+
1031
+ # Get model statistics
1032
+ model_stats = {}
1033
+ for session in sessions:
1034
+ model = session.get("llm_model", "Unknown")
1035
+ if model not in model_stats:
1036
+ model_stats[model] = {"count": 0, "tokens": 0, "cost": 0}
1037
+ model_stats[model]["count"] += 1
1038
+ model_stats[model]["tokens"] += session.get("tokens", 0)
1039
+ model_stats[model]["cost"] += session.get("cost", 0)
1040
+
1041
+ summary = {
1042
+ "total_sessions": len(sessions),
1043
+ "total_time": total_time,
1044
+ "total_cost": total_cost,
1045
+ "total_agents": len(agents_in_timeline),
1046
+ "total_events": sum(session["events"] for session in sessions),
1047
+ "total_tokens": total_tokens,
1048
+ "avg_cost_per_session": (
1049
+ total_cost / len(sessions) if sessions else 0
1050
+ ),
1051
+ "compression_info": {
1052
+ "gaps_compressed": gaps_compressed,
1053
+ "time_saved": time_saved,
1054
+ },
1055
+ "model_stats": model_stats,
1056
+ }
1057
+
1058
+ # Create metadata
1059
+ max_time = max([item["end"] for item in timeline]) if timeline else 0
1060
+ max_cost = (
1061
+ max([point["cumulative_cost"] for point in cost_timeline])
1062
+ if cost_timeline
1063
+ else 0
1064
+ )
1065
+
1066
+ metadata = {
1067
+ "time_range": [0, max_time * 1.1],
1068
+ "cost_range": [0, max_cost * 1.1],
1069
+ "colors": {
1070
+ "human_input": ACTIVITY_COLORS["human_input_waiting"],
1071
+ "processing": ACTIVITY_COLORS["processing"],
1072
+ "agent_transition": ACTIVITY_COLORS["agent_transition"],
1073
+ "cost_line": "#E91E63",
1074
+ },
1075
+ }
1076
+
1077
+ return {
1078
+ "timeline": timeline,
1079
+ "cost_timeline": cost_timeline,
1080
+ "summary": summary,
1081
+ "metadata": metadata,
1082
+ "agents": agents,
1083
+ }
1084
+
1085
+ @staticmethod
1086
+ def get_short_results(results: dict[str, Any]) -> dict[str, Any]:
1087
+ """Remove request/response from the timeline entries.
1088
+
1089
+ Parameters
1090
+ ----------
1091
+ results : dict[str, Any]
1092
+ The original results dictionary.
1093
+
1094
+ Returns
1095
+ -------
1096
+ dict[str, Any]
1097
+ The modified results dictionary with shortened timeline.
1098
+ """
1099
+ new_results = results.copy()
1100
+ new_results["timeline"] = []
1101
+ for item in results["timeline"]:
1102
+ new_item = item.copy()
1103
+ # Remove request and response fields
1104
+ new_item.pop("request", None)
1105
+ new_item.pop("response", None)
1106
+ new_results["timeline"].append(new_item)
1107
+ return new_results
1108
+
1109
+ @staticmethod
1110
+ def get_files(logs_dir: Path | str) -> dict[str, str | None]:
1111
+ """Get all CSV files in the specified directory.
1112
+
1113
+ Parameters
1114
+ ----------
1115
+ logs_dir : Path | str
1116
+ The directory to search for CSV files.
1117
+
1118
+ Returns
1119
+ -------
1120
+ dict[str, str | None]
1121
+ A dictionary mapping CSV file names to their paths
1122
+ or None if not found.
1123
+ """
1124
+ agents_file = os.path.join(logs_dir, "agents.csv")
1125
+ chat_file = os.path.join(logs_dir, "chat_completions.csv")
1126
+ events_file = os.path.join(logs_dir, "events.csv")
1127
+ functions_file = os.path.join(logs_dir, "function_calls.csv")
1128
+
1129
+ return {
1130
+ "agents": agents_file if os.path.exists(agents_file) else None,
1131
+ "chat": chat_file if os.path.exists(chat_file) else None,
1132
+ "events": events_file if os.path.exists(events_file) else None,
1133
+ "functions": (
1134
+ functions_file if os.path.exists(functions_file) else None
1135
+ ),
1136
+ }
1137
+
1138
+
1139
+ def recursive_search(obj: Any, keys_to_find: list[str]) -> str:
1140
+ """Recursively search for keys in a nested structure.
1141
+
1142
+ Parameters
1143
+ ----------
1144
+ obj : Any
1145
+ The object to search within.
1146
+ keys_to_find : list[str]
1147
+ The keys to search for.
1148
+
1149
+ Returns
1150
+ -------
1151
+ str
1152
+ The found value or "Unknown" if not found.
1153
+ """
1154
+ if isinstance(obj, dict):
1155
+ for key in keys_to_find:
1156
+ if key in obj and isinstance(obj[key], str) and obj[key].strip():
1157
+ return obj[key]
1158
+ for value in obj.values():
1159
+ result = recursive_search(value, keys_to_find)
1160
+ if result != "Unknown":
1161
+ return result
1162
+ elif isinstance(obj, list):
1163
+ for item in obj:
1164
+ result = recursive_search(item, keys_to_find)
1165
+ if result != "Unknown":
1166
+ return result
1167
+ return "Unknown"
1168
+
1169
+
1170
+ def main() -> int:
1171
+ """Run the timeline processor."""
1172
+ parser = argparse.ArgumentParser(
1173
+ description="Process timeline CSV files and output JSON"
1174
+ )
1175
+ parser.add_argument(
1176
+ "--output-dir",
1177
+ type=str,
1178
+ required=True,
1179
+ help="Directory to get the csv files from and save the output JSON",
1180
+ )
1181
+
1182
+ args = parser.parse_args()
1183
+
1184
+ output_dir = Path(args.output_dir).resolve()
1185
+ if not output_dir.is_dir():
1186
+ LOG.error("Output directory does not exist: %s", output_dir)
1187
+ return 1
1188
+ LOG.info("Using output directory: %s", output_dir)
1189
+ files = TimelineProcessor.get_files(output_dir)
1190
+ agents_file = files["agents"]
1191
+ chat_file = files["chat"]
1192
+ events_file = files["events"]
1193
+ functions_file = files["functions"]
1194
+ if not any(files.values()):
1195
+ LOG.error(
1196
+ "No CSV files found in the output directory: %s",
1197
+ output_dir,
1198
+ )
1199
+ return 1
1200
+ output_file = os.path.join(output_dir, "timeline.json")
1201
+ if not agents_file or not os.path.exists(agents_file):
1202
+ agents_file = None
1203
+ if not chat_file or not os.path.exists(chat_file):
1204
+ chat_file = None
1205
+ if not events_file or not os.path.exists(events_file):
1206
+ events_file = None
1207
+ if not functions_file or not os.path.exists(functions_file):
1208
+ functions_file = None
1209
+
1210
+ processor = TimelineProcessor()
1211
+
1212
+ try:
1213
+ processor.load_csv_files(
1214
+ agents_file=agents_file,
1215
+ chat_file=chat_file,
1216
+ events_file=events_file,
1217
+ functions_file=functions_file,
1218
+ )
1219
+
1220
+ result = processor.process_timeline()
1221
+
1222
+ with open(output_file, "w", encoding="utf-8") as f:
1223
+ json.dump(result, f, indent=2, default=str)
1224
+
1225
+ print(f"Timeline data saved to {output_file}")
1226
+ print(
1227
+ f"Summary: {result['summary']['total_sessions']} sessions, "
1228
+ f"${result['summary']['total_cost']:.6f} total cost, "
1229
+ f"{result['summary']['total_time']:.1f}s duration"
1230
+ )
1231
+
1232
+ # Print model statistics
1233
+ print("\nModel Statistics:")
1234
+ for model, stats in result["summary"]["model_stats"].items():
1235
+ print(
1236
+ f" {model}: {stats['count']} sessions, "
1237
+ f"{stats['tokens']} tokens, ${stats['cost']:.6f}"
1238
+ )
1239
+
1240
+ except Exception as e:
1241
+ print(f"Error: {e}")
1242
+ return 1
1243
+
1244
+ return 0
1245
+
1246
+
1247
+ if __name__ == "__main__":
1248
+ exit(main())