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.
- waldiez/_version.py +1 -1
- waldiez/cli.py +2 -0
- waldiez/models/chat/chat_message.py +1 -1
- waldiez/runner.py +35 -2
- waldiez/running/base_runner.py +38 -8
- waldiez/running/environment.py +32 -13
- waldiez/running/import_runner.py +13 -0
- waldiez/running/post_run.py +71 -14
- waldiez/running/pre_run.py +42 -0
- waldiez/running/protocol.py +6 -0
- waldiez/running/subprocess_runner.py +4 -0
- waldiez/running/timeline_processor.py +1248 -0
- waldiez/utils/version.py +12 -1
- {waldiez-0.5.2.dist-info → waldiez-0.5.3.dist-info}/METADATA +33 -32
- {waldiez-0.5.2.dist-info → waldiez-0.5.3.dist-info}/RECORD +19 -18
- {waldiez-0.5.2.dist-info → waldiez-0.5.3.dist-info}/WHEEL +0 -0
- {waldiez-0.5.2.dist-info → waldiez-0.5.3.dist-info}/entry_points.txt +0 -0
- {waldiez-0.5.2.dist-info → waldiez-0.5.3.dist-info}/licenses/LICENSE +0 -0
- {waldiez-0.5.2.dist-info → waldiez-0.5.3.dist-info}/licenses/NOTICE.md +0 -0
|
@@ -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())
|