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

311 lines
10 KiB
Python

import logging
from typing import (
List,
Optional,
)
import time
from deepeval.evaluate.configs import (
ErrorConfig,
DisplayConfig,
)
from deepeval.tracing.tracing import (
trace_manager,
Trace,
BaseSpan,
AgentSpan,
LlmSpan,
RetrieverSpan,
ToolSpan,
)
from deepeval.tracing.context import current_trace_context
from deepeval.tracing.api import (
BaseApiSpan,
)
from deepeval.dataset import Golden
from deepeval.errors import DeepEvalError
from deepeval.utils import (
format_error_text,
)
from deepeval.metrics import BaseMetric
from deepeval.test_case import (
LLMTestCase,
)
from deepeval.test_case.api import create_api_test_case
from deepeval.test_run import (
global_test_run_manager,
)
from deepeval.constants import PYTEST_TRACE_TEST_WRAPPER_SPAN_NAME
from deepeval.evaluate.types import TestResult
from deepeval.evaluate.utils import (
create_api_trace,
create_metric_data,
create_test_result,
)
from deepeval.tracing.types import TraceSpanStatus
from deepeval.tracing.api import TraceSpanApiStatus
from deepeval.test_run import TEMP_FILE_PATH
logger = logging.getLogger(__name__)
from deepeval.evaluate.execute._common import (
_execute_metric,
_skip_metrics_for_error,
_trace_error,
log_prompt,
)
def _assert_test_from_current_trace(
golden: Golden,
metrics: Optional[List[BaseMetric]] = None,
error_config: Optional[ErrorConfig] = None,
display_config: Optional[DisplayConfig] = None,
) -> TestResult:
"""Attach the test's live `@observe` trace to the active test run.
Relies on the deepeval pytest plugin's eval scope to keep the trace live
across the test body so it can be read off `current_trace_context` here.
"""
if error_config is None:
error_config = ErrorConfig()
if display_config is None:
display_config = DisplayConfig(show_indicator=False)
current_trace: Optional[Trace] = current_trace_context.get()
if current_trace is None:
raise DeepEvalError(
"No active trace found for this test. "
"`assert_test(golden=..., metrics=...)` must be called inside a "
"pytest test run with `deepeval test run`, and the test body must "
"invoke at least one `@observe`-decorated function."
)
test_run_manager = global_test_run_manager
# Trace is mid-flight (outer wrapper span hasn't closed); stamp end_time.
if current_trace.end_time is None:
current_trace.end_time = time.perf_counter()
# Mirror native Observer behavior: trace errors only if the user's root
# span errors. Nested errors caught by user code don't taint the trace.
user_roots: List[BaseSpan] = []
for s in current_trace.root_spans or []:
if (
getattr(s, "name", None) == PYTEST_TRACE_TEST_WRAPPER_SPAN_NAME
and s.children
):
user_roots.extend(s.children)
else:
user_roots.append(s)
errored = any(s.status == TraceSpanStatus.ERRORED for s in user_roots)
current_trace.status = (
TraceSpanStatus.ERRORED if errored else TraceSpanStatus.SUCCESS
)
# Skip deepeval's internal pytest wrapper and promote its first child.
root_for_dfs: Optional[BaseSpan] = None
is_promoted_root = False
if current_trace.root_spans:
root = current_trace.root_spans[0]
if (
getattr(root, "name", None) == PYTEST_TRACE_TEST_WRAPPER_SPAN_NAME
and root.children
):
root_for_dfs = root.children[0]
is_promoted_root = True
else:
root_for_dfs = root
effective_trace_output = (
current_trace.output
if current_trace.output is not None
else getattr(root_for_dfs, "output", None)
)
trace_api = create_api_trace(trace=current_trace, golden=golden)
trace_api.status = (
TraceSpanApiStatus.ERRORED if errored else TraceSpanApiStatus.SUCCESS
)
if trace_api.output is None and effective_trace_output is not None:
trace_api.output = effective_trace_output
test_case = LLMTestCase(
input=golden.input,
actual_output=(
str(effective_trace_output)
if effective_trace_output is not None
else None
),
expected_output=current_trace.expected_output,
context=current_trace.context,
retrieval_context=current_trace.retrieval_context,
metadata=golden.additional_metadata,
tools_called=current_trace.tools_called,
expected_tools=current_trace.expected_tools,
comments=golden.comments,
name=golden.name,
_dataset_alias=golden._dataset_alias,
_dataset_id=golden._dataset_id,
_dataset_rank=golden._dataset_rank,
)
api_test_case = create_api_test_case(
test_case=test_case,
trace=trace_api,
index=None,
)
def dfs(span: BaseSpan, is_promoted_root: bool = False):
metrics: List[BaseMetric] = list(span.metrics or [])
api_span: BaseApiSpan = trace_manager._convert_span_to_api_span(span)
# Promoted root's parent_uuid still points at the stripped wrapper;
# null it so the backend treats it as a genuine root.
if is_promoted_root:
api_span.parent_uuid = None
if isinstance(span, AgentSpan):
trace_api.agent_spans.append(api_span)
elif isinstance(span, LlmSpan):
trace_api.llm_spans.append(api_span)
log_prompt(span, test_run_manager)
elif isinstance(span, RetrieverSpan):
trace_api.retriever_spans.append(api_span)
elif isinstance(span, ToolSpan):
trace_api.tool_spans.append(api_span)
else:
trace_api.base_spans.append(api_span)
if _skip_metrics_for_error(span=span, trace=current_trace):
api_span.status = TraceSpanApiStatus.ERRORED
api_span.error = span.error or _trace_error(current_trace)
return
for child in span.children:
dfs(child)
if not metrics:
return
requires_trace = any(
getattr(m, "requires_trace", False) for m in metrics
)
llm_test_case: Optional[LLMTestCase] = None
if span.input is not None:
llm_test_case = LLMTestCase(
input=str(span.input),
actual_output=(
str(span.output) if span.output is not None else None
),
expected_output=span.expected_output,
context=span.context,
retrieval_context=span.retrieval_context,
tools_called=span.tools_called,
expected_tools=span.expected_tools,
)
if requires_trace:
if llm_test_case is None:
llm_test_case = LLMTestCase(input="None")
llm_test_case._trace_dict = trace_manager.create_nested_spans_dict(
span
)
elif llm_test_case is None:
api_span.status = TraceSpanApiStatus.ERRORED
api_span.error = format_error_text(
DeepEvalError(
"Span has metrics but no LLMTestCase. "
"Are you sure you called `update_current_span()`?"
)
)
return
api_span.metrics_data = []
for metric in metrics:
metric.skipped = False
metric.error = None
if display_config.verbose_mode is not None:
metric.verbose_mode = display_config.verbose_mode
for metric in metrics:
res = _execute_metric(
metric=metric,
test_case=llm_test_case,
show_metric_indicator=False,
in_component=True,
error_config=error_config,
)
if res == "skip":
continue
metric_data = create_metric_data(metric)
api_span.metrics_data.append(metric_data)
api_test_case.update_status(metric_data.success)
if root_for_dfs is not None:
dfs(root_for_dfs, is_promoted_root=is_promoted_root)
existing_trace_metrics = list(current_trace.metrics or [])
if metrics:
existing_trace_metrics = existing_trace_metrics + list(metrics)
current_trace.metrics = existing_trace_metrics
if current_trace.metrics and not _skip_metrics_for_error(
trace=current_trace
):
llm_test_case_for_trace = LLMTestCase(
input=golden.input or "None",
actual_output=(
str(effective_trace_output)
if effective_trace_output is not None
else None
),
expected_output=current_trace.expected_output
or golden.expected_output,
context=current_trace.context or golden.context,
retrieval_context=current_trace.retrieval_context
or golden.retrieval_context,
tools_called=current_trace.tools_called,
expected_tools=current_trace.expected_tools
or golden.expected_tools,
)
if (
any(
getattr(m, "requires_trace", False)
for m in current_trace.metrics
)
and root_for_dfs is not None
):
llm_test_case_for_trace._trace_dict = (
trace_manager.create_nested_spans_dict(root_for_dfs)
)
trace_api.metrics_data = []
for metric in current_trace.metrics:
metric.skipped = False
metric.error = None
if display_config.verbose_mode is not None:
metric.verbose_mode = display_config.verbose_mode
res = _execute_metric(
metric=metric,
test_case=llm_test_case_for_trace,
show_metric_indicator=False,
in_component=True,
error_config=error_config,
)
if res == "skip":
continue
if not metric.skipped:
metric_data = create_metric_data(metric)
trace_api.metrics_data.append(metric_data)
api_test_case.update_metric_data(metric_data)
api_test_case.update_status(metric_data.success)
test_run_manager.update_test_run(api_test_case, test_case)
test_run_manager.save_test_run(TEMP_FILE_PATH)
return create_test_result(api_test_case)