项目文件夹

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

329 行
10 KiB
Python

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 span’s 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)