项目文件夹

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

405 行
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)