Files
2026-07-13 13:32:05 +08:00

582 lines
21 KiB
Python

from typing import Optional, List, Union
import os
import time
from deepeval.utils import format_turn
from deepeval.test_run.test_run import TestRunResultDisplay
from deepeval.dataset import Golden
from deepeval.metrics import (
ArenaGEval,
BaseMetric,
BaseConversationalMetric,
)
from deepeval.test_case import (
LLMTestCase,
ConversationalTestCase,
)
from deepeval.test_run import (
LLMApiTestCase,
ConversationalApiTestCase,
MetricData,
)
from deepeval.evaluate.types import TestResult
from deepeval.tracing.api import TraceApi, BaseApiSpan, TraceSpanApiStatus
from deepeval.tracing.tracing import BaseSpan, Trace
from deepeval.tracing.types import TraceSpanStatus
from deepeval.tracing.utils import (
perf_counter_to_datetime,
to_zod_compatible_iso,
)
def _is_metric_successful(metric_data: MetricData) -> bool:
"""
Robustly determine success for a metric row.
Rationale:
- If the metric recorded an error, treat as failure.
- Be defensive: custom rows may not be MetricData at runtime.
"""
if getattr(metric_data, "error", None):
return False
s = getattr(metric_data, "success", None)
if isinstance(s, bool):
return s
if s is None:
return False
if isinstance(s, (int, float)):
return bool(s)
if isinstance(s, str):
return s.strip().lower() in {"true", "t", "1", "yes", "y"}
return False
def create_metric_data(metric: BaseMetric) -> MetricData:
if metric.error is not None:
return MetricData(
name=metric.__name__,
threshold=metric.threshold,
score=None,
reason=None,
success=False,
strictMode=metric.strict_mode,
evaluationModel=metric.evaluation_model,
error=metric.error,
evaluationCost=metric.evaluation_cost,
inputTokenCount=metric.input_tokens,
outputTokenCount=metric.output_tokens,
verboseLogs=metric.verbose_logs,
)
else:
return MetricData(
name=metric.__name__,
score=metric.score,
threshold=metric.threshold,
reason=metric.reason,
success=metric.is_successful(),
strictMode=metric.strict_mode,
evaluationModel=metric.evaluation_model,
error=None,
evaluationCost=metric.evaluation_cost,
inputTokenCount=metric.input_tokens,
outputTokenCount=metric.output_tokens,
verboseLogs=metric.verbose_logs,
)
def create_arena_metric_data(metric: ArenaGEval, contestant: str) -> MetricData:
if metric.error is not None:
return MetricData(
name=metric.__name__,
threshold=1,
score=None,
reason=None,
success=False,
strictMode=True,
evaluationModel=metric.evaluation_model,
error=metric.error,
evaluationCost=metric.evaluation_cost,
inputTokenCount=metric.input_tokens,
outputTokenCount=metric.output_tokens,
verboseLogs=metric.verbose_logs,
)
else:
return MetricData(
name=metric.__name__,
score=1 if contestant == metric.winner else 0,
threshold=1,
reason=metric.reason,
success=metric.is_successful(),
strictMode=True,
evaluationModel=metric.evaluation_model,
error=None,
evaluationCost=metric.evaluation_cost,
inputTokenCount=metric.input_tokens,
outputTokenCount=metric.output_tokens,
verboseLogs=metric.verbose_logs,
)
def create_test_result(
api_test_case: Union[LLMApiTestCase, ConversationalApiTestCase],
) -> TestResult:
name = api_test_case.name
index = api_test_case.order
if isinstance(api_test_case, ConversationalApiTestCase):
return TestResult(
name=name,
success=api_test_case.success,
metrics_data=api_test_case.metrics_data,
conversational=True,
index=index,
metadata=api_test_case.metadata,
turns=api_test_case.turns,
)
else:
multimodal = api_test_case.images_mapping
if multimodal:
return TestResult(
name=name,
success=api_test_case.success,
metrics_data=api_test_case.metrics_data,
input=api_test_case.input,
actual_output=api_test_case.actual_output,
conversational=False,
index=index,
multimodal=True,
metadata=api_test_case.metadata,
)
else:
return TestResult(
name=name,
success=api_test_case.success,
metrics_data=api_test_case.metrics_data,
input=api_test_case.input,
actual_output=api_test_case.actual_output,
expected_output=api_test_case.expected_output,
context=api_test_case.context,
retrieval_context=api_test_case.retrieval_context,
conversational=False,
index=index,
multimodal=False,
metadata=api_test_case.metadata,
)
def create_api_trace(trace: Trace, golden: Golden) -> TraceApi:
# Fall back to the golden's input when the trace didn't capture a
# meaningful one of its own. This concern lives here at the
# evaluation/rendering boundary, NOT in the tracer: `@observe`
# faithfully records whatever kwargs were passed (including `{}` for
# positional-only calls), and we shouldn't rewrite general tracing
# behavior to paper over an evaluation-specific rendering/dedupe
# problem. The truthiness check cleanly covers the "absent" cases
# (`None`, `{}`, `""`) that would otherwise show as garbage in the
# trace-level Metrics Summary and break `filter_duplicate_results`.
#
# Span lists start empty and are populated by the eval-iterator's
# DFS walker (``_a_execute_span_test_case`` / its sync twin), which
# categorizes each visited span by isinstance and appends to the
# matching ``trace_api.*_spans`` list. We DON'T pre-populate from
# ``trace.root_spans`` here because the walker is also responsible
# for attaching per-span metric data, error flags, and trace dicts —
# doing it twice (here + walker) would either double-emit or require
# the walker to dedupe.
#
# Trace-level fields (``name``, ``tags``, ``thread_id``, ``user_id``,
# ``metadata``, ``environment``) are forwarded from the trace so that
# OTel-based integrations whose users configured them via instrumentation
# settings or ``update_current_trace(...)`` see them on the dashboard.
# The non-eval REST path (``trace_manager.create_trace_api``) already
# forwards these; mirror its shape here so the eval-iterator path
# doesn't silently drop them.
#
# ``metadata`` sources from ``trace.metadata`` (user-configured
# at instrument time or via ``update_current_trace(...)``). It does
# NOT source from ``golden.additional_metadata`` here — that field
# already populates ``LLMTestCase.metadata`` at every callsite that
# builds a test case from a golden, which is the correct home for
# per-row evaluation context. Conflating the two layers (test-case
# metadata vs trace metadata) silently overwrote whatever the user
# configured on the trace, which is the opposite of what we want:
# the user owns trace metadata, the golden owns test-case metadata,
# both flow to their respective surfaces.
return TraceApi(
uuid=trace.uuid,
baseSpans=[],
agentSpans=[],
llmSpans=[],
retrieverSpans=[],
toolSpans=[],
startTime=(
to_zod_compatible_iso(perf_counter_to_datetime(trace.start_time))
if trace.start_time
else None
),
endTime=(
to_zod_compatible_iso(perf_counter_to_datetime(trace.end_time))
if trace.end_time
else None
),
input=trace.input or golden.input,
output=trace.output,
expected_output=trace.expected_output,
context=trace.context,
retrieval_context=(
[
rc.context if hasattr(rc, "context") else rc
for rc in trace.retrieval_context
]
if trace.retrieval_context
else None
),
tools_called=trace.tools_called,
expected_tools=trace.expected_tools,
metadata=trace.metadata,
name=trace.name,
tags=trace.tags,
threadId=trace.thread_id,
userId=trace.user_id,
environment=trace.environment,
status=(
TraceSpanApiStatus.SUCCESS
if trace.status == TraceSpanStatus.SUCCESS
else TraceSpanApiStatus.ERRORED
),
)
def validate_assert_test_inputs(
golden: Optional[Golden] = None,
test_case: Optional[LLMTestCase] = None,
metrics: Optional[List] = None,
):
# Trace-scoped shape: `assert_test(golden[, metrics])` inside a plugin-wrapped test.
if golden and not test_case:
if metrics is not None and not all(
isinstance(m, BaseMetric) for m in metrics
):
raise ValueError(
"All 'metrics' must be instances of 'BaseMetric' when using "
"`assert_test(golden=..., metrics=...)`."
)
return
if test_case and not metrics:
raise ValueError(
"Both 'test_case' and 'metrics' must be provided together."
)
if test_case and metrics:
if (isinstance(test_case, LLMTestCase)) and not all(
isinstance(metric, BaseMetric) for metric in metrics
):
raise ValueError(
"All 'metrics' for an 'LLMTestCase' must be instances of 'BaseMetric' only."
)
if isinstance(test_case, ConversationalTestCase) and not all(
isinstance(metric, BaseConversationalMetric) for metric in metrics
):
raise ValueError(
"All 'metrics' for an 'ConversationalTestCase' must be instances of 'BaseConversationalMetric' only."
)
return
raise ValueError(
"You must provide either ('golden' [+ 'metrics']) from inside a "
"`deepeval test run` test, or ('test_case' + 'metrics')."
)
def validate_evaluate_inputs(
test_cases: Optional[
Union[List[LLMTestCase], List[ConversationalTestCase]]
] = None,
metrics: Optional[
Union[
List[BaseMetric],
List[BaseConversationalMetric],
]
] = None,
metric_collection: Optional[str] = None,
):
if metric_collection is None and metrics is None:
raise ValueError(
"You must provide either 'metric_collection' or 'metrics'."
)
if metric_collection is not None and metrics is not None:
raise ValueError(
"You cannot provide both 'metric_collection' and 'metrics'."
)
if test_cases and metrics:
for test_case in test_cases:
for metric in metrics:
if (isinstance(test_case, LLMTestCase)) and not isinstance(
metric, BaseMetric
):
raise ValueError(
f"Metric {metric.__name__} is not a valid metric for LLMTestCase."
)
if isinstance(
test_case, ConversationalTestCase
) and not isinstance(metric, BaseConversationalMetric):
print(type(metric))
raise ValueError(
f"Metric {metric.__name__} is not a valid metric for ConversationalTestCase."
)
def print_test_result(test_result: TestResult, display: TestRunResultDisplay):
if test_result.metrics_data is None:
return
if (
display == TestRunResultDisplay.PASSING.value
and test_result.success is False
):
return
elif display == TestRunResultDisplay.FAILING.value and test_result.success:
return
print("")
print("=" * 70 + "\n")
print("Metrics Summary\n")
for metric_data in test_result.metrics_data:
successful = _is_metric_successful(metric_data)
if not successful:
print(
f" - ❌ {metric_data.name} (score: {metric_data.score}, threshold: {metric_data.threshold}, strict: {metric_data.strict_mode}, evaluation model: {metric_data.evaluation_model}, reason: {metric_data.reason}, error: {metric_data.error})"
)
else:
print(
f" - ✅ {metric_data.name} (score: {metric_data.score}, threshold: {metric_data.threshold}, strict: {metric_data.strict_mode}, evaluation model: {metric_data.evaluation_model}, reason: {metric_data.reason}, error: {metric_data.error})"
)
print("")
if test_result.multimodal:
print("For multimodal test case:\n")
print(f" - input: {test_result.input}")
print(f" - actual output: {test_result.actual_output}")
elif test_result.conversational:
print("For conversational test case:\n")
if test_result.turns:
print(" Turns:")
turns = sorted(test_result.turns, key=lambda t: t.order)
for t in turns:
print(format_turn(t))
else:
print(" - No turns recorded in this test case.")
else:
print("For test case:\n")
print(f" - input: {test_result.input}")
print(f" - actual output: {test_result.actual_output}")
print(f" - expected output: {test_result.expected_output}")
print(f" - context: {test_result.context}")
print(f" - retrieval context: {test_result.retrieval_context}")
def write_test_result_to_file(
test_result: TestResult, display: TestRunResultDisplay, output_dir: str
):
def get_log_id(output_dir: str):
ts = time.strftime("%Y%m%d_%H%M%S")
log_path = os.path.join(output_dir, f"test_run_{ts}.log")
return log_path
def aggregate_metric_pass_rates_to_file(test_results: List[TestResult]):
metric_counts = {}
metric_successes = {}
for result in test_results:
if result.metrics_data:
for metric_data in result.metrics_data:
metric_name = metric_data.name
if metric_name not in metric_counts:
metric_counts[metric_name] = 0
metric_successes[metric_name] = 0
metric_counts[metric_name] += 1
if metric_data.success:
metric_successes[metric_name] += 1
metric_pass_rates = {
metric: (metric_successes[metric] / metric_counts[metric])
for metric in metric_counts
}
with open(out_file, "a", encoding="utf-8") as file:
file.write("\n" + "=" * 70 + "\n")
file.write("Overall Metric Pass Rates\n")
for metric, pass_rate in metric_pass_rates.items():
file.write(f"{metric}: {pass_rate:.2%} pass rate")
file.write("\n" + "=" * 70 + "\n")
# Determine output Directory
out_dir = output_dir or os.getcwd()
os.makedirs(out_dir, exist_ok=True)
# Generate log id
out_file = get_log_id(out_dir)
if test_result.metrics_data is None:
return
if (
display == TestRunResultDisplay.PASSING.value
and test_result.success is False
):
return
elif display == TestRunResultDisplay.FAILING.value and test_result.success:
return
with open(out_file, "a", encoding="utf-8") as file:
file.write("\n" + "=" * 70 + "\n\n")
file.write("Metrics Summary\n\n")
for metric_data in test_result.metrics_data:
successful = _is_metric_successful(metric_data)
if not successful:
file.write(
f" - ❌ {metric_data.name} (score: {metric_data.score}, threshold: {metric_data.threshold}, "
f"strict: {metric_data.strict_mode}, evaluation model: {metric_data.evaluation_model}, "
f"reason: {metric_data.reason}, error: {metric_data.error})\n"
)
else:
file.write(
f" - ✅ {metric_data.name} (score: {metric_data.score}, threshold: {metric_data.threshold}, "
f"strict: {metric_data.strict_mode}, evaluation model: {metric_data.evaluation_model}, "
f"reason: {metric_data.reason}, error: {metric_data.error})\n"
)
file.write("\n")
if test_result.multimodal:
file.write("For multimodal test case:\n\n")
file.write(f" - input: {test_result.input}\n")
file.write(f" - actual output: {test_result.actual_output}\n")
elif test_result.conversational:
file.write("For conversational test case:\n\n")
if test_result.turns:
file.write(" Turns:\n")
turns = sorted(test_result.turns, key=lambda t: t.order)
for t in turns:
file.write(format_turn(t) + "\n")
else:
file.write(" - No turns recorded in this test case.\n")
else:
file.write("For test case:\n\n")
file.write(f" - input: {test_result.input}\n")
file.write(f" - actual output: {test_result.actual_output}\n")
file.write(f" - expected output: {test_result.expected_output}\n")
file.write(f" - context: {test_result.context}\n")
file.write(
f" - retrieval context: {test_result.retrieval_context}\n"
)
aggregate_metric_pass_rates_to_file(
[test_result] if not isinstance(test_result, list) else test_result
)
def aggregate_metric_pass_rates(test_results: List[TestResult]) -> dict:
if not test_results:
return {}
metric_counts = {}
metric_successes = {}
for result in test_results:
if result.metrics_data:
for metric_data in result.metrics_data:
metric_name = metric_data.name
if metric_name not in metric_counts:
metric_counts[metric_name] = 0
metric_successes[metric_name] = 0
metric_counts[metric_name] += 1
if metric_data.success:
metric_successes[metric_name] += 1
metric_pass_rates = {
metric: (metric_successes[metric] / metric_counts[metric])
for metric in metric_counts
}
print("\n" + "=" * 70 + "\n")
print("Overall Metric Pass Rates\n")
for metric, pass_rate in metric_pass_rates.items():
print(f"{metric}: {pass_rate:.2%} pass rate")
print("\n" + "=" * 70 + "\n")
return metric_pass_rates
def count_metrics_in_trace(trace: Trace) -> int:
def count_metrics_recursive(span: BaseSpan) -> int:
count = len(span.metrics) if span.metrics else 0
for child in span.children:
count += count_metrics_recursive(child)
return count
return sum(count_metrics_recursive(span) for span in trace.root_spans)
def count_total_metrics_for_trace(trace: Trace) -> int:
"""Span subtree metrics + trace-level metrics."""
return count_metrics_in_trace(trace=trace) + len(trace.metrics or [])
def count_metrics_in_span_subtree(span: BaseSpan) -> int:
total = len(span.metrics or [])
for c in span.children or []:
total += count_metrics_in_span_subtree(c)
return total
def extract_trace_test_results(trace_api: TraceApi) -> List[TestResult]:
test_results: List[TestResult] = []
# Do not emit trace-level ``trace_api.metrics_data`` as its own ``TestResult``.
# The golden ``api_test_case`` path already records those rows via
# ``update_metric_data``; emitting them again here was the root cause of an
# extra dashboard panel (wrong ``name`` / ``success`` vs the main case).
# extract base span results
for span in trace_api.base_spans:
test_results.extend(extract_span_test_results(span))
# extract agent span results
for span in trace_api.agent_spans:
test_results.extend(extract_span_test_results(span))
# extract llm span results
for span in trace_api.llm_spans:
test_results.extend(extract_span_test_results(span))
# extract retriever span results
for span in trace_api.retriever_spans:
test_results.extend(extract_span_test_results(span))
# extract tool span results
for span in trace_api.tool_spans:
test_results.extend(extract_span_test_results(span))
return test_results
def extract_span_test_results(span_api: BaseApiSpan) -> List[TestResult]:
test_results: List[TestResult] = []
if span_api.metrics_data:
test_results.append(
TestResult(
name=span_api.name,
success=span_api.status == TraceSpanApiStatus.SUCCESS,
metrics_data=span_api.metrics_data,
input=span_api.input,
actual_output=span_api.output,
expected_output=span_api.expected_output,
context=span_api.context,
retrieval_context=span_api.retrieval_context,
conversational=False,
)
)
return test_results