From 15b86e85334eaa98910a15ea84580dff7da1e9c5 Mon Sep 17 00:00:00 2001 From: seehi <6580@pm.me> Date: Mon, 3 Jun 2024 10:14:57 +0800 Subject: [PATCH 01/14] experience pool --- config/config2.example.yaml | 3 +++ examples/exp_pool/manager.py | 21 ++++++++++++++++++++ metagpt/config2.py | 4 ++++ metagpt/configs/exp_pool_config.py | 6 ++++++ metagpt/exp_pool/__init__.py | 0 metagpt/exp_pool/decorator.py | 4 ++++ metagpt/exp_pool/manager.py | 32 ++++++++++++++++++++++++++++++ metagpt/exp_pool/schema.py | 25 +++++++++++++++++++++++ metagpt/utils/reflection.py | 9 +++++++++ metagpt/utils/token_counter.py | 2 ++ 10 files changed, 106 insertions(+) create mode 100644 examples/exp_pool/manager.py create mode 100644 metagpt/configs/exp_pool_config.py create mode 100644 metagpt/exp_pool/__init__.py create mode 100644 metagpt/exp_pool/decorator.py create mode 100644 metagpt/exp_pool/manager.py create mode 100644 metagpt/exp_pool/schema.py diff --git a/config/config2.example.yaml b/config/config2.example.yaml index f1158775b..c5ca6e767 100644 --- a/config/config2.example.yaml +++ b/config/config2.example.yaml @@ -74,6 +74,9 @@ s3: secure: false bucket: "test" +experience_pool: + enable_read: false + enable_write: false azure_tts_subscription_key: "YOUR_SUBSCRIPTION_KEY" azure_tts_region: "eastus" diff --git a/examples/exp_pool/manager.py b/examples/exp_pool/manager.py new file mode 100644 index 000000000..f5766f9a5 --- /dev/null +++ b/examples/exp_pool/manager.py @@ -0,0 +1,21 @@ +from metagpt.exp_pool.manager import ExperiencePoolManager +from metagpt.exp_pool.schema import Experience +from pprint import pprint +import asyncio +# import logging +# logging.basicConfig(level=logging.DEBUG) + +async def main(): + req = "2048 game" + exp = Experience(req=req, resp="python code") + + manager = ExperiencePoolManager() + + # pprint(manager.storage.get()) + # manager.create_exp(exp) + result = await manager.query_exp(req) + print(result) + + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/metagpt/config2.py b/metagpt/config2.py index 8c61fdbf2..3f8930401 100644 --- a/metagpt/config2.py +++ b/metagpt/config2.py @@ -21,6 +21,7 @@ from metagpt.configs.search_config import SearchConfig from metagpt.configs.workspace_config import WorkspaceConfig from metagpt.const import CONFIG_ROOT, METAGPT_ROOT from metagpt.utils.yaml_model import YamlModel +from metagpt.configs.exp_pool_config import ExperiencePoolConfig class CLIParams(BaseModel): @@ -67,6 +68,9 @@ class Config(CLIParams, YamlModel): enable_longterm_memory: bool = False code_review_k_times: int = 2 + # Experience Pool Parameters + experience_pool: Optional[ExperiencePoolConfig] = None + # Will be removed in the future metagpt_tti_url: str = "" language: str = "English" diff --git a/metagpt/configs/exp_pool_config.py b/metagpt/configs/exp_pool_config.py new file mode 100644 index 000000000..f7312d2de --- /dev/null +++ b/metagpt/configs/exp_pool_config.py @@ -0,0 +1,6 @@ +from metagpt.utils.yaml_model import YamlModel + + +class ExperiencePoolConfig(YamlModel): + enable_read: bool = False + enable_write: bool = False diff --git a/metagpt/exp_pool/__init__.py b/metagpt/exp_pool/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/metagpt/exp_pool/decorator.py b/metagpt/exp_pool/decorator.py new file mode 100644 index 000000000..6629e8377 --- /dev/null +++ b/metagpt/exp_pool/decorator.py @@ -0,0 +1,4 @@ + + +def exp_cache(func): + pass diff --git a/metagpt/exp_pool/manager.py b/metagpt/exp_pool/manager.py new file mode 100644 index 000000000..c32073a9f --- /dev/null +++ b/metagpt/exp_pool/manager.py @@ -0,0 +1,32 @@ +from pydantic import BaseModel, ConfigDict +from metagpt.exp_pool.schema import Experience +import uuid +import chromadb +from chromadb import Collection, QueryResult +from typing import Optional +from metagpt.rag.engines import SimpleEngine +from metagpt.rag.schema import ChromaRetrieverConfig + + +class ExperiencePoolManager(BaseModel): + def __init__(self, **kwargs): + super().__init__(**kwargs) + self._storage = None + + @property + def storage(self) -> SimpleEngine: + if self._storage is None: + self._storage = SimpleEngine.from_objs(retriever_configs=[ChromaRetrieverConfig(collection_name="experience_pool", persist_path="./chroma_data")]) + return self._storage + + def create_exp(self, exp: Experience): + self.storage.add_objs([exp]) + + async def query_exp(self, req: str) -> list[Experience]: + nodes = await self.storage.aretrieve(req) + exps = [node.metadata["obj"] for node in nodes] + + return exps + + + diff --git a/metagpt/exp_pool/schema.py b/metagpt/exp_pool/schema.py new file mode 100644 index 000000000..359268612 --- /dev/null +++ b/metagpt/exp_pool/schema.py @@ -0,0 +1,25 @@ +from pydantic import BaseModel, Field +from llama_index.core.schema import TextNode + + +class Experience(BaseModel): + req: str = Field(..., description="") + resp: str = Field(..., description="") + + def rag_key(self): + return self.req + + +class ExperienceNodeMetadata(BaseModel): + """Metadata of ExperienceNode.""" + + resp: str = Field(..., description="") + + +class ExperienceNode(TextNode): + """ExperienceNode for RAG.""" + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.excluded_llm_metadata_keys = list(ExperienceNodeMetadata.model_fields.keys()) + self.excluded_embed_metadata_keys = self.excluded_llm_metadata_keys diff --git a/metagpt/utils/reflection.py b/metagpt/utils/reflection.py index 8b8237ae7..688831f06 100644 --- a/metagpt/utils/reflection.py +++ b/metagpt/utils/reflection.py @@ -1,4 +1,5 @@ """class tools, including method inspection, class attributes, inheritance relationships, etc.""" +import inspect def check_methods(C, *methods): @@ -16,3 +17,11 @@ def check_methods(C, *methods): else: return NotImplemented return True + + +def get_func_full_name(func, *args) -> str: + if inspect.ismethod(func) or (inspect.isfunction(func) and "self" in inspect.signature(func).parameters): + cls_name = args[0].__class__.__name__ + return f"{func.__module__}.{cls_name}.{func.__name__}" + + return f"{func.__module__}.{func.__name__}" diff --git a/metagpt/utils/token_counter.py b/metagpt/utils/token_counter.py index 0ba2daa89..496842a2d 100644 --- a/metagpt/utils/token_counter.py +++ b/metagpt/utils/token_counter.py @@ -150,6 +150,8 @@ TOKEN_MAX = { "gpt-4-1106-preview": 128000, "gpt-4-vision-preview": 128000, "gpt-4-1106-vision-preview": 128000, + "gpt-4-turbo": 128000, + "gpt-4o": 128000, "gpt-4": 8192, "gpt-4-0613": 8192, "gpt-4-32k": 32768, From 471310f3b3e879ea269d2941f525c83e04b55938 Mon Sep 17 00:00:00 2001 From: seehi <6580@pm.me> Date: Tue, 4 Jun 2024 10:28:39 +0800 Subject: [PATCH 02/14] experiment pool init --- .gitignore | 1 + config/config2.example.yaml | 6 +- examples/exp_pool/decorator.py | 26 ++++++ examples/exp_pool/manager.py | 21 ----- examples/exp_pool/simple.py | 29 ++++++ metagpt/config2.py | 4 +- metagpt/configs/exp_pool_config.py | 6 +- metagpt/exp_pool/__init__.py | 6 ++ metagpt/exp_pool/decorator.py | 56 ++++++++++- metagpt/exp_pool/manager.py | 113 +++++++++++++++++++---- metagpt/exp_pool/schema.py | 40 +++++++- metagpt/rag/retrievers/bm25_retriever.py | 2 +- metagpt/utils/file.py | 1 - metagpt/utils/reflection.py | 2 +- 14 files changed, 258 insertions(+), 55 deletions(-) create mode 100644 examples/exp_pool/decorator.py delete mode 100644 examples/exp_pool/manager.py create mode 100644 examples/exp_pool/simple.py diff --git a/.gitignore b/.gitignore index aa5edd74a..7c64829ad 100644 --- a/.gitignore +++ b/.gitignore @@ -162,6 +162,7 @@ examples/graph_store.json examples/image__vector_store.json examples/index_store.json .chroma +.chroma_exp_data *~$* workspace/* tmp diff --git a/config/config2.example.yaml b/config/config2.example.yaml index c5ca6e767..c7b2cae2c 100644 --- a/config/config2.example.yaml +++ b/config/config2.example.yaml @@ -74,9 +74,9 @@ s3: secure: false bucket: "test" -experience_pool: - enable_read: false - enable_write: false +exp_pool: + enable_read: true + enable_write: true azure_tts_subscription_key: "YOUR_SUBSCRIPTION_KEY" azure_tts_region: "eastus" diff --git a/examples/exp_pool/decorator.py b/examples/exp_pool/decorator.py new file mode 100644 index 000000000..2f6397f80 --- /dev/null +++ b/examples/exp_pool/decorator.py @@ -0,0 +1,26 @@ +"""Decorator example of experience pool.""" + +import asyncio +import uuid + +from metagpt.exp_pool import exp_cache, exp_manager +from metagpt.logs import logger + + +@exp_cache +async def produce(req): + return f"{req} {uuid.uuid4().hex}" + + +async def main(): + req = "Water" + + resp = await produce(req) + logger.info(f"The resp of `produce{req}` is: {resp}") + + exps = await exp_manager.query_exps(req) + logger.info(f"Find experiences: {exps}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/exp_pool/manager.py b/examples/exp_pool/manager.py deleted file mode 100644 index f5766f9a5..000000000 --- a/examples/exp_pool/manager.py +++ /dev/null @@ -1,21 +0,0 @@ -from metagpt.exp_pool.manager import ExperiencePoolManager -from metagpt.exp_pool.schema import Experience -from pprint import pprint -import asyncio -# import logging -# logging.basicConfig(level=logging.DEBUG) - -async def main(): - req = "2048 game" - exp = Experience(req=req, resp="python code") - - manager = ExperiencePoolManager() - - # pprint(manager.storage.get()) - # manager.create_exp(exp) - result = await manager.query_exp(req) - print(result) - - -if __name__ == "__main__": - asyncio.run(main()) \ No newline at end of file diff --git a/examples/exp_pool/simple.py b/examples/exp_pool/simple.py new file mode 100644 index 000000000..bc20fbcdd --- /dev/null +++ b/examples/exp_pool/simple.py @@ -0,0 +1,29 @@ +"""Simple example of experience pool.""" + +import asyncio + +from metagpt.exp_pool import exp_manager +from metagpt.exp_pool.schema import EntryType, Experience +from metagpt.logs import logger + + +async def main(): + req = "Simple task." + + # 1. Find experiences. + exps = await exp_manager.query_exps(req) + if exps: + logger.info(f"Experiences already exist for the request `{req}`: {exps}") + return + + # 2. Create a new experience if none exist + exp_manager.create_exp(Experience(req=req, resp="Simple echo.", entry_type=EntryType.MANUAL)) + logger.info(f"New experience created for the request `{req}`.") + + # 3. Find again + exps = await exp_manager.query_exps(req) + logger.info(f"Updated experiences: {exps}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/metagpt/config2.py b/metagpt/config2.py index 6f5a1add6..6588a6036 100644 --- a/metagpt/config2.py +++ b/metagpt/config2.py @@ -13,6 +13,7 @@ from pydantic import BaseModel, model_validator from metagpt.configs.browser_config import BrowserConfig from metagpt.configs.embedding_config import EmbeddingConfig +from metagpt.configs.exp_pool_config import ExperiencePoolConfig from metagpt.configs.llm_config import LLMConfig, LLMType from metagpt.configs.mermaid_config import MermaidConfig from metagpt.configs.redis_config import RedisConfig @@ -22,7 +23,6 @@ from metagpt.configs.search_config import SearchConfig from metagpt.configs.workspace_config import WorkspaceConfig from metagpt.const import CONFIG_ROOT, METAGPT_ROOT from metagpt.utils.yaml_model import YamlModel -from metagpt.configs.exp_pool_config import ExperiencePoolConfig class CLIParams(BaseModel): @@ -73,7 +73,7 @@ class Config(CLIParams, YamlModel): code_review_k_times: int = 2 # Experience Pool Parameters - experience_pool: Optional[ExperiencePoolConfig] = None + exp_pool: ExperiencePoolConfig = ExperiencePoolConfig() # Will be removed in the future metagpt_tti_url: str = "" diff --git a/metagpt/configs/exp_pool_config.py b/metagpt/configs/exp_pool_config.py index f7312d2de..3f86173c1 100644 --- a/metagpt/configs/exp_pool_config.py +++ b/metagpt/configs/exp_pool_config.py @@ -1,6 +1,8 @@ +from pydantic import Field + from metagpt.utils.yaml_model import YamlModel class ExperiencePoolConfig(YamlModel): - enable_read: bool = False - enable_write: bool = False + enable_read: bool = Field(default=True, description="Enable to read from experience pool.") + enable_write: bool = Field(default=True, description="Enable to write to experience pool.") diff --git a/metagpt/exp_pool/__init__.py b/metagpt/exp_pool/__init__.py index e69de29bb..aeeb94b38 100644 --- a/metagpt/exp_pool/__init__.py +++ b/metagpt/exp_pool/__init__.py @@ -0,0 +1,6 @@ +"""Experience pool init.""" + +from metagpt.exp_pool.manager import exp_manager +from metagpt.exp_pool.decorator import exp_cache + +__all__ = ["exp_manager", "exp_cache"] diff --git a/metagpt/exp_pool/decorator.py b/metagpt/exp_pool/decorator.py index 6629e8377..1d691b8f3 100644 --- a/metagpt/exp_pool/decorator.py +++ b/metagpt/exp_pool/decorator.py @@ -1,4 +1,56 @@ +"""Experience Decorator.""" + +import asyncio +import functools +from typing import Any, Callable, Optional, TypeVar + +from metagpt.exp_pool.manager import exp_manager +from metagpt.exp_pool.schema import Experience +from metagpt.utils.async_helper import NestAsyncio + +ReturnType = TypeVar("ReturnType") -def exp_cache(func): - pass +def exp_cache(_func: Optional[Callable[..., ReturnType]] = None): + """Decorator to check for a perfect experience and returns it if exists. + + Otherwise, it executes the function, save the result as a new experience, and returns the result. + + This can be applied to both synchronous and asynchronous functions. + """ + + def decorator(func: Callable[..., ReturnType]) -> Callable[..., ReturnType]: + @functools.wraps(func) + async def get_or_create(args: Any, kwargs: Any, is_async: bool) -> ReturnType: + """Attempts to retrieve a cached experience or creates one if not found.""" + + req = f"{func.__name__}_{args}_{kwargs}" + exps = await exp_manager.query_exps(req) + if perfect_exp := exp_manager.extract_one_perfect_exp(exps): + return perfect_exp + + if is_async: + result = await func(*args, **kwargs) + else: + result = func(*args, **kwargs) + + exp_manager.create_exp(Experience(req=req, resp=result)) + + return result + + def sync_wrapper(*args: Any, **kwargs: Any) -> ReturnType: + NestAsyncio.apply_once() + return asyncio.get_event_loop().run_until_complete(get_or_create(args, kwargs, is_async=False)) + + async def async_wrapper(*args: Any, **kwargs: Any) -> ReturnType: + return await get_or_create(args, kwargs, is_async=True) + + if asyncio.iscoroutinefunction(func): + return async_wrapper + else: + return sync_wrapper + + if _func is None: + return decorator + else: + return decorator(_func) diff --git a/metagpt/exp_pool/manager.py b/metagpt/exp_pool/manager.py index c32073a9f..4bc566104 100644 --- a/metagpt/exp_pool/manager.py +++ b/metagpt/exp_pool/manager.py @@ -1,32 +1,105 @@ -from pydantic import BaseModel, ConfigDict -from metagpt.exp_pool.schema import Experience -import uuid -import chromadb -from chromadb import Collection, QueryResult +"""Experience Manager.""" + from typing import Optional + +from pydantic import BaseModel, ConfigDict, model_validator + +from metagpt.config2 import Config, config +from metagpt.exp_pool.schema import MAX_SCORE, Experience from metagpt.rag.engines import SimpleEngine -from metagpt.rag.schema import ChromaRetrieverConfig +from metagpt.rag.schema import ChromaRetrieverConfig, LLMRankerConfig -class ExperiencePoolManager(BaseModel): - def __init__(self, **kwargs): - super().__init__(**kwargs) - self._storage = None +class ExperienceManager(BaseModel): + """ExperienceManager manages the lifecycle of experiences, including CRUD and optimization. + + Attributes: + config (Config): Configuration for managing experiences. + storage (SimpleEngine): Engine to handle the storage and retrieval of experiences. + """ + + model_config = ConfigDict(arbitrary_types_allowed=True) + + config: Config = config + storage: SimpleEngine = None + + @model_validator(mode="after") + def initialize(self): + if self.storage is None: + self.storage = SimpleEngine.from_objs( + retriever_configs=[ + ChromaRetrieverConfig(collection_name="experience_pool", persist_path=".chroma_exp_data") + ], + ranker_configs=[LLMRankerConfig()], + ) + return self - @property - def storage(self) -> SimpleEngine: - if self._storage is None: - self._storage = SimpleEngine.from_objs(retriever_configs=[ChromaRetrieverConfig(collection_name="experience_pool", persist_path="./chroma_data")]) - return self._storage - def create_exp(self, exp: Experience): + """Adds an experience to the storage if writing is enabled. + + Args: + exp (Experience): The experience to add. + """ + if not self.config.exp_pool.enable_write: + return + self.storage.add_objs([exp]) - - async def query_exp(self, req: str) -> list[Experience]: + + async def query_exps(self, req: str, tag: str = "") -> list[Experience]: + """Retrieves and filters experiences. + + Args: + req (str): The query string to retrieve experiences. + tag (str): Optional tag to filter the experiences by. + + Returns: + list[Experience]: A list of experiences that match the args. + """ + if not self.config.exp_pool.enable_read: + return [] + nodes = await self.storage.aretrieve(req) - exps = [node.metadata["obj"] for node in nodes] + exps: list[Experience] = [node.metadata["obj"] for node in nodes] + + # TODO: filter by metadata + if tag: + exps = [exp for exp in exps if exp.tag == tag] return exps - + def extract_one_perfect_exp(self, exps: list[Experience]) -> Optional[Experience]: + """Extracts the first 'perfect' experience from a list of experiences. + Args: + exps (list[Experience]): The experiences to evaluate. + + Returns: + Optional[Experience]: The first perfect experience if found, otherwise None. + """ + for exp in exps: + if self.is_perfect_exp(exp): + return exp + + return None + + @staticmethod + def is_perfect_exp(exp: Experience) -> bool: + """Determines if an experience is considered 'perfect'. + + Args: + exp (Experience): The experience to evaluate. + + Returns: + bool: True if the experience is manually entered, otherwise False. + """ + if not exp: + return False + + # TODO: need more metrics + if exp.metric and exp.metric.score == MAX_SCORE: + return True + + return False + + +exp_manager = ExperienceManager() diff --git a/metagpt/exp_pool/schema.py b/metagpt/exp_pool/schema.py index 359268612..b51bc3c17 100644 --- a/metagpt/exp_pool/schema.py +++ b/metagpt/exp_pool/schema.py @@ -1,10 +1,46 @@ -from pydantic import BaseModel, Field +"""Experience schema.""" + +from enum import Enum +from typing import Optional + from llama_index.core.schema import TextNode +from pydantic import BaseModel, Field + +MAX_SCORE = 10 + + +class ExperienceType(str, Enum): + """Experience Type.""" + + SUCCESS = "success" + FAILURE = "failure" + INSIGHT = "insight" + + +class EntryType(Enum): + """Experience Entry Type.""" + + AUTOMATIC = "Automatic" + MANUAL = "Manual" + + +class Metric(BaseModel): + """Experience Metric.""" + + time_cost: float = Field(default=0.000, description="Time cost, the unit is milliseconds.") + money_cost: float = Field(default=0.000, description="Money cost, the unit is US dollars.") + score: int = Field(default=1, description="Score, a value between 1 and 10.") class Experience(BaseModel): + """Experience.""" + req: str = Field(..., description="") - resp: str = Field(..., description="") + resp: str = Field(..., description="The type is string/json/code.") + metric: Optional[Metric] = Field(default=None, description="Metric.") + exp_type: ExperienceType = Field(default=ExperienceType.SUCCESS, description="The type of experience.") + entry_type: EntryType = Field(default=EntryType.AUTOMATIC, description="Type of entry: Manual or Automatic.") + tag: str = Field(default="", description="Tagging experience.") def rag_key(self): return self.req diff --git a/metagpt/rag/retrievers/bm25_retriever.py b/metagpt/rag/retrievers/bm25_retriever.py index 3b085cb73..dc75d87b0 100644 --- a/metagpt/rag/retrievers/bm25_retriever.py +++ b/metagpt/rag/retrievers/bm25_retriever.py @@ -46,4 +46,4 @@ class DynamicBM25Retriever(BM25Retriever): def persist(self, persist_dir: str, **kwargs) -> None: """Support persist.""" if self._index: - self._index.storage_context.persist(persist_dir) \ No newline at end of file + self._index.storage_context.persist(persist_dir) diff --git a/metagpt/utils/file.py b/metagpt/utils/file.py index a8ed482d9..8861f65dc 100644 --- a/metagpt/utils/file.py +++ b/metagpt/utils/file.py @@ -72,7 +72,6 @@ class File: class MemoryFileSystem(_MemoryFileSystem): - @classmethod def _strip_protocol(cls, path): return super()._strip_protocol(str(path)) diff --git a/metagpt/utils/reflection.py b/metagpt/utils/reflection.py index 688831f06..2683e5657 100644 --- a/metagpt/utils/reflection.py +++ b/metagpt/utils/reflection.py @@ -23,5 +23,5 @@ def get_func_full_name(func, *args) -> str: if inspect.ismethod(func) or (inspect.isfunction(func) and "self" in inspect.signature(func).parameters): cls_name = args[0].__class__.__name__ return f"{func.__module__}.{cls_name}.{func.__name__}" - + return f"{func.__module__}.{func.__name__}" From 6d983908314622125033459cce16c401d47bc87d Mon Sep 17 00:00:00 2001 From: seehi <6580@pm.me> Date: Tue, 4 Jun 2024 11:33:34 +0800 Subject: [PATCH 03/14] experiment pool init --- examples/exp_pool/simple.py | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/examples/exp_pool/simple.py b/examples/exp_pool/simple.py index bc20fbcdd..608578519 100644 --- a/examples/exp_pool/simple.py +++ b/examples/exp_pool/simple.py @@ -9,20 +9,13 @@ from metagpt.logs import logger async def main(): req = "Simple task." + resp = "Simple echo." - # 1. Find experiences. - exps = await exp_manager.query_exps(req) - if exps: - logger.info(f"Experiences already exist for the request `{req}`: {exps}") - return - - # 2. Create a new experience if none exist - exp_manager.create_exp(Experience(req=req, resp="Simple echo.", entry_type=EntryType.MANUAL)) + exp_manager.create_exp(Experience(req=req, resp=resp, entry_type=EntryType.MANUAL)) logger.info(f"New experience created for the request `{req}`.") - # 3. Find again exps = await exp_manager.query_exps(req) - logger.info(f"Updated experiences: {exps}") + logger.info(f"Got experiences: {exps}") if __name__ == "__main__": From 9f817bd59c254ae0b86b1b1ef9d5d57b6f63a44a Mon Sep 17 00:00:00 2001 From: seehi <6580@pm.me> Date: Tue, 4 Jun 2024 11:46:57 +0800 Subject: [PATCH 04/14] experiment pool init --- examples/exp_pool/simple.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/examples/exp_pool/simple.py b/examples/exp_pool/simple.py index 608578519..f270824bf 100644 --- a/examples/exp_pool/simple.py +++ b/examples/exp_pool/simple.py @@ -10,8 +10,9 @@ from metagpt.logs import logger async def main(): req = "Simple task." resp = "Simple echo." + exp = Experience(req=req, resp=resp, entry_type=EntryType.MANUAL) - exp_manager.create_exp(Experience(req=req, resp=resp, entry_type=EntryType.MANUAL)) + exp_manager.create_exp(exp) logger.info(f"New experience created for the request `{req}`.") exps = await exp_manager.query_exps(req) From d10881c0e441e0f37b1645c4ca890c30f2868da3 Mon Sep 17 00:00:00 2001 From: seehi <6580@pm.me> Date: Tue, 4 Jun 2024 15:16:58 +0800 Subject: [PATCH 05/14] add trajectory schema --- metagpt/exp_pool/schema.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/metagpt/exp_pool/schema.py b/metagpt/exp_pool/schema.py index b51bc3c17..e6ae4ee1d 100644 --- a/metagpt/exp_pool/schema.py +++ b/metagpt/exp_pool/schema.py @@ -32,6 +32,14 @@ class Metric(BaseModel): score: int = Field(default=1, description="Score, a value between 1 and 10.") +class Trajectory(BaseModel): + """Experience Trajectory.""" + + plan: str = Field(default="", description="The plan.") + action: str = Field(default="", description="Action for the plan.") + observation: str = Field(default="", description="Output of the action.") + + class Experience(BaseModel): """Experience.""" @@ -41,6 +49,7 @@ class Experience(BaseModel): exp_type: ExperienceType = Field(default=ExperienceType.SUCCESS, description="The type of experience.") entry_type: EntryType = Field(default=EntryType.AUTOMATIC, description="Type of entry: Manual or Automatic.") tag: str = Field(default="", description="Tagging experience.") + traj: Optional[Trajectory] = Field(default=None, description="Trajectory.") def rag_key(self): return self.req From 2eedc23a827acc892c4928bf14c7e1b99f081c59 Mon Sep 17 00:00:00 2001 From: seehi <6580@pm.me> Date: Tue, 4 Jun 2024 22:08:40 +0800 Subject: [PATCH 06/14] add exp_pool test --- metagpt/exp_pool/schema.py | 5 +- tests/metagpt/exp_pool/test_manager.py | 77 ++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 2 deletions(-) create mode 100644 tests/metagpt/exp_pool/test_manager.py diff --git a/metagpt/exp_pool/schema.py b/metagpt/exp_pool/schema.py index e6ae4ee1d..1afcc1508 100644 --- a/metagpt/exp_pool/schema.py +++ b/metagpt/exp_pool/schema.py @@ -1,7 +1,7 @@ """Experience schema.""" from enum import Enum -from typing import Optional +from typing import Any, Optional from llama_index.core.schema import TextNode from pydantic import BaseModel, Field @@ -38,13 +38,14 @@ class Trajectory(BaseModel): plan: str = Field(default="", description="The plan.") action: str = Field(default="", description="Action for the plan.") observation: str = Field(default="", description="Output of the action.") + reward: int = Field(default=0, description="Measure the action.") class Experience(BaseModel): """Experience.""" req: str = Field(..., description="") - resp: str = Field(..., description="The type is string/json/code.") + resp: Any = Field(..., description="The type is string/json/code.") metric: Optional[Metric] = Field(default=None, description="Metric.") exp_type: ExperienceType = Field(default=ExperienceType.SUCCESS, description="The type of experience.") entry_type: EntryType = Field(default=EntryType.AUTOMATIC, description="Type of entry: Manual or Automatic.") diff --git a/tests/metagpt/exp_pool/test_manager.py b/tests/metagpt/exp_pool/test_manager.py new file mode 100644 index 000000000..a0d7005f5 --- /dev/null +++ b/tests/metagpt/exp_pool/test_manager.py @@ -0,0 +1,77 @@ +import pytest + +from metagpt.config2 import Config +from metagpt.configs.exp_pool_config import ExperiencePoolConfig +from metagpt.configs.llm_config import LLMConfig +from metagpt.exp_pool.manager import ExperienceManager +from metagpt.exp_pool.schema import MAX_SCORE, Experience, Metric +from metagpt.rag.engines import SimpleEngine + + +class TestExperienceManager: + @pytest.fixture + def mock_config(self): + return Config(llm=LLMConfig(), exp_pool=ExperiencePoolConfig(enable_write=True, enable_read=True)) + + @pytest.fixture + def mock_storage(self, mocker): + engine = mocker.MagicMock(spec=SimpleEngine) + engine.add_objs = mocker.MagicMock() + engine.aretrieve = mocker.AsyncMock(return_value=[]) + return engine + + @pytest.fixture + def mock_experience_manager(self, mock_config, mock_storage): + return ExperienceManager(config=mock_config, storage=mock_storage) + + @pytest.fixture + def mock_experience(self): + return Experience(req="req", resp="resp") + + def test_initialize_storage(self, mock_experience_manager, mock_storage): + assert mock_experience_manager.storage is mock_storage + + def test_create_exp(self, mock_experience_manager, mock_experience): + mock_experience_manager.create_exp(mock_experience) + mock_experience_manager.storage.add_objs.assert_called_once_with([mock_experience]) + + def test_create_exp_write_disabled(self, mock_experience_manager, mock_experience, mock_config): + mock_config.exp_pool.enable_write = False + mock_experience_manager.create_exp(mock_experience) + mock_experience_manager.storage.add_objs.assert_not_called() + + @pytest.mark.asyncio + async def test_query_exps(self, mock_experience_manager, mocker): + req = "req" + resp = "resp" + tag = "test" + experiences = [Experience(req=req, resp=resp, tag="test"), Experience(req=req, resp=resp, tag="other")] + mock_experience_manager.storage.aretrieve.return_value = [ + mocker.MagicMock(metadata={"obj": exp}) for exp in experiences + ] + + result = await mock_experience_manager.query_exps(req, tag) + assert len(result) == 1 + assert result[0].tag == "test" + + @pytest.mark.asyncio + async def test_query_exps_no_read_permission(self, mock_experience_manager, mock_config): + mock_config.exp_pool.enable_read = False + result = await mock_experience_manager.query_exps("query") + assert result == [] + + def test_extract_one_perfect_exp(self, mock_experience_manager): + experiences = [ + Experience(req="req", resp="resp", metric=Metric(score=MAX_SCORE)), + Experience(req="req", resp="resp"), + ] + perfect_exp: Experience = mock_experience_manager.extract_one_perfect_exp(experiences) + assert perfect_exp is not None + assert perfect_exp.metric.score == MAX_SCORE + + def test_is_perfect_exp(self): + exp = Experience(req="req", resp="resp", metric=Metric(score=MAX_SCORE)) + assert ExperienceManager.is_perfect_exp(exp) == True + + exp = Experience(req="req", resp="resp") + assert ExperienceManager.is_perfect_exp(exp) == False From 1d8d85e9a50f02ddf4b394ff1589c77322ac0c19 Mon Sep 17 00:00:00 2001 From: seehi <6580@pm.me> Date: Wed, 5 Jun 2024 10:32:48 +0800 Subject: [PATCH 07/14] update comment --- metagpt/exp_pool/decorator.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/metagpt/exp_pool/decorator.py b/metagpt/exp_pool/decorator.py index 1d691b8f3..e073ee494 100644 --- a/metagpt/exp_pool/decorator.py +++ b/metagpt/exp_pool/decorator.py @@ -22,18 +22,21 @@ def exp_cache(_func: Optional[Callable[..., ReturnType]] = None): def decorator(func: Callable[..., ReturnType]) -> Callable[..., ReturnType]: @functools.wraps(func) async def get_or_create(args: Any, kwargs: Any, is_async: bool) -> ReturnType: - """Attempts to retrieve a cached experience or creates one if not found.""" + """Attempts to retrieve a perfect experience or creates an experience if not found.""" + # 1. Get exps. req = f"{func.__name__}_{args}_{kwargs}" exps = await exp_manager.query_exps(req) if perfect_exp := exp_manager.extract_one_perfect_exp(exps): return perfect_exp + # 2. Exec func. TODO: pass exps to func if is_async: result = await func(*args, **kwargs) else: result = func(*args, **kwargs) + # 3. Create an exp. exp_manager.create_exp(Experience(req=req, resp=result)) return result From c78cddd1021c5073e7f4e17d22149053fc8e3276 Mon Sep 17 00:00:00 2001 From: seehi <6580@pm.me> Date: Wed, 5 Jun 2024 22:15:09 +0800 Subject: [PATCH 08/14] add exp_pool tests --- examples/exp_pool/decorator.py | 5 +- metagpt/exp_pool/decorator.py | 144 ++++++++++++++++------ metagpt/exp_pool/manager.py | 10 +- metagpt/exp_pool/schema.py | 16 ++- metagpt/exp_pool/scorers/__init__.py | 6 + metagpt/exp_pool/scorers/base.py | 27 +++++ metagpt/exp_pool/scorers/simple.py | 73 ++++++++++++ tests/metagpt/exp_pool/test_decorator.py | 145 +++++++++++++++++++++++ tests/metagpt/exp_pool/test_manager.py | 8 +- 9 files changed, 391 insertions(+), 43 deletions(-) create mode 100644 metagpt/exp_pool/scorers/__init__.py create mode 100644 metagpt/exp_pool/scorers/base.py create mode 100644 metagpt/exp_pool/scorers/simple.py create mode 100644 tests/metagpt/exp_pool/test_decorator.py diff --git a/examples/exp_pool/decorator.py b/examples/exp_pool/decorator.py index 2f6397f80..3f6093e01 100644 --- a/examples/exp_pool/decorator.py +++ b/examples/exp_pool/decorator.py @@ -7,8 +7,9 @@ from metagpt.exp_pool import exp_cache, exp_manager from metagpt.logs import logger -@exp_cache -async def produce(req): +@exp_cache(pass_exps_to_func=True) +async def produce(req, exps=None): + logger.info(f"Previous experiences: {exps}") return f"{req} {uuid.uuid4().hex}" diff --git a/metagpt/exp_pool/decorator.py b/metagpt/exp_pool/decorator.py index e073ee494..9eb4d9e61 100644 --- a/metagpt/exp_pool/decorator.py +++ b/metagpt/exp_pool/decorator.py @@ -4,56 +4,134 @@ import asyncio import functools from typing import Any, Callable, Optional, TypeVar -from metagpt.exp_pool.manager import exp_manager -from metagpt.exp_pool.schema import Experience +from pydantic import BaseModel, ConfigDict + +from metagpt.exp_pool.manager import ExperienceManager, exp_manager +from metagpt.exp_pool.schema import Experience, Metric, QueryType, Score +from metagpt.exp_pool.scorers import ExperienceScorer, SimpleScorer from metagpt.utils.async_helper import NestAsyncio ReturnType = TypeVar("ReturnType") -def exp_cache(_func: Optional[Callable[..., ReturnType]] = None): - """Decorator to check for a perfect experience and returns it if exists. - - Otherwise, it executes the function, save the result as a new experience, and returns the result. +def exp_cache( + _func: Optional[Callable[..., ReturnType]] = None, + query_type: QueryType = QueryType.SEMANTIC, + scorer: Optional[ExperienceScorer] = None, + manager: Optional[ExperienceManager] = None, + pass_exps_to_func: bool = False, +): + """Decorator to get a perfect experience, otherwise, it executes the function, and create a new experience. This can be applied to both synchronous and asynchronous functions. + + Args: + _func: Just to make the decorator more flexible, for example, it can be used directly with @exp_cache by default, without the need for @exp_cache(). + query_type: The type of query to be used when fetching experiences. + scorer: Evaluate experience. Default SimpleScorer. + manager: How to fetch, evaluate and save experience, etc. Default exp_manager. + pass_exps_to_func: To control whether imperfect experiences are passed to the function, if True, the func must have a parameter named 'exps'. """ def decorator(func: Callable[..., ReturnType]) -> Callable[..., ReturnType]: @functools.wraps(func) - async def get_or_create(args: Any, kwargs: Any, is_async: bool) -> ReturnType: - """Attempts to retrieve a perfect experience or creates an experience if not found.""" + async def get_or_create(args: Any, kwargs: Any) -> ReturnType: + handler = ExpCacheHandler( + func=func, + args=args, + kwargs=kwargs, + exp_manager=manager or exp_manager, + exp_scorer=scorer or SimpleScorer(), + pass_exps=pass_exps_to_func, + ) - # 1. Get exps. - req = f"{func.__name__}_{args}_{kwargs}" - exps = await exp_manager.query_exps(req) - if perfect_exp := exp_manager.extract_one_perfect_exp(exps): - return perfect_exp + await handler.fetch_experiences(query_type) + if exp := handler.get_one_perfect_experience(): + return exp - # 2. Exec func. TODO: pass exps to func - if is_async: - result = await func(*args, **kwargs) - else: - result = func(*args, **kwargs) + await handler.execute_function() + await handler.evaluate_experience() + handler.save_experience() - # 3. Create an exp. - exp_manager.create_exp(Experience(req=req, resp=result)) + return handler._result - return result + return ExpCacheHandler.choose_wrapper(func, get_or_create) - def sync_wrapper(*args: Any, **kwargs: Any) -> ReturnType: + return decorator(_func) if _func else decorator + + +class ExpCacheHandler(BaseModel): + model_config = ConfigDict(arbitrary_types_allowed=True) + + func: Callable + args: Any + kwargs: Any + exp_manager: ExperienceManager + exp_scorer: ExperienceScorer + pass_exps: bool + + _exps: list[Experience] = None + _result: Any = None + _score: Score = None + + async def fetch_experiences(self, query_type: QueryType): + """Fetch a potentially perfect existing experience.""" + + req = self.generate_req_identifier() + self._exps = await self.exp_manager.query_exps(req, query_type=query_type) + + def get_one_perfect_experience(self) -> Optional[Experience]: + return self.exp_manager.extract_one_perfect_exp(self._exps) + + async def execute_function(self): + """Execute the function, and save the result.""" + self._result = await self._execute_function() + + async def evaluate_experience(self): + """Evaluate the experience, and save the score.""" + + self._score = await self.exp_scorer.evaluate(self.func, self._result, self.args, self.kwargs) + + def save_experience(self): + """Save the new experience.""" + + req = self.generate_req_identifier() + exp = Experience(req=req, resp=self._result, metric=Metric(score=self._score)) + + self.exp_manager.create_exp(exp) + + def generate_req_identifier(self): + """Generate a unique request identifier based on the function and its arguments.""" + + return f"{self.func.__name__}_{self.args}_{self.kwargs}" + + @staticmethod + def choose_wrapper(func, wrapped_func): + """Choose how to run wrapped_func based on whether the function is asynchronous.""" + + async def async_wrapper(*args, **kwargs): + return await wrapped_func(args, kwargs) + + def sync_wrapper(*args, **kwargs): NestAsyncio.apply_once() - return asyncio.get_event_loop().run_until_complete(get_or_create(args, kwargs, is_async=False)) + return asyncio.get_event_loop().run_until_complete(wrapped_func(args, kwargs)) - async def async_wrapper(*args: Any, **kwargs: Any) -> ReturnType: - return await get_or_create(args, kwargs, is_async=True) + return async_wrapper if asyncio.iscoroutinefunction(func) else sync_wrapper - if asyncio.iscoroutinefunction(func): - return async_wrapper - else: - return sync_wrapper + async def _execute_function(self): + if self.pass_exps: + return await self._execute_function_with_exps() - if _func is None: - return decorator - else: - return decorator(_func) + return await self._execute_function_without_exps() + + async def _execute_function_without_exps(self): + if asyncio.iscoroutinefunction(self.func): + return await self.func(*self.args, **self.kwargs) + + return self.func(*self.args, **self.kwargs) + + async def _execute_function_with_exps(self): + if asyncio.iscoroutinefunction(self.func): + return await self.func(*self.args, **self.kwargs, exps=self._exps) + + return self.func(*self.args, **self.kwargs, exps=self._exps) diff --git a/metagpt/exp_pool/manager.py b/metagpt/exp_pool/manager.py index 4bc566104..58499104d 100644 --- a/metagpt/exp_pool/manager.py +++ b/metagpt/exp_pool/manager.py @@ -5,7 +5,7 @@ from typing import Optional from pydantic import BaseModel, ConfigDict, model_validator from metagpt.config2 import Config, config -from metagpt.exp_pool.schema import MAX_SCORE, Experience +from metagpt.exp_pool.schema import MAX_SCORE, Experience, QueryType from metagpt.rag.engines import SimpleEngine from metagpt.rag.schema import ChromaRetrieverConfig, LLMRankerConfig @@ -45,12 +45,13 @@ class ExperienceManager(BaseModel): self.storage.add_objs([exp]) - async def query_exps(self, req: str, tag: str = "") -> list[Experience]: + async def query_exps(self, req: str, tag: str = "", query_type: QueryType = QueryType.SEMANTIC) -> list[Experience]: """Retrieves and filters experiences. Args: req (str): The query string to retrieve experiences. tag (str): Optional tag to filter the experiences by. + query_type (QueryType): Default semantic to vector matching. exact to same matching. Returns: list[Experience]: A list of experiences that match the args. @@ -65,6 +66,9 @@ class ExperienceManager(BaseModel): if tag: exps = [exp for exp in exps if exp.tag == tag] + if query_type == QueryType.EXACT: + exps = [exp for exp in exps if exp.req == req] + return exps def extract_one_perfect_exp(self, exps: list[Experience]) -> Optional[Experience]: @@ -96,7 +100,7 @@ class ExperienceManager(BaseModel): return False # TODO: need more metrics - if exp.metric and exp.metric.score == MAX_SCORE: + if exp.metric and exp.metric.score.val == MAX_SCORE: return True return False diff --git a/metagpt/exp_pool/schema.py b/metagpt/exp_pool/schema.py index 1afcc1508..9fc665cca 100644 --- a/metagpt/exp_pool/schema.py +++ b/metagpt/exp_pool/schema.py @@ -9,6 +9,13 @@ from pydantic import BaseModel, Field MAX_SCORE = 10 +class QueryType(str, Enum): + """Type of query experiences.""" + + EXACT = "exact" + SEMANTIC = "semantic" + + class ExperienceType(str, Enum): """Experience Type.""" @@ -24,12 +31,19 @@ class EntryType(Enum): MANUAL = "Manual" +class Score(BaseModel): + """Score in Metric.""" + + val: int = Field(default=1, description="Value of the score, Between 1 and 10, higher is better.") + reason: str = Field(default="", description="Reason for the value.") + + class Metric(BaseModel): """Experience Metric.""" time_cost: float = Field(default=0.000, description="Time cost, the unit is milliseconds.") money_cost: float = Field(default=0.000, description="Money cost, the unit is US dollars.") - score: int = Field(default=1, description="Score, a value between 1 and 10.") + score: Score = Field(default=None, description="Score, with value and reason.") class Trajectory(BaseModel): diff --git a/metagpt/exp_pool/scorers/__init__.py b/metagpt/exp_pool/scorers/__init__.py new file mode 100644 index 000000000..85bea88ff --- /dev/null +++ b/metagpt/exp_pool/scorers/__init__.py @@ -0,0 +1,6 @@ +"""Experience scorers init.""" + +from metagpt.exp_pool.scorers.base import ExperienceScorer +from metagpt.exp_pool.scorers.simple import SimpleScorer + +__all__ = ["ExperienceScorer", "SimpleScorer"] diff --git a/metagpt/exp_pool/scorers/base.py b/metagpt/exp_pool/scorers/base.py new file mode 100644 index 000000000..a9d30cffe --- /dev/null +++ b/metagpt/exp_pool/scorers/base.py @@ -0,0 +1,27 @@ +"""Experience Scorers.""" + +from abc import abstractmethod +from typing import Any, Callable + +from pydantic import BaseModel, ConfigDict + +from metagpt.exp_pool.schema import Score + + +class ExperienceScorer(BaseModel): + model_config = ConfigDict(arbitrary_types_allowed=True) + + @abstractmethod + async def evaluate(self, func: Callable, result: Any, args: tuple = None, kwargs: dict = None) -> Score: + """Evaluate the quality of the result produced by the function and parameters. + + Args: + func (Callable): The function whose result is to be evaluated. + result (Any): The result produced by the function. + args (Tuple[Any, ...]): The tuple of arguments that were passed to the function. + kwargs (Dict[str, Any]): The dictionary of keyword arguments that were passed to the function. + + Example: + result = await sample(5, name="foo") + score = await scorer.evaluate(sample, result, args=(5), kwargs={"name": "foo"}) + """ diff --git a/metagpt/exp_pool/scorers/simple.py b/metagpt/exp_pool/scorers/simple.py new file mode 100644 index 000000000..d0301cbc2 --- /dev/null +++ b/metagpt/exp_pool/scorers/simple.py @@ -0,0 +1,73 @@ +"""Evalate by llm.""" +import inspect +import json +from typing import Any, Callable + +from pydantic import Field + +from metagpt.exp_pool.schema import Score +from metagpt.exp_pool.scorers.base import ExperienceScorer +from metagpt.llm import LLM +from metagpt.provider.base_llm import BaseLLM +from metagpt.utils.common import parse_json_code_block + +SIMPLE_SCORER_TEMPLATE = """ +Role: You're an expert score evaluator. You specialize in assessing the output of the given function, based on its intended requirement and produced result. + +## Context +### Function Name +{func_name} + +### Function Document +{func_doc} + +### Function Signature +{func_signature} + +### Function Parameters +args: {func_args} +kwargs: {func_kwargs} + +### Produced Result By Function and Parameters +{func_result} + +## Format Example +```json +{{ + "val": "the value of the score, int from 1 to 10, higher is better.", + "reason": "an explanation supporting the score." +}} +``` + +## Instructions +- Understand the function and requirements given by the user. +- Analyze the results produced by the function. +- Grade the results based on level of alignment with the requirements. +- Provide a score on a scale defined by user or a default scale (1 to 10). + +## Constraint +Format: Just print the result in json format like **Format Example**. + +## Action +Follow instructions, generate output and make sure it follows the **Constraint**. +""" + + +class SimpleScorer(ExperienceScorer): + llm: BaseLLM = Field(default_factory=LLM) + + async def evaluate(self, func: Callable, result: Any, args: tuple = None, kwargs: dict = None) -> Score: + """Evaluate the quality of content.""" + + prompt = SIMPLE_SCORER_TEMPLATE.format( + func_name=func.__name__, + func_doc=func.__doc__, + func_signature=inspect.signature(func), + func_args=args, + func_kwargs=kwargs, + func_result=result, + ) + resp = await self.llm.aask(prompt) + resp_json = json.loads(parse_json_code_block(resp)[0]) + + return Score(**resp_json) diff --git a/tests/metagpt/exp_pool/test_decorator.py b/tests/metagpt/exp_pool/test_decorator.py new file mode 100644 index 000000000..508229d18 --- /dev/null +++ b/tests/metagpt/exp_pool/test_decorator.py @@ -0,0 +1,145 @@ +import asyncio + +import pytest + +from metagpt.exp_pool.decorator import ExpCacheHandler +from metagpt.exp_pool.manager import ExperienceManager +from metagpt.exp_pool.schema import Experience, QueryType, Score +from metagpt.exp_pool.scorers import SimpleScorer +from metagpt.rag.engines import SimpleEngine + + +class TestExpCache: + @pytest.fixture + def mock_func(self, mocker): + return mocker.AsyncMock() + + @pytest.fixture + def mock_exp_manager(self, mocker): + manager = mocker.MagicMock(spec=ExperienceManager) + manager.storage = mocker.MagicMock(spec=SimpleEngine) + manager.query_exps = mocker.AsyncMock() + manager.create_exp = mocker.MagicMock() + manager.extract_one_perfect_exp = mocker.MagicMock() + return manager + + @pytest.fixture + def mock_scorer(self, mocker): + scorer = mocker.MagicMock(spec=SimpleScorer) + scorer.evaluate = mocker.AsyncMock() + return scorer + + @pytest.fixture + def exp_cache_handler(self, mock_func, mock_exp_manager, mock_scorer): + return ExpCacheHandler( + func=mock_func, args=(), kwargs={}, exp_manager=mock_exp_manager, exp_scorer=mock_scorer, pass_exps=False + ) + + @pytest.mark.asyncio + async def test_fetch_experiences(self, exp_cache_handler, mock_exp_manager): + await exp_cache_handler.fetch_experiences(QueryType.SEMANTIC) + mock_exp_manager.query_exps.assert_called_once() + + @pytest.mark.asyncio + async def test_perfect_experience_found(self, exp_cache_handler, mock_exp_manager, mock_func): + # Setup: Assume perfect experience is found + perfect_exp = Experience(req="req", resp="resp") + mock_exp_manager.extract_one_perfect_exp.return_value = perfect_exp + + # Execute + exp_cache_handler._exps = [perfect_exp] # Simulate fetched experiences + result = exp_cache_handler.get_one_perfect_experience() + + # Assert + assert result.resp == "resp" + mock_func.assert_not_called() # Function should not be called + + @pytest.mark.asyncio + async def test_execute_function_when_no_perfect_exp(self, exp_cache_handler, mock_exp_manager, mock_func): + # Setup: No perfect experience + mock_exp_manager.extract_one_perfect_exp.return_value = None + mock_func.return_value = "Computed result" + + # Execute + await exp_cache_handler.execute_function() + + # Assert + assert exp_cache_handler._result == "Computed result" + mock_func.assert_called_once() + + @pytest.mark.asyncio + async def test_evaluate_and_save_experience(self, exp_cache_handler, mock_scorer, mock_exp_manager): + # Setup + mock_scorer.evaluate.return_value = Score(value=100) + exp_cache_handler._result = "Computed result" + + # Execute + await exp_cache_handler.evaluate_experience() + exp_cache_handler.save_experience() + + # Assert + mock_scorer.evaluate.assert_called_once() + mock_exp_manager.create_exp.assert_called_once() + + @pytest.mark.asyncio + async def test_async_function_execution_with_exps(self, exp_cache_handler, mock_exp_manager, mock_func): + # Setup + exp_cache_handler.pass_exps = True + mock_func.return_value = "Async result with exps" + mock_exp_manager.extract_one_perfect_exp.return_value = None + exp_cache_handler._exps = [Experience(req="req", resp="resp")] + + # Execute + await exp_cache_handler.execute_function() + + # Assert + mock_func.assert_called_once_with(exps=exp_cache_handler._exps) + assert exp_cache_handler._result == "Async result with exps" + + def test_sync_function_execution_with_exps(self, mocker, exp_cache_handler, mock_exp_manager, mock_func): + # Setup + exp_cache_handler.func = mocker.Mock(return_value="Sync result with exps") + exp_cache_handler.pass_exps = True + mock_exp_manager.extract_one_perfect_exp.return_value = None + exp_cache_handler._exps = [Experience(req="req", resp="resp")] + + # Execute + asyncio.get_event_loop().run_until_complete(exp_cache_handler.execute_function()) + + # Assert + exp_cache_handler.func.assert_called_once_with(exps=exp_cache_handler._exps) + assert exp_cache_handler._result == "Sync result with exps" + + def test_wrapper_selection_async(self, mocker, exp_cache_handler, mock_func): + # Setup + mock_func = mocker.AsyncMock() + + # Execute + wrapper = ExpCacheHandler.choose_wrapper(mock_func, exp_cache_handler.execute_function) + + # Assert + assert asyncio.iscoroutinefunction(wrapper), "Wrapper should be asynchronous" + + def test_wrapper_selection_sync(self, exp_cache_handler, mocker): + # Setup + sync_func = mocker.Mock() + + # Execute + wrapper = ExpCacheHandler.choose_wrapper(sync_func, exp_cache_handler.execute_function) + + # Assert + assert not asyncio.iscoroutinefunction(wrapper), "Wrapper should be synchronous" + + @pytest.mark.asyncio + async def test_generate_req_identifier(self, exp_cache_handler): + # Setup + exp_cache_handler.func = lambda x: x + exp_cache_handler.args = (42,) + exp_cache_handler.kwargs = {"y": 3.14} + + # Execute + req_id = exp_cache_handler.generate_req_identifier() + + # Assert + expected_id = "_(42,)_{'y': 3.14}" + assert req_id == expected_id, "Request identifier should match the expected format" diff --git a/tests/metagpt/exp_pool/test_manager.py b/tests/metagpt/exp_pool/test_manager.py index a0d7005f5..3e8f47417 100644 --- a/tests/metagpt/exp_pool/test_manager.py +++ b/tests/metagpt/exp_pool/test_manager.py @@ -4,7 +4,7 @@ from metagpt.config2 import Config from metagpt.configs.exp_pool_config import ExperiencePoolConfig from metagpt.configs.llm_config import LLMConfig from metagpt.exp_pool.manager import ExperienceManager -from metagpt.exp_pool.schema import MAX_SCORE, Experience, Metric +from metagpt.exp_pool.schema import MAX_SCORE, Experience, Metric, Score from metagpt.rag.engines import SimpleEngine @@ -62,15 +62,15 @@ class TestExperienceManager: def test_extract_one_perfect_exp(self, mock_experience_manager): experiences = [ - Experience(req="req", resp="resp", metric=Metric(score=MAX_SCORE)), + Experience(req="req", resp="resp", metric=Metric(score=Score(val=MAX_SCORE))), Experience(req="req", resp="resp"), ] perfect_exp: Experience = mock_experience_manager.extract_one_perfect_exp(experiences) assert perfect_exp is not None - assert perfect_exp.metric.score == MAX_SCORE + assert perfect_exp.metric.score.val == MAX_SCORE def test_is_perfect_exp(self): - exp = Experience(req="req", resp="resp", metric=Metric(score=MAX_SCORE)) + exp = Experience(req="req", resp="resp", metric=Metric(score=Score(val=MAX_SCORE))) assert ExperienceManager.is_perfect_exp(exp) == True exp = Experience(req="req", resp="resp") From d148a3217bbe6eb2aa80bdd0132801da98fd1a14 Mon Sep 17 00:00:00 2001 From: seehi <6580@pm.me> Date: Wed, 5 Jun 2024 23:26:09 +0800 Subject: [PATCH 09/14] add handle_exception to ensure robustness --- metagpt/exp_pool/decorator.py | 14 ++++++++++++-- metagpt/exp_pool/manager.py | 3 +++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/metagpt/exp_pool/decorator.py b/metagpt/exp_pool/decorator.py index 9eb4d9e61..9cf924779 100644 --- a/metagpt/exp_pool/decorator.py +++ b/metagpt/exp_pool/decorator.py @@ -10,6 +10,7 @@ from metagpt.exp_pool.manager import ExperienceManager, exp_manager from metagpt.exp_pool.schema import Experience, Metric, QueryType, Score from metagpt.exp_pool.scorers import ExperienceScorer, SimpleScorer from metagpt.utils.async_helper import NestAsyncio +from metagpt.utils.exceptions import handle_exception ReturnType = TypeVar("ReturnType") @@ -50,8 +51,7 @@ def exp_cache( return exp await handler.execute_function() - await handler.evaluate_experience() - handler.save_experience() + await handler.process_experience() return handler._result @@ -87,6 +87,16 @@ class ExpCacheHandler(BaseModel): """Execute the function, and save the result.""" self._result = await self._execute_function() + @handle_exception + async def process_experience(self): + """Process experience. + + Evaluates and saves experience. + Use `handle_exception` to ensure robustness, do not stop subsequent operations. + """ + await self.evaluate_experience() + self.save_experience() + async def evaluate_experience(self): """Evaluate the experience, and save the score.""" diff --git a/metagpt/exp_pool/manager.py b/metagpt/exp_pool/manager.py index 58499104d..546086b37 100644 --- a/metagpt/exp_pool/manager.py +++ b/metagpt/exp_pool/manager.py @@ -8,6 +8,7 @@ from metagpt.config2 import Config, config from metagpt.exp_pool.schema import MAX_SCORE, Experience, QueryType from metagpt.rag.engines import SimpleEngine from metagpt.rag.schema import ChromaRetrieverConfig, LLMRankerConfig +from metagpt.utils.exceptions import handle_exception class ExperienceManager(BaseModel): @@ -34,6 +35,7 @@ class ExperienceManager(BaseModel): ) return self + @handle_exception def create_exp(self, exp: Experience): """Adds an experience to the storage if writing is enabled. @@ -45,6 +47,7 @@ class ExperienceManager(BaseModel): self.storage.add_objs([exp]) + @handle_exception(default_return=[]) async def query_exps(self, req: str, tag: str = "", query_type: QueryType = QueryType.SEMANTIC) -> list[Experience]: """Retrieves and filters experiences. From 1679757d9f3cc086ab251d0c6be4e542f1f14830 Mon Sep 17 00:00:00 2001 From: seehi <6580@pm.me> Date: Thu, 6 Jun 2024 20:18:40 +0800 Subject: [PATCH 10/14] update exp_pool example --- examples/exp_pool/simple.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/examples/exp_pool/simple.py b/examples/exp_pool/simple.py index f270824bf..3216e78b8 100644 --- a/examples/exp_pool/simple.py +++ b/examples/exp_pool/simple.py @@ -9,8 +9,7 @@ from metagpt.logs import logger async def main(): req = "Simple task." - resp = "Simple echo." - exp = Experience(req=req, resp=resp, entry_type=EntryType.MANUAL) + exp = Experience(req=req, resp="echo", entry_type=EntryType.MANUAL) exp_manager.create_exp(exp) logger.info(f"New experience created for the request `{req}`.") From 16fd197e068cd10947340382d246ab505d5f0860 Mon Sep 17 00:00:00 2001 From: seehi <6580@pm.me> Date: Fri, 7 Jun 2024 10:30:37 +0800 Subject: [PATCH 11/14] update comment --- metagpt/exp_pool/scorers/simple.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/metagpt/exp_pool/scorers/simple.py b/metagpt/exp_pool/scorers/simple.py index d0301cbc2..5779f7fb1 100644 --- a/metagpt/exp_pool/scorers/simple.py +++ b/metagpt/exp_pool/scorers/simple.py @@ -1,4 +1,5 @@ -"""Evalate by llm.""" +"""Simple Scorer.""" + import inspect import json from typing import Any, Callable @@ -57,8 +58,17 @@ class SimpleScorer(ExperienceScorer): llm: BaseLLM = Field(default_factory=LLM) async def evaluate(self, func: Callable, result: Any, args: tuple = None, kwargs: dict = None) -> Score: - """Evaluate the quality of content.""" + """Evaluates the quality of content by LLM. + Args: + func: The function to evaluate. + result: The result produced by the function. + args: The positional arguments used when calling the function, if any. + kwargs: The keyword arguments used when calling the function, if any. + + Returns: + A Score object containing the evaluation results. + """ prompt = SIMPLE_SCORER_TEMPLATE.format( func_name=func.__name__, func_doc=func.__doc__, From 547bbfcffc2086ea3958fbf829aab55ec9e3e17d Mon Sep 17 00:00:00 2001 From: seehi <6580@pm.me> Date: Fri, 7 Jun 2024 14:35:47 +0800 Subject: [PATCH 12/14] update comment --- metagpt/exp_pool/manager.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/metagpt/exp_pool/manager.py b/metagpt/exp_pool/manager.py index 546086b37..35ee5fdac 100644 --- a/metagpt/exp_pool/manager.py +++ b/metagpt/exp_pool/manager.py @@ -14,7 +14,7 @@ from metagpt.utils.exceptions import handle_exception class ExperienceManager(BaseModel): """ExperienceManager manages the lifecycle of experiences, including CRUD and optimization. - Attributes: + Args: config (Config): Configuration for managing experiences. storage (SimpleEngine): Engine to handle the storage and retrieval of experiences. """ From 7ac8397cc97ce7ad361f9420715ae55b5c1b5d88 Mon Sep 17 00:00:00 2001 From: seehi <6580@pm.me> Date: Fri, 7 Jun 2024 18:15:23 +0800 Subject: [PATCH 13/14] add scorer example --- examples/exp_pool/{simple.py => manager.py} | 0 examples/exp_pool/scorer.py | 25 +++++++++++++++++++++ metagpt/exp_pool/scorers/simple.py | 4 ++-- 3 files changed, 27 insertions(+), 2 deletions(-) rename examples/exp_pool/{simple.py => manager.py} (100%) create mode 100644 examples/exp_pool/scorer.py diff --git a/examples/exp_pool/simple.py b/examples/exp_pool/manager.py similarity index 100% rename from examples/exp_pool/simple.py rename to examples/exp_pool/manager.py diff --git a/examples/exp_pool/scorer.py b/examples/exp_pool/scorer.py new file mode 100644 index 000000000..1efe07bdf --- /dev/null +++ b/examples/exp_pool/scorer.py @@ -0,0 +1,25 @@ +import asyncio + +from metagpt.exp_pool.scorers import SimpleScorer +from metagpt.logs import logger + + +def echo(req: str): + """Echo from req.""" + + return req + + +async def simple(): + scorer = SimpleScorer() + + score = await scorer.evaluate(echo, "data", ("data",)) + logger.info(f"The score is: {score}") + + +async def main(): + await simple() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/metagpt/exp_pool/scorers/simple.py b/metagpt/exp_pool/scorers/simple.py index 5779f7fb1..84995b60f 100644 --- a/metagpt/exp_pool/scorers/simple.py +++ b/metagpt/exp_pool/scorers/simple.py @@ -10,7 +10,7 @@ from metagpt.exp_pool.schema import Score from metagpt.exp_pool.scorers.base import ExperienceScorer from metagpt.llm import LLM from metagpt.provider.base_llm import BaseLLM -from metagpt.utils.common import parse_json_code_block +from metagpt.utils.common import CodeParser SIMPLE_SCORER_TEMPLATE = """ Role: You're an expert score evaluator. You specialize in assessing the output of the given function, based on its intended requirement and produced result. @@ -78,6 +78,6 @@ class SimpleScorer(ExperienceScorer): func_result=result, ) resp = await self.llm.aask(prompt) - resp_json = json.loads(parse_json_code_block(resp)[0]) + resp_json = json.loads(CodeParser.parse_code(resp, lang="json")) return Score(**resp_json) From 797a8c5326c13feebaa1d0676b7eebf571eb980e Mon Sep 17 00:00:00 2001 From: seehi <6580@pm.me> Date: Tue, 11 Jun 2024 15:40:01 +0800 Subject: [PATCH 14/14] change req in exp --- metagpt/exp_pool/decorator.py | 29 +++++++++++++++++++------- metagpt/exp_pool/manager.py | 3 +-- metagpt/utils/reflection.py | 25 +++++++++++++++++----- tests/metagpt/utils/test_reflection.py | 29 ++++++++++++++++++++++++++ 4 files changed, 71 insertions(+), 15 deletions(-) create mode 100644 tests/metagpt/utils/test_reflection.py diff --git a/metagpt/exp_pool/decorator.py b/metagpt/exp_pool/decorator.py index 9cf924779..e559797a3 100644 --- a/metagpt/exp_pool/decorator.py +++ b/metagpt/exp_pool/decorator.py @@ -11,6 +11,7 @@ from metagpt.exp_pool.schema import Experience, Metric, QueryType, Score from metagpt.exp_pool.scorers import ExperienceScorer, SimpleScorer from metagpt.utils.async_helper import NestAsyncio from metagpt.utils.exceptions import handle_exception +from metagpt.utils.reflection import get_class_name ReturnType = TypeVar("ReturnType") @@ -43,7 +44,7 @@ def exp_cache( kwargs=kwargs, exp_manager=manager or exp_manager, exp_scorer=scorer or SimpleScorer(), - pass_exps=pass_exps_to_func, + pass_exps_to_func=pass_exps_to_func, ) await handler.fetch_experiences(query_type) @@ -68,16 +69,17 @@ class ExpCacheHandler(BaseModel): kwargs: Any exp_manager: ExperienceManager exp_scorer: ExperienceScorer - pass_exps: bool + pass_exps_to_func: bool = False _exps: list[Experience] = None _result: Any = None _score: Score = None + _req: str = None async def fetch_experiences(self, query_type: QueryType): """Fetch a potentially perfect existing experience.""" - req = self.generate_req_identifier() + req = self._get_req_identifier() self._exps = await self.exp_manager.query_exps(req, query_type=query_type) def get_one_perfect_experience(self) -> Optional[Experience]: @@ -105,15 +107,26 @@ class ExpCacheHandler(BaseModel): def save_experience(self): """Save the new experience.""" - req = self.generate_req_identifier() + req = self._get_req_identifier() exp = Experience(req=req, resp=self._result, metric=Metric(score=self._score)) self.exp_manager.create_exp(exp) - def generate_req_identifier(self): - """Generate a unique request identifier based on the function and its arguments.""" + def _get_req_identifier(self): + """Generate a unique request identifier based on the function and its arguments. - return f"{self.func.__name__}_{self.args}_{self.kwargs}" + Result Example: + - "write_prd-('2048',)-{}" + - "WritePRD.run-('2048',)-{}" + """ + if not self._req: + cls_name = get_class_name(self.func, *self.args) + func_name = f"{cls_name}.{self.func.__name__}" if cls_name else self.func.__name__ + args = self.args[1:] if cls_name and len(self.args) >= 1 else self.args + + self._req = f"{func_name}-{args}-{self.kwargs}" + + return self._req @staticmethod def choose_wrapper(func, wrapped_func): @@ -129,7 +142,7 @@ class ExpCacheHandler(BaseModel): return async_wrapper if asyncio.iscoroutinefunction(func) else sync_wrapper async def _execute_function(self): - if self.pass_exps: + if self.pass_exps_to_func: return await self._execute_function_with_exps() return await self._execute_function_without_exps() diff --git a/metagpt/exp_pool/manager.py b/metagpt/exp_pool/manager.py index 35ee5fdac..7382fe8f1 100644 --- a/metagpt/exp_pool/manager.py +++ b/metagpt/exp_pool/manager.py @@ -7,7 +7,7 @@ from pydantic import BaseModel, ConfigDict, model_validator from metagpt.config2 import Config, config from metagpt.exp_pool.schema import MAX_SCORE, Experience, QueryType from metagpt.rag.engines import SimpleEngine -from metagpt.rag.schema import ChromaRetrieverConfig, LLMRankerConfig +from metagpt.rag.schema import ChromaRetrieverConfig from metagpt.utils.exceptions import handle_exception @@ -31,7 +31,6 @@ class ExperienceManager(BaseModel): retriever_configs=[ ChromaRetrieverConfig(collection_name="experience_pool", persist_path=".chroma_exp_data") ], - ranker_configs=[LLMRankerConfig()], ) return self diff --git a/metagpt/utils/reflection.py b/metagpt/utils/reflection.py index 2683e5657..9b10a4b3e 100644 --- a/metagpt/utils/reflection.py +++ b/metagpt/utils/reflection.py @@ -19,9 +19,24 @@ def check_methods(C, *methods): return True -def get_func_full_name(func, *args) -> str: - if inspect.ismethod(func) or (inspect.isfunction(func) and "self" in inspect.signature(func).parameters): - cls_name = args[0].__class__.__name__ - return f"{func.__module__}.{cls_name}.{func.__name__}" +def get_class_name(func, *args) -> str: + """Returns the class name of the object that a method belongs to. - return f"{func.__module__}.{func.__name__}" + - If `func` is a bound method, extracts the class name directly from the method. + - If `func` is an unbound method and `args` are provided, assumes the first argument is `self` and extracts the class name. + - Returns an empty string if neither condition is met. + """ + if inspect.ismethod(func): + return func.__self__.__class__.__name__ + + if inspect.isfunction(func) and "self" in inspect.signature(func).parameters and args: + return args[0].__class__.__name__ + + return "" + + +def get_func_or_method_name(func, *args) -> str: + """Function name, or method name with class name.""" + cls_name = get_class_name(func, *args) + + return f"{cls_name}.{func.__name__}" if cls_name else f"{func.__name__}" diff --git a/tests/metagpt/utils/test_reflection.py b/tests/metagpt/utils/test_reflection.py new file mode 100644 index 000000000..e78e1b400 --- /dev/null +++ b/tests/metagpt/utils/test_reflection.py @@ -0,0 +1,29 @@ +from metagpt.utils.reflection import get_func_or_method_name + + +def simple_function(): + pass + + +class SampleClass: + def method(self): + pass + + +class TestFunctionOrMethodName: + def test_simple_function(self): + assert get_func_or_method_name(simple_function) == "simple_function" + + def test_class_method_without_args(self): + sample_instance = SampleClass() + assert get_func_or_method_name(sample_instance.method) == "SampleClass.method" + + def test_class_method_with_args(self): + sample_instance = SampleClass() + assert get_func_or_method_name(SampleClass.method, sample_instance) == "SampleClass.method" + + def test_function_with_no_args(self): + assert get_func_or_method_name(simple_function) == "simple_function" + + def test_method_without_instance(self): + assert get_func_or_method_name(SampleClass.method) == "method"