Files

252 lines
8.4 KiB
Python
Raw Permalink Normal View History

2024-05-21 22:48:41 +08:00
from __future__ import annotations
import functools
2024-05-30 10:33:07 +08:00
import importlib
2024-05-21 22:48:41 +08:00
import json
2024-05-30 10:33:07 +08:00
import multiprocessing as mp
import os
2024-08-01 14:56:03 +08:00
import pickle
import random
2024-05-30 10:33:07 +08:00
from collections.abc import Callable
from pathlib import Path
2024-08-01 14:56:03 +08:00
from typing import Any, ClassVar, NoReturn, cast
2024-05-21 22:48:41 +08:00
from filelock import FileLock
2024-07-25 15:20:04 +08:00
from fuzzywuzzy import fuzz # type: ignore[import-untyped]
2024-05-21 22:48:41 +08:00
from rdagent.core.conf import RD_AGENT_SETTINGS
from rdagent.oai.llm_conf import LLM_SETTINGS
2024-05-21 22:48:41 +08:00
class RDAgentException(Exception): # noqa: N818
2024-05-21 22:48:41 +08:00
pass
2024-07-25 15:20:04 +08:00
class SingletonBaseClass:
"""
Because we try to support defining Singleton with `class A(SingletonBaseClass)`
instead of `A(metaclass=SingletonMeta)` this class becomes necessary.
"""
2024-07-25 15:20:04 +08:00
_instance_dict: ClassVar[dict] = {}
2024-05-21 22:48:41 +08:00
2024-07-25 15:20:04 +08:00
def __new__(cls, *args: Any, **kwargs: Any) -> Any:
2024-06-05 15:36:15 +08:00
# Since it's hard to align the difference call using args and kwargs, we strictly ask to use kwargs in Singleton
2024-06-12 15:12:11 +08:00
if args:
2024-07-16 20:35:42 +08:00
# TODO: this restriction can be solved.
2024-06-12 15:12:11 +08:00
exception_message = "Please only use kwargs in Singleton to avoid misunderstanding."
raise RDAgentException(exception_message)
2024-08-01 14:56:03 +08:00
class_name = [(-1, f"{cls.__module__}.{cls.__name__}")]
args_l = [(i, args[i]) for i in args]
kwargs_l = sorted(kwargs.items())
2024-08-01 14:56:03 +08:00
all_args = class_name + args_l + kwargs_l
2024-07-26 11:00:21 +08:00
kwargs_hash = hash(tuple(all_args))
2024-06-05 15:36:15 +08:00
if kwargs_hash not in cls._instance_dict:
2024-07-25 15:20:04 +08:00
cls._instance_dict[kwargs_hash] = super().__new__(cls) # Corrected call
2024-06-05 15:36:15 +08:00
return cls._instance_dict[kwargs_hash]
2024-05-21 22:48:41 +08:00
2024-08-01 14:56:03 +08:00
def __reduce__(self) -> NoReturn:
"""
NOTE:
When loading an object from a pickle, the __new__ method does not receive the `kwargs`
it was initialized with. This makes it difficult to retrieve the correct singleton object.
Therefore, we have made it unpicklable.
2024-08-01 14:56:03 +08:00
"""
msg = f"Instances of {self.__class__.__name__} cannot be pickled"
raise pickle.PicklingError(msg)
2024-06-12 15:12:11 +08:00
def parse_json(response: str) -> Any:
2024-05-21 22:48:41 +08:00
try:
return json.loads(response)
except json.decoder.JSONDecodeError:
pass
2024-06-12 15:12:11 +08:00
error_message = f"Failed to parse response: {response}, please report it or help us to fix it."
raise ValueError(error_message)
2024-05-21 22:48:41 +08:00
2024-06-12 15:12:11 +08:00
def similarity(text1: str, text2: str) -> int:
2024-05-21 22:48:41 +08:00
text1 = text1 if isinstance(text1, str) else ""
text2 = text2 if isinstance(text2, str) else ""
# Maybe we can use other similarity algorithm such as tfidf
return cast("int", fuzz.ratio(text1, text2)) # mypy does not regard it as int
2024-05-21 22:48:41 +08:00
def import_class(class_path: str) -> Any:
"""
Parameters
----------
class_path : str
class path like"scripts.factor_implementation.baselines.naive.one_shot.OneshotFactorGen"
Returns
-------
class of `class_path`
Raises
------
ImportError
If module or class cannot be found.
2024-05-21 22:48:41 +08:00
"""
try:
module_path, class_name = class_path.rsplit(".", 1)
except ValueError:
raise ImportError(f"Invalid class path: {class_path!r}")
try:
module = importlib.import_module(module_path)
except ModuleNotFoundError as e:
raise ImportError(f"Module not found: {module_path!r}") from e
try:
return getattr(module, class_name)
except AttributeError as e:
raise ImportError(f"Class not found: {class_name!r} in {module_path!r}") from e
2024-05-21 22:48:41 +08:00
class CacheSeedGen:
"""
It is a global seed generator to generate a sequence of seeds.
This will support the feature `use_auto_chat_cache_seed_gen` claim
NOTE:
- This seed is specifically for the cache and is different from a regular seed.
- If the cache is removed, setting the same seed will not produce the same QA trace.
"""
def __init__(self) -> None:
self.set_seed(LLM_SETTINGS.init_chat_cache_seed)
def set_seed(self, seed: int) -> None:
random.seed(seed)
def get_next_seed(self) -> int:
"""generate next random int"""
return random.randint(0, 10000) # noqa: S311
LLM_CACHE_SEED_GEN = CacheSeedGen()
def _subprocess_wrapper(f: Callable, seed: int, args: list) -> Any:
"""
It is a function wrapper. To ensure the subprocess has a fixed start seed.
"""
LLM_CACHE_SEED_GEN.set_seed(seed)
return f(*args)
2024-05-21 22:48:41 +08:00
def multiprocessing_wrapper(func_calls: list[tuple[Callable, tuple]], n: int) -> list:
"""It will use multiprocessing to call the functions in func_calls with the given parameters.
The results equals to `return [f(*args) for f, args in func_calls]`
It will not call multiprocessing if `n=1`
NOTE:
We cooperate with chat_cache_seed feature
We ensure get the same seed trace even we have multiple number of seed
2024-05-21 22:48:41 +08:00
Parameters
----------
func_calls : List[Tuple[Callable, Tuple]]
the list of functions and their parameters
n : int
the number of subprocesses
Returns
-------
list
"""
if n == 1 or max(1, min(n, len(func_calls))) == 1:
2024-05-21 22:48:41 +08:00
return [f(*args) for f, args in func_calls]
with mp.Pool(processes=max(1, min(n, len(func_calls)))) as pool:
results = [
pool.apply_async(_subprocess_wrapper, args=(f, LLM_CACHE_SEED_GEN.get_next_seed(), args))
for f, args in func_calls
]
2024-05-21 22:48:41 +08:00
return [result.get() for result in results]
def cache_with_pickle(hash_func: Callable, post_process_func: Callable | None = None, force: bool = False) -> Callable:
"""
This decorator will cache the return value of the function with pickle.
The cache key is generated by the hash_func. The hash function returns a string or None.
If it returns None, the cache will not be used. The cache will be stored in the folder
specified by RD_AGENT_SETTINGS.pickle_cache_folder_path_str with name hash_key.pkl.
The post_process_func will be called with the original arguments and the cached result
to give each caller a chance to process the cached result. The post_process_func should
return the final result.
Parameters
----------
hash_func : Callable
The function to generate the hash key for the cache.
post_process_func : Callable | None, optional
The function to process the cached result, by default None.
force : bool, optional
If True, the cache will be used even if RD_AGENT_SETTINGS.cache_with_pickle is False, by default False.
"""
def cache_decorator(func: Callable) -> Callable:
@functools.wraps(func)
def cache_wrapper(*args: Any, **kwargs: Any) -> Any:
if not RD_AGENT_SETTINGS.cache_with_pickle and not force:
2024-10-14 19:35:21 +08:00
return func(*args, **kwargs)
target_folder = Path(RD_AGENT_SETTINGS.pickle_cache_folder_path_str) / f"{func.__module__}.{func.__name__}"
target_folder.mkdir(parents=True, exist_ok=True)
hash_key = hash_func(*args, **kwargs)
if hash_key is None:
return func(*args, **kwargs)
cache_file = target_folder / f"{hash_key}.pkl"
lock_file = target_folder / f"{hash_key}.lock"
if cache_file.exists():
with cache_file.open("rb") as f:
cached_res = pickle.load(f)
return post_process_func(*args, cached_res=cached_res, **kwargs) if post_process_func else cached_res
if RD_AGENT_SETTINGS.use_file_lock:
with FileLock(lock_file):
result = func(*args, **kwargs)
else:
result = func(*args, **kwargs)
2024-10-14 19:35:21 +08:00
with cache_file.open("wb") as f:
pickle.dump(result, f)
return result
return cache_wrapper
return cache_decorator
def safe_resolve_path(user_path: Path | str, safe_root: Path | str | None = None) -> Path:
"""Resolve a user-provided path safely against an allowed root directory.
Args:
user_path: Path provided by user/LLM/config
safe_root: If provided, the resolved path must be within this directory
Raises:
ValueError: If path resolves outside safe_root
OSError: If path cannot be resolved
"""
resolved = Path(user_path).expanduser().resolve()
if safe_root is not None:
root_resolved = Path(safe_root).expanduser().resolve()
try:
resolved.relative_to(root_resolved)
except ValueError:
raise ValueError(
f"Path {user_path} resolves to {resolved}, "
f"outside allowed root {root_resolved}"
)
return resolved