Files
confident-ai--deepeval/deepeval/evaluate/execute/_common.py
T
2026-07-13 13:32:05 +08:00

329 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import inspect
import logging
from typing import (
List,
Optional,
Union,
)
import asyncio
from deepeval.evaluate.configs import (
ErrorConfig,
)
from deepeval.tracing.tracing import (
trace_manager,
Trace,
BaseSpan,
LlmSpan,
)
from deepeval.errors import MissingTestCaseParamsError
from deepeval.utils import (
format_error_text,
are_timeouts_disabled,
get_gather_timeout_seconds,
)
from deepeval.metrics import (
BaseMetric,
)
from deepeval.models.retry_policy import (
set_outer_deadline,
reset_outer_deadline,
)
from deepeval.test_case import (
LLMTestCase,
ConversationalTestCase,
)
from deepeval.test_run import (
global_test_run_manager,
TestRunManager,
)
from deepeval.evaluate.types import TestResult
from deepeval.tracing.types import TraceSpanStatus
from deepeval.config.settings import get_settings
from deepeval.test_run import TEMP_FILE_PATH
from deepeval.confident.api import is_confident
from deepeval.test_run.hyperparameters import (
process_hyperparameters,
process_prompts,
)
logger = logging.getLogger(__name__)
def _timeout_msg(action: str, seconds: float) -> str:
if are_timeouts_disabled():
return (
f"Timeout occurred while {action} "
"(DeepEval timeouts are disabled; this likely came from the model/provider SDK or network layer). "
"Set DEEPEVAL_LOG_STACK_TRACES=1 for full traceback."
)
return (
f"Timed out after {seconds:.2f}s while {action}. "
"Increase DEEPEVAL_PER_TASK_TIMEOUT_SECONDS_OVERRIDE or set "
"DEEPEVAL_LOG_STACK_TRACES=1 for full traceback."
)
def _log_gather_timeout(
logger,
*,
exc: Optional[BaseException] = None,
pending: Optional[int] = None,
) -> None:
settings = get_settings()
if are_timeouts_disabled():
logger.warning(
"A task raised %s while waiting for gathered results; DeepEval gather/per-task timeouts are disabled%s. "
"This likely came from the model/provider SDK or network layer.",
type(exc).__name__ if exc else "TimeoutError",
f" (pending={pending})" if pending is not None else "",
exc_info=settings.DEEPEVAL_LOG_STACK_TRACES,
)
else:
if pending is not None:
logger.warning(
"Gather TIMEOUT after %.1fs; pending=%d tasks. "
"Some metrics may be marked as timed out. "
"To give tasks more time, consider increasing "
"DEEPEVAL_PER_TASK_TIMEOUT_SECONDS_OVERRIDE or "
"DEEPEVAL_TASK_GATHER_BUFFER_SECONDS_OVERRIDE.",
get_gather_timeout_seconds(),
pending,
)
else:
logger.warning(
"gather TIMEOUT after %.1fs. Some metrics may be marked as timed out. "
"To give tasks more time, consider increasing "
"DEEPEVAL_PER_TASK_TIMEOUT_SECONDS_OVERRIDE or "
"DEEPEVAL_TASK_GATHER_BUFFER_SECONDS_OVERRIDE.",
get_gather_timeout_seconds(),
)
def _skip_metrics_for_error(
span: Optional[BaseSpan] = None,
trace: Optional[Trace] = None,
) -> bool:
# trace failure: skip everything under this trace
if trace is not None and trace.status == TraceSpanStatus.ERRORED:
return True
# span failure: skip this spans metrics
if span is not None and span.status == TraceSpanStatus.ERRORED:
return True
return False
def _trace_error(current_trace: Trace) -> Optional[str]:
def _first_err(s: BaseSpan) -> Optional[str]:
if s.status == TraceSpanStatus.ERRORED and s.error:
return s.error
for c in s.children or []:
e = _first_err(c)
if e:
return e
return None
for root in current_trace.root_spans or []:
e = _first_err(root)
if e:
return e
return None
def _get_trace_by_uuid_anywhere(trace_uuid: str):
"""
Resolver for a trace UUID across the manager's state.
First tries the manager's indexed lookup, which (covers active/in-flight traces,
then does a linear scan of the full `trace_manager.traces` list, which covers
traces that were recorded/closed earlier or not yet indexed. Returns
the concrete Trace object or None if not found.
"""
tr = trace_manager.get_trace_by_uuid(trace_uuid)
if tr:
return tr
for tr in trace_manager.traces:
if tr.uuid == trace_uuid:
return tr
return None
def _pick_root_for_marking(trace):
"""
Choose the most appropriate root span to annotate on error/cancel.
Heuristic:
- Prefer the most recent open root, which will have no `end_time` since this is the
span currently in flight.
- If none are open, use the last root span if it exists.
- If the trace has no roots, return None.
This favors marking the active root in multi root traces while remaining
stable for already closed traces.
"""
open_roots = [rs for rs in trace.root_spans if rs.end_time is None]
return (
open_roots[-1]
if open_roots
else (trace.root_spans[-1] if trace.root_spans else None)
)
def _resolve_trace_and_root_for_task(t: asyncio.Task):
"""
Resolve trace and root for a completed task using the weak binding map.
Steps:
1. Look up the task in `trace_manager.task_bindings` to get the
bound `trace_uuid` and, if available, `root_span_uuid`.
2. Resolve the Trace with `_get_trace_by_uuid_anywhere`.
3. If a bound root UUID exists, try to find that exact root on the trace.
4. Otherwise, fall back to `_pick_root_for_marking(trace)`.
Returns a trace / root tuple. Either may be `None` when no binding is
present. This function is used by `on_task_done` to robustly mark error/cancel
states without assuming a single root trace or a root that is still open.
"""
binding = trace_manager.task_bindings.get(t) or {}
trace_uuid = binding.get("trace_uuid")
root_span_uuid = binding.get("root_span_uuid")
trace = _get_trace_by_uuid_anywhere(trace_uuid) if trace_uuid else None
root = None
if trace and root_span_uuid:
root = next(
(rs for rs in trace.root_spans if rs.uuid == root_span_uuid), None
)
if trace and root is None:
root = _pick_root_for_marking(trace)
return trace, root
async def _snapshot_tasks():
cur = asyncio.current_task()
# `all_tasks` returns tasks for the current running loop only
return {t for t in asyncio.all_tasks() if t is not cur}
def filter_duplicate_results(
main_result: TestResult, results: List[TestResult]
) -> List[TestResult]:
return [
result
for result in results
if not (
(result.input == main_result.input)
and (result.actual_output == main_result.actual_output)
and (result.metrics_data == main_result.metrics_data)
)
]
async def _await_with_outer_deadline(obj, *args, timeout: float, **kwargs):
token = set_outer_deadline(timeout)
try:
if inspect.isawaitable(obj):
coro = obj
else:
coro = obj(*args, **kwargs)
if get_settings().DEEPEVAL_DISABLE_TIMEOUTS:
return await coro
return await asyncio.wait_for(coro, timeout=timeout)
finally:
reset_outer_deadline(token)
def _execute_metric(
metric: BaseMetric,
test_case: Union[LLMTestCase, ConversationalTestCase],
show_metric_indicator: bool,
in_component: bool,
error_config: ErrorConfig,
) -> Optional[str]:
try:
metric.measure(
test_case,
_show_indicator=show_metric_indicator,
_in_component=in_component,
_log_metric_to_confident=False,
)
except MissingTestCaseParamsError as e:
if error_config.skip_on_missing_params:
metric.skipped = True
metric.error = None
metric.success = None
return "skip"
else:
if error_config.ignore_errors:
metric.error = format_error_text(e)
metric.success = False
else:
raise
except TypeError:
try:
metric.measure(test_case)
except MissingTestCaseParamsError as e:
if error_config.skip_on_missing_params:
metric.skipped = True
metric.error = None
metric.success = None
return "skip"
else:
if error_config.ignore_errors:
metric.error = format_error_text(e)
metric.success = False
else:
raise
except Exception as e:
if error_config.ignore_errors:
metric.error = format_error_text(e)
metric.success = False
else:
raise
except Exception as e:
if error_config.ignore_errors:
metric.error = format_error_text(e)
metric.success = False
else:
raise
def log_prompt(
llm_span: LlmSpan,
test_run_manager: TestRunManager,
):
prompt = llm_span.prompt
if prompt is None:
return
span_hyperparameters = {}
prompt_hash = prompt.hash if is_confident() else None
key = f"{prompt.alias}_{prompt_hash}"
span_hyperparameters[key] = prompt
test_run = test_run_manager.get_test_run()
if test_run.prompts is None:
test_run.prompts = []
if test_run.hyperparameters is None:
test_run.hyperparameters = {}
if key not in test_run.hyperparameters:
test_run.hyperparameters.update(
process_hyperparameters(span_hyperparameters, False)
)
existing_prompt_keys = {f"{p.alias}_{p.hash}" for p in test_run.prompts}
new_prompts = process_prompts(span_hyperparameters)
for new_prompt in new_prompts:
new_prompt_key = f"{new_prompt.alias}_{new_prompt.hash}"
if new_prompt_key not in existing_prompt_keys:
test_run.prompts.append(new_prompt)
global_test_run_manager.save_test_run(TEMP_FILE_PATH)