confident-ai--deepeval
160 行
4.8 KiB
Python
160 行
4.8 KiB
Python
"""Load `test_run_*.json` files into nested `Trace` / `BaseSpan` view models.
|
|
|
|
The on-disk shape is `TraceApi`: five flat span buckets linked via
|
|
`parentUuid`. The loader pops the buckets, validates each span dict
|
|
into a single `BaseSpan` class (the `.type` field is the discriminator),
|
|
and wires up `children` / `root_spans`.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
from deepeval.inspect.types import BaseSpan, Trace
|
|
|
|
|
|
class InspectLoadError(Exception):
|
|
"""File unreadable or top-level JSON malformed."""
|
|
|
|
|
|
class NoTracesError(InspectLoadError):
|
|
"""File parsed but contains zero traces."""
|
|
|
|
|
|
_SPAN_BUCKETS: List[str] = [
|
|
"baseSpans",
|
|
"agentSpans",
|
|
"llmSpans",
|
|
"retrieverSpans",
|
|
"toolSpans",
|
|
]
|
|
|
|
|
|
def find_latest_test_run(folder: str | Path) -> Path:
|
|
"""Most recently modified `test_run_*.json` under `folder`.
|
|
|
|
Sorted by mtime (not filename) so a manually-copied file with a
|
|
stale timestamp in its name still ranks correctly.
|
|
"""
|
|
|
|
folder_path = Path(folder)
|
|
if not folder_path.is_dir():
|
|
raise FileNotFoundError(
|
|
f"Results folder not found: {folder_path}. "
|
|
"Pass `results_folder=...` to `DisplayConfig(...)` or set "
|
|
"the `DEEPEVAL_RESULTS_FOLDER` env var."
|
|
)
|
|
|
|
candidates = sorted(
|
|
folder_path.glob("test_run_*.json"),
|
|
key=lambda p: p.stat().st_mtime,
|
|
reverse=True,
|
|
)
|
|
if not candidates:
|
|
raise FileNotFoundError(
|
|
f"No test_run_*.json files found in {folder_path}."
|
|
)
|
|
return candidates[0]
|
|
|
|
|
|
def load_test_run(path: str | Path) -> List[Trace]:
|
|
p = Path(path)
|
|
try:
|
|
with open(p, "r", encoding="utf-8") as f:
|
|
data = json.load(f)
|
|
except (OSError, json.JSONDecodeError) as e:
|
|
raise InspectLoadError(f"Failed to read test run from {p}: {e}") from e
|
|
|
|
if not isinstance(data, dict):
|
|
raise InspectLoadError(
|
|
f"Expected the top-level JSON in {p} to be an object; "
|
|
f"got {type(data).__name__}."
|
|
)
|
|
|
|
traces: List[Trace] = []
|
|
for case in data.get("testCases", []):
|
|
trace_dict = case.get("trace") if isinstance(case, dict) else None
|
|
if trace_dict:
|
|
traces.append(_parse_trace(trace_dict))
|
|
|
|
if not traces:
|
|
raise NoTracesError(
|
|
f"{p} contains no traces. `deepeval inspect` shows trace "
|
|
"trees; runs without tracing data have nothing to display."
|
|
)
|
|
return traces
|
|
|
|
|
|
def _parse_trace(trace_dict: Dict[str, Any]) -> Trace:
|
|
# Pop the bucket keys before validating so the residual dict is
|
|
# clean trace-level data; spans are validated and linked separately.
|
|
trace_dict = dict(trace_dict)
|
|
span_dicts: List[Dict[str, Any]] = []
|
|
for bucket_name in _SPAN_BUCKETS:
|
|
for span_dict in trace_dict.pop(bucket_name, None) or []:
|
|
if isinstance(span_dict, dict):
|
|
span_dicts.append(span_dict)
|
|
|
|
spans = _build_span_tree(span_dicts)
|
|
roots = [s for s in spans.values() if not _has_known_parent(s, spans)]
|
|
roots.sort(key=lambda s: s.start_time or "")
|
|
|
|
trace = Trace.model_validate(trace_dict)
|
|
trace.root_spans = roots
|
|
return trace
|
|
|
|
|
|
def _build_span_tree(
|
|
span_dicts: List[Dict[str, Any]],
|
|
) -> Dict[str, BaseSpan]:
|
|
by_uuid: Dict[str, BaseSpan] = {}
|
|
for span_dict in span_dicts:
|
|
span = BaseSpan.model_validate(span_dict)
|
|
# On UUID collision keep the first occurrence; silently swapping
|
|
# would be the wrong fallback for malformed input.
|
|
if span.uuid not in by_uuid:
|
|
by_uuid[span.uuid] = span
|
|
|
|
for span in by_uuid.values():
|
|
parent = by_uuid.get(span.parent_uuid) if span.parent_uuid else None
|
|
if parent is not None:
|
|
parent.children.append(span)
|
|
|
|
for span in by_uuid.values():
|
|
span.children.sort(key=lambda c: c.start_time or "")
|
|
|
|
return by_uuid
|
|
|
|
|
|
def _has_known_parent(span: BaseSpan, by_uuid: Dict[str, BaseSpan]) -> bool:
|
|
return bool(span.parent_uuid) and span.parent_uuid in by_uuid
|
|
|
|
|
|
def run_id_from_path(path: str | Path) -> str:
|
|
return Path(path).stem
|
|
|
|
|
|
def summarize_test_run(path: str | Path) -> Optional[Dict[str, Any]]:
|
|
"""Run-level pass/fail + duration counts for the header bar.
|
|
|
|
Returns `None` if the file can't be opened — the header then falls
|
|
back to showing just the run id and trace count.
|
|
"""
|
|
|
|
try:
|
|
with open(path, "r", encoding="utf-8") as f:
|
|
data = json.load(f)
|
|
except (OSError, json.JSONDecodeError):
|
|
return None
|
|
if not isinstance(data, dict):
|
|
return None
|
|
|
|
return {
|
|
"test_passed": data.get("testPassed"),
|
|
"test_failed": data.get("testFailed"),
|
|
"run_duration": data.get("runDuration"),
|
|
"evaluation_cost": data.get("evaluationCost"),
|
|
}
|