confident-ai--deepeval
329 行
10 KiB
Python
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)
|