项目文件夹

文件
2026-07-13 13:32:05 +08:00

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"),
}