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

405 lines
14 KiB
Python

import logging
import sys
import json
import os
from typing import List, Optional, Dict, Union
from enum import Enum
from pydantic import BaseModel, Field
from deepeval.utils import make_model_config
from deepeval.test_case import SingleTurnParams, LLMTestCase, ToolCallParams
from deepeval.test_run.api import MetricData
from deepeval.utils import (
delete_file_if_exists,
is_read_only_env,
serialize,
)
from deepeval.metrics import BaseMetric
from deepeval.constants import HIDDEN_DIR
logger = logging.getLogger(__name__)
portalocker = None
if not is_read_only_env():
try:
import portalocker
except Exception as e:
logger.warning("failed to import portalocker: %s", e)
else:
logger.warning("READ_ONLY filesystem: skipping disk cache for test runs.")
CACHE_FILE_NAME = f"{HIDDEN_DIR}/.deepeval-cache.json"
TEMP_CACHE_FILE_NAME = f"{HIDDEN_DIR}/.temp-deepeval-cache.json"
class MetricConfiguration(BaseModel):
model_config = make_model_config(arbitrary_types_allowed=True)
##### Required fields #####
threshold: float
evaluation_model: Optional[str] = None
strict_mode: bool = False
criteria: Optional[str] = None
include_reason: Optional[bool] = None
n: Optional[int] = None
##### Optional fields #####
evaluation_steps: Optional[List[str]] = None
assessment_questions: Optional[List[str]] = None
embeddings: Optional[str] = None
evaluation_params: Optional[
Union[List[SingleTurnParams], List[ToolCallParams]]
] = None
class CachedMetricData(BaseModel):
metric_data: MetricData
metric_configuration: MetricConfiguration
class CachedTestCase(BaseModel):
cached_metrics_data: List[CachedMetricData] = Field(
default_factory=lambda: []
)
hyperparameters: Optional[str] = Field(None)
class CustomEncoder(json.JSONEncoder):
def default(self, obj):
if isinstance(obj, Enum):
return obj.value
elif isinstance(obj, BaseModel):
return obj.model_dump(by_alias=True, exclude_none=True)
return json.JSONEncoder.default(self, obj)
class CachedTestRun(BaseModel):
test_cases_lookup_map: Optional[Dict[str, CachedTestCase]] = Field(
default_factory=lambda: {}
)
# saves to file (this happens at the very end of a test run)
def save(self, f):
try:
body = self.model_dump(by_alias=True, exclude_none=True)
except AttributeError:
# Pydantic version below 2.0
body = self.dict(by_alias=True, exclude_none=True)
json.dump(body, f, cls=CustomEncoder)
f.flush()
os.fsync(f.fileno())
return self
# load from file (this happens initially during a test run)
@classmethod
def load(cls, data):
return cls(**data)
def get_cached_api_test_case(self, key: str) -> CachedTestCase:
return self.test_cases_lookup_map.get(key, None)
class TestRunCacheManager:
def __init__(self):
self.disable_write_cache: Optional[bool] = None
self.cached_test_run: Optional[CachedTestRun] = None
self.cache_file_name: str = CACHE_FILE_NAME
self.temp_cached_test_run: Optional[CachedTestRun] = None
self.temp_cache_file_name: str = TEMP_CACHE_FILE_NAME
def get_cached_test_case(
self, test_case: LLMTestCase, hyperparameters: Union[Dict, None]
) -> Union[CachedTestCase, None]:
if self.disable_write_cache or portalocker is None:
return None
cached_test_run = self.get_cached_test_run()
cache_dict = {
SingleTurnParams.INPUT.value: test_case.input,
SingleTurnParams.ACTUAL_OUTPUT.value: test_case.actual_output,
SingleTurnParams.EXPECTED_OUTPUT.value: test_case.expected_output,
SingleTurnParams.CONTEXT.value: test_case.context,
SingleTurnParams.RETRIEVAL_CONTEXT.value: (
[
rc.context if hasattr(rc, "context") else rc
for rc in test_case.retrieval_context
]
if test_case.retrieval_context
else None
),
"hyperparameters": hyperparameters,
}
test_case_cache_key = serialize(cache_dict)
cached_test_case = cached_test_run.get_cached_api_test_case(
test_case_cache_key
)
return cached_test_case
def cache_test_case(
self,
test_case: LLMTestCase,
new_cache_test_case: CachedTestCase,
hyperparameters: Union[Dict, None],
to_temp: bool = False,
):
if self.disable_write_cache or portalocker is None:
return
cache_dict = {
SingleTurnParams.INPUT.value: test_case.input,
SingleTurnParams.ACTUAL_OUTPUT.value: test_case.actual_output,
SingleTurnParams.EXPECTED_OUTPUT.value: test_case.expected_output,
SingleTurnParams.CONTEXT.value: test_case.context,
SingleTurnParams.RETRIEVAL_CONTEXT.value: (
[
rc.context if hasattr(rc, "context") else rc
for rc in test_case.retrieval_context
]
if test_case.retrieval_context
else None
),
"hyperparameters": hyperparameters,
}
test_case_cache_key = serialize(cache_dict)
cached_test_run = self.get_cached_test_run(from_temp=to_temp)
cached_test_run.test_cases_lookup_map[test_case_cache_key] = (
new_cache_test_case
)
self.save_cached_test_run(to_temp=to_temp)
def set_cached_test_run(
self, cached_test_run: CachedTestRun, temp: bool = False
):
if self.disable_write_cache or portalocker is None:
return
if temp:
self.temp_cached_test_run = cached_test_run
else:
self.cached_test_run = cached_test_run
def save_cached_test_run(self, to_temp: bool = False):
if self.disable_write_cache or portalocker is None:
return
if to_temp:
try:
with portalocker.Lock(
self.temp_cache_file_name, mode="w"
) as file:
self.temp_cached_test_run = self.temp_cached_test_run.save(
file
)
except Exception as e:
print(
f"In save_cached_test_run, temp={to_temp}, Error saving test run to disk {e}",
file=sys.stderr,
)
else:
try:
with portalocker.Lock(self.cache_file_name, mode="w") as file:
self.cached_test_run = self.cached_test_run.save(file)
except Exception as e:
print(
f"In save_cached_test_run, temp={to_temp}, Error saving test run to disk {e}",
file=sys.stderr,
)
def create_cached_test_run(self, temp: bool = False):
if self.disable_write_cache or portalocker is None:
return
cached_test_run = CachedTestRun()
self.set_cached_test_run(cached_test_run, temp)
self.save_cached_test_run(to_temp=temp)
def get_cached_test_run(
self, from_temp: bool = False
) -> Union[CachedTestRun, None]:
if self.disable_write_cache or portalocker is None:
return
should_create_cached_test_run = False
if from_temp:
if self.temp_cached_test_run:
return self.temp_cached_test_run
if not os.path.exists(self.temp_cache_file_name):
self.create_cached_test_run(temp=from_temp)
try:
with portalocker.Lock(
self.temp_cache_file_name,
mode="r",
flags=portalocker.LOCK_SH | portalocker.LOCK_NB,
) as file:
content = file.read().strip()
try:
data = json.loads(content)
self.temp_cached_test_run = CachedTestRun.load(data)
except Exception:
should_create_cached_test_run = True
except portalocker.exceptions.LockException as e:
print(
f"In get_cached_test_run, temp={from_temp}, Lock acquisition failed: {e}",
file=sys.stderr,
)
if should_create_cached_test_run:
self.create_cached_test_run(temp=from_temp)
return self.temp_cached_test_run
else:
if self.cached_test_run:
return self.cached_test_run
if not os.path.exists(self.cache_file_name):
self.create_cached_test_run()
try:
with portalocker.Lock(
self.cache_file_name,
mode="r",
flags=portalocker.LOCK_SH | portalocker.LOCK_NB,
) as file:
content = file.read().strip()
try:
data = json.loads(content)
self.cached_test_run = CachedTestRun.load(data)
except Exception:
should_create_cached_test_run = True
except portalocker.exceptions.LockException as e:
print(
f"In get_cached_test_run, temp={from_temp}, Lock acquisition failed: {e}",
file=sys.stderr,
)
if should_create_cached_test_run:
self.create_cached_test_run(temp=from_temp)
return self.cached_test_run
def wrap_up_cached_test_run(self):
if portalocker is None:
return
if self.disable_write_cache:
# Clear cache if write cache is disabled
delete_file_if_exists(self.cache_file_name)
delete_file_if_exists(self.temp_cache_file_name)
return
self.get_cached_test_run(from_temp=True)
try:
with portalocker.Lock(self.cache_file_name, mode="w") as file:
self.temp_cached_test_run = self.temp_cached_test_run.save(file)
except Exception as e:
print(
f"In wrap_up_cached_test_run, Error saving test run to disk, {e}",
file=sys.stderr,
)
finally:
delete_file_if_exists(self.temp_cache_file_name)
global_test_run_cache_manager = TestRunCacheManager()
############ Helper Functions #############
class Cache:
@staticmethod
def get_metric_data(
metric: BaseMetric, cached_test_case: Optional[CachedTestCase]
) -> Optional[CachedMetricData]:
if not cached_test_case:
return None
for cached_metric_data in cached_test_case.cached_metrics_data:
if (
cached_metric_data.metric_data.name == metric.__name__
and Cache.same_metric_configs(
metric,
cached_metric_data.metric_configuration,
)
):
return cached_metric_data
return None
@staticmethod
def same_metric_configs(
metric: BaseMetric,
metric_configuration: MetricConfiguration,
) -> bool:
config_fields = [
"threshold",
"evaluation_model",
"strict_mode",
"include_reason",
"n",
"language",
"embeddings",
"evaluation_params",
"assessment_questions",
"evaluation_steps",
]
for field in config_fields:
metric_value = getattr(metric, field, None)
cached_value = getattr(metric_configuration, field, None)
# TODO: Refactor. This won't work well with custom metrics
if field == "evaluation_steps":
if metric_value is not None:
if metric_value == cached_value:
continue
else:
try:
# For GEval only
if metric.criteria is not None:
criteria_value = getattr(metric, "criteria", None)
cached_criteria_value = getattr(
metric_configuration, "criteria", None
)
if criteria_value != cached_criteria_value:
return False
continue
except Exception:
# For non-GEval
continue
if field == "embeddings" and metric_value is not None:
metric_value = metric_value.__class__.__name__
if metric_value != cached_value:
return False
return True
@staticmethod
def create_metric_configuration(metric: BaseMetric) -> MetricConfiguration:
config_kwargs = {}
config_fields = [
"threshold",
"evaluation_model",
"strict_mode",
"include_reason", # checked
"n", # checked
"criteria", # checked
"language", # can't check
"embeddings", #
"strict_mode", # checked
"evaluation_steps", # checked
"evaluation_params", # checked
"assessment_questions", # checked
]
for field in config_fields:
value = getattr(metric, field, None)
if field == "embeddings" and value is not None:
value = value.__class__.__name__
config_kwargs[field] = value
return MetricConfiguration(**config_kwargs)