|
6 | 6 | import json |
7 | 7 | import logging |
8 | 8 | import os |
| 9 | +import re |
9 | 10 | import shutil |
10 | 11 | import tempfile |
11 | 12 | from typing import Any |
|
24 | 25 | EvalRunConfig, |
25 | 26 | OpenAIEvalDef, |
26 | 27 | ) |
| 28 | +from ..converter import convert_traces |
27 | 29 | from ..extraction import get_extractor |
28 | 30 | from ..runner import RunResult, get_loader, load_eval_set, run_evaluation |
29 | 31 | from ..trace_metrics import extract_performance_metrics, extract_trace_metadata |
30 | 32 | from .models import ( |
31 | 33 | ApiKeyStatus, |
32 | 34 | ConfigData, |
| 35 | + ConvertTracesData, |
33 | 36 | EvalSetValidation, |
34 | 37 | HealthData, |
35 | 38 | MetricInfo, |
|
40 | 43 | SSETraceProgress, |
41 | 44 | SSETraceProgressEvent, |
42 | 45 | StandardResponse, |
| 46 | + TraceConversionEntry, |
| 47 | + TraceConversionMetadata, |
43 | 48 | ) |
44 | 49 |
|
45 | 50 | logger = logging.getLogger(__name__) |
@@ -257,6 +262,131 @@ async def validate_eval_set( |
257 | 262 | shutil.rmtree(temp_dir) |
258 | 263 |
|
259 | 264 |
|
| 265 | +def _session_name_from_filename(filename: str) -> str | None: |
| 266 | + """Extract a session name from a trace filename, stripping known prefixes.""" |
| 267 | + base = re.sub(r"\.(jsonl?|json)$", "", filename, flags=re.IGNORECASE) |
| 268 | + for prefix in ("trace_", "agentevals_"): |
| 269 | + if base.startswith(prefix): |
| 270 | + return base[len(prefix) :] |
| 271 | + return None |
| 272 | + |
| 273 | + |
| 274 | +def _serialize_invocation(inv) -> dict[str, Any]: |
| 275 | + """Serialize an ADK Invocation to a camelCase dict matching the frontend Invocation type.""" |
| 276 | + inv_dict: dict[str, Any] = { |
| 277 | + "invocation_id": inv.invocation_id, |
| 278 | + } |
| 279 | + if inv.user_content: |
| 280 | + inv_dict["user_content"] = inv.user_content.model_dump(exclude_none=True) |
| 281 | + if inv.final_response: |
| 282 | + inv_dict["final_response"] = inv.final_response.model_dump(exclude_none=True) |
| 283 | + if inv.intermediate_data: |
| 284 | + inv_dict["intermediate_data"] = inv.intermediate_data.model_dump(exclude_none=True) |
| 285 | + if inv.creation_timestamp: |
| 286 | + inv_dict["creation_timestamp"] = inv.creation_timestamp |
| 287 | + return _camel_keys(inv_dict) |
| 288 | + |
| 289 | + |
| 290 | +@router.post("/convert", response_model=StandardResponse[ConvertTracesData]) |
| 291 | +async def convert_trace_files( |
| 292 | + trace_files: list[UploadFile] = File(...), |
| 293 | + trace_format: str = Form(""), |
| 294 | +): |
| 295 | + """Convert trace files to invocations and metadata without running evaluation.""" |
| 296 | + temp_dir = tempfile.mkdtemp() |
| 297 | + try: |
| 298 | + trace_paths = [] |
| 299 | + for trace_file in trace_files: |
| 300 | + if not trace_file.filename: |
| 301 | + continue |
| 302 | + |
| 303 | + if not (trace_file.filename.endswith(".json") or trace_file.filename.endswith(".jsonl")): |
| 304 | + raise HTTPException( |
| 305 | + status_code=400, |
| 306 | + detail=f"Invalid file extension for {trace_file.filename}. Only .json and .jsonl files are allowed.", |
| 307 | + ) |
| 308 | + |
| 309 | + trace_path = os.path.join(temp_dir, trace_file.filename) |
| 310 | + with open(trace_path, "wb") as f: # noqa: ASYNC230 |
| 311 | + content = await trace_file.read() |
| 312 | + |
| 313 | + if len(content) > 10 * 1024 * 1024: |
| 314 | + raise HTTPException( |
| 315 | + status_code=400, |
| 316 | + detail=f"File {trace_file.filename} exceeds 10MB limit", |
| 317 | + ) |
| 318 | + |
| 319 | + f.write(content) |
| 320 | + trace_paths.append(trace_path) |
| 321 | + |
| 322 | + if not trace_paths: |
| 323 | + raise HTTPException(status_code=400, detail="No valid trace files provided") |
| 324 | + |
| 325 | + fmt = trace_format |
| 326 | + if not fmt: |
| 327 | + if trace_paths[0].endswith(".jsonl"): |
| 328 | + fmt = "otlp-json" |
| 329 | + else: |
| 330 | + fmt = "jaeger-json" |
| 331 | + |
| 332 | + loader = get_loader(fmt) |
| 333 | + all_traces = [] |
| 334 | + trace_to_filename: dict[str, str] = {} |
| 335 | + for path in trace_paths: |
| 336 | + try: |
| 337 | + traces = loader.load(path) |
| 338 | + filename = os.path.basename(path) |
| 339 | + for t in traces: |
| 340 | + trace_to_filename[t.trace_id] = filename |
| 341 | + all_traces.extend(traces) |
| 342 | + except Exception as exc: |
| 343 | + logger.warning(f"Failed to load trace file '{path}': {exc}") |
| 344 | + |
| 345 | + if not all_traces: |
| 346 | + raise HTTPException(status_code=400, detail="No traces found in uploaded files") |
| 347 | + |
| 348 | + conversion_results = convert_traces(all_traces) |
| 349 | + trace_map = {t.trace_id: t for t in all_traces} |
| 350 | + |
| 351 | + entries: list[TraceConversionEntry] = [] |
| 352 | + for conv_result in conversion_results: |
| 353 | + invocations = [_serialize_invocation(inv) for inv in conv_result.invocations] |
| 354 | + |
| 355 | + trace = trace_map.get(conv_result.trace_id) |
| 356 | + meta = TraceConversionMetadata() |
| 357 | + if trace: |
| 358 | + meta_dict = extract_trace_metadata(trace) |
| 359 | + filename = trace_to_filename.get(conv_result.trace_id, "") |
| 360 | + session_name = _session_name_from_filename(filename) |
| 361 | + meta = TraceConversionMetadata( |
| 362 | + agent_name=meta_dict.get("agent_name"), |
| 363 | + model=meta_dict.get("model"), |
| 364 | + start_time=meta_dict.get("start_time"), |
| 365 | + user_input_preview=meta_dict.get("user_input_preview"), |
| 366 | + final_output_preview=meta_dict.get("final_output_preview"), |
| 367 | + session_name=session_name, |
| 368 | + ) |
| 369 | + |
| 370 | + entries.append( |
| 371 | + TraceConversionEntry( |
| 372 | + trace_id=conv_result.trace_id, |
| 373 | + invocations=invocations, |
| 374 | + warnings=conv_result.warnings, |
| 375 | + metadata=meta, |
| 376 | + ) |
| 377 | + ) |
| 378 | + |
| 379 | + return StandardResponse(data=ConvertTracesData(traces=entries)) |
| 380 | + |
| 381 | + except HTTPException: |
| 382 | + raise |
| 383 | + except Exception as exc: |
| 384 | + logger.exception("Trace conversion failed") |
| 385 | + raise HTTPException(status_code=500, detail=f"Internal error: {exc!s}") from exc |
| 386 | + finally: |
| 387 | + shutil.rmtree(temp_dir) |
| 388 | + |
| 389 | + |
260 | 390 | @router.post("/evaluate", response_model=StandardResponse[RunResult]) |
261 | 391 | async def evaluate_traces( |
262 | 392 | trace_files: list[UploadFile] = File(...), |
|
0 commit comments