confident-ai--deepeval
1219 行
44 KiB
Python
1219 行
44 KiB
Python
from enum import Enum
|
|
import os
|
|
import json
|
|
from pathlib import Path
|
|
from pydantic import BaseModel, Field
|
|
from typing import Any, Optional, List, Dict, Union, Tuple
|
|
import sys
|
|
from rich.table import Table
|
|
from rich.console import Console
|
|
from rich import print
|
|
|
|
|
|
from deepeval.metrics import BaseMetric
|
|
from deepeval.confident.api import Api, Endpoints, HttpMethods, is_confident
|
|
from deepeval.test_run.api import (
|
|
LLMApiTestCase,
|
|
ConversationalApiTestCase,
|
|
TestRunHttpResponse,
|
|
MetricData,
|
|
)
|
|
from deepeval.tracing.utils import make_json_serializable
|
|
from deepeval.tracing.api import SpanApiType, span_api_type_literals
|
|
from deepeval.test_case import LLMTestCase, ConversationalTestCase
|
|
from deepeval.utils import (
|
|
delete_file_if_exists,
|
|
get_is_running_deepeval,
|
|
get_test_run_official,
|
|
is_read_only_env,
|
|
open_browser,
|
|
shorten,
|
|
format_turn,
|
|
len_short,
|
|
)
|
|
from deepeval.test_run.cache import global_test_run_cache_manager
|
|
from deepeval.constants import CONFIDENT_TEST_CASE_BATCH_SIZE, HIDDEN_DIR
|
|
from deepeval.prompt import (
|
|
PromptMessage,
|
|
ModelSettings,
|
|
PromptInterpolationType,
|
|
OutputType,
|
|
)
|
|
from rich.panel import Panel
|
|
from rich.columns import Columns
|
|
|
|
portalocker = None
|
|
if not is_read_only_env():
|
|
try:
|
|
import portalocker
|
|
except Exception as e:
|
|
print(
|
|
f"Warning: failed to import portalocker: {e}",
|
|
file=sys.stderr,
|
|
)
|
|
else:
|
|
print(
|
|
"Warning: DeepEval is configured for read only environment. Test runs will not be written to disk."
|
|
)
|
|
|
|
|
|
TEMP_FILE_PATH = f"{HIDDEN_DIR}/.temp_test_run_data.json"
|
|
LATEST_TEST_RUN_FILE_PATH = f"{HIDDEN_DIR}/.latest_test_run.json"
|
|
# Full TestRun payload (same as timestamped exports); overwritten each run.
|
|
LATEST_FULL_TEST_RUN_FILE_PATH = f"{HIDDEN_DIR}/.latest_run_full.json"
|
|
LATEST_TEST_RUN_DATA_KEY = "testRunData"
|
|
LATEST_TEST_RUN_LINK_KEY = "testRunLink"
|
|
console = Console()
|
|
|
|
|
|
class TestRunResultDisplay(Enum):
|
|
ALL = "all"
|
|
FAILING = "failing"
|
|
PASSING = "passing"
|
|
|
|
|
|
class MetricScoreType(BaseModel):
|
|
metric: str
|
|
score: float
|
|
|
|
@classmethod
|
|
def from_metric(cls, metric: BaseMetric):
|
|
return cls(metric=metric.__name__, score=metric.score)
|
|
|
|
|
|
class MetricScores(BaseModel):
|
|
metric: str
|
|
scores: List[float]
|
|
passes: int
|
|
fails: int
|
|
errors: int
|
|
|
|
|
|
class TraceMetricScores(BaseModel):
|
|
agent: Dict[str, Dict[str, MetricScores]] = Field(default_factory=dict)
|
|
tool: Dict[str, Dict[str, MetricScores]] = Field(default_factory=dict)
|
|
retriever: Dict[str, Dict[str, MetricScores]] = Field(default_factory=dict)
|
|
llm: Dict[str, Dict[str, MetricScores]] = Field(default_factory=dict)
|
|
base: Dict[str, Dict[str, MetricScores]] = Field(default_factory=dict)
|
|
|
|
|
|
class PromptData(BaseModel):
|
|
alias: Optional[str] = None
|
|
hash: Optional[str] = None
|
|
version: Optional[str] = None
|
|
text_template: Optional[str] = None
|
|
messages_template: Optional[List[PromptMessage]] = None
|
|
model_settings: Optional[ModelSettings] = None
|
|
output_type: Optional[OutputType] = None
|
|
interpolation_type: Optional[PromptInterpolationType] = None
|
|
|
|
|
|
class MetricsAverageDict:
|
|
def __init__(self):
|
|
self.metric_dict = {}
|
|
self.metric_count = {}
|
|
|
|
def add_metric(self, metric_name, score):
|
|
if metric_name not in self.metric_dict:
|
|
self.metric_dict[metric_name] = score
|
|
self.metric_count[metric_name] = 1
|
|
else:
|
|
self.metric_dict[metric_name] += score
|
|
self.metric_count[metric_name] += 1
|
|
|
|
def get_average_metric_score(self):
|
|
return [
|
|
MetricScoreType(
|
|
metric=metric,
|
|
score=self.metric_dict[metric] / self.metric_count[metric],
|
|
)
|
|
for metric in self.metric_dict
|
|
]
|
|
|
|
|
|
class RemainingTestRun(BaseModel):
|
|
testRunId: str
|
|
test_cases: List[LLMApiTestCase] = Field(
|
|
alias="testCases", default_factory=lambda: []
|
|
)
|
|
conversational_test_cases: List[ConversationalApiTestCase] = Field(
|
|
alias="conversationalTestCases", default_factory=lambda: []
|
|
)
|
|
|
|
|
|
class TestRun(BaseModel):
|
|
test_file: Optional[str] = Field(
|
|
None,
|
|
alias="testFile",
|
|
)
|
|
test_cases: List[LLMApiTestCase] = Field(
|
|
alias="testCases", default_factory=lambda: []
|
|
)
|
|
conversational_test_cases: List[ConversationalApiTestCase] = Field(
|
|
alias="conversationalTestCases", default_factory=lambda: []
|
|
)
|
|
metrics_scores: List[MetricScores] = Field(
|
|
default_factory=lambda: [], alias="metricsScores"
|
|
)
|
|
trace_metrics_scores: Optional[TraceMetricScores] = Field(
|
|
None, alias="traceMetricsScores"
|
|
)
|
|
identifier: Optional[str] = None
|
|
hyperparameters: Optional[Dict[str, Any]] = Field(None)
|
|
prompts: Optional[List[PromptData]] = Field(None)
|
|
test_passed: Optional[int] = Field(None, alias="testPassed")
|
|
test_failed: Optional[int] = Field(None, alias="testFailed")
|
|
run_duration: float = Field(0.0, alias="runDuration")
|
|
evaluation_cost: Union[float, None] = Field(None, alias="evaluationCost")
|
|
dataset_alias: Optional[str] = Field(None, alias="datasetAlias")
|
|
dataset_id: Optional[str] = Field(None, alias="datasetId")
|
|
official: bool = False
|
|
|
|
def add_test_case(
|
|
self, api_test_case: Union[LLMApiTestCase, ConversationalApiTestCase]
|
|
):
|
|
if isinstance(api_test_case, ConversationalApiTestCase):
|
|
self.conversational_test_cases.append(api_test_case)
|
|
else:
|
|
self.test_cases.append(api_test_case)
|
|
|
|
if api_test_case.evaluation_cost is not None:
|
|
if self.evaluation_cost is None:
|
|
self.evaluation_cost = api_test_case.evaluation_cost
|
|
else:
|
|
self.evaluation_cost += api_test_case.evaluation_cost
|
|
|
|
def set_dataset_properties(
|
|
self,
|
|
test_case: Union[LLMTestCase, ConversationalTestCase],
|
|
):
|
|
if self.dataset_alias is None:
|
|
self.dataset_alias = test_case._dataset_alias
|
|
|
|
if self.dataset_id is None:
|
|
self.dataset_id = test_case._dataset_id
|
|
|
|
@staticmethod
|
|
def _assign_unique_orders(test_cases):
|
|
"""Assign unique sequential orders to a sorted list of test cases.
|
|
|
|
Preserves the original gap-filling behaviour (only touch test cases
|
|
whose order is ``None``) **unless** duplicates are detected. When
|
|
multiple ``evaluate()`` calls accumulate into the same test run each
|
|
call starts its order counter from 0, producing duplicates such as
|
|
``[0, 0, 1, 1, ...]``. Confident AI treats ``order`` as a unique
|
|
position identifier, so duplicates cause earlier test cases to be
|
|
displayed as *Skipped*. In that case we fall back to a full
|
|
sequential re-number to guarantee uniqueness.
|
|
"""
|
|
# --- original logic: fill Nones, keep existing values ---
|
|
highest_order = 0
|
|
for test_case in test_cases:
|
|
if test_case.order is None:
|
|
test_case.order = highest_order
|
|
highest_order = test_case.order + 1
|
|
|
|
# --- check for duplicates introduced by accumulation ---
|
|
seen = set()
|
|
has_duplicates = False
|
|
for test_case in test_cases:
|
|
if test_case.order in seen:
|
|
has_duplicates = True
|
|
break
|
|
seen.add(test_case.order)
|
|
|
|
if has_duplicates:
|
|
for i, test_case in enumerate(test_cases):
|
|
test_case.order = i
|
|
|
|
def sort_test_cases(self):
|
|
self.test_cases.sort(
|
|
key=lambda x: (x.order if x.order is not None else float("inf"))
|
|
)
|
|
self._assign_unique_orders(self.test_cases)
|
|
|
|
self.conversational_test_cases.sort(
|
|
key=lambda x: (x.order if x.order is not None else float("inf"))
|
|
)
|
|
self._assign_unique_orders(self.conversational_test_cases)
|
|
|
|
def construct_metrics_scores(self) -> int:
|
|
# Use a dict to aggregate scores, passes, and fails for each metric.
|
|
metrics_dict: Dict[str, Dict[str, Any]] = {}
|
|
# Add dict for trace metrics
|
|
trace_metrics_dict: Dict[
|
|
span_api_type_literals, Dict[str, Dict[str, Dict[str, Any]]]
|
|
] = {
|
|
SpanApiType.AGENT.value: {},
|
|
SpanApiType.TOOL.value: {},
|
|
SpanApiType.RETRIEVER.value: {},
|
|
SpanApiType.LLM.value: {},
|
|
SpanApiType.BASE.value: {},
|
|
}
|
|
valid_scores = 0
|
|
|
|
def process_metric_data(metric_data: MetricData):
|
|
"""
|
|
Process and aggregate metric data for overall test metrics.
|
|
|
|
Args:
|
|
metric_data: The metric data to process
|
|
"""
|
|
nonlocal valid_scores
|
|
metric_name = metric_data.name
|
|
score = metric_data.score
|
|
success = metric_data.success
|
|
|
|
if metric_name not in metrics_dict:
|
|
metrics_dict[metric_name] = {
|
|
"scores": [],
|
|
"passes": 0,
|
|
"fails": 0,
|
|
"errors": 0,
|
|
}
|
|
|
|
metric_dict = metrics_dict[metric_name]
|
|
|
|
if score is None or success is None:
|
|
metric_dict["errors"] += 1
|
|
else:
|
|
valid_scores += 1
|
|
metric_dict["scores"].append(score)
|
|
if success:
|
|
metric_dict["passes"] += 1
|
|
else:
|
|
metric_dict["fails"] += 1
|
|
|
|
def process_span_metric_data(
|
|
metric_data: MetricData,
|
|
span_type: span_api_type_literals,
|
|
span_name: str,
|
|
):
|
|
"""
|
|
Process and aggregate metric data for a specific span.
|
|
|
|
Args:
|
|
metric_data: The metric data to process
|
|
span_type: The type of span (agent, tool, retriever, llm, base)
|
|
span_name: The name of the span
|
|
"""
|
|
metric_name = metric_data.name
|
|
score = metric_data.score
|
|
success = metric_data.success
|
|
|
|
if span_name not in trace_metrics_dict[span_type]:
|
|
trace_metrics_dict[span_type][span_name] = {}
|
|
|
|
if metric_name not in trace_metrics_dict[span_type][span_name]:
|
|
trace_metrics_dict[span_type][span_name][metric_name] = {
|
|
"scores": [],
|
|
"passes": 0,
|
|
"fails": 0,
|
|
"errors": 0,
|
|
}
|
|
|
|
metric_dict = trace_metrics_dict[span_type][span_name][metric_name]
|
|
|
|
if score is None or success is None:
|
|
metric_dict["errors"] += 1
|
|
else:
|
|
metric_dict["scores"].append(score)
|
|
if success:
|
|
metric_dict["passes"] += 1
|
|
else:
|
|
metric_dict["fails"] += 1
|
|
|
|
def process_spans(spans, span_type: span_api_type_literals):
|
|
"""
|
|
Process all metrics for a list of spans of a specific type.
|
|
|
|
Args:
|
|
spans: List of spans to process
|
|
span_type: The type of spans being processed
|
|
"""
|
|
for span in spans:
|
|
if span.metrics_data is not None:
|
|
for metric_data in span.metrics_data:
|
|
process_metric_data(metric_data)
|
|
process_span_metric_data(
|
|
metric_data, span_type, span.name
|
|
)
|
|
|
|
# Process non-conversational test cases.
|
|
for test_case in self.test_cases:
|
|
if test_case.metrics_data is None:
|
|
continue
|
|
for metric_data in test_case.metrics_data:
|
|
process_metric_data(metric_data)
|
|
|
|
if test_case.trace is None:
|
|
continue
|
|
|
|
# Process all span types using the helper function
|
|
process_spans(test_case.trace.agent_spans, SpanApiType.AGENT.value)
|
|
process_spans(test_case.trace.tool_spans, SpanApiType.TOOL.value)
|
|
process_spans(
|
|
test_case.trace.retriever_spans, SpanApiType.RETRIEVER.value
|
|
)
|
|
process_spans(test_case.trace.llm_spans, SpanApiType.LLM.value)
|
|
process_spans(test_case.trace.base_spans, SpanApiType.BASE.value)
|
|
|
|
# Process conversational test cases.
|
|
for convo_test_case in self.conversational_test_cases:
|
|
if convo_test_case.metrics_data is not None:
|
|
for metric_data in convo_test_case.metrics_data:
|
|
process_metric_data(metric_data)
|
|
|
|
# Create MetricScores objects with the aggregated data.
|
|
self.metrics_scores = [
|
|
MetricScores(
|
|
metric=metric,
|
|
scores=data["scores"],
|
|
passes=data["passes"],
|
|
fails=data["fails"],
|
|
errors=data["errors"],
|
|
)
|
|
for metric, data in metrics_dict.items()
|
|
]
|
|
|
|
# Create a single TraceMetricScores object instead of a list
|
|
trace_metrics_score = TraceMetricScores()
|
|
has_span_metrics = False
|
|
|
|
for span_type, spans in trace_metrics_dict.items():
|
|
if not spans: # Skip empty span types
|
|
continue
|
|
|
|
span_dict = {}
|
|
for span_name, metrics in spans.items():
|
|
span_dict[span_name] = {
|
|
metric_name: MetricScores(
|
|
metric=metric_name,
|
|
scores=metric_data["scores"],
|
|
passes=metric_data["passes"],
|
|
fails=metric_data["fails"],
|
|
errors=metric_data["errors"],
|
|
)
|
|
for metric_name, metric_data in metrics.items()
|
|
}
|
|
|
|
if span_dict: # Only set if there are spans
|
|
has_span_metrics = True
|
|
setattr(trace_metrics_score, span_type, span_dict)
|
|
|
|
# Set to None if no span metrics were found
|
|
self.trace_metrics_scores = (
|
|
trace_metrics_score if has_span_metrics else None
|
|
)
|
|
return valid_scores
|
|
|
|
def calculate_test_passes_and_fails(self):
|
|
test_passed = 0
|
|
test_failed = 0
|
|
for test_case in self.test_cases:
|
|
if test_case.success is not None:
|
|
if test_case.success:
|
|
test_passed += 1
|
|
else:
|
|
test_failed += 1
|
|
|
|
for test_case in self.conversational_test_cases:
|
|
# we don't count for conversational messages success
|
|
if test_case.success is not None:
|
|
if test_case.success:
|
|
test_passed += 1
|
|
else:
|
|
test_failed += 1
|
|
|
|
self.test_passed = test_passed
|
|
self.test_failed = test_failed
|
|
|
|
def save(self, f):
|
|
try:
|
|
body = self.model_dump(by_alias=True, exclude_none=True)
|
|
except AttributeError:
|
|
body = self.dict(by_alias=True, exclude_none=True)
|
|
json.dump(body, f, cls=TestRunEncoder)
|
|
f.flush()
|
|
os.fsync(f.fileno())
|
|
return self
|
|
|
|
@classmethod
|
|
def load(cls, f):
|
|
data: dict = json.load(f)
|
|
return cls(**data)
|
|
|
|
def guard_mllm_test_cases(self):
|
|
for test_case in self.test_cases:
|
|
if test_case.is_multimodal():
|
|
raise ValueError(
|
|
"Unable to send multimodal test cases to Confident AI."
|
|
)
|
|
|
|
|
|
class TestRunEncoder(json.JSONEncoder):
|
|
def default(self, obj):
|
|
if isinstance(obj, Enum):
|
|
return obj.value
|
|
return make_json_serializable(obj)
|
|
|
|
|
|
class TestRunManager:
|
|
def __init__(self):
|
|
self.test_run = None
|
|
self.temp_file_path = TEMP_FILE_PATH
|
|
self.save_to_disk = False
|
|
self.disable_request = False
|
|
self.results_folder: Optional[str] = None
|
|
self.results_subfolder: Optional[str] = None
|
|
# Timestamped export if one was written, else rolling snapshot.
|
|
# Consumed by the post-run inspect prompt.
|
|
self.last_saved_path: Optional[Path] = None
|
|
|
|
def reset(self):
|
|
self.test_run = None
|
|
self.temp_file_path = TEMP_FILE_PATH
|
|
self.save_to_disk = False
|
|
self.disable_request = False
|
|
self.results_folder = None
|
|
self.results_subfolder = None
|
|
self.last_saved_path = None
|
|
|
|
def configure_local_store(
|
|
self,
|
|
results_folder: Optional[str] = None,
|
|
results_subfolder: Optional[str] = None,
|
|
):
|
|
"""Configure where `save_test_run_locally` writes the full TestRun JSON.
|
|
|
|
Values set here take precedence over the `DEEPEVAL_RESULTS_FOLDER`
|
|
env var. Intended to be called from `evaluate()` / `evals_iterator()`
|
|
right before `wrap_up_test_run()`.
|
|
"""
|
|
self.results_folder = results_folder
|
|
self.results_subfolder = results_subfolder
|
|
# The manager is a long-lived singleton, so a previous run's path
|
|
# could linger and mislead the inspect prompt into offering a stale
|
|
# file. Clear it whenever a new run configures its local store.
|
|
self.last_saved_path = None
|
|
|
|
def set_test_run(self, test_run: TestRun):
|
|
self.test_run = test_run
|
|
|
|
def create_test_run(
|
|
self,
|
|
identifier: Optional[str] = None,
|
|
file_name: Optional[str] = None,
|
|
disable_request: Optional[bool] = False,
|
|
):
|
|
self.disable_request = disable_request
|
|
test_run = TestRun(
|
|
identifier=identifier,
|
|
testFile=file_name,
|
|
testCases=[],
|
|
metricsScores=[],
|
|
hyperparameters=None,
|
|
testPassed=None,
|
|
testFailed=None,
|
|
)
|
|
self.set_test_run(test_run)
|
|
|
|
if self.save_to_disk:
|
|
self.save_test_run(self.temp_file_path)
|
|
|
|
def get_test_run(self, identifier: Optional[str] = None):
|
|
if self.test_run is None:
|
|
self.create_test_run(identifier=identifier)
|
|
|
|
if portalocker and self.save_to_disk:
|
|
try:
|
|
with portalocker.Lock(
|
|
self.temp_file_path,
|
|
mode="r",
|
|
flags=portalocker.LOCK_SH | portalocker.LOCK_NB,
|
|
) as file:
|
|
loaded = self.test_run.load(file)
|
|
# only overwrite if loading actually worked
|
|
self.test_run = loaded
|
|
except (
|
|
FileNotFoundError,
|
|
json.JSONDecodeError,
|
|
portalocker.exceptions.LockException,
|
|
) as e:
|
|
print(
|
|
f"Warning: Could not load test run from disk: {e}",
|
|
file=sys.stderr,
|
|
)
|
|
|
|
return self.test_run
|
|
|
|
def save_test_run(self, path: str, save_under_key: Optional[str] = None):
|
|
if portalocker and self.save_to_disk:
|
|
try:
|
|
# ensure parent directory exists
|
|
parent = os.path.dirname(path)
|
|
if parent:
|
|
os.makedirs(parent, exist_ok=True)
|
|
|
|
with portalocker.Lock(path, mode="w") as file:
|
|
if save_under_key:
|
|
try:
|
|
test_run_data = self.test_run.model_dump(
|
|
by_alias=True, exclude_none=True
|
|
)
|
|
except AttributeError:
|
|
# Pydantic version below 2.0
|
|
test_run_data = self.test_run.dict(
|
|
by_alias=True, exclude_none=True
|
|
)
|
|
wrapper_data = {save_under_key: test_run_data}
|
|
json.dump(wrapper_data, file, cls=TestRunEncoder)
|
|
file.flush()
|
|
os.fsync(file.fileno())
|
|
else:
|
|
self.test_run.save(file)
|
|
except portalocker.exceptions.LockException:
|
|
pass
|
|
|
|
def save_final_test_run_link(self, link: str):
|
|
if portalocker:
|
|
try:
|
|
with portalocker.Lock(
|
|
LATEST_TEST_RUN_FILE_PATH, mode="w"
|
|
) as file:
|
|
json.dump({LATEST_TEST_RUN_LINK_KEY: link}, file)
|
|
file.flush()
|
|
os.fsync(file.fileno())
|
|
except portalocker.exceptions.LockException:
|
|
pass
|
|
|
|
def update_test_run(
|
|
self,
|
|
api_test_case: Union[LLMApiTestCase, ConversationalApiTestCase],
|
|
test_case: Union[LLMTestCase, ConversationalTestCase],
|
|
):
|
|
if (
|
|
api_test_case.metrics_data is not None
|
|
and len(api_test_case.metrics_data) == 0
|
|
and api_test_case.trace is None
|
|
):
|
|
return
|
|
|
|
if portalocker and self.save_to_disk:
|
|
try:
|
|
with portalocker.Lock(
|
|
self.temp_file_path,
|
|
mode="r+",
|
|
flags=portalocker.LOCK_EX,
|
|
) as file:
|
|
file.seek(0)
|
|
self.test_run = self.test_run.load(file)
|
|
|
|
# Update the test run object
|
|
self.test_run.add_test_case(api_test_case)
|
|
self.test_run.set_dataset_properties(test_case)
|
|
|
|
# Save the updated test run back to the file
|
|
file.seek(0)
|
|
file.truncate()
|
|
self.test_run.save(file)
|
|
except (
|
|
FileNotFoundError,
|
|
json.JSONDecodeError,
|
|
portalocker.exceptions.LockException,
|
|
) as e:
|
|
print(
|
|
f"Warning: Could not update test run on disk: {e}",
|
|
file=sys.stderr,
|
|
)
|
|
if self.test_run is None:
|
|
# guarantee a valid in-memory run so the update can proceed.
|
|
# never destroy in-memory state on I/O failure.
|
|
self.create_test_run()
|
|
self.test_run.add_test_case(api_test_case)
|
|
self.test_run.set_dataset_properties(test_case)
|
|
else:
|
|
if self.test_run is None:
|
|
self.create_test_run()
|
|
|
|
self.test_run.add_test_case(api_test_case)
|
|
self.test_run.set_dataset_properties(test_case)
|
|
|
|
def clear_test_run(self):
|
|
self.test_run = None
|
|
|
|
@staticmethod
|
|
def _calculate_success_rate(pass_count: int, fail_count: int) -> str:
|
|
"""Calculate success rate percentage or return error message."""
|
|
total = pass_count + fail_count
|
|
if total > 0:
|
|
return str(round((100 * pass_count) / total, 2))
|
|
return "Cannot display metrics for component-level evals, please run 'deepeval view' to see results on Confident AI."
|
|
|
|
@staticmethod
|
|
def _get_metric_status(metric_data: MetricData) -> str:
|
|
"""Get formatted status string for a metric."""
|
|
if metric_data.error:
|
|
return "[red]ERRORED[/red]"
|
|
elif metric_data.success:
|
|
return "[green]PASSED[/green]"
|
|
return "[red]FAILED[/red]"
|
|
|
|
@staticmethod
|
|
def _format_metric_score(metric_data: MetricData) -> str:
|
|
"""Format metric score with evaluation details."""
|
|
evaluation_model = metric_data.evaluation_model or "n/a"
|
|
metric_score = (
|
|
round(metric_data.score, 2)
|
|
if metric_data.score is not None
|
|
else None
|
|
)
|
|
|
|
return (
|
|
f"{metric_score} "
|
|
f"(threshold={metric_data.threshold}, "
|
|
f"evaluation model={evaluation_model}, "
|
|
f"reason={metric_data.reason}, "
|
|
f"error={metric_data.error})"
|
|
)
|
|
|
|
@staticmethod
|
|
def _should_skip_test_case(
|
|
test_case, display: TestRunResultDisplay
|
|
) -> bool:
|
|
"""Determine if test case should be skipped based on display filter."""
|
|
if display == TestRunResultDisplay.PASSING and not test_case.success:
|
|
return True
|
|
elif display == TestRunResultDisplay.FAILING and test_case.success:
|
|
return True
|
|
return False
|
|
|
|
@staticmethod
|
|
def _count_metric_results(
|
|
metrics_data: List[MetricData],
|
|
) -> tuple[int, int]:
|
|
"""Count passing and failing metrics."""
|
|
pass_count = 0
|
|
fail_count = 0
|
|
for metric_data in metrics_data:
|
|
if metric_data.success:
|
|
pass_count += 1
|
|
else:
|
|
fail_count += 1
|
|
return pass_count, fail_count
|
|
|
|
def _add_test_case_header_row(
|
|
self,
|
|
table: Table,
|
|
test_case_name: str,
|
|
pass_count: int,
|
|
fail_count: int,
|
|
):
|
|
"""Add test case header row with name and success rate."""
|
|
success_rate = self._calculate_success_rate(pass_count, fail_count)
|
|
table.add_row(
|
|
test_case_name,
|
|
*[""] * 3,
|
|
f"{success_rate}%",
|
|
)
|
|
|
|
def _add_metric_rows(self, table: Table, metrics_data: List[MetricData]):
|
|
"""Add metric detail rows to the table."""
|
|
for metric_data in metrics_data:
|
|
status = self._get_metric_status(metric_data)
|
|
formatted_score = self._format_metric_score(metric_data)
|
|
|
|
table.add_row(
|
|
"",
|
|
str(metric_data.name),
|
|
formatted_score,
|
|
status,
|
|
"",
|
|
)
|
|
|
|
def _add_separator_row(self, table: Table):
|
|
"""Add empty separator row between test cases."""
|
|
table.add_row(*[""] * len(table.columns))
|
|
|
|
def display_results_table(
|
|
self, test_run: TestRun, display: TestRunResultDisplay
|
|
):
|
|
"""Display test results in a formatted table."""
|
|
|
|
table = Table(title="Test Results")
|
|
column_config = dict(justify="left")
|
|
column_names = [
|
|
"Test case",
|
|
"Metric",
|
|
"Score",
|
|
"Status",
|
|
"Overall Success Rate",
|
|
]
|
|
|
|
for name in column_names:
|
|
table.add_column(name, **column_config)
|
|
|
|
# Process regular test cases
|
|
for index, test_case in enumerate(test_run.test_cases):
|
|
if test_case.metrics_data is None or self._should_skip_test_case(
|
|
test_case, display
|
|
):
|
|
continue
|
|
pass_count, fail_count = self._count_metric_results(
|
|
test_case.metrics_data
|
|
)
|
|
self._add_test_case_header_row(
|
|
table, test_case.name, pass_count, fail_count
|
|
)
|
|
self._add_metric_rows(table, test_case.metrics_data)
|
|
|
|
if index < len(test_run.test_cases) - 1:
|
|
self._add_separator_row(table)
|
|
|
|
# Process conversational test cases
|
|
for index, conversational_test_case in enumerate(
|
|
test_run.conversational_test_cases
|
|
):
|
|
if self._should_skip_test_case(conversational_test_case, display):
|
|
continue
|
|
|
|
conversational_test_case_name = conversational_test_case.name
|
|
|
|
if conversational_test_case.turns:
|
|
turns_table = Table(
|
|
title=f"Conversation - {conversational_test_case_name}",
|
|
show_header=True,
|
|
header_style="bold",
|
|
)
|
|
turns_table.add_column("#", justify="right", width=3)
|
|
turns_table.add_column("Role", justify="left", width=10)
|
|
|
|
# subtract fixed widths + borders and padding.
|
|
# ~20 as a safe buffer
|
|
details_max_width = max(
|
|
48, min(120, console.width - 3 - 10 - 20)
|
|
)
|
|
turns_table.add_column(
|
|
"Details",
|
|
justify="left",
|
|
overflow="fold",
|
|
max_width=details_max_width,
|
|
)
|
|
|
|
# truncate when too long
|
|
tools_max_width = min(60, max(24, console.width // 3))
|
|
turns_table.add_column(
|
|
"Tools",
|
|
justify="left",
|
|
no_wrap=True,
|
|
overflow="ellipsis",
|
|
max_width=tools_max_width,
|
|
)
|
|
|
|
sorted_turns = sorted(
|
|
conversational_test_case.turns, key=lambda t: t.order
|
|
)
|
|
|
|
for t in sorted_turns:
|
|
tools = t.tools_called or []
|
|
tool_names = ", ".join(tc.name for tc in tools)
|
|
|
|
# omit order, role and tools since we show them in a separate columns.
|
|
details = format_turn(
|
|
t,
|
|
include_tools_in_header=False,
|
|
include_order_role_in_header=False,
|
|
)
|
|
|
|
turns_table.add_row(
|
|
str(t.order),
|
|
t.role,
|
|
details,
|
|
shorten(tool_names, len_short()),
|
|
)
|
|
|
|
console.print(turns_table)
|
|
else:
|
|
console.print(
|
|
f"[dim]No turns recorded for {conversational_test_case_name}.[/dim]"
|
|
)
|
|
if conversational_test_case.metrics_data is not None:
|
|
pass_count, fail_count = self._count_metric_results(
|
|
conversational_test_case.metrics_data
|
|
)
|
|
self._add_test_case_header_row(
|
|
table, conversational_test_case.name, pass_count, fail_count
|
|
)
|
|
self._add_metric_rows(
|
|
table, conversational_test_case.metrics_data
|
|
)
|
|
|
|
if index < len(test_run.conversational_test_cases) - 1:
|
|
self._add_separator_row(table)
|
|
|
|
if index < len(test_run.test_cases) - 1:
|
|
self._add_separator_row(table)
|
|
|
|
table.add_row(
|
|
"[bold red]Note: Use Confident AI with DeepEval to analyze failed test cases for more details[/bold red]",
|
|
*[""] * (len(table.columns) - 1),
|
|
)
|
|
print(table)
|
|
|
|
def post_test_run(self, test_run: TestRun) -> Optional[Tuple[str, str]]:
|
|
if (
|
|
len(test_run.test_cases) == 0
|
|
and len(test_run.conversational_test_cases) == 0
|
|
):
|
|
print("No test cases found, unable to upload to Confident AI.")
|
|
return
|
|
|
|
api = Api()
|
|
|
|
is_conversational_run = len(test_run.conversational_test_cases) > 0
|
|
all_test_cases_to_process = (
|
|
test_run.conversational_test_cases
|
|
if is_conversational_run
|
|
else test_run.test_cases
|
|
)
|
|
|
|
custom_batch_size = os.getenv(CONFIDENT_TEST_CASE_BATCH_SIZE)
|
|
if custom_batch_size and custom_batch_size.isdigit():
|
|
BATCH_SIZE = int(custom_batch_size)
|
|
else:
|
|
BATCH_SIZE = 20 if is_conversational_run else 40
|
|
|
|
initial_batch = all_test_cases_to_process[:BATCH_SIZE]
|
|
remaining_test_cases_to_process = all_test_cases_to_process[BATCH_SIZE:]
|
|
|
|
if len(remaining_test_cases_to_process) > 0:
|
|
console.print(
|
|
"Sending a large test run to Confident, this might take a bit longer than usual..."
|
|
)
|
|
|
|
####################
|
|
### POST REQUEST ###
|
|
####################
|
|
if is_conversational_run:
|
|
test_run.conversational_test_cases = initial_batch
|
|
else:
|
|
test_run.test_cases = initial_batch
|
|
|
|
try:
|
|
test_run.prompts = None
|
|
body = test_run.model_dump(by_alias=True, exclude_none=True)
|
|
except AttributeError:
|
|
# Pydantic version below 2.0
|
|
body = test_run.dict(by_alias=True, exclude_none=True)
|
|
|
|
json_str = json.dumps(body, cls=TestRunEncoder)
|
|
body = json.loads(json_str)
|
|
|
|
data, link = api.send_request(
|
|
method=HttpMethods.POST,
|
|
endpoint=Endpoints.TEST_RUN_ENDPOINT,
|
|
body=body,
|
|
)
|
|
|
|
if not isinstance(data, dict) or "id" not in data:
|
|
# try to show helpful details
|
|
detail = None
|
|
if isinstance(data, dict):
|
|
detail = (
|
|
data.get("detail")
|
|
or data.get("message")
|
|
or data.get("error")
|
|
)
|
|
# fall back to repr for visibility
|
|
raise RuntimeError(
|
|
f"Confident API response missing 'id'. "
|
|
f"detail={detail!r} raw={type(data).__name__}:{repr(data)[:500]}"
|
|
)
|
|
|
|
res = TestRunHttpResponse(
|
|
id=data["id"],
|
|
)
|
|
|
|
################################################
|
|
### Send the remaining test cases in batches ###
|
|
################################################
|
|
total_remaining = len(remaining_test_cases_to_process)
|
|
num_remaining_batches = (
|
|
(total_remaining + BATCH_SIZE - 1) // BATCH_SIZE
|
|
if total_remaining > 0
|
|
else 0
|
|
)
|
|
|
|
for i in range(num_remaining_batches):
|
|
start_index = i * BATCH_SIZE
|
|
batch = remaining_test_cases_to_process[
|
|
start_index : start_index + BATCH_SIZE
|
|
]
|
|
|
|
if len(batch) == 0:
|
|
break # Should not happen with correct num_remaining_batches, but as a safeguard
|
|
|
|
# Create RemainingTestRun with the correct list populated
|
|
if is_conversational_run:
|
|
remaining_test_run = RemainingTestRun(
|
|
testRunId=res.id,
|
|
testCases=[], # This will be empty
|
|
conversationalTestCases=batch,
|
|
)
|
|
else:
|
|
remaining_test_run = RemainingTestRun(
|
|
testRunId=res.id,
|
|
testCases=batch,
|
|
conversationalTestCases=[], # This will be empty
|
|
)
|
|
|
|
body = None
|
|
try:
|
|
body = remaining_test_run.model_dump(
|
|
by_alias=True, exclude_none=True
|
|
)
|
|
except AttributeError:
|
|
# Pydantic version below 2.0
|
|
body = remaining_test_run.dict(by_alias=True, exclude_none=True)
|
|
|
|
try:
|
|
_, _ = api.send_request(
|
|
method=HttpMethods.PUT,
|
|
endpoint=Endpoints.TEST_RUN_ENDPOINT,
|
|
body=body,
|
|
)
|
|
except Exception as e:
|
|
message = f"Unexpected error when sending some test cases. Incomplete test run available at {link}"
|
|
raise Exception(message) from e
|
|
|
|
console.print(
|
|
"[rgb(5,245,141)]✓[/rgb(5,245,141)] Done 🎉! View results on "
|
|
f"[link={link}]{link}[/link]"
|
|
)
|
|
self.save_final_test_run_link(link)
|
|
open_browser(link)
|
|
return link, res.id
|
|
|
|
def save_test_run_locally(self):
|
|
"""Persist the current TestRun to disk.
|
|
|
|
Always writes a rolling snapshot to `.deepeval/.latest_run_full.json`.
|
|
Additionally writes a timestamped `test_run_<YYYYMMDD_HHMMSS>.json` to
|
|
`results_folder` (or `DEEPEVAL_RESULTS_FOLDER`) when set.
|
|
"""
|
|
if self.test_run is None:
|
|
return
|
|
|
|
from deepeval.evaluate.local_store import (
|
|
resolve_target_dir,
|
|
write_rolling_test_run,
|
|
write_test_run,
|
|
)
|
|
|
|
rolling_path = write_rolling_test_run(self.test_run)
|
|
if rolling_path is not None:
|
|
self.last_saved_path = rolling_path
|
|
|
|
target_dir = resolve_target_dir(
|
|
results_folder=self.results_folder,
|
|
results_subfolder=self.results_subfolder,
|
|
)
|
|
if target_dir is None:
|
|
return
|
|
|
|
if target_dir.exists() and target_dir.is_file():
|
|
print(
|
|
f"❌ Error: results_folder={target_dir} already exists and is a file.\n"
|
|
"Detailed results won't be saved. Please specify a folder or an available path."
|
|
)
|
|
return
|
|
|
|
try:
|
|
path = write_test_run(target_dir, self.test_run)
|
|
self.last_saved_path = path
|
|
print(f"Test run saved at {path}")
|
|
except Exception as e:
|
|
print(
|
|
f"Warning: failed to save test run to {target_dir}: {e}",
|
|
file=sys.stderr,
|
|
)
|
|
|
|
def wrap_up_test_run(
|
|
self,
|
|
runDuration: float,
|
|
display_table: bool = True,
|
|
display: Optional[TestRunResultDisplay] = TestRunResultDisplay.ALL,
|
|
) -> Optional[Tuple[str, str]]:
|
|
test_run = self.get_test_run()
|
|
if test_run is None:
|
|
print("Test Run is empty, please try again.")
|
|
delete_file_if_exists(self.temp_file_path)
|
|
return
|
|
elif (
|
|
len(test_run.test_cases) == 0
|
|
and len(test_run.conversational_test_cases) == 0
|
|
):
|
|
print("No test cases found, please try again.")
|
|
delete_file_if_exists(self.temp_file_path)
|
|
return
|
|
|
|
# Mark the run as the official if requested via the `--official` CLI flag or `evaluate(official=True)`.
|
|
# Set here, in the main process right before upload, so it rides along in the test run
|
|
# creation payload (and survives any xdist worker disk round-trips).
|
|
if get_test_run_official():
|
|
test_run.official = True
|
|
|
|
# Don't block the post when all metrics errored — the spans still
|
|
# carry the underlying error info (populated by ``Observer.__exit__``)
|
|
# which the dashboard can render. Just warn so it's not mistaken
|
|
# for a successful run.
|
|
valid_scores = test_run.construct_metrics_scores()
|
|
if valid_scores == 0:
|
|
console.print(
|
|
"\n[bold yellow]⚠ WARNING:[/bold yellow] All metrics errored "
|
|
"across every test case — no metric scores were recorded. "
|
|
"Posting the run anyway so you can inspect the trace + span "
|
|
"errors on the Confident AI dashboard.\n"
|
|
)
|
|
test_run.run_duration = runDuration
|
|
test_run.calculate_test_passes_and_fails()
|
|
test_run.sort_test_cases()
|
|
|
|
if global_test_run_cache_manager.disable_write_cache is None:
|
|
global_test_run_cache_manager.disable_write_cache = not bool(
|
|
get_is_running_deepeval()
|
|
)
|
|
global_test_run_cache_manager.wrap_up_cached_test_run()
|
|
|
|
if display_table:
|
|
self.display_results_table(test_run, display)
|
|
|
|
if test_run.hyperparameters is None:
|
|
console.print(
|
|
"\n[bold yellow]⚠ WARNING:[/bold yellow] No hyperparameters logged.\n"
|
|
"» [bold blue][link=https://deepeval.com/docs/evaluation-prompts]Log hyperparameters[/link][/bold blue] to attribute prompts and models to your test runs.\n\n"
|
|
+ "=" * 80
|
|
)
|
|
else:
|
|
if not test_run.prompts:
|
|
console.print(
|
|
"\n[bold yellow]⚠ WARNING:[/bold yellow] No prompts logged.\n"
|
|
"» [bold blue][link=https://deepeval.com/docs/evaluation-prompts]Log prompts[/link][/bold blue] to evaluate and optimize your prompt templates and models.\n\n"
|
|
+ "=" * 80
|
|
)
|
|
else:
|
|
console.print("\n[bold green]✓ Prompts Logged[/bold green]\n")
|
|
self._render_prompts_panels(prompts=test_run.prompts)
|
|
|
|
self.save_test_run_locally()
|
|
delete_file_if_exists(self.temp_file_path)
|
|
confident_enabled = is_confident()
|
|
if confident_enabled and self.disable_request is False:
|
|
return self.post_test_run(test_run)
|
|
else:
|
|
self.save_test_run(
|
|
LATEST_TEST_RUN_FILE_PATH,
|
|
save_under_key=LATEST_TEST_RUN_DATA_KEY,
|
|
)
|
|
token_cost = (
|
|
f"{test_run.evaluation_cost} USD"
|
|
if test_run.evaluation_cost
|
|
else "None"
|
|
)
|
|
console.print(
|
|
f"\n\n[rgb(5,245,141)]✓[/rgb(5,245,141)] Evaluation completed 🎉! (time taken: {round(runDuration, 2)}s | token cost: {token_cost})\n"
|
|
f"» Test Results ({test_run.test_passed + test_run.test_failed} total tests):\n",
|
|
f" » Pass Rate: {round((test_run.test_passed / (test_run.test_passed + test_run.test_failed)) * 100, 2)}% | Passed: [bold green]{test_run.test_passed}[/bold green] | Failed: [bold red]{test_run.test_failed}[/bold red]\n\n",
|
|
"=" * 80,
|
|
"\n\n» Want to share evals with your team, or a place for your test cases to live? ❤️ 🏡\n"
|
|
" » Run [bold]'deepeval view'[/bold] to analyze and save testing results on [rgb(106,0,255)]Confident AI[/rgb(106,0,255)].\n\n",
|
|
)
|
|
|
|
def get_latest_test_run_data(self) -> Optional[TestRun]:
|
|
try:
|
|
if os.path.exists(LATEST_TEST_RUN_FILE_PATH):
|
|
with open(LATEST_TEST_RUN_FILE_PATH, "r") as file:
|
|
data = json.load(file)
|
|
return TestRun.model_validate(
|
|
data[LATEST_TEST_RUN_DATA_KEY]
|
|
)
|
|
except (FileNotFoundError, json.JSONDecodeError, Exception):
|
|
pass
|
|
return None
|
|
|
|
def get_latest_test_run_link(self) -> Optional[str]:
|
|
try:
|
|
if os.path.exists(LATEST_TEST_RUN_FILE_PATH):
|
|
with open(LATEST_TEST_RUN_FILE_PATH, "r") as file:
|
|
data = json.load(file)
|
|
return data[LATEST_TEST_RUN_LINK_KEY]
|
|
except (FileNotFoundError, json.JSONDecodeError, Exception):
|
|
pass
|
|
return None
|
|
|
|
def _render_prompts_panels(self, prompts: List[PromptData]) -> None:
|
|
|
|
def format_string(
|
|
v, default="[dim]None[/dim]", color: Optional[str] = None
|
|
):
|
|
formatted_string = str(v) if v not in (None, "", []) else default
|
|
return (
|
|
f"{formatted_string}"
|
|
if color is None or v in (None, "", [])
|
|
else f"[{color}]{formatted_string}[/]"
|
|
)
|
|
|
|
panels = []
|
|
for prompt in prompts:
|
|
lines = []
|
|
p_type = (
|
|
"messages"
|
|
if prompt.messages_template
|
|
else ("text" if prompt.text_template else "—")
|
|
)
|
|
if p_type:
|
|
lines.append(f"type: {format_string(p_type, color='blue')}")
|
|
if prompt.output_type:
|
|
lines.append(
|
|
f"output_type: {format_string(prompt.output_type, color='blue')}"
|
|
)
|
|
if prompt.interpolation_type:
|
|
lines.append(
|
|
f"interpolation_type: {format_string(prompt.interpolation_type, color='blue')}"
|
|
)
|
|
if prompt.model_settings:
|
|
ms = prompt.model_settings
|
|
settings_lines = [
|
|
"Model Settings:",
|
|
f" – provider: {format_string(ms.provider, color='green')}",
|
|
f" – name: {format_string(ms.name, color='green')}",
|
|
f" – temperature: {format_string(ms.temperature, color='green')}",
|
|
f" – max_tokens: {format_string(ms.max_tokens, color='green')}",
|
|
f" – top_p: {format_string(ms.top_p, color='green')}",
|
|
f" – frequency_penalty: {format_string(ms.frequency_penalty, color='green')}",
|
|
f" – presence_penalty: {format_string(ms.presence_penalty, color='green')}",
|
|
f" – stop_sequence: {format_string(ms.stop_sequence, color='green')}",
|
|
f" – reasoning_effort: {format_string(ms.reasoning_effort, color='green')}",
|
|
f" – verbosity: {format_string(ms.verbosity, color='green')}",
|
|
]
|
|
lines.append("")
|
|
lines.extend(settings_lines)
|
|
title = f"{format_string(prompt.alias)}"
|
|
if prompt.hash:
|
|
title += f" ({prompt.hash})"
|
|
body = "\n".join(lines)
|
|
panel = Panel(
|
|
body,
|
|
title=title,
|
|
title_align="left",
|
|
expand=False,
|
|
padding=(1, 6, 1, 2),
|
|
)
|
|
panels.append(panel)
|
|
|
|
if panels:
|
|
console.print(Columns(panels, equal=False, expand=False))
|
|
|
|
|
|
global_test_run_manager = TestRunManager()
|