|
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,141 @@ 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 is not None: |
| 280 | + inv_dict["user_content"] = inv.user_content.model_dump(exclude_none=True) |
| 281 | + if inv.final_response is not None: |
| 282 | + inv_dict["final_response"] = inv.final_response.model_dump(exclude_none=True) |
| 283 | + if inv.intermediate_data is not None: |
| 284 | + inv_dict["intermediate_data"] = inv.intermediate_data.model_dump(exclude_none=True) |
| 285 | + if inv.creation_timestamp is not None: |
| 286 | + inv_dict["creation_timestamp"] = inv.creation_timestamp |
| 287 | + return _camel_keys(inv_dict) |
| 288 | + |
| 289 | + |
| 290 | +def _get_format_for_file(path: str, explicit_format: str) -> str: |
| 291 | + """Return the loader format for a single file, auto-detecting from extension.""" |
| 292 | + if explicit_format: |
| 293 | + return explicit_format |
| 294 | + return "otlp-json" if path.lower().endswith(".jsonl") else "jaeger-json" |
| 295 | + |
| 296 | + |
| 297 | +@router.post("/convert", response_model=StandardResponse[ConvertTracesData]) |
| 298 | +async def convert_trace_files( |
| 299 | + trace_files: list[UploadFile] = File(...), |
| 300 | + trace_format: str = Form(""), |
| 301 | +): |
| 302 | + """Convert trace files to invocations and metadata without running evaluation.""" |
| 303 | + temp_dir = tempfile.mkdtemp() |
| 304 | + try: |
| 305 | + saved_files: list[tuple[str, str]] = [] # (path, original_filename) |
| 306 | + for idx, trace_file in enumerate(trace_files): |
| 307 | + if not trace_file.filename: |
| 308 | + continue |
| 309 | + |
| 310 | + original = trace_file.filename |
| 311 | + lower = original.lower() |
| 312 | + if not (lower.endswith(".json") or lower.endswith(".jsonl")): |
| 313 | + raise HTTPException( |
| 314 | + status_code=400, |
| 315 | + detail=f"Invalid file extension for {original}. Only .json and .jsonl files are allowed.", |
| 316 | + ) |
| 317 | + |
| 318 | + safe_name = f"{idx}_{os.path.basename(original)}" |
| 319 | + trace_path = os.path.join(temp_dir, safe_name) |
| 320 | + with open(trace_path, "wb") as f: # noqa: ASYNC230 |
| 321 | + content = await trace_file.read() |
| 322 | + |
| 323 | + if len(content) > 10 * 1024 * 1024: |
| 324 | + raise HTTPException( |
| 325 | + status_code=400, |
| 326 | + detail=f"File {original} exceeds 10MB limit", |
| 327 | + ) |
| 328 | + |
| 329 | + f.write(content) |
| 330 | + saved_files.append((trace_path, original)) |
| 331 | + |
| 332 | + if not saved_files: |
| 333 | + raise HTTPException(status_code=400, detail="No valid trace files provided") |
| 334 | + |
| 335 | + all_traces = [] |
| 336 | + trace_to_filename: dict[str, str] = {} |
| 337 | + load_warnings: list[str] = [] |
| 338 | + for path, original in saved_files: |
| 339 | + fmt = _get_format_for_file(path, trace_format) |
| 340 | + loader = get_loader(fmt) |
| 341 | + try: |
| 342 | + traces = loader.load(path) |
| 343 | + for t in traces: |
| 344 | + trace_to_filename[t.trace_id] = original |
| 345 | + all_traces.extend(traces) |
| 346 | + except Exception as exc: |
| 347 | + msg = f"Failed to load '{original}': {exc}" |
| 348 | + logger.warning(msg) |
| 349 | + load_warnings.append(msg) |
| 350 | + |
| 351 | + if not all_traces: |
| 352 | + detail = "No traces found in uploaded files" |
| 353 | + if load_warnings: |
| 354 | + detail += ". Errors: " + "; ".join(load_warnings) |
| 355 | + raise HTTPException(status_code=400, detail=detail) |
| 356 | + |
| 357 | + conversion_results = convert_traces(all_traces) |
| 358 | + trace_map = {t.trace_id: t for t in all_traces} |
| 359 | + |
| 360 | + entries: list[TraceConversionEntry] = [] |
| 361 | + for conv_result in conversion_results: |
| 362 | + invocations = [_serialize_invocation(inv) for inv in conv_result.invocations] |
| 363 | + warnings = list(conv_result.warnings) |
| 364 | + |
| 365 | + trace = trace_map.get(conv_result.trace_id) |
| 366 | + meta = TraceConversionMetadata() |
| 367 | + if trace: |
| 368 | + meta_dict = extract_trace_metadata(trace) |
| 369 | + filename = trace_to_filename.get(conv_result.trace_id, "") |
| 370 | + session_name = _session_name_from_filename(filename) |
| 371 | + meta = TraceConversionMetadata( |
| 372 | + agent_name=meta_dict.get("agent_name"), |
| 373 | + model=meta_dict.get("model"), |
| 374 | + start_time=meta_dict.get("start_time"), |
| 375 | + user_input_preview=meta_dict.get("user_input_preview"), |
| 376 | + final_output_preview=meta_dict.get("final_output_preview"), |
| 377 | + session_name=session_name, |
| 378 | + ) |
| 379 | + |
| 380 | + entries.append( |
| 381 | + TraceConversionEntry( |
| 382 | + trace_id=conv_result.trace_id, |
| 383 | + invocations=invocations, |
| 384 | + warnings=warnings, |
| 385 | + metadata=meta, |
| 386 | + ) |
| 387 | + ) |
| 388 | + |
| 389 | + return StandardResponse(data=ConvertTracesData(traces=entries)) |
| 390 | + |
| 391 | + except HTTPException: |
| 392 | + raise |
| 393 | + except Exception as exc: |
| 394 | + logger.exception("Trace conversion failed") |
| 395 | + raise HTTPException(status_code=500, detail=f"Internal error: {exc!s}") from exc |
| 396 | + finally: |
| 397 | + shutil.rmtree(temp_dir) |
| 398 | + |
| 399 | + |
260 | 400 | @router.post("/evaluate", response_model=StandardResponse[RunResult]) |
261 | 401 | async def evaluate_traces( |
262 | 402 | trace_files: list[UploadFile] = File(...), |
|
0 commit comments