Skip to content

Commit cd6a0a2

Browse files
Merge pull request #76 from agentevals-dev/chore/consolidate-conversion-logic
Consolidate trace-to-invocation conversion
2 parents b4cb9ed + 19d116f commit cd6a0a2

15 files changed

Lines changed: 373 additions & 1270 deletions

File tree

src/agentevals/api/models.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,26 @@ class DebugLoadData(CamelModel):
111111
count: int
112112

113113

114+
class TraceConversionMetadata(CamelModel):
115+
agent_name: str | None = None
116+
model: str | None = None
117+
start_time: int | None = None
118+
user_input_preview: str | None = None
119+
final_output_preview: str | None = None
120+
session_name: str | None = None
121+
122+
123+
class TraceConversionEntry(CamelModel):
124+
trace_id: str
125+
invocations: list[dict[str, Any]]
126+
warnings: list[str] = Field(default_factory=list)
127+
metadata: TraceConversionMetadata = Field(default_factory=TraceConversionMetadata)
128+
129+
130+
class ConvertTracesData(CamelModel):
131+
traces: list[TraceConversionEntry]
132+
133+
114134
# ---------------------------------------------------------------------------
115135
# SSE evaluation event models
116136
# ---------------------------------------------------------------------------

src/agentevals/api/routes.py

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import json
77
import logging
88
import os
9+
import re
910
import shutil
1011
import tempfile
1112
from typing import Any
@@ -24,12 +25,14 @@
2425
EvalRunConfig,
2526
OpenAIEvalDef,
2627
)
28+
from ..converter import convert_traces
2729
from ..extraction import get_extractor
2830
from ..runner import RunResult, get_loader, load_eval_set, run_evaluation
2931
from ..trace_metrics import extract_performance_metrics, extract_trace_metadata
3032
from .models import (
3133
ApiKeyStatus,
3234
ConfigData,
35+
ConvertTracesData,
3336
EvalSetValidation,
3437
HealthData,
3538
MetricInfo,
@@ -40,6 +43,8 @@
4043
SSETraceProgress,
4144
SSETraceProgressEvent,
4245
StandardResponse,
46+
TraceConversionEntry,
47+
TraceConversionMetadata,
4348
)
4449

4550
logger = logging.getLogger(__name__)
@@ -257,6 +262,141 @@ async def validate_eval_set(
257262
shutil.rmtree(temp_dir)
258263

259264

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+
260400
@router.post("/evaluate", response_model=StandardResponse[RunResult])
261401
async def evaluate_traces(
262402
trace_files: list[UploadFile] = File(...),

ui/src/api/client.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { RunResult, EvalConfig, TraceResult, MetricMetadata, StandardResponse } from '../lib/types';
1+
import type { RunResult, EvalConfig, TraceResult, MetricMetadata, StandardResponse, ConvertTracesResponse } from '../lib/types';
22
import { config } from '../config';
33

44
const API_BASE_URL = `${config.api.baseUrl}/api`;
@@ -11,6 +11,34 @@ async function unwrap<T>(response: Response): Promise<T> {
1111
return json.data;
1212
}
1313

14+
export async function convertTraces(traceFiles: File[], traceFormat?: string): Promise<ConvertTracesResponse> {
15+
const formData = new FormData();
16+
traceFiles.forEach(file => formData.append('trace_files', file));
17+
if (traceFormat) {
18+
formData.append('trace_format', traceFormat);
19+
}
20+
21+
const response = await fetch(`${API_BASE_URL}/convert`, {
22+
method: 'POST',
23+
body: formData,
24+
});
25+
26+
if (!response.ok) {
27+
let errorMessage = `API error: ${response.statusText}`;
28+
try {
29+
const errorData = await response.json();
30+
if (errorData.detail) {
31+
errorMessage = errorData.detail;
32+
}
33+
} catch {
34+
// Fallback to statusText
35+
}
36+
throw new Error(errorMessage);
37+
}
38+
39+
return unwrap<ConvertTracesResponse>(response);
40+
}
41+
1442
export async function evaluateTracesAPI(
1543
traceFiles: File[],
1644
evalSetFile: File | null,

ui/src/components/builder/TraceUploadZone.tsx

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,41 +2,41 @@ import React, { useCallback } from 'react';
22
import { css } from '@emotion/react';
33
import { Upload, FileJson } from 'lucide-react';
44
import { message } from 'antd';
5-
import { loadJaegerTraces } from '../../lib/trace-loader';
6-
import { generateEvalSetFromTraces } from '../../lib/evalset-builder';
5+
import { convertTraces } from '../../api/client';
6+
import { generateEvalSet } from '../../lib/evalset-builder';
77
import { useTraceContext } from '../../context/TraceContext';
88

99
export const TraceUploadZone: React.FC = () => {
1010
const { actions } = useTraceContext();
1111

1212
const handleFileUpload = useCallback(async (file: File) => {
1313
try {
14-
const content = await file.text();
15-
const traces = await loadJaegerTraces(content);
14+
const response = await convertTraces([file]);
1615

17-
if (traces.length === 0) {
16+
if (response.traces.length === 0) {
1817
message.error('No valid traces found in the file');
1918
return;
2019
}
2120

22-
const filename = file.name.replace('.json', '');
23-
const evalSet = generateEvalSetFromTraces(traces, filename);
21+
const filename = file.name.replace(/\.(jsonl?|json)$/i, '');
22+
const traceData = response.traces.map(t => ({ traceId: t.traceId, invocations: t.invocations }));
23+
const evalSet = generateEvalSet(traceData, filename);
2424

2525
actions.setBuilderEvalSet(evalSet);
26-
message.success(`Loaded ${traces.length} trace(s) and generated EvalSet!`);
26+
message.success(`Loaded ${response.traces.length} trace(s) and generated EvalSet!`);
2727
} catch (error) {
2828
console.error('Failed to load trace:', error);
29-
message.error('Failed to load trace file. Please ensure it is a valid Jaeger JSON file.');
29+
message.error('Failed to load trace file. Please ensure it is a valid trace file.');
3030
}
3131
}, [actions]);
3232

3333
const handleDrop = useCallback((e: React.DragEvent) => {
3434
e.preventDefault();
3535
const file = e.dataTransfer.files[0];
36-
if (file && file.name.endsWith('.json')) {
36+
if (file && (file.name.endsWith('.json') || file.name.endsWith('.jsonl'))) {
3737
handleFileUpload(file);
3838
} else {
39-
message.error('Please upload a .json file');
39+
message.error('Please upload a .json or .jsonl file');
4040
}
4141
}, [handleFileUpload]);
4242

@@ -67,7 +67,7 @@ export const TraceUploadZone: React.FC = () => {
6767
<input
6868
type="file"
6969
id="trace-file-input"
70-
accept=".json"
70+
accept=".json,.jsonl"
7171
onChange={handleFileInput}
7272
css={fileInputStyle}
7373
/>

ui/src/components/dashboard/TraceCard.tsx

Lines changed: 11 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,7 @@ import type { TraceResult } from '../../lib/types';
77
import { truncateTraceId, getStatusColor, getStatusGlow, copyToClipboard } from '../../lib/utils';
88
import { MetricScoreCard } from './MetricScoreCard';
99
import { useTraceContext } from '../../context/TraceContext';
10-
import { loadJaegerTraces } from '../../lib/trace-loader';
11-
import { generateEvalSetFromTraces } from '../../lib/evalset-builder';
10+
import { generateEvalSet } from '../../lib/evalset-builder';
1211

1312
interface TraceCardProps {
1413
traceResult: TraceResult;
@@ -162,31 +161,23 @@ export const TraceCard: React.FC<TraceCardProps> = ({ traceResult, threshold, on
162161
copyToClipboard(traceId);
163162
};
164163

165-
const handleCreateEvalSet = async (e: React.MouseEvent) => {
164+
const handleCreateEvalSet = (e: React.MouseEvent) => {
166165
e.stopPropagation();
167166

168167
try {
169-
let matchingTrace = null;
170-
let matchingFilename = '';
171-
172-
for (const file of state.traceFiles) {
173-
const content = await file.text();
174-
const traces = await loadJaegerTraces(content);
175-
const found = traces.find(t => t.traceId === traceId);
176-
177-
if (found) {
178-
matchingTrace = found;
179-
matchingFilename = file.name.replace('.json', '');
180-
break;
181-
}
182-
}
168+
const metadata = state.traceMetadata.get(traceId);
169+
const invocations = metadata?.invocations || [];
183170

184-
if (!matchingTrace) {
185-
message.error('Could not find trace in uploaded files');
171+
if (invocations.length === 0) {
172+
message.error('No invocations found for this trace');
186173
return;
187174
}
188175

189-
const evalSet = generateEvalSetFromTraces([matchingTrace], matchingFilename);
176+
const filename = metadata?.sessionId || traceId.substring(0, 12);
177+
const evalSet = generateEvalSet(
178+
[{ traceId, invocations }],
179+
filename
180+
);
190181
actions.setBuilderEvalSet(evalSet);
191182
actions.setCurrentView('builder');
192183
message.success('EvalSet created! Edit and save when ready.');

0 commit comments

Comments
 (0)