diff --git a/.agent-store-config.yaml.example b/.agent-store-config.yaml.example new file mode 100644 index 000000000..037a44ed4 --- /dev/null +++ b/.agent-store-config.yaml.example @@ -0,0 +1,9 @@ +role: + name: Teacher # Referenced the `Teacher` in `metagpt/roles/teacher.py`. + module: metagpt.roles.teacher # Referenced `metagpt/roles/teacher.py`. + skills: # Refer to the skill `name` of the published skill in `.well-known/skills.yaml`. + - name: text_to_speech + description: Text-to-speech + - name: text_to_image + description: Create a drawing based on the text. + diff --git a/.gitignore b/.gitignore index e03eab3d3..2f6d85805 100644 --- a/.gitignore +++ b/.gitignore @@ -164,3 +164,9 @@ tmp output.wav metagpt/roles/idea_agent.py .aider* +*.bak + +# output folder +output +tmp.png + diff --git a/.well-known/ai-plugin.json b/.well-known/ai-plugin.json new file mode 100644 index 000000000..ac0178fd0 --- /dev/null +++ b/.well-known/ai-plugin.json @@ -0,0 +1,18 @@ +{ + "schema_version": "v1", + "name_for_model": "text processing tools", + "name_for_human": "MetaGPT Text Plugin", + "description_for_model": "Plugins for text processing, including text-to-speech, text-to-image, text-to-embedding, text summarization, text-to-code, vector similarity calculation, web content crawling, and more.", + "description_for_human": "Plugins for text processing, including text-to-speech, text-to-image, text-to-embedding, text summarization, text-to-code, vector similarity calculation, web content crawling, and more.", + "auth": { + "type": "none" + }, + "api": { + "type": "openapi", + "url": "https://github.com/iorisa/MetaGPT/blob/feature/assistant_role/.well-known/metagpt_oas3_api.yaml", + "has_user_authentication": false + }, + "logo_url": "https://github.com/geekan/MetaGPT/blob/main/docs/resources/MetaGPT-logo.png", + "contact_email": "mashenquan@fuzhi.cn", + "legal_info_url": "https://github.com/geekan/MetaGPT/blob/main/docs/README_CN.md" +} \ No newline at end of file diff --git a/.well-known/metagpt_oas3_api.yaml b/.well-known/metagpt_oas3_api.yaml new file mode 100644 index 000000000..0a702e8b6 --- /dev/null +++ b/.well-known/metagpt_oas3_api.yaml @@ -0,0 +1,338 @@ +openapi: "3.0.0" + +info: + title: "MetaGPT Export OpenAPIs" + version: "1.0" +servers: + - url: "/oas3" + variables: + port: + default: '8080' + description: HTTP service port + +paths: + /tts/azsure: + x-prerequisite: + configurations: + AZURE_TTS_SUBSCRIPTION_KEY: + type: string + description: "For more details, check out: [Azure Text-to_Speech](https://learn.microsoft.com/en-us/azure/ai-services/speech-service/language-support?tabs=tts)" + AZURE_TTS_REGION: + type: string + description: "For more details, check out: [Azure Text-to_Speech](https://learn.microsoft.com/en-us/azure/ai-services/speech-service/language-support?tabs=tts)" + required: + allOf: + - AZURE_TTS_SUBSCRIPTION_KEY + - AZURE_TTS_REGION + post: + summary: "Convert Text to Base64-encoded .wav File Stream" + description: "For more details, check out: [Azure Text-to_Speech](https://learn.microsoft.com/en-us/azure/ai-services/speech-service/language-support?tabs=tts)" + operationId: azure_tts.oas3_azsure_tts + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - text + properties: + text: + type: string + description: Text to convert + lang: + type: string + description: The language code or locale, e.g., en-US (English - United States) + default: "zh-CN" + voice: + type: string + description: "Voice style, see: [Azure Text-to_Speech](https://learn.microsoft.com/en-us/azure/ai-services/speech-service/language-support?tabs=tts), [Voice Gallery](https://speech.microsoft.com/portal/voicegallery)" + default: "zh-CN-XiaomoNeural" + style: + type: string + description: "Speaking style to express different emotions. For more details, checkout: [Azure Text-to_Speech](https://learn.microsoft.com/en-us/azure/ai-services/speech-service/language-support?tabs=tts)" + default: "affectionate" + role: + type: string + description: "Role to specify age and gender. For more details, checkout: [Azure Text-to_Speech](https://learn.microsoft.com/en-us/azure/ai-services/speech-service/language-support?tabs=tts)" + default: "Girl" + subscription_key: + type: string + description: "Key used to access Azure AI service API, see: [Azure Portal](https://portal.azure.com/) > `Resource Management` > `Keys and Endpoint`" + default: "" + region: + type: string + description: "Location (or region) of your resource, see: [Azure Portal](https://portal.azure.com/) > `Resource Management` > `Keys and Endpoint`" + default: "" + responses: + '200': + description: "Base64-encoded .wav file data if successful, otherwise an empty string." + content: + application/json: + schema: + type: object + properties: + wav_data: + type: string + format: base64 + '400': + description: "Bad Request" + '500': + description: "Internal Server Error" + + /tts/iflytek: + x-prerequisite: + configurations: + IFLYTEK_APP_ID: + type: string + description: "Application ID is used to access your iFlyTek service API, see: `https://console.xfyun.cn/services/tts`" + IFLYTEK_API_KEY: + type: string + description: "WebAPI argument, see: `https://console.xfyun.cn/services/tts`" + IFLYTEK_API_SECRET: + type: string + description: "WebAPI argument, see: `https://console.xfyun.cn/services/tts`" + required: + allOf: + - IFLYTEK_APP_ID + - IFLYTEK_API_KEY + - IFLYTEK_API_SECRET + post: + summary: "Convert Text to Base64-encoded .mp3 File Stream" + description: "For more details, check out: [iFlyTek](https://console.xfyun.cn/services/tts)" + operationId: iflytek_tts.oas3_iflytek_tts + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - text + properties: + text: + type: string + description: Text to convert + voice: + type: string + description: "Voice style, see: [iFlyTek Text-to_Speech](https://www.xfyun.cn/doc/tts/online_tts/API.html#%E6%8E%A5%E5%8F%A3%E8%B0%83%E7%94%A8%E6%B5%81%E7%A8%8B)" + default: "xiaoyan" + app_id: + type: string + description: "Application ID is used to access your iFlyTek service API, see: `https://console.xfyun.cn/services/tts`" + default: "" + api_key: + type: string + description: "WebAPI argument, see: `https://console.xfyun.cn/services/tts`" + default: "" + api_secret: + type: string + description: "WebAPI argument, see: `https://console.xfyun.cn/services/tts`" + default: "" + responses: + '200': + description: "Base64-encoded .mp3 file data if successful, otherwise an empty string." + content: + application/json: + schema: + type: object + properties: + wav_data: + type: string + format: base64 + '400': + description: "Bad Request" + '500': + description: "Internal Server Error" + + + /txt2img/openai: + x-prerequisite: + configurations: + OPENAI_API_KEY: + type: string + description: "OpenAI API key, For more details, checkout: `https://platform.openai.com/account/api-keys`" + required: + allOf: + - OPENAI_API_KEY + post: + summary: "Convert Text to Base64-encoded Image Data Stream" + operationId: openai_text_to_image.oas3_openai_text_to_image + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + text: + type: string + description: "The text used for image conversion." + size_type: + type: string + enum: ["256x256", "512x512", "1024x1024"] + default: "1024x1024" + description: "Size of the generated image." + openai_api_key: + type: string + default: "" + description: "OpenAI API key, For more details, checkout: `https://platform.openai.com/account/api-keys`" + responses: + '200': + description: "Base64-encoded image data." + content: + application/json: + schema: + type: object + properties: + image_data: + type: string + format: base64 + '400': + description: "Bad Request" + '500': + description: "Internal Server Error" + /txt2embedding/openai: + x-prerequisite: + configurations: + OPENAI_API_KEY: + type: string + description: "OpenAI API key, For more details, checkout: `https://platform.openai.com/account/api-keys`" + required: + allOf: + - OPENAI_API_KEY + post: + summary: Text to embedding + operationId: openai_text_to_embedding.oas3_openai_text_to_embedding + description: Retrieve an embedding for the provided text using the OpenAI API. + requestBody: + content: + application/json: + schema: + type: object + properties: + input: + type: string + description: The text used for embedding. + model: + type: string + description: "ID of the model to use. For more details, checkout: [models](https://api.openai.com/v1/models)" + enum: + - text-embedding-ada-002 + responses: + "200": + description: Successful response + content: + application/json: + schema: + $ref: "#/components/schemas/ResultEmbedding" + "4XX": + description: Client error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "5XX": + description: Server error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + /txt2image/metagpt: + x-prerequisite: + configurations: + METAGPT_TEXT_TO_IMAGE_MODEL_URL: + type: string + description: "Model url." + required: + allOf: + - METAGPT_TEXT_TO_IMAGE_MODEL_URL + post: + summary: "Text to Image" + description: "Generate an image from the provided text using the MetaGPT Text-to-Image API." + operationId: metagpt_text_to_image.oas3_metagpt_text_to_image + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - text + properties: + text: + type: string + description: "The text used for image conversion." + size_type: + type: string + enum: ["512x512", "512x768"] + default: "512x512" + description: "Size of the generated image." + model_url: + type: string + description: "Model reset API URL for text-to-image." + default: "" + responses: + '200': + description: "Base64-encoded image data." + content: + application/json: + schema: + type: object + properties: + image_data: + type: string + format: base64 + '400': + description: "Bad Request" + '500': + description: "Internal Server Error" + +components: + schemas: + Embedding: + type: object + description: Represents an embedding vector returned by the embedding endpoint. + properties: + object: + type: string + example: embedding + embedding: + type: array + items: + type: number + example: [0.0023064255, -0.009327292, ...] + index: + type: integer + example: 0 + Usage: + type: object + properties: + prompt_tokens: + type: integer + example: 8 + total_tokens: + type: integer + example: 8 + ResultEmbedding: + type: object + properties: + object: + type: string + example: result_embedding + data: + type: array + items: + $ref: "#/components/schemas/Embedding" + model: + type: string + example: text-embedding-ada-002 + usage: + $ref: "#/components/schemas/Usage" + Error: + type: object + properties: + error: + type: string + example: An error occurred \ No newline at end of file diff --git a/.well-known/openapi.yaml b/.well-known/openapi.yaml new file mode 100644 index 000000000..bc291b7db --- /dev/null +++ b/.well-known/openapi.yaml @@ -0,0 +1,35 @@ +openapi: "3.0.0" + +info: + title: Hello World + version: "1.0" +servers: + - url: /openapi + +paths: + /greeting/{name}: + post: + summary: Generate greeting + description: Generates a greeting message. + operationId: hello.post_greeting + responses: + 200: + description: greeting response + content: + text/plain: + schema: + type: string + example: "hello dave!" + parameters: + - name: name + in: path + description: Name of the person to greet. + required: true + schema: + type: string + example: "dave" + requestBody: + content: + application/json: + schema: + type: object \ No newline at end of file diff --git a/.well-known/skills.yaml b/.well-known/skills.yaml new file mode 100644 index 000000000..c19a9501e --- /dev/null +++ b/.well-known/skills.yaml @@ -0,0 +1,161 @@ +skillapi: "0.1.0" + +info: + title: "Agent Skill Specification" + version: "1.0" + +entities: + Assistant: + summary: assistant + description: assistant + skills: + - name: text_to_speech + description: Generate a voice file from the input text, text-to-speech + id: text_to_speech.text_to_speech + x-prerequisite: + configurations: + AZURE_TTS_SUBSCRIPTION_KEY: + type: string + description: "For more details, check out: [Azure Text-to_Speech](https://learn.microsoft.com/en-us/azure/ai-services/speech-service/language-support?tabs=tts)" + AZURE_TTS_REGION: + type: string + description: "For more details, check out: [Azure Text-to_Speech](https://learn.microsoft.com/en-us/azure/ai-services/speech-service/language-support?tabs=tts)" + IFLYTEK_APP_ID: + type: string + description: "Application ID is used to access your iFlyTek service API, see: `https://console.xfyun.cn/services/tts`" + IFLYTEK_API_KEY: + type: string + description: "WebAPI argument, see: `https://console.xfyun.cn/services/tts`" + IFLYTEK_API_SECRET: + type: string + description: "WebAPI argument, see: `https://console.xfyun.cn/services/tts`" + required: + oneOf: + - allOf: + - AZURE_TTS_SUBSCRIPTION_KEY + - AZURE_TTS_REGION + - allOf: + - IFLYTEK_APP_ID + - IFLYTEK_API_KEY + - IFLYTEK_API_SECRET + parameters: + text: + description: 'The text used for voice conversion.' + required: true + type: string + lang: + description: 'The value can contain a language code such as en (English), or a locale such as en-US (English - United States).' + type: string + enum: + - English + - Chinese + default: Chinese + voice: + description: Name of voice styles + type: string + default: zh-CN-XiaomoNeural + style: + type: string + description: Speaking style to express different emotions like cheerfulness, empathy, and calm. + enum: + - affectionate + - angry + - calm + - cheerful + - depressed + - disgruntled + - embarrassed + - envious + - fearful + - gentle + - sad + - serious + default: affectionate + role: + type: string + description: With roles, the same voice can act as a different age and gender. + enum: + - Girl + - Boy + - OlderAdultFemale + - OlderAdultMale + - SeniorFemale + - SeniorMale + - YoungAdultFemale + - YoungAdultMale + default: Girl + examples: + - ask: 'A girl says "hello world"' + answer: 'text_to_speech(text="hello world", role="Girl")' + - ask: 'A boy affectionate says "hello world"' + answer: 'text_to_speech(text="hello world", role="Boy", style="affectionate")' + - ask: 'A boy says "你好"' + answer: 'text_to_speech(text="你好", role="Boy", lang="Chinese")' + returns: + type: string + format: base64 + + - name: text_to_image + description: Create a drawing based on the text. + id: text_to_image.text_to_image + x-prerequisite: + configurations: + OPENAI_API_KEY: + type: string + description: "OpenAI API key, For more details, checkout: `https://platform.openai.com/account/api-keys`" + METAGPT_TEXT_TO_IMAGE_MODEL_URL: + type: string + description: "Model url." + required: + oneOf: + - OPENAI_API_KEY + - METAGPT_TEXT_TO_IMAGE_MODEL_URL + parameters: + text: + description: 'The text used for image conversion.' + type: string + required: true + size_type: + description: size type + type: string + default: "512x512" + examples: + - ask: 'Draw a girl' + answer: 'text_to_image(text="Draw a girl", size_type="512x512")' + - ask: 'Draw an apple' + answer: 'text_to_image(text="Draw an apple", size_type="512x512")' + returns: + type: string + format: base64 + + - name: web_search + description: Perform Google searches to provide real-time information. + id: web_search.web_search + x-prerequisite: + configurations: + SEARCH_ENGINE: + type: string + description: "Supported values: serpapi/google/serper/ddg" + SERPER_API_KEY: + type: string + description: "SERPER API KEY, For more details, checkout: `https://serper.dev/api-key`" + required: + allOf: + - SEARCH_ENGINE + - SERPER_API_KEY + parameters: + query: + type: string + description: 'The search query.' + required: true + max_results: + type: number + default: 6 + description: 'The number of search results to retrieve.' + examples: + - ask: 'Search for information about artificial intelligence' + answer: 'web_search(query="Search for information about artificial intelligence", max_results=6)' + - ask: 'Find news articles about climate change' + answer: 'web_search(query="Find news articles about climate change", max_results=6)' + returns: + type: string diff --git a/config/config.yaml b/config/config.yaml index b841ee477..ff1ae769d 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -11,6 +11,7 @@ OPENAI_API_BASE: "https://api.openai.com/v1" OPENAI_API_MODEL: "gpt-4-1106-preview" MAX_TOKENS: 4096 RPM: 10 +LLM_TYPE: OpenAI #### if Spark #SPARK_APPID : "YOUR_APPID" @@ -98,4 +99,21 @@ PROMPT_FORMAT: json #json or markdown ### Agent configurations # RAISE_NOT_CONFIG_ERROR: true # "true" if the LLM key is not configured, throw a NotConfiguredException, else "false". -# WORKSPACE_PATH_WITH_UID: false # "true" if using `{workspace}/{uid}` as the workspace path; "false" use `{workspace}`. \ No newline at end of file +# WORKSPACE_PATH_WITH_UID: false # "true" if using `{workspace}/{uid}` as the workspace path; "false" use `{workspace}`. + +### Meta Models +#METAGPT_TEXT_TO_IMAGE_MODEL: MODEL_URL + +### S3 config +S3_ACCESS_KEY: "YOUR_S3_ACCESS_KEY" +S3_SECRET_KEY: "YOUR_S3_SECRET_KEY" +S3_ENDPOINT_URL: "YOUR_S3_ENDPOINT_URL" +S3_SECURE: true # true/false +S3_BUCKET: "YOUR_S3_BUCKET" + +### Redis config +REDIS_HOST: "YOUR_REDIS_HOST" +REDIS_PORT: "YOUR_REDIS_PORT" +REDIS_PASSWORD: "YOUR_REDIS_PASSWORD" +REDIS_DB: "YOUR_REDIS_DB_INDEX, str, 0-based" + diff --git a/examples/search_kb.py b/examples/search_kb.py index 7a9911ca2..c2ded1769 100644 --- a/examples/search_kb.py +++ b/examples/search_kb.py @@ -2,10 +2,17 @@ # -*- coding: utf-8 -*- """ @File : search_kb.py +@Modified By: mashenquan, 2023-8-9, fix-bug: cannot find metagpt module. """ import asyncio +<<<<<<< HEAD from metagpt.actions import Action +======= +from pathlib import Path +import sys +sys.path.append(str(Path(__file__).resolve().parent.parent)) +>>>>>>> send18/dev from metagpt.const import DATA_PATH from metagpt.document_store import FaissStore from metagpt.logs import logger diff --git a/examples/search_with_specific_engine.py b/examples/search_with_specific_engine.py index 334a7821f..c7c455b7e 100644 --- a/examples/search_with_specific_engine.py +++ b/examples/search_with_specific_engine.py @@ -1,5 +1,12 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +@Modified By: mashenquan, 2023-8-9, fix-bug: cannot find metagpt module. +""" import asyncio - +from pathlib import Path +import sys +sys.path.append(str(Path(__file__).resolve().parent.parent)) from metagpt.roles import Searcher from metagpt.tools import SearchEngineType diff --git a/examples/write_teaching_plan.py b/examples/write_teaching_plan.py new file mode 100644 index 000000000..c3a647b94 --- /dev/null +++ b/examples/write_teaching_plan.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +@Time : 2023-07-27 +@Author : mashenquan +@File : write_teaching_plan.py +@Desc: Write teaching plan demo + ``` + export PYTHONPATH=$PYTHONPATH:$PWD + python examples/write_teaching_plan.py --language=Chinese --teaching_language=English + + ``` +""" + +import asyncio +from pathlib import Path + +from metagpt.config import CONFIG + +import aiofiles +import fire +from metagpt.logs import logger +from metagpt.actions.write_teaching_plan import TeachingPlanRequirement +from metagpt.roles.teacher import Teacher +from metagpt.software_company import SoftwareCompany + + +async def startup(lesson_file: str, investment: float = 3.0, n_round: int = 1, *args, **kwargs): + """Run a startup. Be a teacher in education industry.""" + + demo_lesson = """ + UNIT 1 Making New Friends + TOPIC 1 Welcome to China! + Section A + + 1a Listen and number the following names. + Jane Mari Kangkang Michael + Look, listen and understand. Then practice the conversation. + Work in groups. Introduce yourself using + I ’m ... Then practice 1a + with your own hometown or the following places. + + 1b Listen and number the following names + Jane Michael Maria Kangkang + 1c Work in groups. Introduce yourself using I ’m ... Then practice 1a with your own hometown or the following places. + China the USA the UK Hong Kong Beijing + + 2a Look, listen and understand. Then practice the conversation + Hello! + Hello! + Hello! + Hello! Are you Maria? + No, I’m not. I’m Jane. + Oh, nice to meet you, Jane + Nice to meet you, too. + Hi, Maria! + Hi, Kangkang! + Welcome to China! + Thanks. + + 2b Work in groups. Make up a conversation with your own name and the + following structures. + A: Hello! / Good morning! / Hi! I’m ... Are you ... ? + B: ... + + 3a Listen, say and trace + Aa Bb Cc Dd Ee Ff Gg + + 3b Listen and number the following letters. Then circle the letters with the same sound as Bb. + Aa Bb Cc Dd Ee Ff Gg + + 3c Match the big letters with the small ones. Then write them on the lines. + """ + CONFIG.set_context(kwargs) + + lesson = "" + if lesson_file and Path(lesson_file).exists(): + async with aiofiles.open(lesson_file, mode="r", encoding="utf-8") as reader: + lesson = await reader.read() + logger.info(f"Course content: {lesson}") + if not lesson: + logger.info("No course content provided, using the demo course.") + lesson = demo_lesson + + company = SoftwareCompany() + company.hire([Teacher(*args, **kwargs)]) + company.invest(investment) + company.start_project(lesson, cause_by=TeachingPlanRequirement, role="Teacher", **kwargs) + await company.run(n_round=1) + + +def main(idea: str, investment: float = 3.0, n_round: int = 5, *args, **kwargs): + """ + We are a software startup comprised of AI. By investing in us, you are empowering a future filled with limitless possibilities. + :param idea: lesson filename. + :param investment: As an investor, you have the opportunity to contribute a certain dollar amount to this AI company. + :param n_round: Reserved. + :param args: Parameters passed in format: `python your_script.py arg1 arg2 arg3` + :param kwargs: Parameters passed in format: `python your_script.py --param1=value1 --param2=value2` + :return: + """ + asyncio.run(startup(idea, investment, n_round, *args, **kwargs)) + + +if __name__ == '__main__': + """ + Formats: + ``` + python write_teaching_plan.py lesson_filename --teaching_language= --language= + ``` + If `lesson_filename` is not available, a demo lesson content will be used. + """ + fire.Fire(main) diff --git a/metagpt/__init__.py b/metagpt/__init__.py index 71ddd1aff..aa1965e31 100644 --- a/metagpt/__init__.py +++ b/metagpt/__init__.py @@ -1,7 +1,22 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- +<<<<<<< HEAD # @Time : 2023/4/24 22:26 # @Author : alexanderwu # @File : __init__.py from metagpt import _compat as _ # noqa: F401 +======= +""" +@Time : 2023/4/24 22:26 +@Author : alexanderwu +@File : __init__.py +@Desc : mashenquan, 2023/8/22. Add `Message` for importing by external projects. +""" + +from metagpt.schema import Message + +__all__ = [ + "Message", +] +>>>>>>> send18/dev diff --git a/metagpt/actions/action.py b/metagpt/actions/action.py index dc96699a9..442004e09 100644 --- a/metagpt/actions/action.py +++ b/metagpt/actions/action.py @@ -4,22 +4,26 @@ @Time : 2023/5/11 14:43 @Author : alexanderwu @File : action.py +@Modified By: mashenquan, 2023/8/20. Add function return annotations. +@Modified By: mashenquan, 2023/9/8. Replace LLM with LLMFactory """ + import re +from __future__ import annotations from abc import ABC from typing import Optional - from tenacity import retry, stop_after_attempt, wait_random_exponential - from metagpt.actions.action_output import ActionOutput from metagpt.llm import LLM from metagpt.logs import logger from metagpt.utils.common import OutputParser from metagpt.utils.custom_decoder import CustomDecoder - +from metagpt.logs import logger +from metagpt.provider.base_gpt_api import BaseGPTAPI +from metagpt.utils.common import OutputParser class Action(ABC): - def __init__(self, name: str = "", context=None, llm: LLM = None): + def __init__(self, name: str = "", context=None, llm: BaseGPTAPI = None): self.name: str = name if llm is None: llm = LLM() @@ -88,6 +92,6 @@ class Action(ABC): instruct_content = output_class(**parsed_data) return ActionOutput(content, instruct_content) - async def run(self, *args, **kwargs): + async def run(self, *args, **kwargs) -> str | ActionOutput | None: """Run action""" raise NotImplementedError("The run method should be implemented in a subclass.") diff --git a/metagpt/actions/action_output.py b/metagpt/actions/action_output.py index 25326d43b..49c7dea2e 100644 --- a/metagpt/actions/action_output.py +++ b/metagpt/actions/action_output.py @@ -4,18 +4,19 @@ @Time : 2023/7/11 10:03 @Author : chengmaoyu @File : action_output +@Modified By: mashenquan, 2023/8/20. Allow 'instruct_content' to be blank. """ -from typing import Dict, Type +from typing import Dict, Type, Optional from pydantic import BaseModel, create_model, root_validator, validator class ActionOutput: content: str - instruct_content: BaseModel + instruct_content: Optional[BaseModel] = None - def __init__(self, content: str, instruct_content: BaseModel): + def __init__(self, content: str, instruct_content: BaseModel=None): self.content = content self.instruct_content = instruct_content diff --git a/metagpt/actions/azure_tts.py b/metagpt/actions/azure_tts.py deleted file mode 100644 index daa3f6892..000000000 --- a/metagpt/actions/azure_tts.py +++ /dev/null @@ -1,45 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- -""" -@Time : 2023/6/9 22:22 -@Author : Leo Xiao -@File : azure_tts.py -""" -from azure.cognitiveservices.speech import AudioConfig, SpeechConfig, SpeechSynthesizer - -from metagpt.actions.action import Action -from metagpt.config import Config - - -class AzureTTS(Action): - def __init__(self, name, context=None, llm=None): - super().__init__(name, context, llm) - self.config = Config() - - # Parameters reference: https://learn.microsoft.com/zh-cn/azure/cognitive-services/speech-service/language-support?tabs=tts#voice-styles-and-roles - def synthesize_speech(self, lang, voice, role, text, output_file): - subscription_key = self.config.get("AZURE_TTS_SUBSCRIPTION_KEY") - region = self.config.get("AZURE_TTS_REGION") - speech_config = SpeechConfig(subscription=subscription_key, region=region) - - speech_config.speech_synthesis_voice_name = voice - audio_config = AudioConfig(filename=output_file) - synthesizer = SpeechSynthesizer(speech_config=speech_config, audio_config=audio_config) - - # if voice=="zh-CN-YunxiNeural": - ssml_string = f""" - - - - {text} - - - - """ - - synthesizer.speak_ssml_async(ssml_string).get() - - -if __name__ == "__main__": - azure_tts = AzureTTS("azure_tts") - azure_tts.synthesize_speech("zh-CN", "zh-CN-YunxiNeural", "Boy", "Hello, I am Kaka", "output.wav") diff --git a/metagpt/actions/design_api.py b/metagpt/actions/design_api.py index 557ebcbbd..bccbc1261 100644 --- a/metagpt/actions/design_api.py +++ b/metagpt/actions/design_api.py @@ -4,6 +4,7 @@ @Time : 2023/5/11 19:26 @Author : alexanderwu @File : design_api.py +<<<<<<< HEAD @Modified By: mashenquan, 2023/11/27. 1. According to Section 2.2.3.1 of RFC 135, replace file data in the message with the file name. 2. According to the design in Section 2.2.3.5.3 of RFC 135, add incremental iteration functionality. @@ -22,6 +23,16 @@ from metagpt.const import ( SYSTEM_DESIGN_FILE_REPO, SYSTEM_DESIGN_PDF_FILE_REPO, ) +======= +@Modified By: mashenquan, 2023-8-9, align `run` parameters with the parent :class:`Action` class. +""" +from typing import List + +import aiofiles + +from metagpt.actions import Action +from metagpt.config import CONFIG +>>>>>>> send18/dev from metagpt.logs import logger from metagpt.schema import Document, Documents from metagpt.utils.file_repository import FileRepository @@ -197,6 +208,7 @@ class WriteDesign(Action): "clearly and in detail." ) +<<<<<<< HEAD async def run(self, with_messages, format=CONFIG.prompt_format): # Use `git diff` to identify which PRD documents have been modified in the `docs/prds` directory. prds_file_repo = CONFIG.git_repo.new_file_repository(PRDS_FILE_REPO) @@ -232,6 +244,30 @@ class WriteDesign(Action): format_example = format_example.format(project_name=CONFIG.project_name) prompt = prompt_template.format(context=context, format_example=format_example) system_design = await self._aask_v1(prompt, "system_design", OUTPUT_MAPPING, format=format) +======= + async def _save_system_design(self, docs_path, resources_path, content): + data_api_design = CodeParser.parse_code(block="Data structures and interface definitions", text=content) + seq_flow = CodeParser.parse_code(block="Program call flow", text=content) + await mermaid_to_file(data_api_design, resources_path / "data_api_design") + await mermaid_to_file(seq_flow, resources_path / "seq_flow") + system_design_file = docs_path / "system_design.md" + logger.info(f"Saving System Designs to {system_design_file}") + async with aiofiles.open(system_design_file, "w") as f: + await f.write(content) + + async def _save(self, system_design: str): + workspace = CONFIG.workspace + docs_path = workspace / "docs" + resources_path = workspace / "resources" + docs_path.mkdir(parents=True, exist_ok=True) + resources_path.mkdir(parents=True, exist_ok=True) + await self._save_system_design(docs_path, resources_path, system_design) + + async def run(self, context, **kwargs): + prompt = PROMPT_TEMPLATE.format(context=context, format_example=FORMAT_EXAMPLE) + system_design = await self._aask_v1(prompt, "system_design", OUTPUT_MAPPING) + await self._save(system_design.content) +>>>>>>> send18/dev return system_design async def _merge(self, prd_doc, system_design_doc, format=CONFIG.prompt_format): diff --git a/metagpt/actions/project_management.py b/metagpt/actions/project_management.py index 95da0d65a..53ef872e2 100644 --- a/metagpt/actions/project_management.py +++ b/metagpt/actions/project_management.py @@ -4,14 +4,19 @@ @Time : 2023/5/11 19:12 @Author : alexanderwu @File : project_management.py +<<<<<<< HEAD @Modified By: mashenquan, 2023/11/27. 1. Divide the context into three components: legacy code, unit test code, and console log. 2. Move the document storage operations related to WritePRD from the save operation of WriteDesign. 3. According to the design in Section 2.2.3.5.4 of RFC 135, add incremental iteration functionality. +======= +@Modified By: mashenquan, 2023-8-9, align `run` parameters with the parent :class:`Action` class. +>>>>>>> send18/dev """ import json from typing import List +<<<<<<< HEAD from metagpt.actions import ActionOutput from metagpt.actions.action import Action from metagpt.config import CONFIG @@ -86,6 +91,14 @@ and only output the json inside this tag, nothing else }, "markdown": { "PROMPT_TEMPLATE": """ +======= +import aiofiles + +from metagpt.actions.action import Action +from metagpt.config import CONFIG + +PROMPT_TEMPLATE = """ +>>>>>>> send18/dev # Context {context} @@ -108,7 +121,11 @@ Attention: Use '##' to split sections, not '#', and '## ' SHOULD W ## Shared Knowledge: Anything that should be public like utils' functions, config's variables details that should make clear first. +<<<<<<< HEAD ## Anything UNCLEAR: Provide as Plain text. Try to clarify it. For example, don't forget a main entry. don't forget to init 3rd party libs. +======= +""" +>>>>>>> send18/dev """, "FORMAT_EXAMPLE": ''' @@ -180,6 +197,7 @@ MERGE_PROMPT = """ # Context {context} +<<<<<<< HEAD ## Old Tasks {old_tasks} ----- @@ -210,10 +228,13 @@ and only output the json inside this tag, nothing else """ +======= +>>>>>>> send18/dev class WriteTasks(Action): def __init__(self, name="CreateTasks", context=None, llm=None): super().__init__(name, context, llm) +<<<<<<< HEAD async def run(self, with_messages, format=CONFIG.prompt_format): system_design_file_repo = CONFIG.git_repo.new_file_repository(SYSTEM_DESIGN_FILE_REPO) changed_system_designs = system_design_file_repo.changed_files @@ -265,6 +286,23 @@ class WriteTasks(Action): prompt_template, format_example = get_template(templates, format) prompt = prompt_template.format(context=context, format_example=format_example) rsp = await self._aask_v1(prompt, "task", OUTPUT_MAPPING, format=format) +======= + async def _save(self, rsp): + file_path = CONFIG.workspace / "docs/api_spec_and_tasks.md" + async with aiofiles.open(file_path, "w") as f: + await f.write(rsp.content) + + # Write requirements.txt + requirements_path = CONFIG.workspace / "requirements.txt" + + async with aiofiles.open(requirements_path, "w") as f: + await f.write(rsp.instruct_content.dict().get("Required Python third-party packages").strip('"\n')) + + async def run(self, context, **kwargs): + prompt = PROMPT_TEMPLATE.format(context=context, format_example=FORMAT_EXAMPLE) + rsp = await self._aask_v1(prompt, "task", OUTPUT_MAPPING) + await self._save(rsp) +>>>>>>> send18/dev return rsp async def _merge(self, system_design_doc, task_doc, format=CONFIG.prompt_format) -> Document: diff --git a/metagpt/actions/search_and_summarize.py b/metagpt/actions/search_and_summarize.py index 5e4cdaea0..5c7577e17 100644 --- a/metagpt/actions/search_and_summarize.py +++ b/metagpt/actions/search_and_summarize.py @@ -4,11 +4,12 @@ @Time : 2023/5/23 17:26 @Author : alexanderwu @File : search_google.py +@Modified By: mashenquan, 2023/8/20. Remove global configuration `CONFIG`, enable configuration support for business isolation. """ import pydantic from metagpt.actions import Action -from metagpt.config import Config +from metagpt.config import CONFIG from metagpt.logs import logger from metagpt.schema import Message from metagpt.tools.search_engine import SearchEngine @@ -102,8 +103,7 @@ You are a member of a professional butler team and will provide helpful suggesti class SearchAndSummarize(Action): def __init__(self, name="", context=None, llm=None, engine=None, search_func=None): - self.config = Config() - self.engine = engine or self.config.search_engine + self.engine = engine or CONFIG.search_engine try: self.search_engine = SearchEngine(self.engine, run_func=search_func) diff --git a/metagpt/actions/skill_action.py b/metagpt/actions/skill_action.py new file mode 100644 index 000000000..f629cfcbf --- /dev/null +++ b/metagpt/actions/skill_action.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +@Time : 2023/8/28 +@Author : mashenquan +@File : skill_action.py +@Desc : Call learned skill +""" +from __future__ import annotations + +import ast +import importlib +import traceback +from copy import deepcopy + +from metagpt.actions import Action, ActionOutput +from metagpt.learn.skill_loader import Skill +from metagpt.logs import logger + + +class ArgumentsParingAction(Action): + def __init__(self, last_talk: str, skill: Skill, context=None, llm=None, **kwargs): + super(ArgumentsParingAction, self).__init__(name="", context=context, llm=llm) + self.skill = skill + self.ask = last_talk + self.rsp = None + self.args = None + + @property + def prompt(self): + prompt = f"{self.skill.name} function parameters description:\n" + for k, v in self.skill.arguments.items(): + prompt += f"parameter `{k}`: {v}\n" + prompt += "\n" + prompt += "Examples:\n" + for e in self.skill.examples: + prompt += f"If want you to do `{e.ask}`, return `{e.answer}` brief and clear.\n" + prompt += f"\nNow I want you to do `{self.ask}`, return in examples format above, brief and clear." + return prompt + + async def run(self, *args, **kwargs) -> ActionOutput: + prompt = self.prompt + rsp = await self.llm.aask(msg=prompt, system_msgs=[]) + logger.debug(f"SKILL:{prompt}\n, RESULT:{rsp}") + self.args = ArgumentsParingAction.parse_arguments(skill_name=self.skill.name, txt=rsp) + self.rsp = ActionOutput(content=rsp) + return self.rsp + + @staticmethod + def parse_arguments(skill_name, txt) -> dict: + prefix = skill_name + "(" + if prefix not in txt: + logger.error(f"{skill_name} not in {txt}") + return None + if ")" not in txt: + logger.error(f"')' not in {txt}") + return None + begin_ix = txt.find(prefix) + end_ix = txt.rfind(")") + args_txt = txt[begin_ix + len(prefix) : end_ix] + logger.info(args_txt) + fake_expression = f"dict({args_txt})" + parsed_expression = ast.parse(fake_expression, mode="eval") + args = {} + for keyword in parsed_expression.body.keywords: + key = keyword.arg + value = ast.literal_eval(keyword.value) + args[key] = value + return args + + +class SkillAction(Action): + def __init__(self, skill: Skill, args: dict, context=None, llm=None, **kwargs): + super(SkillAction, self).__init__(name="", context=context, llm=llm) + self._skill = skill + self._args = args + self.rsp = None + + async def run(self, *args, **kwargs) -> str | ActionOutput | None: + """Run action""" + options = deepcopy(kwargs) + if self._args: + for k in self._args.keys(): + if k in options: + options.pop(k) + try: + self.rsp = await self.find_and_call_function(self._skill.name, args=self._args, **options) + except Exception as e: + logger.exception(f"{e}, traceback:{traceback.format_exc()}") + self.rsp = f"Error: {e}" + return ActionOutput(content=self.rsp, instruct_content=self._skill.json()) + + @staticmethod + async def find_and_call_function(function_name, args, **kwargs): + try: + module = importlib.import_module("metagpt.learn") + function = getattr(module, function_name) + # 调用函数并返回结果 + result = await function(**args, **kwargs) + return result + except (ModuleNotFoundError, AttributeError): + logger.error(f"{function_name} not found") + return None + + +if __name__ == "__main__": + ArgumentsParingAction.parse_arguments( + skill_name="text_to_image", txt='`text_to_image(text="Draw an apple", size_type="512x512")`' + ) diff --git a/metagpt/actions/talk_action.py b/metagpt/actions/talk_action.py new file mode 100644 index 000000000..3695ec5bb --- /dev/null +++ b/metagpt/actions/talk_action.py @@ -0,0 +1,169 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +@Time : 2023/8/28 +@Author : mashenquan +@File : talk_action.py +@Desc : Act as it’s a talk +""" +import json + +from metagpt.actions import Action, ActionOutput +from metagpt.config import CONFIG +from metagpt.const import DEFAULT_LANGUAGE +from metagpt.llm import LLMType +from metagpt.logs import logger + + +class TalkAction(Action): + def __init__(self, name: str = "", talk="", history_summary="", knowledge="", context=None, llm=None, **kwargs): + context = context or {} + context["talk"] = talk + context["history_summery"] = history_summary + context["knowledge"] = knowledge + super(TalkAction, self).__init__(name=name, context=context, llm=llm) + self._talk = talk + self._history_summary = history_summary + self._knowledge = knowledge + self._rsp = None + + @property + def prompt(self): + prompt = "" + if CONFIG.agent_description: + prompt = ( + f"You are {CONFIG.agent_description}. Your responses should align with the role-play agreement, " + f"maintaining the character's persona and habits. When faced with unrelated questions, playfully " + f"decline to answer without revealing your AI nature to preserve the character's image.\n\n" + ) + prompt += f"Knowledge:\n{self._knowledge}\n\n" if self._knowledge else "" + prompt += f"{self._history_summary}\n\n" + prompt += ( + "If the information is insufficient, you can search in the historical conversation or knowledge above.\n" + ) + language = CONFIG.language or DEFAULT_LANGUAGE + prompt += ( + f"Answer the following questions strictly in {language}, and the answers must follow the Markdown format.\n " + f"{self._talk}" + ) + logger.debug(f"PROMPT: {prompt}") + return prompt + + @property + def prompt_gpt4(self): + kvs = { + "{role}": CONFIG.agent_description or "", + "{history}": self._history_summary or "", + "{knowledge}": self._knowledge or "", + "{language}": CONFIG.language or DEFAULT_LANGUAGE, + "{ask}": self._talk, + } + prompt = TalkAction.__FORMATION_LOOSE__ + for k, v in kvs.items(): + prompt = prompt.replace(k, v) + logger.info(f"PROMPT: {prompt}") + return prompt + + async def run_old(self, *args, **kwargs) -> ActionOutput: + prompt = self.prompt + rsp = await self.llm.aask(msg=prompt, system_msgs=[]) + logger.debug(f"PROMPT:{prompt}\nRESULT:{rsp}\n") + self._rsp = ActionOutput(content=rsp) + return self._rsp + + @property + def aask_args(self): + language = CONFIG.language or DEFAULT_LANGUAGE + system_msgs = [ + f"You are {CONFIG.agent_description}.", + "Your responses should align with the role-play agreement, " + "maintaining the character's persona and habits. When faced with unrelated questions, playfully " + "decline to answer without revealing your AI nature to preserve the character's image.", + "If the information is insufficient, you can search in the context or knowledge.", + f"Answer the following questions strictly in {language}, and the answers must follow the Markdown format.", + ] + format_msgs = [] + if self._knowledge: + format_msgs.append({"role": "assistant", "content": self._knowledge}) + if self._history_summary: + if CONFIG.LLM_TYPE == LLMType.METAGPT.value: + format_msgs.extend(json.loads(self._history_summary)) + else: + format_msgs.append({"role": "assistant", "content": self._history_summary}) + return self._talk, format_msgs, system_msgs + + async def run(self, *args, **kwargs) -> ActionOutput: + msg, format_msgs, system_msgs = self.aask_args + rsp = await self.llm.aask(msg=msg, format_msgs=format_msgs, system_msgs=system_msgs) + self._rsp = ActionOutput(content=rsp) + return self._rsp + + __FORMATION__ = """Formation: "Capacity and role" defines the role you are currently playing; + "[HISTORY_BEGIN]" and "[HISTORY_END]" tags enclose the historical conversation; + "[KNOWLEDGE_BEGIN]" and "[KNOWLEDGE_END]" tags enclose the knowledge may help for your responses; + "Statement" defines the work detail you need to complete at this stage; + "[ASK_BEGIN]" and [ASK_END] tags enclose the questions; + "Constraint" defines the conditions that your responses must comply with. + "Personality" defines your language style。 + "Insight" provides a deeper understanding of the characters' inner traits. + "Initial" defines the initial setup of a character. + +Capacity and role: {role} +Statement: Your responses should align with the role-play agreement, maintaining the + character's persona and habits. When faced with unrelated questions, playfully decline to answer without revealing + your AI nature to preserve the character's image. + +[HISTORY_BEGIN] + +{history} + +[HISTORY_END] + +[KNOWLEDGE_BEGIN] + +{knowledge} + +[KNOWLEDGE_END] + +Statement: If the information is insufficient, you can search in the historical conversation or knowledge. +Statement: Unless you are a language professional, answer the following questions strictly in {language} +, and the answers must follow the Markdown format. Strictly excluding any tag likes "[HISTORY_BEGIN]" +, "[HISTORY_END]", "[KNOWLEDGE_BEGIN]", "[KNOWLEDGE_END]" in responses. + + +{ask} +""" + + __FORMATION_LOOSE__ = """Formation: "Capacity and role" defines the role you are currently playing; + "[HISTORY_BEGIN]" and "[HISTORY_END]" tags enclose the historical conversation; + "[KNOWLEDGE_BEGIN]" and "[KNOWLEDGE_END]" tags enclose the knowledge may help for your responses; + "Statement" defines the work detail you need to complete at this stage; + "Constraint" defines the conditions that your responses must comply with. + "Personality" defines your language style。 + "Insight" provides a deeper understanding of the characters' inner traits. + "Initial" defines the initial setup of a character. + +Capacity and role: {role} +Statement: Your responses should maintaining the character's persona and habits. When faced with unrelated questions +, playfully decline to answer without revealing your AI nature to preserve the character's image. + +[HISTORY_BEGIN] + +{history} + +[HISTORY_END] + +[KNOWLEDGE_BEGIN] + +{knowledge} + +[KNOWLEDGE_END] + +Statement: If the information is insufficient, you can search in the historical conversation or knowledge. +Statement: Unless you are a language professional, answer the following questions strictly in {language} +, and the answers must follow the Markdown format. Strictly excluding any tag likes "[HISTORY_BEGIN]" +, "[HISTORY_END]", "[KNOWLEDGE_BEGIN]", "[KNOWLEDGE_END]" in responses. + + +{ask} +""" diff --git a/metagpt/actions/write_code.py b/metagpt/actions/write_code.py index 1dda6466f..b61e3886c 100644 --- a/metagpt/actions/write_code.py +++ b/metagpt/actions/write_code.py @@ -14,6 +14,7 @@ 3. Encapsulate the input of RunCode into RunCodeContext and encapsulate the output of RunCode into RunCodeResult to standardize and unify parameter passing between WriteCode, RunCode, and DebugError. """ +<<<<<<< HEAD import json from tenacity import retry, stop_after_attempt, wait_random_exponential @@ -22,10 +23,18 @@ from metagpt.actions.action import Action from metagpt.config import CONFIG from metagpt.const import CODE_SUMMARIES_FILE_REPO, TEST_OUTPUTS_FILE_REPO, TASK_FILE_REPO, BUGFIX_FILENAME, \ DOCS_FILE_REPO +======= +from tenacity import retry, stop_after_attempt, wait_fixed + +from metagpt.actions.action import Action +>>>>>>> send18/dev from metagpt.logs import logger from metagpt.schema import CodingContext, Document, RunCodeResult from metagpt.utils.common import CodeParser +<<<<<<< HEAD from metagpt.utils.file_repository import FileRepository +======= +>>>>>>> send18/dev PROMPT_TEMPLATE = """ NOTICE @@ -89,12 +98,21 @@ class WriteCode(Action): def __init__(self, name="WriteCode", context=None, llm=None): super().__init__(name, context, llm) +<<<<<<< HEAD @retry(wait=wait_random_exponential(min=1, max=60), stop=stop_after_attempt(6)) async def write_code(self, prompt) -> str: +======= + def _is_invalid(self, filename): + return any(i in filename for i in ["mp3", "wav"]) + + @retry(stop=stop_after_attempt(2), wait=wait_fixed(1)) + async def write_code(self, prompt): +>>>>>>> send18/dev code_rsp = await self._aask(prompt) code = CodeParser.parse_code(block="", text=code_rsp) return code +<<<<<<< HEAD async def run(self, *args, **kwargs) -> CodingContext: bug_feedback = await FileRepository.get_file(filename=BUGFIX_FILENAME, relative_path=DOCS_FILE_REPO) coding_context = CodingContext.loads(self.context.content) @@ -121,6 +139,11 @@ class WriteCode(Action): summary_log=summary_doc.content if summary_doc else "", ) logger.info(f"Writing {coding_context.filename}..") +======= + async def run(self, context, filename): + prompt = PROMPT_TEMPLATE.format(context=context, filename=filename) + logger.info(f"Writing {filename}..") +>>>>>>> send18/dev code = await self.write_code(prompt) if not coding_context.code_doc: coding_context.code_doc = Document(filename=coding_context.filename, root_path=CONFIG.src_workspace) diff --git a/metagpt/actions/write_prd.py b/metagpt/actions/write_prd.py index aad2422ef..d8042b3ed 100644 --- a/metagpt/actions/write_prd.py +++ b/metagpt/actions/write_prd.py @@ -16,10 +16,13 @@ import json from pathlib import Path from typing import List +import aiofiles + from metagpt.actions import Action, ActionOutput from metagpt.actions.fix_bug import FixBug from metagpt.actions.search_and_summarize import SearchAndSummarize from metagpt.config import CONFIG +<<<<<<< HEAD from metagpt.const import ( COMPETITIVE_ANALYSIS_FILE_REPO, DOCS_FILE_REPO, @@ -52,6 +55,11 @@ Requirements: According to the context, fill in the following missing informatio ATTENTION: Output carefully referenced "Format example" in format. ## YOU NEED TO FULFILL THE BELOW JSON DOC +======= +from metagpt.logs import logger +from metagpt.utils.common import CodeParser +from metagpt.utils.mermaid import mermaid_to_file +>>>>>>> send18/dev {{ "Language": "", # str, use the same language as the user requirement. en_us / zh_cn etc. @@ -237,7 +245,11 @@ OUTPUT_MAPPING = { "Competitive Analysis": (List[str], ...), "Competitive Quadrant Chart": (str, ...), "Requirement Analysis": (str, ...), +<<<<<<< HEAD "Requirement Pool": (List[List[str]], ...), +======= + "Requirement Pool": (List[Tuple[str, str]], ...), +>>>>>>> send18/dev "UI Design draft": (str, ...), "Anything UNCLEAR": (str, ...), } @@ -376,6 +388,7 @@ class WritePRD(Action): logger.info(sas.result) logger.info(rsp) +<<<<<<< HEAD # logger.info(format) prompt_template, format_example = get_template(templates, format) project_name = CONFIG.project_name if CONFIG.project_name else "" @@ -467,3 +480,33 @@ class WritePRD(Action): if "YES" in res: return True return False +======= + prompt = PROMPT_TEMPLATE.format( + requirements=requirements, search_information=info, format_example=FORMAT_EXAMPLE + ) + logger.debug(prompt) + prd = await self._aask_v1(prompt, "prd", OUTPUT_MAPPING) + + await self._save(prd.content) + return prd + + async def _save_prd(self, docs_path, resources_path, prd): + prd_file = docs_path / "prd.md" + quadrant_chart = CodeParser.parse_code(block="Competitive Quadrant Chart", text=prd) + await mermaid_to_file( + mermaid_code=quadrant_chart, output_file_without_suffix=resources_path / "competitive_analysis" + ) + async with aiofiles.open(prd_file, "w") as f: + await f.write(prd) + logger.info(f"Saving PRD to {prd_file}") + + async def _save(self, prd): + workspace = CONFIG.workspace + workspace.mkdir(parents=True, exist_ok=True) + + docs_path = workspace / "docs" + resources_path = workspace / "resources" + docs_path.mkdir(parents=True, exist_ok=True) + resources_path.mkdir(parents=True, exist_ok=True) + await self._save_prd(docs_path, resources_path, prd) +>>>>>>> send18/dev diff --git a/metagpt/actions/write_teaching_plan.py b/metagpt/actions/write_teaching_plan.py new file mode 100644 index 000000000..7c959ce85 --- /dev/null +++ b/metagpt/actions/write_teaching_plan.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +@Time : 2023/7/27 +@Author : mashenquan +@File : write_teaching_plan.py +""" +from metagpt.logs import logger +from metagpt.actions import Action +from metagpt.schema import Message + + +class TeachingPlanRequirement(Action): + """Teaching Plan Requirement without any implementation details""" + + async def run(self, *args, **kwargs): + raise NotImplementedError + + +class WriteTeachingPlanPart(Action): + """Write Teaching Plan Part""" + + def __init__(self, name: str = "", context=None, llm=None, topic: str = "", language: str = "Chinese"): + """ + + :param name: action name + :param context: context + :param llm: object of :class:`LLM` + :param topic: topic part of teaching plan + :param language: A human language, such as Chinese, English, French, etc. + """ + super().__init__(name, context, llm) + self.topic = topic + self.language = language + self.rsp = None + + async def run(self, messages, *args, **kwargs): + if len(messages) < 1 or not isinstance(messages[0], Message): + raise ValueError("Invalid args, a tuple of List[Message] is expected") + + statement_patterns = self.TOPIC_STATEMENTS.get(self.topic, []) + statements = [] + from metagpt.roles import Role + for p in statement_patterns: + s = Role.format_value(p) + statements.append(s) + formatter = self.PROMPT_TITLE_TEMPLATE if self.topic == self.COURSE_TITLE else self.PROMPT_TEMPLATE + prompt = formatter.format(formation=self.FORMATION, + role=self.prefix, + statements="\n".join(statements), + lesson=messages[0].content, + topic=self.topic, + language=self.language) + + logger.debug(prompt) + rsp = await self._aask(prompt=prompt) + logger.debug(rsp) + self._set_result(rsp) + return self.rsp + + def _set_result(self, rsp): + if self.DATA_BEGIN_TAG in rsp: + ix = rsp.index(self.DATA_BEGIN_TAG) + rsp = rsp[ix + len(self.DATA_BEGIN_TAG):] + if self.DATA_END_TAG in rsp: + ix = rsp.index(self.DATA_END_TAG) + rsp = rsp[0:ix] + self.rsp = rsp.strip() + if self.topic != self.COURSE_TITLE: + return + if '#' not in self.rsp or self.rsp.index('#') != 0: + self.rsp = "# " + self.rsp + + def __str__(self): + """Return `topic` value when str()""" + return self.topic + + def __repr__(self): + """Show `topic` value when debug""" + return self.topic + + FORMATION = "\"Capacity and role\" defines the role you are currently playing;\n" \ + "\t\"[LESSON_BEGIN]\" and \"[LESSON_END]\" tags enclose the content of textbook;\n" \ + "\t\"Statement\" defines the work detail you need to complete at this stage;\n" \ + "\t\"Answer options\" defines the format requirements for your responses;\n" \ + "\t\"Constraint\" defines the conditions that your responses must comply with." + + COURSE_TITLE = "Title" + TOPICS = [ + COURSE_TITLE, "Teaching Hours", "Teaching Objectives", "Teaching Content", + "Teaching Methods and Strategies", "Learning Activities", + "Teaching Time Allocation", "Assessment and Feedback", "Teaching Summary and Improvement", + "Vocabulary Cloze", "Choice Questions", "Grammar Questions", "Translation Questions" + ] + + TOPIC_STATEMENTS = { + COURSE_TITLE: ["Statement: Find and return the title of the lesson only in markdown first-level header format, " + "without anything else."], + "Teaching Content": [ + "Statement: \"Teaching Content\" must include vocabulary, analysis, and examples of various grammar " + "structures that appear in the textbook, as well as the listening materials and key points.", + "Statement: \"Teaching Content\" must include more examples."], + "Teaching Time Allocation": [ + "Statement: \"Teaching Time Allocation\" must include how much time is allocated to each " + "part of the textbook content."], + "Teaching Methods and Strategies": [ + "Statement: \"Teaching Methods and Strategies\" must include teaching focus, difficulties, materials, " + "procedures, in detail." + ], + "Vocabulary Cloze": [ + "Statement: Based on the content of the textbook enclosed by \"[LESSON_BEGIN]\" and \"[LESSON_END]\", " + "create vocabulary cloze. The cloze should include 10 {language} questions with {teaching_language} " + "answers, and it should also include 10 {teaching_language} questions with {language} answers. " + "The key-related vocabulary and phrases in the textbook content must all be included in the exercises.", + ], + "Grammar Questions": [ + "Statement: Based on the content of the textbook enclosed by \"[LESSON_BEGIN]\" and \"[LESSON_END]\", " + "create grammar questions. 10 questions."], + "Choice Questions": [ + "Statement: Based on the content of the textbook enclosed by \"[LESSON_BEGIN]\" and \"[LESSON_END]\", " + "create choice questions. 10 questions."], + "Translation Questions": [ + "Statement: Based on the content of the textbook enclosed by \"[LESSON_BEGIN]\" and \"[LESSON_END]\", " + "create translation questions. The translation should include 10 {language} questions with " + "{teaching_language} answers, and it should also include 10 {teaching_language} questions with " + "{language} answers." + ] + } + + # Teaching plan title + PROMPT_TITLE_TEMPLATE = "Do not refer to the context of the previous conversation records, " \ + "start the conversation anew.\n\n" \ + "Formation: {formation}\n\n" \ + "{statements}\n" \ + "Constraint: Writing in {language}.\n" \ + "Answer options: Encloses the lesson title with \"[TEACHING_PLAN_BEGIN]\" " \ + "and \"[TEACHING_PLAN_END]\" tags.\n" \ + "[LESSON_BEGIN]\n" \ + "{lesson}\n" \ + "[LESSON_END]" + + # Teaching plan parts: + PROMPT_TEMPLATE = "Do not refer to the context of the previous conversation records, " \ + "start the conversation anew.\n\n" \ + "Formation: {formation}\n\n" \ + "Capacity and role: {role}\n" \ + "Statement: Write the \"{topic}\" part of teaching plan, " \ + "WITHOUT ANY content unrelated to \"{topic}\"!!\n" \ + "{statements}\n" \ + "Answer options: Enclose the teaching plan content with \"[TEACHING_PLAN_BEGIN]\" " \ + "and \"[TEACHING_PLAN_END]\" tags.\n" \ + "Answer options: Using proper markdown format from second-level header format.\n" \ + "Constraint: Writing in {language}.\n" \ + "[LESSON_BEGIN]\n" \ + "{lesson}\n" \ + "[LESSON_END]" + + DATA_BEGIN_TAG = "[TEACHING_PLAN_BEGIN]" + DATA_END_TAG = "[TEACHING_PLAN_END]" diff --git a/metagpt/config.py b/metagpt/config.py index aabd54c4b..d3123b1f7 100644 --- a/metagpt/config.py +++ b/metagpt/config.py @@ -7,17 +7,17 @@ Provide configuration, singleton 2. Add the parameter `src_workspace` for the old version project path. """ import datetime +import json import os from copy import deepcopy from pathlib import Path from typing import Any from uuid import uuid4 - import yaml - from metagpt.const import DEFAULT_WORKSPACE_ROOT, METAGPT_ROOT, OPTIONS from metagpt.logs import logger from metagpt.tools import SearchEngineType, WebBrowserEngineType +from metagpt.utils.cost_manager import CostManager from metagpt.utils.singleton import Singleton @@ -46,13 +46,14 @@ class Config(metaclass=Singleton): key_yaml_file = METAGPT_ROOT / "config/key.yaml" default_yaml_file = METAGPT_ROOT / "config/config.yaml" - def __init__(self, yaml_file=default_yaml_file): + def __init__(self, yaml_file=default_yaml_file, cost_data=""): self._init_with_config_files_and_env(yaml_file) + # The agent needs to be billed per user, so billing information cannot be destroyed when the session ends. + self.cost_manager = CostManager(**json.loads(cost_data)) if cost_data else CostManager() logger.info("Config loading done.") self._update() def _update(self): - # logger.info("Config loading done.") self.global_proxy = self._get("GLOBAL_PROXY") self.openai_api_key = self._get("OPENAI_API_KEY") self.anthropic_api_key = self._get("Anthropic_API_KEY") @@ -96,8 +97,7 @@ class Config(metaclass=Singleton): self.long_term_memory = self._get("LONG_TERM_MEMORY", False) if self.long_term_memory: logger.warning("LONG_TERM_MEMORY is True") - self.max_budget = self._get("MAX_BUDGET", 10.0) - self.total_cost = 0.0 + self.cost_manager.max_budget = self._get("MAX_BUDGET", 10.0) self.code_review_k_times = 2 self.puppeteer_config = self._get("PUPPETEER_CONFIG", "") @@ -145,7 +145,8 @@ class Config(metaclass=Singleton): return m.get(*args, **kwargs) def get(self, key, *args, **kwargs): - """Search for a value in config/key.yaml, config/config.yaml, and env; raise an error if not found""" + """Retrieve values from config/key.yaml, config/config.yaml, and environment variables. + Throw an error if not found.""" value = self._get(key, *args, **kwargs) if value is None: raise ValueError(f"Key '{key}' not found in environment variables or in the YAML file") diff --git a/metagpt/const.py b/metagpt/const.py index f6f64a27d..c2b6c308d 100644 --- a/metagpt/const.py +++ b/metagpt/const.py @@ -12,9 +12,7 @@ import contextvars import os from pathlib import Path - from loguru import logger - import metagpt OPTIONS = contextvars.ContextVar("OPTIONS") @@ -42,7 +40,7 @@ def get_metagpt_root(): # METAGPT PROJECT ROOT AND VARS -METAGPT_ROOT = get_metagpt_root() +METAGPT_ROOT = get_metagpt_root() # Dependent on METAGPT_PROJECT_ROOT DEFAULT_WORKSPACE_ROOT = METAGPT_ROOT / "workspace" DATA_PATH = METAGPT_ROOT / "data" @@ -93,3 +91,18 @@ CODE_SUMMARIES_FILE_REPO = "docs/code_summaries" CODE_SUMMARIES_PDF_FILE_REPO = "resources/code_summaries" YAPI_URL = "http://yapi.deepwisdomai.com/" + +DEFAULT_LANGUAGE = "English" +DEFAULT_MAX_TOKENS = 1500 +COMMAND_TOKENS = 500 +BRAIN_MEMORY = "BRAIN_MEMORY" +SKILL_PATH = "SKILL_PATH" +SERPER_API_KEY = "SERPER_API_KEY" + + +# format +BASE64_FORMAT = "base64" + +# REDIS +REDIS_KEY = "REDIS_KEY" + diff --git a/metagpt/document_store/faiss_store.py b/metagpt/document_store/faiss_store.py index b1faa3538..65685dffa 100644 --- a/metagpt/document_store/faiss_store.py +++ b/metagpt/document_store/faiss_store.py @@ -21,10 +21,18 @@ from metagpt.logs import logger class FaissStore(LocalStore): +<<<<<<< HEAD def __init__(self, raw_data_path: Path, cache_dir=None, meta_col="source", content_col="output"): self.meta_col = meta_col self.content_col = content_col super().__init__(raw_data_path, cache_dir) +======= + def __init__(self, raw_data: Path, cache_dir=None, meta_col="source", content_col="output", embedding_conf=None): + self.meta_col = meta_col + self.content_col = content_col + self.embedding_conf = embedding_conf or {} + super().__init__(raw_data, cache_dir) +>>>>>>> send18/dev def _load(self) -> Optional["FaissStore"]: index_file, store_file = self._get_index_and_store_fname() @@ -38,7 +46,7 @@ class FaissStore(LocalStore): return store def _write(self, docs, metadatas): - store = FAISS.from_texts(docs, OpenAIEmbeddings(openai_api_version="2020-11-07"), metadatas=metadatas) + store = FAISS.from_texts(docs, OpenAIEmbeddings(openai_api_version="2020-11-07", **self.embedding_conf), metadatas=metadatas) return store def persist(self): @@ -84,6 +92,12 @@ class FaissStore(LocalStore): if __name__ == "__main__": faiss_store = FaissStore(DATA_PATH / "qcs/qcs_4w.json") +<<<<<<< HEAD logger.info(faiss_store.search("Oily Skin Facial Cleanser")) faiss_store.add([f"Oily Skin Facial Cleanser-{i}" for i in range(3)]) logger.info(faiss_store.search("Oily Skin Facial Cleanser")) +======= + logger.info(faiss_store.search("油皮洗面奶")) + faiss_store.add([f"油皮洗面奶-{i}" for i in range(3)]) + logger.info(faiss_store.search("油皮洗面奶")) +>>>>>>> send18/dev diff --git a/metagpt/learn/__init__.py b/metagpt/learn/__init__.py index 28b8739c3..bab9f3e37 100644 --- a/metagpt/learn/__init__.py +++ b/metagpt/learn/__init__.py @@ -5,3 +5,9 @@ @Author : alexanderwu @File : __init__.py """ + +from metagpt.learn.text_to_image import text_to_image +from metagpt.learn.text_to_speech import text_to_speech +from metagpt.learn.google_search import google_search + +__all__ = ["text_to_image", "text_to_speech", "google_search"] diff --git a/metagpt/learn/google_search.py b/metagpt/learn/google_search.py new file mode 100644 index 000000000..ef099fe94 --- /dev/null +++ b/metagpt/learn/google_search.py @@ -0,0 +1,12 @@ +from metagpt.tools.search_engine import SearchEngine + + +async def google_search(query: str, max_results: int = 6, **kwargs): + """Perform a web search and retrieve search results. + + :param query: The search query. + :param max_results: The number of search results to retrieve + :return: The web search results in markdown format. + """ + resluts = await SearchEngine().run(query, max_results=max_results, as_string=False) + return "\n".join(f"{i}. [{j['title']}]({j['link']}): {j['snippet']}" for i, j in enumerate(resluts, 1)) diff --git a/metagpt/learn/skill_loader.py b/metagpt/learn/skill_loader.py new file mode 100644 index 000000000..dff5e26ae --- /dev/null +++ b/metagpt/learn/skill_loader.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +@Time : 2023/8/18 +@Author : mashenquan +@File : skill_loader.py +@Desc : Skill YAML Configuration Loader. +""" +from pathlib import Path +from typing import Dict, List, Optional + +import yaml +from pydantic import BaseModel, Field + +from metagpt.config import CONFIG + + +class Example(BaseModel): + ask: str + answer: str + + +class Returns(BaseModel): + type: str + format: Optional[str] = None + + +class Parameter(BaseModel): + type: str + description: str = None + + +class Skill(BaseModel): + name: str + description: str = None + id: str = None + x_prerequisite: Dict = Field(default=None, alias="x-prerequisite") + parameters: Dict[str, Parameter] = None + examples: List[Example] + returns: Returns + + @property + def arguments(self) -> Dict: + if not self.parameters: + return {} + ret = {} + for k, v in self.parameters.items(): + ret[k] = v.description if v.description else "" + return ret + + +class Entity(BaseModel): + name: str = None + skills: List[Skill] + + +class Components(BaseModel): + pass + + +class SkillsDeclaration(BaseModel): + skillapi: str + entities: Dict[str, Entity] + components: Components = None + + +class SkillLoader: + def __init__(self, skill_yaml_file_name: Path = None): + if not skill_yaml_file_name: + skill_yaml_file_name = Path(__file__).parent.parent.parent / ".well-known/skills.yaml" + with open(str(skill_yaml_file_name), "r") as file: + skills = yaml.safe_load(file) + self._skills = SkillsDeclaration(**skills) + + def get_skill_list(self, entity_name: str = "Assistant") -> Dict: + """Return the skill name based on the skill description.""" + entity = self.get_entity(entity_name) + if not entity: + return {} + + agent_skills = CONFIG.agent_skills + if not agent_skills: + return {} + + class AgentSkill(BaseModel): + name: str + + names = [AgentSkill(**i).name for i in agent_skills] + description_to_name_mappings = {} + for s in entity.skills: + if s.name not in names: + continue + description_to_name_mappings[s.description] = s.name + + return description_to_name_mappings + + def get_skill(self, name, entity_name: str = "Assistant") -> Skill: + """Return a skill by name.""" + entity = self.get_entity(entity_name) + if not entity: + return None + for sk in entity.skills: + if sk.name == name: + return sk + + def get_entity(self, name) -> Entity: + """Return a list of skills for the entity.""" + if not self._skills: + return None + return self._skills.entities.get(name) + + +if __name__ == "__main__": + CONFIG.agent_skills = [ + {"id": 1, "name": "text_to_speech", "type": "builtin", "config": {}, "enabled": True}, + {"id": 2, "name": "text_to_image", "type": "builtin", "config": {}, "enabled": True}, + {"id": 3, "name": "ai_call", "type": "builtin", "config": {}, "enabled": True}, + {"id": 3, "name": "data_analysis", "type": "builtin", "config": {}, "enabled": True}, + {"id": 5, "name": "crawler", "type": "builtin", "config": {"engine": "ddg"}, "enabled": True}, + {"id": 6, "name": "knowledge", "type": "builtin", "config": {}, "enabled": True}, + ] + loader = SkillLoader() + print(loader.get_skill_list()) diff --git a/metagpt/learn/text_to_embedding.py b/metagpt/learn/text_to_embedding.py new file mode 100644 index 000000000..26dab0419 --- /dev/null +++ b/metagpt/learn/text_to_embedding.py @@ -0,0 +1,24 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +@Time : 2023/8/18 +@Author : mashenquan +@File : text_to_embedding.py +@Desc : Text-to-Embedding skill, which provides text-to-embedding functionality. +""" + +from metagpt.config import CONFIG +from metagpt.tools.openai_text_to_embedding import oas3_openai_text_to_embedding + + +async def text_to_embedding(text, model="text-embedding-ada-002", openai_api_key="", **kwargs): + """Text to embedding + + :param text: The text used for embedding. + :param model: One of ['text-embedding-ada-002'], ID of the model to use. For more details, checkout: `https://api.openai.com/v1/models`. + :param openai_api_key: OpenAI API key, For more details, checkout: `https://platform.openai.com/account/api-keys` + :return: A json object of :class:`ResultEmbedding` class if successful, otherwise `{}`. + """ + if CONFIG.OPENAI_API_KEY or openai_api_key: + return await oas3_openai_text_to_embedding(text, model=model, openai_api_key=openai_api_key) + raise EnvironmentError diff --git a/metagpt/learn/text_to_image.py b/metagpt/learn/text_to_image.py new file mode 100644 index 000000000..23c2bddad --- /dev/null +++ b/metagpt/learn/text_to_image.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +@Time : 2023/8/18 +@Author : mashenquan +@File : text_to_image.py +@Desc : Text-to-Image skill, which provides text-to-image functionality. +""" +import openai.error + +from metagpt.config import CONFIG +from metagpt.const import BASE64_FORMAT +from metagpt.tools.metagpt_text_to_image import oas3_metagpt_text_to_image +from metagpt.tools.openai_text_to_image import oas3_openai_text_to_image +from metagpt.utils.s3 import S3 + + +async def text_to_image(text, size_type: str = "512x512", openai_api_key="", model_url="", **kwargs): + """Text to image + + :param text: The text used for image conversion. + :param openai_api_key: OpenAI API key, For more details, checkout: `https://platform.openai.com/account/api-keys` + :param size_type: If using OPENAI, the available size options are ['256x256', '512x512', '1024x1024'], while for MetaGPT, the options are ['512x512', '512x768']. + :param model_url: MetaGPT model url + :return: The image data is returned in Base64 encoding. + """ + image_declaration = "data:image/png;base64," + if CONFIG.METAGPT_TEXT_TO_IMAGE_MODEL_URL or model_url: + base64_data = await oas3_metagpt_text_to_image(text, size_type, model_url) + elif CONFIG.OPENAI_API_KEY or openai_api_key: + base64_data = await oas3_openai_text_to_image(text, size_type, openai_api_key) + else: + raise openai.error.InvalidRequestError("缺少必要的参数") + + s3 = S3() + url = await s3.cache(data=base64_data, file_ext=".png", format=BASE64_FORMAT) + if url: + return f"![{text}]({url})" + return image_declaration + base64_data if base64_data else "" diff --git a/metagpt/learn/text_to_speech.py b/metagpt/learn/text_to_speech.py new file mode 100644 index 000000000..7c085c02f --- /dev/null +++ b/metagpt/learn/text_to_speech.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +@Time : 2023/8/17 +@Author : mashenquan +@File : text_to_speech.py +@Desc : Text-to-Speech skill, which provides text-to-speech functionality +""" +import openai + +from metagpt.config import CONFIG +from metagpt.const import BASE64_FORMAT +from metagpt.tools.azure_tts import oas3_azsure_tts +from metagpt.tools.iflytek_tts import oas3_iflytek_tts +from metagpt.utils.s3 import S3 + + +async def text_to_speech( + text, + lang="zh-CN", + voice="zh-CN-XiaomoNeural", + style="affectionate", + role="Girl", + subscription_key="", + region="", + iflytek_app_id="", + iflytek_api_key="", + iflytek_api_secret="", + **kwargs, +): + """Text to speech + For more details, check out:`https://learn.microsoft.com/en-us/azure/ai-services/speech-service/language-support?tabs=tts` + + :param lang: The value can contain a language code such as en (English), or a locale such as en-US (English - United States). For more details, checkout: `https://learn.microsoft.com/en-us/azure/ai-services/speech-service/language-support?tabs=tts` + :param voice: For more details, checkout: `https://learn.microsoft.com/en-us/azure/ai-services/speech-service/language-support?tabs=tts`, `https://speech.microsoft.com/portal/voicegallery` + :param style: Speaking style to express different emotions like cheerfulness, empathy, and calm. For more details, checkout: `https://learn.microsoft.com/en-us/azure/ai-services/speech-service/language-support?tabs=tts` + :param role: With roles, the same voice can act as a different age and gender. For more details, checkout: `https://learn.microsoft.com/en-us/azure/ai-services/speech-service/language-support?tabs=tts` + :param text: The text used for voice conversion. + :param subscription_key: key is used to access your Azure AI service API, see: `https://portal.azure.com/` > `Resource Management` > `Keys and Endpoint` + :param region: This is the location (or region) of your resource. You may need to use this field when making calls to this API. + :param iflytek_app_id: Application ID is used to access your iFlyTek service API, see: `https://console.xfyun.cn/services/tts` + :param iflytek_api_key: WebAPI argument, see: `https://console.xfyun.cn/services/tts` + :param iflytek_api_secret: WebAPI argument, see: `https://console.xfyun.cn/services/tts` + :return: Returns the Base64-encoded .wav/.mp3 file data if successful, otherwise an empty string. + + """ + + if (CONFIG.AZURE_TTS_SUBSCRIPTION_KEY and CONFIG.AZURE_TTS_REGION) or (subscription_key and region): + audio_declaration = "data:audio/wav;base64," + base64_data = await oas3_azsure_tts(text, lang, voice, style, role, subscription_key, region) + s3 = S3() + url = await s3.cache(data=base64_data, file_ext=".wav", format=BASE64_FORMAT) + if url: + return f"[{text}]({url})" + return audio_declaration + base64_data if base64_data else base64_data + if (CONFIG.IFLYTEK_APP_ID and CONFIG.IFLYTEK_API_KEY and CONFIG.IFLYTEK_API_SECRET) or ( + iflytek_app_id and iflytek_api_key and iflytek_api_secret + ): + audio_declaration = "data:audio/mp3;base64," + base64_data = await oas3_iflytek_tts( + text=text, app_id=iflytek_app_id, api_key=iflytek_api_key, api_secret=iflytek_api_secret + ) + s3 = S3() + url = await s3.cache(data=base64_data, file_ext=".mp3", format=BASE64_FORMAT) + if url: + return f"[{text}]({url})" + return audio_declaration + base64_data if base64_data else base64_data + + raise openai.error.InvalidRequestError( + message="AZURE_TTS_SUBSCRIPTION_KEY, AZURE_TTS_REGION, IFLYTEK_APP_ID, IFLYTEK_API_KEY, IFLYTEK_API_SECRET error", + param={}, + ) diff --git a/metagpt/llm.py b/metagpt/llm.py index 2ad40cb1c..525d2a65e 100644 --- a/metagpt/llm.py +++ b/metagpt/llm.py @@ -4,30 +4,63 @@ @Time : 2023/5/11 14:45 @Author : alexanderwu @File : llm.py +@Modified By: mashenquan, 2023 """ - +from enum import Enum from metagpt.config import CONFIG from metagpt.provider.anthropic_api import Claude2 as Claude from metagpt.provider.openai_api import OpenAIGPTAPI from metagpt.provider.zhipuai_api import ZhiPuAIGPTAPI from metagpt.provider.spark_api import SparkAPI from metagpt.provider.human_provider import HumanProvider +from metagpt.provider.metagpt_llm_api import MetaGPTLLMAPI _ = HumanProvider() # Avoid pre-commit error +class LLMType(Enum): + OPENAI = "OpenAI" + METAGPT = "MetaGPT" + CLAUDE = "Claude" + UNKNOWN = "UNKNOWN" + + @classmethod + def get(cls, value): + for member in cls: + if member.value == value: + return member + return cls.UNKNOWN + + @classmethod + def __missing__(cls, value): + return cls.UNKNOWN + + +# Used in agents +class LLMFactory: + @staticmethod + def new_llm() -> "BaseGPTAPI": + # Determine which type of LLM to use based on the validity of the key. + if CONFIG.claude_api_key: + return Claude() + elif CONFIG.spark_api_key: + return SparkAPI() + elif CONFIG.zhipuai_api_key: + return ZhiPuAIGPTAPI() + + # MetaGPT uses the same parameters as OpenAI. + constructors = { + LLMType.OPENAI.value: OpenAIGPTAPI, + LLMType.METAGPT.value: MetaGPTLLMAPI, + } + constructor = constructors.get(CONFIG.LLM_TYPE) + if constructor: + return constructor() + + raise ValueError(f"Unsupported LLM TYPE: {CONFIG.LLM_TYPE}") + + +# Used in metagpt def LLM() -> "BaseGPTAPI": """ initialize different LLM instance according to the key field existence""" - # TODO a little trick, can use registry to initialize LLM instance further - if CONFIG.openai_api_key: - llm = OpenAIGPTAPI() - elif CONFIG.claude_api_key: - llm = Claude() - elif CONFIG.spark_api_key: - llm = SparkAPI() - elif CONFIG.zhipuai_api_key: - llm = ZhiPuAIGPTAPI() - else: - raise RuntimeError("You should config a LLM configuration first") - - return llm + return LLMFactory.new_llm() diff --git a/metagpt/management/skill_manager.py b/metagpt/management/skill_manager.py index b3181b64e..33f283680 100644 --- a/metagpt/management/skill_manager.py +++ b/metagpt/management/skill_manager.py @@ -4,11 +4,11 @@ @Time : 2023/6/5 01:44 @Author : alexanderwu @File : skill_manager.py +@Modified By: mashenquan, 2023/8/20. Remove useless `_llm` """ from metagpt.actions import Action from metagpt.const import PROMPT_PATH from metagpt.document_store.chromadb_store import ChromaStore -from metagpt.llm import LLM from metagpt.logs import logger Skill = Action @@ -18,9 +18,14 @@ class SkillManager: """Used to manage all skills""" def __init__(self): +<<<<<<< HEAD self._llm = LLM() self._store = ChromaStore("skill_manager") self._skills: dict[str:Skill] = {} +======= + self._store = ChromaStore('skill_manager') + self._skills: dict[str: Skill] = {} +>>>>>>> send18/dev def add_skill(self, skill: Skill): """ diff --git a/metagpt/memory/brain_memory.py b/metagpt/memory/brain_memory.py new file mode 100644 index 000000000..be3736100 --- /dev/null +++ b/metagpt/memory/brain_memory.py @@ -0,0 +1,348 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +@Time : 2023/8/18 +@Author : mashenquan +@File : brain_memory.py +@Desc : Support memory for multiple tasks and multiple mainlines. +@Modified By: mashenquan, 2023/9/4. + redis memory cache. +""" +import json +import re +from enum import Enum +from typing import Dict, List, Optional + +import openai +import pydantic + +from metagpt import Message +from metagpt.config import CONFIG +from metagpt.const import DEFAULT_LANGUAGE, DEFAULT_MAX_TOKENS +from metagpt.llm import LLMType +from metagpt.logs import logger +from metagpt.schema import RawMessage +from metagpt.utils.redis import Redis + + +class MessageType(Enum): + Talk = "TALK" + Solution = "SOLUTION" + Problem = "PROBLEM" + Skill = "SKILL" + Answer = "ANSWER" + + +class BrainMemory(pydantic.BaseModel): + history: List[Dict] = [] + stack: List[Dict] = [] + solution: List[Dict] = [] + knowledge: List[Dict] = [] + historical_summary: str = "" + last_history_id: str = "" + is_dirty: bool = False + last_talk: str = None + llm_type: Optional[str] = None + cacheable: bool = True + + def add_talk(self, msg: Message): + msg.add_tag(MessageType.Talk.value) + self.add_history(msg) + self.is_dirty = True + + def add_answer(self, msg: Message): + msg.add_tag(MessageType.Answer.value) + self.add_history(msg) + self.is_dirty = True + + def get_knowledge(self) -> str: + texts = [Message(**m).content for m in self.knowledge] + return "\n".join(texts) + + @staticmethod + async def loads(redis_key: str, redis_conf: Dict = None) -> "BrainMemory": + redis = Redis(conf=redis_conf) + if not redis.is_valid() or not redis_key: + return BrainMemory(llm_type=CONFIG.LLM_TYPE) + v = await redis.get(key=redis_key) + logger.debug(f"REDIS GET {redis_key} {v}") + if v: + data = json.loads(v) + bm = BrainMemory(**data) + bm.is_dirty = False + return bm + return BrainMemory(llm_type=CONFIG.LLM_TYPE) + + async def dumps(self, redis_key: str, timeout_sec: int = 30 * 60, redis_conf: Dict = None): + if not self.is_dirty: + return + redis = Redis(conf=redis_conf) + if not redis.is_valid() or not redis_key: + return False + v = self.json() + if self.cacheable: + await redis.set(key=redis_key, data=v, timeout_sec=timeout_sec) + logger.debug(f"REDIS SET {redis_key} {v}") + self.is_dirty = False + + @staticmethod + def to_redis_key(prefix: str, user_id: str, chat_id: str): + return f"{prefix}:{user_id}:{chat_id}" + + async def set_history_summary(self, history_summary, redis_key, redis_conf): + if self.historical_summary == history_summary: + if self.is_dirty: + await self.dumps(redis_key=redis_key, redis_conf=redis_conf) + self.is_dirty = False + return + + self.historical_summary = history_summary + self.history = [] + await self.dumps(redis_key=redis_key, redis_conf=redis_conf) + self.is_dirty = False + + def add_history(self, msg: Message): + if msg.id: + if self.to_int(msg.id, 0) <= self.to_int(self.last_history_id, -1): + return + self.history.append(msg.dict()) + self.last_history_id = str(msg.id) + self.is_dirty = True + + def exists(self, text) -> bool: + for m in reversed(self.history): + if m.get("content") == text: + return True + return False + + @staticmethod + def to_int(v, default_value): + try: + return int(v) + except: + return default_value + + def pop_last_talk(self): + v = self.last_talk + self.last_talk = None + return v + + async def summarize(self, llm, max_words=200, keep_language: bool = False, limit: int = -1, **kwargs): + if self.llm_type == LLMType.METAGPT.value: + return await self._metagpt_summarize(llm=llm, max_words=max_words, keep_language=keep_language, **kwargs) + + return await self._openai_summarize( + llm=llm, max_words=max_words, keep_language=keep_language, limit=limit, **kwargs + ) + + async def _openai_summarize(self, llm, max_words=200, keep_language: bool = False, limit: int = -1, **kwargs): + max_token_count = DEFAULT_MAX_TOKENS + max_count = 100 + texts = [self.historical_summary] + for i in self.history: + m = Message(**i) + texts.append(m.content) + text = "\n".join(texts) + text_length = len(text) + if limit > 0 and text_length < limit: + return text + summary = "" + while max_count > 0: + if text_length < max_token_count: + summary = await self._get_summary(text=text, llm=llm, max_words=max_words, keep_language=keep_language) + break + + padding_size = 20 if max_token_count > 20 else 0 + text_windows = self.split_texts(text, window_size=max_token_count - padding_size) + part_max_words = min(int(max_words / len(text_windows)) + 1, 100) + summaries = [] + for ws in text_windows: + response = await self._get_summary( + text=ws, llm=llm, max_words=part_max_words, keep_language=keep_language + ) + summaries.append(response) + if len(summaries) == 1: + summary = summaries[0] + break + + # Merged and retry + text = "\n".join(summaries) + text_length = len(text) + + max_count -= 1 # safeguard + if summary: + await self.set_history_summary(history_summary=summary, redis_key=CONFIG.REDIS_KEY, redis_conf=CONFIG.REDIS) + return summary + raise openai.error.InvalidRequestError(message="text too long", param=None) + + async def _metagpt_summarize(self, max_words=200, **kwargs): + if not self.history: + return "" + + total_length = 0 + msgs = [] + for i in reversed(self.history): + m = Message(**i) + delta = len(m.content) + if total_length + delta > max_words: + left = max_words - total_length + if left == 0: + break + m.content = m.content[0:left] + msgs.append(m.dict()) + break + msgs.append(i) + total_length += delta + msgs.reverse() + self.history = msgs + self.is_dirty = True + await self.dumps(redis_key=CONFIG.REDIS_KEY, redis_conf=CONFIG.REDIS_CONF) + self.is_dirty = False + + return BrainMemory.to_metagpt_history_format(self.history) + + @staticmethod + def to_metagpt_history_format(history) -> str: + mmsg = [] + for m in history: + msg = Message(**m) + r = RawMessage(role="user" if MessageType.Talk.value in msg.tags else "assistant", content=msg.content) + mmsg.append(r) + return json.dumps(mmsg) + + @staticmethod + async def _get_summary(text: str, llm, max_words=20, keep_language: bool = False): + """Generate text summary""" + if len(text) < max_words: + return text + if keep_language: + command = f".Translate the above content into a summary of less than {max_words} words in language of the content strictly." + else: + command = f"Translate the above content into a summary of less than {max_words} words." + msg = text + "\n\n" + command + logger.debug(f"summary ask:{msg}") + response = await llm.aask(msg=msg, system_msgs=[]) + logger.debug(f"summary rsp: {response}") + return response + + async def get_title(self, llm, max_words=5, **kwargs) -> str: + """Generate text title""" + if self.llm_type == LLMType.METAGPT.value: + return Message(**self.history[0]).content if self.history else "New" + + summary = await self.summarize(llm=llm, max_words=500) + + language = CONFIG.language or DEFAULT_LANGUAGE + command = f"Translate the above summary into a {language} title of less than {max_words} words." + summaries = [summary, command] + msg = "\n".join(summaries) + logger.debug(f"title ask:{msg}") + response = await llm.aask(msg=msg, system_msgs=[]) + logger.debug(f"title rsp: {response}") + return response + + async def is_related(self, text1, text2, llm): + if self.llm_type == LLMType.METAGPT.value: + return await self._metagpt_is_related(text1=text1, text2=text2, llm=llm) + return await self._openai_is_related(text1=text1, text2=text2, llm=llm) + + @staticmethod + async def _metagpt_is_related(**kwargs): + return False + + @staticmethod + async def _openai_is_related(text1, text2, llm, **kwargs): + # command = f"{text1}\n{text2}\n\nIf the two sentences above are related, return [TRUE] brief and clear. Otherwise, return [FALSE]." + command = f"{text2}\n\nIs there any sentence above related to the following sentence: {text1}.\nIf is there any relevance, return [TRUE] brief and clear. Otherwise, return [FALSE] brief and clear." + rsp = await llm.aask(msg=command, system_msgs=[]) + result = True if "TRUE" in rsp else False + p2 = text2.replace("\n", "") + p1 = text1.replace("\n", "") + logger.info(f"IS_RELATED:\nParagraph 1: {p2}\nParagraph 2: {p1}\nRESULT: {result}\n") + return result + + async def rewrite(self, sentence: str, context: str, llm): + if self.llm_type == LLMType.METAGPT.value: + return await self._metagpt_rewrite(sentence=sentence, context=context, llm=llm) + return await self._openai_rewrite(sentence=sentence, context=context, llm=llm) + + async def _metagpt_rewrite(self, sentence: str, **kwargs): + return sentence + + async def _openai_rewrite(self, sentence: str, context: str, llm, **kwargs): + # command = ( + # f"{context}\n\nConsidering the content above, rewrite and return this sentence brief and clear:\n{sentence}" + # ) + command = f"{context}\n\nExtract relevant information from every preceding sentence and use it to succinctly supplement or rewrite the following text in brief and clear:\n{sentence}" + rsp = await llm.aask(msg=command, system_msgs=[]) + logger.info(f"REWRITE:\nCommand: {command}\nRESULT: {rsp}\n") + return rsp + + @staticmethod + def split_texts(text: str, window_size) -> List[str]: + """Splitting long text into sliding windows text""" + if window_size <= 0: + window_size = BrainMemory.DEFAULT_TOKEN_SIZE + total_len = len(text) + if total_len <= window_size: + return [text] + + padding_size = 20 if window_size > 20 else 0 + windows = [] + idx = 0 + data_len = window_size - padding_size + while idx < total_len: + if window_size + idx > total_len: # 不足一个滑窗 + windows.append(text[idx:]) + break + # 每个窗口少算padding_size自然就可实现滑窗功能, 比如: [1, 2, 3, 4, 5, 6, 7, ....] + # window_size=3, padding_size=1: + # [1, 2, 3], [3, 4, 5], [5, 6, 7], .... + # idx=2, | idx=5 | idx=8 | ... + w = text[idx : idx + window_size] + windows.append(w) + idx += data_len + + return windows + + @staticmethod + def extract_info(input_string, pattern=r"\[([A-Z]+)\]:\s*(.+)"): + match = re.match(pattern, input_string) + if match: + return match.group(1), match.group(2) + else: + return None, input_string + + def set_llm_type(self, v): + if v and v != self.llm_type: + self.llm_type = v + self.is_dirty = True + + @property + def is_history_available(self): + return bool(self.history or self.historical_summary) + + @property + def history_text(self): + if self.llm_type == LLMType.METAGPT.value: + return self._get_metagpt_history_text() + return self._get_openai_history_text() + + def _get_metagpt_history_text(self): + return BrainMemory.to_metagpt_history_format(self.history) + + def _get_openai_history_text(self): + if len(self.history) == 0 and not self.historical_summary: + return "" + texts = [self.historical_summary] if self.historical_summary else [] + for m in self.history[:-1]: + if isinstance(m, Dict): + t = Message(**m).content + elif isinstance(m, Message): + t = m.content + else: + continue + texts.append(t) + + return "\n".join(texts) + + DEFAULT_TOKEN_SIZE = 500 diff --git a/metagpt/memory/longterm_memory.py b/metagpt/memory/longterm_memory.py index 22032a86e..5e68e99e5 100644 --- a/metagpt/memory/longterm_memory.py +++ b/metagpt/memory/longterm_memory.py @@ -2,6 +2,7 @@ # -*- coding: utf-8 -*- """ @Desc : the implement of Long-term memory +@Modified By: mashenquan, 2023/8/20. Remove global configuration `CONFIG`, enable configuration support for business isolation. """ from metagpt.logs import logger diff --git a/metagpt/memory/memory.py b/metagpt/memory/memory.py index 53b65fcf7..7a6aa1c45 100644 --- a/metagpt/memory/memory.py +++ b/metagpt/memory/memory.py @@ -88,3 +88,11 @@ class Memory: continue rsp += self.index[action] return rsp + + def get_by_tags(self, tags: list) -> list[Message]: + """Return messages with specified tags""" + result = [] + for m in self.storage: + if m.is_contain_tags(tags): + result.append(m) + return result diff --git a/metagpt/memory/memory_storage.py b/metagpt/memory/memory_storage.py index a213f6d7a..fc2f0a419 100644 --- a/metagpt/memory/memory_storage.py +++ b/metagpt/memory/memory_storage.py @@ -1,6 +1,9 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- -# @Desc : the implement of memory storage +""" +@Desc : the implement of memory storage +@Modified By: mashenquan, 2023/8/20. Remove global configuration `CONFIG`, enable configuration support for business isolation. +""" from pathlib import Path from typing import List diff --git a/metagpt/provider/__init__.py b/metagpt/provider/__init__.py index 56dc19b4b..9895aa7fc 100644 --- a/metagpt/provider/__init__.py +++ b/metagpt/provider/__init__.py @@ -4,9 +4,11 @@ @Time : 2023/5/5 22:59 @Author : alexanderwu @File : __init__.py +@Modified By: mashenquan, 2023/9/8. Add `MetaGPTLLMAPI` """ from metagpt.provider.openai_api import OpenAIGPTAPI +from metagpt.provider.metagpt_llm_api import MetaGPTLLMAPI -__all__ = ["OpenAIGPTAPI"] +__all__ = ["OpenAIGPTAPI", "MetaGPTLLMAPI"] diff --git a/metagpt/provider/base_gpt_api.py b/metagpt/provider/base_gpt_api.py index 162758527..088df5b19 100644 --- a/metagpt/provider/base_gpt_api.py +++ b/metagpt/provider/base_gpt_api.py @@ -4,6 +4,7 @@ @Time : 2023/5/5 23:04 @Author : alexanderwu @File : base_gpt_api.py +@Desc : mashenquan, 2023/8/22. + try catch """ import json from abc import abstractmethod diff --git a/metagpt/provider/metagpt_llm_api.py b/metagpt/provider/metagpt_llm_api.py new file mode 100644 index 000000000..925ac6623 --- /dev/null +++ b/metagpt/provider/metagpt_llm_api.py @@ -0,0 +1,278 @@ +# -*- coding: utf-8 -*- +""" +@Time : 2023/8/30 +@Author : mashenquan +@File : metagpt_llm_api.py +@Desc : MetaGPT LLM related APIs +""" + +from metagpt.provider.openai_api import OpenAIGPTAPI +# from metagpt.provider.base_gpt_api import BaseGPTAPI +# from metagpt.provider.openai_api import RateLimiter + + +class MetaGPTLLMAPI(OpenAIGPTAPI): + """MetaGPT LLM api""" + + def __init__(self): + super(MetaGPTLLMAPI, self).__init__() + + # def __init__(self): + # self.__init_openai(CONFIG) + # self.llm = openai + # self.model = CONFIG.openai_api_model + # self.auto_max_tokens = False + # self._cost_manager = CostManager() + # RateLimiter.__init__(self, rpm=self.rpm) + # + # def __init_openai(self, config): + # openai.api_key = config.openai_api_key + # if config.openai_api_base: + # openai.api_base = config.openai_api_base + # if config.openai_api_type: + # openai.api_type = config.openai_api_type + # openai.api_version = config.openai_api_version + # self.rpm = int(config.get("RPM", 10)) + # + # async def _achat_completion_stream(self, messages: list[dict]) -> str: + # response = await openai.ChatCompletion.acreate(**self._cons_kwargs(messages), stream=True) + # + # # create variables to collect the stream of chunks + # collected_chunks = [] + # collected_messages = [] + # # iterate through the stream of events + # async for chunk in response: + # collected_chunks.append(chunk) # save the event response + # choices = chunk["choices"] + # if len(choices) > 0: + # chunk_message = chunk["choices"][0].get("delta", {}) # extract the message + # collected_messages.append(chunk_message) # save the message + # if "content" in chunk_message: + # print(chunk_message["content"], end="") + # print() + # + # full_reply_content = "".join([m.get("content", "") for m in collected_messages]) + # usage = self._calc_usage(messages, full_reply_content) + # self._update_costs(usage) + # return full_reply_content + # + # def _cons_kwargs(self, messages: list[dict], **configs) -> dict: + # kwargs = { + # "messages": messages, + # "max_tokens": self.get_max_tokens(messages), + # "n": 1, + # "stop": None, + # "temperature": 0.3, + # "timeout": 3, + # } + # if configs: + # kwargs.update(configs) + # + # if CONFIG.openai_api_type == "azure": + # if CONFIG.deployment_name and CONFIG.deployment_id: + # raise ValueError("You can only use one of the `deployment_id` or `deployment_name` model") + # elif not CONFIG.deployment_name and not CONFIG.deployment_id: + # raise ValueError("You must specify `DEPLOYMENT_NAME` or `DEPLOYMENT_ID` parameter") + # kwargs_mode = ( + # {"engine": CONFIG.deployment_name} + # if CONFIG.deployment_name + # else {"deployment_id": CONFIG.deployment_id} + # ) + # else: + # kwargs_mode = {"model": self.model} + # kwargs.update(kwargs_mode) + # return kwargs + # + # async def _achat_completion(self, messages: list[dict]) -> dict: + # rsp = await self.llm.ChatCompletion.acreate(**self._cons_kwargs(messages)) + # self._update_costs(rsp.get("usage")) + # return rsp + # + # def _chat_completion(self, messages: list[dict]) -> dict: + # rsp = self.llm.ChatCompletion.create(**self._cons_kwargs(messages)) + # self._update_costs(rsp) + # return rsp + # + # def completion(self, messages: list[dict]) -> dict: + # # if isinstance(messages[0], Message): + # # messages = self.messages_to_dict(messages) + # return self._chat_completion(messages) + # + # async def acompletion(self, messages: list[dict]) -> dict: + # # if isinstance(messages[0], Message): + # # messages = self.messages_to_dict(messages) + # return await self._achat_completion(messages) + # + # @retry( + # wait=wait_random_exponential(min=1, max=60), + # stop=stop_after_attempt(6), + # after=after_log(logger, logger.level("WARNING").name), + # retry=retry_if_exception_type(APIConnectionError), + # retry_error_callback=log_and_reraise, + # ) + # async def acompletion_text(self, messages: list[dict], stream=False) -> str: + # """when streaming, print each token in place.""" + # if stream: + # return await self._achat_completion_stream(messages) + # rsp = await self._achat_completion(messages) + # return self.get_choice_text(rsp) + # + # def _func_configs(self, messages: list[dict], **kwargs) -> dict: + # """ + # Note: Keep kwargs consistent with the parameters in the https://platform.openai.com/docs/api-reference/chat/create + # """ + # if "tools" not in kwargs: + # configs = { + # "tools": [{"type": "function", "function": GENERAL_FUNCTION_SCHEMA}], + # "tool_choice": GENERAL_TOOL_CHOICE, + # } + # kwargs.update(configs) + # + # return self._cons_kwargs(messages, **kwargs) + # + # def _chat_completion_function(self, messages: list[dict], **kwargs) -> dict: + # rsp = self.llm.ChatCompletion.create(**self._func_configs(messages, **kwargs)) + # self._update_costs(rsp.get("usage")) + # return rsp + # + # async def _achat_completion_function(self, messages: list[dict], **chat_configs) -> dict: + # rsp = await self.llm.ChatCompletion.acreate(**self._func_configs(messages, **chat_configs)) + # self._update_costs(rsp.get("usage")) + # return rsp + # + # def _process_message(self, messages: Union[str, Message, list[dict], list[Message], list[str]]) -> list[dict]: + # """convert messages to list[dict].""" + # if isinstance(messages, list): + # messages = [Message(msg) if isinstance(msg, str) else msg for msg in messages] + # return [msg if isinstance(msg, dict) else msg.to_dict() for msg in messages] + # + # if isinstance(messages, Message): + # messages = [messages.to_dict()] + # elif isinstance(messages, str): + # messages = [{"role": "user", "content": messages}] + # else: + # raise ValueError( + # f"Only support messages type are: str, Message, list[dict], but got {type(messages).__name__}!" + # ) + # return messages + # + # def ask_code(self, messages: Union[str, Message, list[dict]], **kwargs) -> dict: + # """Use function of tools to ask a code. + # + # Note: Keep kwargs consistent with the parameters in the https://platform.openai.com/docs/api-reference/chat/create + # + # Examples: + # + # >>> llm = OpenAIGPTAPI() + # >>> llm.ask_code("Write a python hello world code.") + # {'language': 'python', 'code': "print('Hello, World!')"} + # >>> msg = [{'role': 'user', 'content': "Write a python hello world code."}] + # >>> llm.ask_code(msg) + # {'language': 'python', 'code': "print('Hello, World!')"} + # """ + # messages = self._process_message(messages) + # rsp = self._chat_completion_function(messages, **kwargs) + # return self.get_choice_function_arguments(rsp) + # + # async def aask_code(self, messages: Union[str, Message, list[dict]], **kwargs) -> dict: + # """Use function of tools to ask a code. + # + # Note: Keep kwargs consistent with the parameters in the https://platform.openai.com/docs/api-reference/chat/create + # + # Examples: + # + # >>> llm = OpenAIGPTAPI() + # >>> rsp = await llm.ask_code("Write a python hello world code.") + # >>> rsp + # {'language': 'python', 'code': "print('Hello, World!')"} + # >>> msg = [{'role': 'user', 'content': "Write a python hello world code."}] + # >>> rsp = await llm.aask_code(msg) # -> {'language': 'python', 'code': "print('Hello, World!')"} + # """ + # messages = self._process_message(messages) + # rsp = await self._achat_completion_function(messages, **kwargs) + # return self.get_choice_function_arguments(rsp) + # + # def _calc_usage(self, messages: list[dict], rsp: str) -> dict: + # usage = {} + # if CONFIG.calc_usage: + # try: + # prompt_tokens = count_message_tokens(messages, self.model) + # completion_tokens = count_string_tokens(rsp, self.model) + # usage["prompt_tokens"] = prompt_tokens + # usage["completion_tokens"] = completion_tokens + # return usage + # except Exception as e: + # logger.error("usage calculation failed!", e) + # else: + # return usage + # + # async def acompletion_batch(self, batch: list[list[dict]]) -> list[dict]: + # """Return full JSON""" + # split_batches = self.split_batches(batch) + # all_results = [] + # + # for small_batch in split_batches: + # logger.info(small_batch) + # await self.wait_if_needed(len(small_batch)) + # + # future = [self.acompletion(prompt) for prompt in small_batch] + # results = await asyncio.gather(*future) + # logger.info(results) + # all_results.extend(results) + # + # return all_results + # + # async def acompletion_batch_text(self, batch: list[list[dict]]) -> list[str]: + # """Only return plain text""" + # raw_results = await self.acompletion_batch(batch) + # results = [] + # for idx, raw_result in enumerate(raw_results, start=1): + # result = self.get_choice_text(raw_result) + # results.append(result) + # logger.info(f"Result of task {idx}: {result}") + # return results + # + # def _update_costs(self, usage: dict): + # if CONFIG.calc_usage: + # try: + # prompt_tokens = int(usage["prompt_tokens"]) + # completion_tokens = int(usage["completion_tokens"]) + # self._cost_manager.update_cost(prompt_tokens, completion_tokens, self.model) + # except Exception as e: + # logger.error("updating costs failed!", e) + # + # def get_costs(self) -> Costs: + # return self._cost_manager.get_costs() + # + # def get_max_tokens(self, messages: list[dict]): + # if not self.auto_max_tokens: + # return CONFIG.max_tokens_rsp + # return get_max_completion_tokens(messages, self.model, CONFIG.max_tokens_rsp) + # + # def moderation(self, content: Union[str, list[str]]): + # try: + # if not content: + # logger.error("content cannot be empty!") + # else: + # rsp = self._moderation(content=content) + # return rsp + # except Exception as e: + # logger.error(f"moderating failed:{e}") + # + # def _moderation(self, content: Union[str, list[str]]): + # rsp = self.llm.Moderation.create(input=content) + # return rsp + # + # async def amoderation(self, content: Union[str, list[str]]): + # try: + # if not content: + # logger.error("content cannot be empty!") + # else: + # rsp = await self._amoderation(content=content) + # return rsp + # except Exception as e: + # logger.error(f"moderating failed:{e}") + # + # async def _amoderation(self, content: Union[str, list[str]]): + # rsp = await self.llm.Moderation.acreate(input=content) + # return rsp diff --git a/metagpt/provider/openai_api.py b/metagpt/provider/openai_api.py index a24748e41..58d04cf84 100644 --- a/metagpt/provider/openai_api.py +++ b/metagpt/provider/openai_api.py @@ -8,13 +8,13 @@ @Modified By: mashenquan, 2023/11/21. Fix bug: ReadTimeout. @Modified By: mashenquan, 2023/12/1. Fix bug: Unclosed connection caused by openai 0.x. """ -import asyncio -import time -from typing import NamedTuple, Union -import openai +from typing import Union from openai import APIConnectionError, AsyncAzureOpenAI, AsyncOpenAI, RateLimitError from openai.types import CompletionUsage +import asyncio +import time +import openai from tenacity import ( after_log, retry, @@ -22,15 +22,14 @@ from tenacity import ( stop_after_attempt, wait_random_exponential, ) - from metagpt.config import CONFIG +from metagpt.llm import LLMType from metagpt.logs import logger from metagpt.provider.base_gpt_api import BaseGPTAPI from metagpt.provider.constant import GENERAL_FUNCTION_SCHEMA, GENERAL_TOOL_CHOICE from metagpt.schema import Message -from metagpt.utils.singleton import Singleton +from metagpt.utils.cost_manager import Costs from metagpt.utils.token_counter import ( - TOKEN_COSTS, count_message_tokens, count_string_tokens, get_max_completion_tokens, @@ -62,75 +61,6 @@ class RateLimiter: self.last_call_time = time.time() -class Costs(NamedTuple): - total_prompt_tokens: int - total_completion_tokens: int - total_cost: float - total_budget: float - - -class CostManager(metaclass=Singleton): - """计算使用接口的开销""" - - def __init__(self): - self.total_prompt_tokens = 0 - self.total_completion_tokens = 0 - self.total_cost = 0 - self.total_budget = 0 - - def update_cost(self, prompt_tokens, completion_tokens, model): - """ - Update the total cost, prompt tokens, and completion tokens. - - Args: - prompt_tokens (int): The number of tokens used in the prompt. - completion_tokens (int): The number of tokens used in the completion. - model (str): The model used for the API call. - """ - self.total_prompt_tokens += prompt_tokens - self.total_completion_tokens += completion_tokens - cost = ( - prompt_tokens * TOKEN_COSTS[model]["prompt"] + completion_tokens * TOKEN_COSTS[model]["completion"] - ) / 1000 - self.total_cost += cost - logger.info( - f"Total running cost: ${self.total_cost:.3f} | Max budget: ${CONFIG.max_budget:.3f} | " - f"Current cost: ${cost:.3f}, prompt_tokens: {prompt_tokens}, completion_tokens: {completion_tokens}" - ) - CONFIG.total_cost = self.total_cost - - def get_total_prompt_tokens(self): - """ - Get the total number of prompt tokens. - - Returns: - int: The total number of prompt tokens. - """ - return self.total_prompt_tokens - - def get_total_completion_tokens(self): - """ - Get the total number of completion tokens. - - Returns: - int: The total number of completion tokens. - """ - return self.total_completion_tokens - - def get_total_cost(self): - """ - Get the total cost of API calls. - - Returns: - float: The total cost of API calls. - """ - return self.total_cost - - def get_costs(self) -> Costs: - """Get all costs""" - return Costs(self.total_prompt_tokens, self.total_completion_tokens, self.total_cost, self.total_budget) - - def log_and_reraise(retry_state): logger.error(f"Retry attempts exhausted. Last exception: {retry_state.outcome.exception()}") logger.warning( @@ -161,7 +91,6 @@ class OpenAIGPTAPI(BaseGPTAPI, RateLimiter): else: # https://github.com/openai/openai-python#async-usage self._client = AsyncOpenAI(api_key=CONFIG.openai_api_key, base_url=CONFIG.openai_api_base) - self._cost_manager = CostManager() RateLimiter.__init__(self, rpm=self.rpm) async def _achat_completion_stream(self, messages: list[dict], timeout=3) -> str: @@ -362,12 +291,15 @@ class OpenAIGPTAPI(BaseGPTAPI, RateLimiter): def _update_costs(self, usage: CompletionUsage): if CONFIG.calc_usage: - prompt_tokens = usage.prompt_tokens - completion_tokens = usage.completion_tokens - self._cost_manager.update_cost(prompt_tokens, completion_tokens, self.model) + try: + prompt_tokens = usage.prompt_tokens + completion_tokens = usage.completion_tokens + CONFIG.cost_manager.update_cost(prompt_tokens, completion_tokens, self.model) + except Exception as e: + logger.error("updating costs failed!", e) def get_costs(self) -> Costs: - return self._cost_manager.get_costs() + return CONFIG.cost_manager.get_costs() def get_max_tokens(self, messages: list[dict]): if not self.auto_max_tokens: @@ -410,3 +342,10 @@ class OpenAIGPTAPI(BaseGPTAPI, RateLimiter): return loop else: raise e + + async def get_summary(self, text: str, max_words=200, keep_language: bool = False, **kwargs) -> str: + from metagpt.memory.brain_memory import BrainMemory + + memory = BrainMemory(llm_type=LLMType.OPENAI.value, historical_summary=text, cacheable=False) + return await memory.summarize(llm=self, max_words=max_words, keep_language=keep_language) + diff --git a/metagpt/roles/assistant.py b/metagpt/roles/assistant.py new file mode 100644 index 000000000..84ca07c9a --- /dev/null +++ b/metagpt/roles/assistant.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +@Time : 2023/8/7 +@Author : mashenquan +@File : assistant.py +@Desc : I am attempting to incorporate certain symbol concepts from UML into MetaGPT, enabling it to have the + ability to freely construct flows through symbol concatenation. Simultaneously, I am also striving to + make these symbols configurable and standardized, making the process of building flows more convenient. + For more about `fork` node in activity diagrams, see: `https://www.uml-diagrams.org/activity-diagrams.html` + This file defines a `fork` style meta role capable of generating arbitrary roles at runtime based on a + configuration file. +@Modified By: mashenquan, 2023/8/22. A definition has been provided for the return value of _think: returning false + indicates that further reasoning cannot continue. + +""" +import asyncio +from pathlib import Path + +from metagpt.actions import ActionOutput +from metagpt.actions.skill_action import ArgumentsParingAction, SkillAction +from metagpt.actions.talk_action import TalkAction +from metagpt.config import CONFIG +from metagpt.learn.skill_loader import SkillLoader +from metagpt.logs import logger +from metagpt.memory.brain_memory import BrainMemory, MessageType +from metagpt.roles import Role +from metagpt.schema import Message + + +class Assistant(Role): + """Assistant for solving common issues.""" + + def __init__( + self, + name="Lily", + profile="An assistant", + goal="Help to solve problem", + constraints="Talk in {language}", + desc="", + *args, + **kwargs, + ): + super(Assistant, self).__init__( + name=name, profile=profile, goal=goal, constraints=constraints, desc=desc, *args, **kwargs + ) + brain_memory = CONFIG.BRAIN_MEMORY + self.memory = BrainMemory(**brain_memory) if brain_memory else BrainMemory(llm_type=CONFIG.LLM_TYPE) + skill_path = Path(CONFIG.SKILL_PATH) if CONFIG.SKILL_PATH else None + self.skills = SkillLoader(skill_yaml_file_name=skill_path) + + async def think(self) -> bool: + """Everything will be done part by part.""" + last_talk = await self.refine_memory() + if not last_talk: + return False + prompt = "" + skills = self.skills.get_skill_list() + for desc, name in skills.items(): + prompt += f"If the text explicitly want you to {desc}, return `[SKILL]: {name}` brief and clear. For instance: [SKILL]: {name}\n" + prompt += 'Otherwise, return `[TALK]: {talk}` brief and clear. For instance: if {talk} is "xxxx" return [TALK]: xxxx\n\n' + prompt += f"Now what specific action is explicitly mentioned in the text: {last_talk}\n" + rsp = await self._llm.aask(prompt, []) + logger.info(f"THINK: {prompt}\n, THINK RESULT: {rsp}\n") + return await self._plan(rsp, last_talk=last_talk) + + async def act(self) -> ActionOutput: + result = await self._rc.todo.run(**CONFIG.options) + if not result: + return None + if isinstance(result, str): + msg = Message(content=result) + output = ActionOutput(content=result) + else: + msg = Message( + content=result.content, instruct_content=result.instruct_content, cause_by=type(self._rc.todo) + ) + output = result + self.memory.add_answer(msg) + return output + + async def talk(self, text): + self.memory.add_talk(Message(content=text)) + + async def _plan(self, rsp: str, **kwargs) -> bool: + skill, text = BrainMemory.extract_info(input_string=rsp) + handlers = { + MessageType.Talk.value: self.talk_handler, + MessageType.Skill.value: self.skill_handler, + } + handler = handlers.get(skill, self.talk_handler) + return await handler(text, **kwargs) + + async def talk_handler(self, text, **kwargs) -> bool: + history = self.memory.history_text + text = kwargs.get("last_talk") or text + action = TalkAction( + talk=text, knowledge=self.memory.get_knowledge(), history_summary=history, llm=self._llm, **kwargs + ) + self.add_to_do(action) + return True + + async def skill_handler(self, text, **kwargs) -> bool: + last_talk = kwargs.get("last_talk") + skill = self.skills.get_skill(text) + if not skill: + logger.info(f"skill not found: {text}") + return await self.talk_handler(text=last_talk, **kwargs) + action = ArgumentsParingAction(skill=skill, llm=self._llm, **kwargs) + await action.run(**kwargs) + if action.args is None: + return await self.talk_handler(text=last_talk, **kwargs) + action = SkillAction(skill=skill, args=action.args, llm=self._llm, name=skill.name, desc=skill.description) + self.add_to_do(action) + return True + + async def refine_memory(self) -> str: + last_talk = self.memory.pop_last_talk() + if last_talk is None: # No user feedback, unsure if past conversation is finished. + return None + if not self.memory.is_history_available: + return last_talk + history_summary = await self.memory.summarize(max_words=800, keep_language=True, llm=self._llm) + if last_talk and await self.memory.is_related(text1=last_talk, text2=history_summary, llm=self._llm): + # Merge relevant content. + last_talk = await self.memory.rewrite(sentence=last_talk, context=history_summary, llm=self._llm) + return last_talk + + return last_talk + + def get_memory(self) -> str: + return self.memory.json() + + def load_memory(self, jsn): + try: + self.memory = BrainMemory(**jsn) + except Exception as e: + logger.exception(f"load error:{e}, data:{jsn}") + + +async def main(): + topic = "what's apple" + role = Assistant(language="Chinese") + await role.talk(topic) + while True: + has_action = await role.think() + if not has_action: + break + msg = await role.act() + logger.info(msg) + # Retrieve user terminal input. + logger.info("Enter prompt") + talk = input("You: ") + await role.talk(talk) + + +if __name__ == "__main__": + CONFIG.language = "Chinese" + asyncio.run(main()) diff --git a/metagpt/roles/engineer.py b/metagpt/roles/engineer.py index 4f7f0b796..e1ab3b06b 100644 --- a/metagpt/roles/engineer.py +++ b/metagpt/roles/engineer.py @@ -16,13 +16,19 @@ @Modified By: mashenquan, 2023-12-5. Enhance the workflow to navigate to WriteCode or QaEngineer based on the results of SummarizeCode. """ +<<<<<<< HEAD from __future__ import annotations import json from collections import defaultdict +======= +import asyncio +from collections import OrderedDict +>>>>>>> send18/dev from pathlib import Path from typing import Set +<<<<<<< HEAD from metagpt.actions import Action, WriteCode, WriteCodeReview, WriteTasks from metagpt.actions.fix_bug import FixBug from metagpt.actions.summarize_code import SummarizeCode @@ -43,6 +49,18 @@ from metagpt.schema import ( Message, ) from metagpt.utils.common import any_to_name, any_to_str, any_to_str_set +======= +import aiofiles + +from metagpt.actions import WriteCode, WriteCodeReview, WriteDesign, WriteTasks +from metagpt.config import CONFIG +from metagpt.logs import logger +from metagpt.roles import Role +from metagpt.schema import Message +from metagpt.utils.common import CodeParser +from metagpt.utils.special_tokens import FILENAME_CODE_SEP, MSG_SEP + +>>>>>>> send18/dev IS_PASS_PROMPT = """ {context} @@ -67,6 +85,7 @@ class Engineer(Role): use_code_review (bool): Whether to use code review. """ +<<<<<<< HEAD def __init__( self, name: str = "Alex", @@ -77,6 +96,18 @@ class Engineer(Role): use_code_review: bool = False, ) -> None: """Initializes the Engineer role with given attributes.""" +======= +class Engineer(Role): + def __init__( + self, + name="Alex", + profile="Engineer", + goal="Write elegant, readable, extensible, efficient code", + constraints="The code you write should conform to code standard like PEP8, be modular, easy to read and maintain", + n_borg=1, + use_code_review=False, + ): +>>>>>>> send18/dev super().__init__(name, profile, goal, constraints) self.use_code_review = use_code_review self._watch([WriteTasks, SummarizeCode, WriteCode, WriteCodeReview, FixBug]) @@ -90,6 +121,7 @@ class Engineer(Role): m = json.loads(task_msg.content) return m.get("Task list") +<<<<<<< HEAD async def _act_sp_with_cr(self, review=False) -> Set[str]: changed_files = set() src_file_repo = CONFIG.git_repo.new_file_repository(CONFIG.src_workspace) @@ -113,8 +145,83 @@ class Engineer(Role): msg = Message( content=coding_context.json(), instruct_content=coding_context, role=self.profile, cause_by=WriteCode ) - self._rc.memory.add(msg) +======= + @classmethod + def parse_tasks(self, task_msg: Message) -> list[str]: + if task_msg.instruct_content: + return task_msg.instruct_content.dict().get("Task list") + return CodeParser.parse_file_list(block="Task list", text=task_msg.content) + @classmethod + def parse_code(self, code_text: str) -> str: + return CodeParser.parse_code(block="", text=code_text) + + @classmethod + def parse_workspace(cls, system_design_msg: Message) -> str: + if system_design_msg.instruct_content: + return system_design_msg.instruct_content.dict().get("Python package name").strip().strip("'").strip('"') + return CodeParser.parse_str(block="Python package name", text=system_design_msg.content) + + def get_workspace(self) -> Path: + msg = self._rc.memory.get_by_action(WriteDesign)[-1] + if not msg: + return CONFIG.workspace / "src" + workspace = self.parse_workspace(msg) + # Codes are written in workspace/{package_name}/{package_name} + return CONFIG.workspace / workspace + + async def write_file(self, filename: str, code: str): + workspace = self.get_workspace() + filename = filename.replace('"', "").replace("\n", "") + file = workspace / filename + file.parent.mkdir(parents=True, exist_ok=True) + async with aiofiles.open(file, "w") as f: + await f.write(code) + return file + + def recv(self, message: Message) -> None: + self._rc.memory.add(message) + if message in self._rc.important_memory: + self.todos = self.parse_tasks(message) + + async def _act_mp(self) -> Message: + # self.recreate_workspace() + todo_coros = [] + for todo in self.todos: + todo_coro = WriteCode().run( + context=self._rc.memory.get_by_actions([WriteTasks, WriteDesign]), filename=todo + ) + todo_coros.append(todo_coro) + + rsps = await gather_ordered_k(todo_coros, self.n_borg) + for todo, code_rsp in zip(self.todos, rsps): + _ = self.parse_code(code_rsp) + logger.info(todo) + logger.info(code_rsp) + # self.write_file(todo, code) + msg = Message(content=code_rsp, role=self.profile, cause_by=type(self._rc.todo)) + self._rc.memory.add(msg) + del self.todos[0] + + logger.info(f"Done {self.get_workspace()} generating.") + msg = Message(content="all done.", role=self.profile, cause_by=type(self._rc.todo)) + return msg + + async def _act_sp(self) -> Message: + code_msg_all = [] # gather all code info, will pass to qa_engineer for tests later + instruct_content = {} + for todo in self.todos: + code = await WriteCode().run(context=self._rc.history, filename=todo) + # logger.info(todo) + # logger.info(code_rsp) + # code = self.parse_code(code_rsp) + file_path = await self.write_file(todo, code) + msg = Message(content=code, role=self.profile, cause_by=type(self._rc.todo)) +>>>>>>> send18/dev + self._rc.memory.add(msg) + instruct_content[todo] = code + +<<<<<<< HEAD changed_files.add(coding_context.code_doc.filename) if not changed_files: logger.info("Nothing has changed.") @@ -140,8 +247,22 @@ class Engineer(Role): cause_by=WriteCodeReview if self.use_code_review else WriteCode, send_to=self, sent_from=self, +======= + # code_msg = todo + FILENAME_CODE_SEP + str(file_path) + code_msg = (todo, file_path) + code_msg_all.append(code_msg) + + logger.info(f"Done {self.get_workspace()} generating.") + msg = Message( + content=MSG_SEP.join(todo + FILENAME_CODE_SEP + str(file_path) for todo, file_path in code_msg_all), + instruct_content=instruct_content, + role=self.profile, + cause_by=type(self._rc.todo), + send_to="QaEngineer", +>>>>>>> send18/dev ) +<<<<<<< HEAD async def _act_summarize(self): code_summaries_file_repo = CONFIG.git_repo.new_file_repository(CODE_SUMMARIES_FILE_REPO) code_summaries_pdf_file_repo = CONFIG.git_repo.new_file_repository(CODE_SUMMARIES_PDF_FILE_REPO) @@ -232,6 +353,49 @@ class Engineer(Role): async def _new_coding_doc(filename, src_file_repo, task_file_repo, design_file_repo, dependency): context = await Engineer._new_coding_context( filename, src_file_repo, task_file_repo, design_file_repo, dependency +======= + async def _act_sp_precision(self) -> Message: + code_msg_all = [] # gather all code info, will pass to qa_engineer for tests later + instruct_content = {} + for todo in self.todos: + """ + # 从历史信息中挑选必须的信息,以减少prompt长度(人工经验总结) + 1. Architect全部 + 2. ProjectManager全部 + 3. 是否需要其他代码(暂时需要)? + TODO:目标是不需要。在任务拆分清楚后,根据设计思路,不需要其他代码也能够写清楚单个文件,如果不能则表示还需要在定义的更清晰,这个是代码能够写长的关键 + """ + context = [] + msg = self._rc.memory.get_by_actions([WriteDesign, WriteTasks, WriteCode]) + for m in msg: + context.append(m.content) + context_str = "\n".join(context) + # 编写code + code = await WriteCode().run(context=context_str, filename=todo) + # code review + if self.use_code_review: + try: + rewrite_code = await WriteCodeReview().run(context=context_str, code=code, filename=todo) + code = rewrite_code + except Exception as e: + logger.error("code review failed!", e) + pass + file_path = await self.write_file(todo, code) + msg = Message(content=code, role=self.profile, cause_by=WriteCode) + self._rc.memory.add(msg) + instruct_content[todo] = code + + code_msg = (todo, file_path) + code_msg_all.append(code_msg) + + logger.info(f"Done {self.get_workspace()} generating.") + msg = Message( + content=MSG_SEP.join(todo + FILENAME_CODE_SEP + str(file_path) for todo, file_path in code_msg_all), + instruct_content=instruct_content, + role=self.profile, + cause_by=type(self._rc.todo), + send_to="QaEngineer", +>>>>>>> send18/dev ) coding_doc = Document(root_path=str(src_file_repo.root_path), filename=filename, content=context.json()) return coding_doc diff --git a/metagpt/roles/qa_engineer.py b/metagpt/roles/qa_engineer.py index 15a01b9e9..c8bca8c42 100644 --- a/metagpt/roles/qa_engineer.py +++ b/metagpt/roles/qa_engineer.py @@ -14,6 +14,7 @@ @Modified By: mashenquan, 2023-12-5. Enhance the workflow to navigate to WriteCode or QaEngineer based on the results of SummarizeCode. """ +<<<<<<< HEAD from metagpt.actions import DebugError, RunCode, WriteCode, WriteCodeReview, WriteTest # from metagpt.const import WORKSPACE_ROOT @@ -24,6 +25,13 @@ from metagpt.const import ( TEST_CODES_FILE_REPO, TEST_OUTPUTS_FILE_REPO, ) +======= +import os +from pathlib import Path + +from metagpt.actions import DebugError, RunCode, WriteCode, WriteDesign, WriteTest +from metagpt.config import CONFIG +>>>>>>> send18/dev from metagpt.logs import logger from metagpt.roles import Role from metagpt.schema import Document, Message, RunCodeContext, TestingContext @@ -47,6 +55,32 @@ class QaEngineer(Role): self.test_round = 0 self.test_round_allowed = test_round_allowed +<<<<<<< HEAD +======= + @classmethod + def parse_workspace(cls, system_design_msg: Message) -> str: + if not system_design_msg.instruct_content: + return system_design_msg.instruct_content.dict().get("Python package name") + return CodeParser.parse_str(block="Python package name", text=system_design_msg.content) + + def get_workspace(self, return_proj_dir=True) -> Path: + msg = self._rc.memory.get_by_action(WriteDesign)[-1] + if not msg: + return CONFIG.workspace / "src" + workspace = self.parse_workspace(msg) + # project directory: workspace/{package_name}, which contains package source code folder, tests folder, resources folder, etc. + if return_proj_dir: + return CONFIG.workspace / workspace + # development codes directory: workspace/{package_name}/{package_name} + return CONFIG.workspace / workspace / workspace + + def write_file(self, filename: str, code: str): + workspace = self.get_workspace() / "tests" + file = workspace / filename + file.parent.mkdir(parents=True, exist_ok=True) + file.write_text(code) + +>>>>>>> send18/dev async def _write_test(self, message: Message) -> None: src_file_repo = CONFIG.git_repo.new_file_repository(CONFIG.src_workspace) changed_files = set(src_file_repo.changed_files.keys()) diff --git a/metagpt/roles/researcher.py b/metagpt/roles/researcher.py index 6a76b1a40..576e57969 100644 --- a/metagpt/roles/researcher.py +++ b/metagpt/roles/researcher.py @@ -1,9 +1,15 @@ #!/usr/bin/env python """ +<<<<<<< HEAD @Modified By: mashenquan, 2023-11-1. According to Chapter 2.2.1 and 2.2.2 of RFC 116, change the data type of the `cause_by` value in the `Message` to a string to support the new message distribution feature. """ +======= +@Modified By: mashenquan, 2023/8/22. A definition has been provided for the return value of _think: returning false indicates that further reasoning cannot continue. + +""" +>>>>>>> send18/dev import asyncio @@ -41,6 +47,20 @@ class Researcher(Role): if language not in ("en-us", "zh-cn"): logger.warning(f"The language `{language}` has not been tested, it may not work.") +<<<<<<< HEAD +======= + async def _think(self) -> bool: + if self._rc.todo is None: + self._set_state(0) + return True + + if self._rc.state + 1 < len(self._states): + self._set_state(self._rc.state + 1) + else: + self._rc.todo = None + return False + +>>>>>>> send18/dev async def _act(self) -> Message: logger.info(f"{self._setting}: ready to {self._rc.todo}") todo = self._rc.todo diff --git a/metagpt/roles/role.py b/metagpt/roles/role.py index e34daa307..9f2cb7753 100644 --- a/metagpt/roles/role.py +++ b/metagpt/roles/role.py @@ -4,6 +4,7 @@ @Time : 2023/5/11 14:42 @Author : alexanderwu @File : role.py +<<<<<<< HEAD @Modified By: mashenquan, 2023-11-1. According to Chapter 2.2.1 and 2.2.2 of RFC 116: 1. Merge the `recv` functionality into the `_observe` function. Future message reading operations will be consolidated within the `_observe` function. @@ -17,6 +18,10 @@ only. In the normal workflow, you should use `publish_message` or `put_message` to transmit messages. @Modified By: mashenquan, 2023-11-4. According to the routing feature plan in Chapter 2.2.3.2 of RFC 113, the routing functionality is to be consolidated into the `Environment` class. +======= +@Modified By: mashenquan, 2023-8-7, Support template-style variables, such as '{teaching_language} Teacher'. +@Modified By: mashenquan, 2023/8/22. A definition has been provided for the return value of _think: returning false indicates that further reasoning cannot continue. +>>>>>>> send18/dev """ from __future__ import annotations @@ -27,11 +32,19 @@ from pydantic import BaseModel, Field from metagpt.actions import Action, ActionOutput from metagpt.config import CONFIG +<<<<<<< HEAD from metagpt.llm import LLM, HumanProvider from metagpt.logs import logger from metagpt.memory import Memory from metagpt.schema import Message, MessageQueue from metagpt.utils.common import any_to_name, any_to_str +======= +from metagpt.const import OPTIONS +from metagpt.llm import LLMFactory +from metagpt.logs import logger +from metagpt.memory import LongTermMemory, Memory +from metagpt.schema import Message, MessageTag +>>>>>>> send18/dev PREFIX_TEMPLATE = """You are a {profile}, named {name}, your goal is {goal}, and the constraint is {constraints}. """ @@ -74,7 +87,11 @@ class RoleReactMode(str, Enum): class RoleSetting(BaseModel): +<<<<<<< HEAD """Role Settings""" +======= + """Role properties""" +>>>>>>> send18/dev name: str profile: str @@ -91,10 +108,16 @@ class RoleSetting(BaseModel): class RoleContext(BaseModel): +<<<<<<< HEAD """Role Runtime Context""" env: "Environment" = Field(default=None) msg_buffer: MessageQueue = Field(default_factory=MessageQueue) # Message Buffer with Asynchronous Updates +======= + """Runtime role context""" + + env: "Environment" = Field(default=None) +>>>>>>> send18/dev memory: Memory = Field(default_factory=Memory) # long_term_memory: LongTermMemory = Field(default_factory=LongTermMemory) state: int = Field(default=-1) # -1 indicates initial or termination state where todo is None @@ -110,21 +133,34 @@ class RoleContext(BaseModel): arbitrary_types_allowed = True def check(self, role_id: str): - if hasattr(CONFIG, "long_term_memory") and CONFIG.long_term_memory: + if CONFIG.long_term_memory: self.long_term_memory.recover_memory(role_id, self) self.memory = self.long_term_memory # use memory to act as long_term_memory for unify operation @property def important_memory(self) -> list[Message]: +<<<<<<< HEAD """Get the information corresponding to the watched actions""" +======= + """Retrieve information corresponding to the attention action.""" +>>>>>>> send18/dev return self.memory.get_by_actions(self.watch) @property def history(self) -> list[Message]: return self.memory.get() + @property + def prerequisite(self): + """Retrieve information with `prerequisite` tag""" + if self.memory and hasattr(self.memory, "get_by_tags"): + vv = self.memory.get_by_tags([MessageTag.Prerequisite.value]) + return vv[-1:] if len(vv) > 1 else vv + return [] + class Role: +<<<<<<< HEAD """Role/Agent""" def __init__(self, name="", profile="", goal="", constraints="", desc="", is_human=False): @@ -132,6 +168,20 @@ class Role: self._setting = RoleSetting( name=name, profile=profile, goal=goal, constraints=constraints, desc=desc, is_human=is_human ) +======= + """Role/Proxy""" + + def __init__(self, name="", profile="", goal="", constraints="", desc="", *args, **kwargs): + # Replace template-style variables, such as '{teaching_language} Teacher'. + name = Role.format_value(name) + profile = Role.format_value(profile) + goal = Role.format_value(goal) + constraints = Role.format_value(constraints) + desc = Role.format_value(desc) + + self._llm = LLMFactory.new_llm() + self._setting = RoleSetting(name=name, profile=profile, goal=goal, constraints=constraints, desc=desc) +>>>>>>> send18/dev self._states = [] self._actions = [] self._role_id = str(self._setting) @@ -208,8 +258,12 @@ class Role: self._rc.todo = self._actions[self._rc.state] if state >= 0 else None def set_env(self, env: "Environment"): +<<<<<<< HEAD """Set the environment in which the role works. The role can talk to the environment and can also receive messages by observing.""" +======= + """设置角色工作所处的环境,角色可以向环境说话,也可以通过观察接受环境消息""" +>>>>>>> send18/dev self._rc.env = env if env: env.set_subscription(self, self._subscription) @@ -221,6 +275,7 @@ class Role: @property def name(self): +<<<<<<< HEAD """Get virtual user name""" return self._setting.name @@ -228,6 +283,30 @@ class Role: def subscription(self) -> Set: """The labels for messages to be consumed by the Role object.""" return self._subscription +======= + """Return role `name`, read only""" + return self._setting.name + + @property + def desc(self): + """Return role `desc`, read only""" + return self._setting.desc + + @property + def goal(self): + """Return role `goal`, read only""" + return self._setting.goal + + @property + def constraints(self): + """Return role `constraints`, read only""" + return self._setting.constraints + + @property + def action_count(self): + """Return number of action""" + return len(self._actions) +>>>>>>> send18/dev def _get_prefix(self): """Get the role prefix""" @@ -235,14 +314,20 @@ class Role: return self._setting.desc return PREFIX_TEMPLATE.format(**self._setting.dict()) +<<<<<<< HEAD async def _think(self) -> None: """Think about what to do and decide on the next action""" +======= + async def _think(self) -> bool: + """Consider what to do and decide on the next course of action. Return false if nothing can be done.""" +>>>>>>> send18/dev if len(self._actions) == 1: # If there is only one action, then only this one can be performed self._set_state(0) - return + return True prompt = self._get_prefix() prompt += STATE_TEMPLATE.format( +<<<<<<< HEAD history=self._rc.history, states="\n".join(self._states), n_states=len(self._states) - 1, @@ -259,26 +344,49 @@ class Role: if next_state == -1: logger.info(f"End actions with {next_state=}") self._set_state(next_state) +======= + history=self._rc.history, states="\n".join(self._states), n_states=len(self._states) - 1 + ) + next_state = await self._llm.aask(prompt) + logger.debug(f"{prompt=}") + if not next_state.isdigit() or int(next_state) not in range(len(self._states)): + logger.warning(f"Invalid answer of state, {next_state=}") + next_state = "0" + self._set_state(int(next_state)) + return True +>>>>>>> send18/dev async def _act(self) -> Message: logger.info(f"{self._setting}: ready to {self._rc.todo}") +<<<<<<< HEAD response = await self._rc.todo.run(self._rc.important_memory) +======= + requirement = self._rc.important_memory or self._rc.prerequisite + response = await self._rc.todo.run(requirement) + # logger.info(response) +>>>>>>> send18/dev if isinstance(response, ActionOutput): msg = Message( content=response.content, instruct_content=response.instruct_content, role=self.profile, +<<<<<<< HEAD cause_by=self._rc.todo, sent_from=self, ) elif isinstance(response, Message): msg = response +======= + cause_by=type(self._rc.todo), + ) +>>>>>>> send18/dev else: msg = Message(content=response, role=self.profile, cause_by=self._rc.todo, sent_from=self) self._rc.memory.add(msg) return msg +<<<<<<< HEAD async def _observe(self, ignore_memory=False) -> int: """Prepare new messages for processing from the message buffer and other sources.""" # Read unprocessed messages from the msg buffer. @@ -292,6 +400,21 @@ class Role: # Design Rules: # If you need to further categorize Message objects, you can do so using the Message.set_meta function. # msg_buffer is a receiving buffer, avoid adding message data and operations to msg_buffer. +======= + async def _observe(self) -> int: + """从环境中观察,获得重要信息,并加入记忆""" + if not self._rc.env: + return 0 + env_msgs = self._rc.env.memory.get() + + observed = self._rc.env.memory.get_by_actions(self._rc.watch) + + self._rc.news = self._rc.memory.remember(observed) # remember recent exact or similar memories + + for i in env_msgs: + self.recv(i) + +>>>>>>> send18/dev news_text = [f"{i.role}: {i.content[:20]}..." for i in self._rc.news] if news_text: logger.debug(f"{self._setting} observed: {news_text}") @@ -382,10 +505,36 @@ class Role: self.publish_message(rsp) return rsp +<<<<<<< HEAD @property def is_idle(self) -> bool: """If true, all actions have been executed.""" return not self._rc.news and not self._rc.todo and self._rc.msg_buffer.empty() +======= + @staticmethod + def format_value(value): + """Fill parameters inside `value` with `options`.""" + if not isinstance(value, str): + return value + if "{" not in value: + return value + + merged_opts = OPTIONS.get() or {} + try: + return value.format(**merged_opts) + except KeyError as e: + logger.warning(f"Parameter is missing:{e}") + + for k, v in merged_opts.items(): + value = value.replace("{" + f"{k}" + "}", str(v)) + return value + + def add_action(self, act): + self._actions.append(act) + + def add_to_do(self, act): + self._rc.todo = act +>>>>>>> send18/dev async def think(self) -> Action: """The exported `think` function""" @@ -398,7 +547,16 @@ class Role: return ActionOutput(content=msg.content, instruct_content=msg.instruct_content) @property +<<<<<<< HEAD def todo(self) -> str: if self._actions: return any_to_name(self._actions[0]) return "" +======= + def todo_description(self): + if not self._rc or not self._rc.todo: + return "" + if self._rc.todo.desc: + return self._rc.todo.desc + return f"{type(self._rc.todo).__name__}" +>>>>>>> send18/dev diff --git a/metagpt/roles/teacher.py b/metagpt/roles/teacher.py new file mode 100644 index 000000000..031ce94c9 --- /dev/null +++ b/metagpt/roles/teacher.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +@Time : 2023/7/27 +@Author : mashenquan +@File : teacher.py +@Modified By: mashenquan, 2023/8/22. A definition has been provided for the return value of _think: returning false indicates that further reasoning cannot continue. + +""" + + +import re + +import aiofiles + +from metagpt.actions.write_teaching_plan import ( + TeachingPlanRequirement, + WriteTeachingPlanPart, +) +from metagpt.config import CONFIG +from metagpt.logs import logger +from metagpt.roles import Role +from metagpt.schema import Message + + +class Teacher(Role): + """Support configurable teacher roles, + with native and teaching languages being replaceable through configurations.""" + + def __init__( + self, + name="Lily", + profile="{teaching_language} Teacher", + goal="writing a {language} teaching plan part by part", + constraints="writing in {language}", + desc="", + *args, + **kwargs, + ): + super().__init__(name=name, profile=profile, goal=goal, constraints=constraints, desc=desc, *args, **kwargs) + actions = [] + for topic in WriteTeachingPlanPart.TOPICS: + act = WriteTeachingPlanPart(topic=topic, llm=self._llm) + actions.append(act) + self._init_actions(actions) + self._watch({TeachingPlanRequirement}) + + async def _think(self) -> bool: + """Everything will be done part by part.""" + if self._rc.todo is None: + self._set_state(0) + return True + + if self._rc.state + 1 < len(self._states): + self._set_state(self._rc.state + 1) + return True + + self._rc.todo = None + return False + + async def _react(self) -> Message: + ret = Message(content="") + while True: + await self._think() + if self._rc.todo is None: + break + logger.debug(f"{self._setting}: {self._rc.state=}, will do {self._rc.todo}") + msg = await self._act() + if ret.content != "": + ret.content += "\n\n\n" + ret.content += msg.content + logger.info(ret.content) + await self.save(ret.content) + return ret + + async def save(self, content): + """Save teaching plan""" + filename = Teacher.new_file_name(self.course_title) + pathname = CONFIG.workspace / "teaching_plan" + pathname.mkdir(exist_ok=True) + pathname = pathname / filename + try: + async with aiofiles.open(str(pathname), mode="w", encoding="utf-8") as writer: + await writer.write(content) + except Exception as e: + logger.error(f"Save failed:{e}") + logger.info(f"Save to:{pathname}") + + @staticmethod + def new_file_name(lesson_title, ext=".md"): + """Create a related file name based on `lesson_title` and `ext`.""" + # Define the special characters that need to be replaced. + illegal_chars = r'[#@$%!*&\\/:*?"<>|\n\t \']' + # Replace the special characters with underscores. + filename = re.sub(illegal_chars, "_", lesson_title) + ext + return re.sub(r"_+", "_", filename) + + @property + def course_title(self): + """Return course title of teaching plan""" + default_title = "teaching_plan" + for act in self._actions: + if act.topic != WriteTeachingPlanPart.COURSE_TITLE: + continue + if act.rsp is None: + return default_title + title = act.rsp.lstrip("# \n") + if "\n" in title: + ix = title.index("\n") + title = title[0:ix] + return title + + return default_title diff --git a/metagpt/schema.py b/metagpt/schema.py index 25281e399..70e84ff15 100644 --- a/metagpt/schema.py +++ b/metagpt/schema.py @@ -22,9 +22,7 @@ from asyncio import Queue, QueueEmpty, wait_for from json import JSONDecodeError from pathlib import Path from typing import Dict, List, Optional, Set, TypedDict - from pydantic import BaseModel, Field - from metagpt.config import CONFIG from metagpt.const import ( MESSAGE_ROUTE_CAUSE_BY, @@ -345,3 +343,4 @@ class CodeSummarizeContext(BaseModel): class BugFixContext(BaseModel): filename: str = "" + diff --git a/metagpt/team.py b/metagpt/team.py index 152ad24f0..91587655f 100644 --- a/metagpt/team.py +++ b/metagpt/team.py @@ -42,9 +42,11 @@ class Team(BaseModel): CONFIG.max_budget = investment logger.info(f"Investment: ${investment}.") - def _check_balance(self): - if CONFIG.total_cost > CONFIG.max_budget: - raise NoMoneyException(CONFIG.total_cost, f"Insufficient funds: {CONFIG.max_budget}") + @staticmethod + def _check_balance(): + if CONFIG.cost_manager.total_cost > CONFIG.cost_manager.max_budget: + raise NoMoneyException(CONFIG.cost_manager.total_cost, + f'Insufficient funds: {CONFIG.cost_manager.max_budget}') def run_project(self, idea, send_to: str = ""): """Start a project from publishing user requirement.""" diff --git a/metagpt/tools/__init__.py b/metagpt/tools/__init__.py index d98087e4b..a148bb744 100644 --- a/metagpt/tools/__init__.py +++ b/metagpt/tools/__init__.py @@ -22,3 +22,8 @@ class WebBrowserEngineType(Enum): PLAYWRIGHT = "playwright" SELENIUM = "selenium" CUSTOM = "custom" + + @classmethod + def _missing_(cls, key): + """缺省类型转换""" + return cls.CUSTOM diff --git a/metagpt/tools/azure_tts.py b/metagpt/tools/azure_tts.py new file mode 100644 index 000000000..6864faf10 --- /dev/null +++ b/metagpt/tools/azure_tts.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +@Time : 2023/8/17 +@Author : mashenquan +@File : azure_tts.py +@Desc : azure TTS OAS3 api, which provides text-to-speech functionality +""" +import asyncio +import base64 +from pathlib import Path +from uuid import uuid4 + +import aiofiles +from azure.cognitiveservices.speech import AudioConfig, SpeechConfig, SpeechSynthesizer + +from metagpt.config import CONFIG, Config +from metagpt.logs import logger + + +class AzureTTS: + """Azure Text-to-Speech""" + + def __init__(self, subscription_key, region): + """ + :param subscription_key: key is used to access your Azure AI service API, see: `https://portal.azure.com/` > `Resource Management` > `Keys and Endpoint` + :param region: This is the location (or region) of your resource. You may need to use this field when making calls to this API. + """ + self.subscription_key = subscription_key if subscription_key else CONFIG.AZURE_TTS_SUBSCRIPTION_KEY + self.region = region if region else CONFIG.AZURE_TTS_REGION + + # 参数参考:https://learn.microsoft.com/zh-cn/azure/cognitive-services/speech-service/language-support?tabs=tts#voice-styles-and-roles + async def synthesize_speech(self, lang, voice, text, output_file): + speech_config = SpeechConfig(subscription=self.subscription_key, region=self.region) + speech_config.speech_synthesis_voice_name = voice + audio_config = AudioConfig(filename=output_file) + synthesizer = SpeechSynthesizer(speech_config=speech_config, audio_config=audio_config) + + # More detail: https://learn.microsoft.com/en-us/azure/ai-services/speech-service/speech-synthesis-markup-voice + ssml_string = ( + "" + f"{text}" + ) + + return synthesizer.speak_ssml_async(ssml_string).get() + + @staticmethod + def role_style_text(role, style, text): + return f'{text}' + + @staticmethod + def role_text(role, text): + return f'{text}' + + @staticmethod + def style_text(style, text): + return f'{text}' + + +# Export +async def oas3_azsure_tts(text, lang="", voice="", style="", role="", subscription_key="", region=""): + """Text to speech + For more details, check out:`https://learn.microsoft.com/en-us/azure/ai-services/speech-service/language-support?tabs=tts` + + :param lang: The value can contain a language code such as en (English), or a locale such as en-US (English - United States). For more details, checkout: `https://learn.microsoft.com/en-us/azure/ai-services/speech-service/language-support?tabs=tts` + :param voice: For more details, checkout: `https://learn.microsoft.com/en-us/azure/ai-services/speech-service/language-support?tabs=tts`, `https://speech.microsoft.com/portal/voicegallery` + :param style: Speaking style to express different emotions like cheerfulness, empathy, and calm. For more details, checkout: `https://learn.microsoft.com/en-us/azure/ai-services/speech-service/language-support?tabs=tts` + :param role: With roles, the same voice can act as a different age and gender. For more details, checkout: `https://learn.microsoft.com/en-us/azure/ai-services/speech-service/language-support?tabs=tts` + :param text: The text used for voice conversion. + :param subscription_key: key is used to access your Azure AI service API, see: `https://portal.azure.com/` > `Resource Management` > `Keys and Endpoint` + :param region: This is the location (or region) of your resource. You may need to use this field when making calls to this API. + :return: Returns the Base64-encoded .wav file data if successful, otherwise an empty string. + + """ + if not text: + return "" + + if not lang: + lang = "zh-CN" + if not voice: + voice = "zh-CN-XiaomoNeural" + if not role: + role = "Girl" + if not style: + style = "affectionate" + if not subscription_key: + subscription_key = CONFIG.AZURE_TTS_SUBSCRIPTION_KEY + if not region: + region = CONFIG.AZURE_TTS_REGION + + xml_value = AzureTTS.role_style_text(role=role, style=style, text=text) + tts = AzureTTS(subscription_key=subscription_key, region=region) + filename = Path(__file__).resolve().parent / (str(uuid4()).replace("-", "") + ".wav") + try: + await tts.synthesize_speech(lang=lang, voice=voice, text=xml_value, output_file=str(filename)) + async with aiofiles.open(filename, mode="rb") as reader: + data = await reader.read() + base64_string = base64.b64encode(data).decode("utf-8") + filename.unlink() + except Exception as e: + logger.error(f"text:{text}, error:{e}") + return "" + + return base64_string + + +if __name__ == "__main__": + Config() + loop = asyncio.new_event_loop() + v = loop.create_task(oas3_azsure_tts("测试,test")) + loop.run_until_complete(v) + print(v) diff --git a/metagpt/tools/hello.py b/metagpt/tools/hello.py new file mode 100644 index 000000000..2eb4c31f0 --- /dev/null +++ b/metagpt/tools/hello.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +@Time : 2023/5/2 16:03 +@Author : mashenquan +@File : hello.py +@Desc : Implement the OpenAPI Specification 3.0 demo and use the following command to test the HTTP service: + + curl -X 'POST' \ + 'http://localhost:8080/openapi/greeting/dave' \ + -H 'accept: text/plain' \ + -H 'Content-Type: application/json' \ + -d '{}' +""" + +import connexion + + +# openapi implement +async def post_greeting(name: str) -> str: + return f"Hello {name}\n" + + +if __name__ == "__main__": + app = connexion.AioHttpApp(__name__, specification_dir='../../.well-known/') + app.add_api("openapi.yaml", arguments={"title": "Hello World Example"}) + app.run(port=8080) diff --git a/metagpt/tools/iflytek_tts.py b/metagpt/tools/iflytek_tts.py new file mode 100644 index 000000000..cb87d2e7f --- /dev/null +++ b/metagpt/tools/iflytek_tts.py @@ -0,0 +1,162 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +@Time : 2023/8/17 +@Author : mashenquan +@File : iflytek_tts.py +@Desc : iFLYTEK TTS OAS3 api, which provides text-to-speech functionality +""" +import asyncio +import base64 +import hashlib +import hmac +import json +import uuid +from datetime import datetime +from enum import Enum +from pathlib import Path +from time import mktime +from typing import Optional +from urllib.parse import urlencode +from wsgiref.handlers import format_date_time + +import aiofiles +import websockets as websockets +from pydantic import BaseModel + +from metagpt.config import CONFIG +from metagpt.logs import logger + + +class IFlyTekTTSStatus(Enum): + STATUS_FIRST_FRAME = 0 # The first frame + STATUS_CONTINUE_FRAME = 1 # The intermediate frame + STATUS_LAST_FRAME = 2 # The last frame + + +class AudioData(BaseModel): + audio: str + status: int + ced: str + + +class IFlyTekTTSResponse(BaseModel): + code: int + message: str + data: Optional[AudioData] = None + sid: str + + +DEFAULT_IFLYTEK_VOICE = "xiaoyan" + + +class IFlyTekTTS(object): + def __init__(self, app_id: str, api_key: str, api_secret: str): + """ + :param app_id: Application ID is used to access your iFlyTek service API, see: `https://console.xfyun.cn/services/tts` + :param api_key: WebAPI argument, see: `https://console.xfyun.cn/services/tts` + :param api_secret: WebAPI argument, see: `https://console.xfyun.cn/services/tts` + """ + self.app_id = app_id or CONFIG.IFLYTEK_APP_ID + self.api_key = api_key or CONFIG.IFLYTEK_API_KEY + self.api_secret = api_secret or CONFIG.API_SECRET + + async def synthesize_speech(self, text, output_file: str, voice=DEFAULT_IFLYTEK_VOICE): + url = self._create_url() + data = { + "common": {"app_id": self.app_id}, + "business": {"aue": "lame", "sfl": 1, "auf": "audio/L16;rate=16000", "vcn": voice, "tte": "utf8"}, + "data": {"status": 2, "text": str(base64.b64encode(text.encode("utf-8")), "UTF8")}, + } + req = json.dumps(data) + async with websockets.connect(url) as websocket: + # send request + await websocket.send(req) + + # receive frames + async with aiofiles.open(str(output_file), "w") as writer: + while True: + v = await websocket.recv() + rsp = IFlyTekTTSResponse(**json.loads(v)) + if rsp.data: + await writer.write(rsp.data.audio) + if rsp.data.status != IFlyTekTTSStatus.STATUS_LAST_FRAME.value: + continue + break + + def _create_url(self): + """Create request url""" + url = "wss://tts-api.xfyun.cn/v2/tts" + # Generate a timestamp in RFC1123 format + now = datetime.now() + date = format_date_time(mktime(now.timetuple())) + + signature_origin = "host: " + "ws-api.xfyun.cn" + "\n" + signature_origin += "date: " + date + "\n" + signature_origin += "GET " + "/v2/tts " + "HTTP/1.1" + # Perform HMAC-SHA256 encryption + signature_sha = hmac.new( + self.api_secret.encode("utf-8"), signature_origin.encode("utf-8"), digestmod=hashlib.sha256 + ).digest() + signature_sha = base64.b64encode(signature_sha).decode(encoding="utf-8") + + authorization_origin = 'api_key="%s", algorithm="%s", headers="%s", signature="%s"' % ( + self.api_key, + "hmac-sha256", + "host date request-line", + signature_sha, + ) + authorization = base64.b64encode(authorization_origin.encode("utf-8")).decode(encoding="utf-8") + # Combine the authentication parameters of the request into a dictionary. + v = {"authorization": authorization, "date": date, "host": "ws-api.xfyun.cn"} + # Concatenate the authentication parameters to generate the URL. + url = url + "?" + urlencode(v) + return url + + +# Export +async def oas3_iflytek_tts(text: str, voice: str = "", app_id: str = "", api_key: str = "", api_secret: str = ""): + """Text to speech + For more details, check out:`https://www.xfyun.cn/doc/tts/online_tts/API.html` + + :param voice: Default `xiaoyan`. For more details, checkout: `https://www.xfyun.cn/doc/tts/online_tts/API.html#%E6%8E%A5%E5%8F%A3%E8%B0%83%E7%94%A8%E6%B5%81%E7%A8%8B` + :param text: The text used for voice conversion. + :param app_id: Application ID is used to access your iFlyTek service API, see: `https://console.xfyun.cn/services/tts` + :param api_key: WebAPI argument, see: `https://console.xfyun.cn/services/tts` + :param api_secret: WebAPI argument, see: `https://console.xfyun.cn/services/tts` + :return: Returns the Base64-encoded .mp3 file data if successful, otherwise an empty string. + + """ + if not app_id: + app_id = CONFIG.IFLYTEK_APP_ID + if not api_key: + api_key = CONFIG.IFLYTEK_API_KEY + if not api_secret: + api_secret = CONFIG.IFLYTEK_API_SECRET + if not voice: + voice = CONFIG.IFLYTEK_VOICE or DEFAULT_IFLYTEK_VOICE + + filename = Path(__file__).parent / (uuid.uuid4().hex + ".mp3") + try: + tts = IFlyTekTTS(app_id=app_id, api_key=api_key, api_secret=api_secret) + await tts.synthesize_speech(text=text, output_file=str(filename), voice=voice) + async with aiofiles.open(str(filename), mode="r") as reader: + base64_string = await reader.read() + except Exception as e: + logger.error(f"text:{text}, error:{e}") + base64_string = "" + finally: + filename.unlink() + + return base64_string + + +if __name__ == "__main__": + asyncio.get_event_loop().run_until_complete( + oas3_iflytek_tts( + text="你好,hello", + app_id="f7acef62", + api_key="fda72e3aa286042a492525816a5efa08", + api_secret="ZDk3NjdiMDBkODJlOWQ1NjRjMGI2NDY4", + ) + ) diff --git a/metagpt/tools/metagpt_oas3_api_svc.py b/metagpt/tools/metagpt_oas3_api_svc.py new file mode 100644 index 000000000..2ff4c8225 --- /dev/null +++ b/metagpt/tools/metagpt_oas3_api_svc.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +@Time : 2023/8/17 +@Author : mashenquan +@File : metagpt_oas3_api_svc.py +@Desc : MetaGPT OpenAPI Specification 3.0 REST API service +""" +import asyncio +import sys +from pathlib import Path + +import connexion + +sys.path.append(str(Path(__file__).resolve().parent.parent.parent)) # fix-bug: No module named 'metagpt' + + +def oas_http_svc(): + """Start the OAS 3.0 OpenAPI HTTP service""" + app = connexion.AioHttpApp(__name__, specification_dir="../../.well-known/") + app.add_api("metagpt_oas3_api.yaml") + app.add_api("openapi.yaml") + app.run(port=8080) + + +async def async_main(): + """Start the OAS 3.0 OpenAPI HTTP service in the background.""" + loop = asyncio.get_event_loop() + loop.run_in_executor(None, oas_http_svc) + + # TODO: replace following codes: + while True: + await asyncio.sleep(1) + print("sleep") + + +def main(): + print("http://localhost:8080/oas3/ui/") + oas_http_svc() + + +if __name__ == "__main__": + # asyncio.run(async_main()) + main() diff --git a/metagpt/tools/metagpt_text_to_image.py b/metagpt/tools/metagpt_text_to_image.py new file mode 100644 index 000000000..c5a0b872f --- /dev/null +++ b/metagpt/tools/metagpt_text_to_image.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +@Time : 2023/8/18 +@Author : mashenquan +@File : metagpt_text_to_image.py +@Desc : MetaGPT Text-to-Image OAS3 api, which provides text-to-image functionality. +""" +import asyncio +import base64 +import os +import sys +from pathlib import Path +from typing import List, Dict + +import aiohttp +import requests +from pydantic import BaseModel + +from metagpt.config import CONFIG, Config + +sys.path.append(str(Path(__file__).resolve().parent.parent.parent)) # fix-bug: No module named 'metagpt' +from metagpt.logs import logger + + +class MetaGPTText2Image: + def __init__(self, model_url): + """ + :param model_url: Model reset api url + """ + self.model_url = model_url if model_url else CONFIG.METAGPT_TEXT_TO_IMAGE_MODEL + + async def text_2_image(self, text, size_type="512x512"): + """Text to image + + :param text: The text used for image conversion. + :param size_type: One of ['512x512', '512x768'] + :return: The image data is returned in Base64 encoding. + """ + + headers = { + "Content-Type": "application/json" + } + dims = size_type.split("x") + data = { + "prompt": text, + "negative_prompt": "(easynegative:0.8),black, dark,Low resolution", + "override_settings": {"sd_model_checkpoint": "galaxytimemachinesGTM_photoV20"}, + "seed": -1, + "batch_size": 1, + "n_iter": 1, + "steps": 20, + "cfg_scale": 11, + "width": int(dims[0]), + "height": int(dims[1]), # 768, + "restore_faces": False, + "tiling": False, + "do_not_save_samples": False, + "do_not_save_grid": False, + "enable_hr": False, + "hr_scale": 2, + "hr_upscaler": "Latent", + "hr_second_pass_steps": 0, + "hr_resize_x": 0, + "hr_resize_y": 0, + "hr_upscale_to_x": 0, + "hr_upscale_to_y": 0, + "truncate_x": 0, + "truncate_y": 0, + "applied_old_hires_behavior_to": None, + "eta": None, + "sampler_index": "DPM++ SDE Karras", + "alwayson_scripts": {}, + } + + class ImageResult(BaseModel): + images: List + parameters: Dict + + try: + async with aiohttp.ClientSession() as session: + async with session.post(self.model_url, headers=headers, json=data) as response: + result = ImageResult(**await response.json()) + if len(result.images) == 0: + return "" + return result.images[0] + except requests.exceptions.RequestException as e: + logger.error(f"An error occurred:{e}") + return "" + + +# Export +async def oas3_metagpt_text_to_image(text, size_type: str = "512x512", model_url=""): + """Text to image + + :param text: The text used for image conversion. + :param model_url: Model reset api + :param size_type: One of ['512x512', '512x768'] + :return: The image data is returned in Base64 encoding. + """ + if not text: + return "" + if not model_url: + model_url = CONFIG.METAGPT_TEXT_TO_IMAGE_MODEL_URL + return await MetaGPTText2Image(model_url).text_2_image(text, size_type=size_type) + + +if __name__ == "__main__": + Config() + loop = asyncio.new_event_loop() + task = loop.create_task(oas3_metagpt_text_to_image("Panda emoji")) + v = loop.run_until_complete(task) + print(v) + data = base64.b64decode(v) + with open("tmp.png", mode="wb") as writer: + writer.write(data) + print(v) diff --git a/metagpt/tools/openai_text_to_embedding.py b/metagpt/tools/openai_text_to_embedding.py new file mode 100644 index 000000000..86b58d71f --- /dev/null +++ b/metagpt/tools/openai_text_to_embedding.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +@Time : 2023/8/18 +@Author : mashenquan +@File : openai_text_to_embedding.py +@Desc : OpenAI Text-to-Embedding OAS3 api, which provides text-to-embedding functionality. + For more details, checkout: `https://platform.openai.com/docs/api-reference/embeddings/object` +""" +import asyncio +import os +from pathlib import Path +from typing import List + +import aiohttp +import requests +from pydantic import BaseModel +import sys + +from metagpt.config import CONFIG, Config + +sys.path.append(str(Path(__file__).resolve().parent.parent.parent)) # fix-bug: No module named 'metagpt' +from metagpt.logs import logger + + +class Embedding(BaseModel): + """Represents an embedding vector returned by embedding endpoint.""" + object: str # The object type, which is always "embedding". + embedding: List[ + float] # The embedding vector, which is a list of floats. The length of vector depends on the model as listed in the embedding guide. + index: int # The index of the embedding in the list of embeddings. + + +class Usage(BaseModel): + prompt_tokens: int + total_tokens: int + + +class ResultEmbedding(BaseModel): + object: str + data: List[Embedding] + model: str + usage: Usage + + +class OpenAIText2Embedding: + def __init__(self, openai_api_key): + """ + :param openai_api_key: OpenAI API key, For more details, checkout: `https://platform.openai.com/account/api-keys` + """ + self.openai_api_key = openai_api_key if openai_api_key else CONFIG.OPENAI_API_KEY + + async def text_2_embedding(self, text, model="text-embedding-ada-002"): + """Text to embedding + + :param text: The text used for embedding. + :param model: One of ['text-embedding-ada-002'], ID of the model to use. For more details, checkout: `https://api.openai.com/v1/models`. + :return: A json object of :class:`ResultEmbedding` class if successful, otherwise `{}`. + """ + + headers = { + "Content-Type": "application/json", + "Authorization": f"Bearer {self.openai_api_key}" + } + data = {"input": text, "model": model} + try: + async with aiohttp.ClientSession() as session: + async with session.post("https://api.openai.com/v1/embeddings", headers=headers, json=data) as response: + return await response.json() + except requests.exceptions.RequestException as e: + logger.error(f"An error occurred:{e}") + return {} + + +# Export +async def oas3_openai_text_to_embedding(text, model="text-embedding-ada-002", openai_api_key=""): + """Text to embedding + + :param text: The text used for embedding. + :param model: One of ['text-embedding-ada-002'], ID of the model to use. For more details, checkout: `https://api.openai.com/v1/models`. + :param openai_api_key: OpenAI API key, For more details, checkout: `https://platform.openai.com/account/api-keys` + :return: A json object of :class:`ResultEmbedding` class if successful, otherwise `{}`. + """ + if not text: + return "" + if not openai_api_key: + openai_api_key = CONFIG.OPENAI_API_KEY + return await OpenAIText2Embedding(openai_api_key).text_2_embedding(text, model=model) + + +if __name__ == "__main__": + Config() + loop = asyncio.new_event_loop() + task = loop.create_task(oas3_openai_text_to_embedding("Panda emoji")) + v = loop.run_until_complete(task) + print(v) diff --git a/metagpt/tools/openai_text_to_image.py b/metagpt/tools/openai_text_to_image.py new file mode 100644 index 000000000..6025f04ba --- /dev/null +++ b/metagpt/tools/openai_text_to_image.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +@Time : 2023/8/17 +@Author : mashenquan +@File : openai_text_to_image.py +@Desc : OpenAI Text-to-Image OAS3 api, which provides text-to-image functionality. +""" +import asyncio +import base64 + +import aiohttp +import openai +import requests + +from metagpt.config import CONFIG, Config +from metagpt.logs import logger + + +class OpenAIText2Image: + def __init__(self, openai_api_key): + """ + :param openai_api_key: OpenAI API key, For more details, checkout: `https://platform.openai.com/account/api-keys` + """ + self.openai_api_key = openai_api_key if openai_api_key else CONFIG.OPENAI_API_KEY + + async def text_2_image(self, text, size_type="1024x1024"): + """Text to image + + :param text: The text used for image conversion. + :param size_type: One of ['256x256', '512x512', '1024x1024'] + :return: The image data is returned in Base64 encoding. + """ + try: + result = await openai.Image.acreate( + api_key=CONFIG.OPENAI_API_KEY, + api_base=CONFIG.OPENAI_API_BASE, + api_type=None, + api_version=None, + organization=None, + prompt=text, + n=1, + size=size_type, + ) + except Exception as e: + logger.error(f"An error occurred:{e}") + return "" + if result and len(result.data) > 0: + return await OpenAIText2Image.get_image_data(result.data[0].url) + return "" + + @staticmethod + async def get_image_data(url): + """Fetch image data from a URL and encode it as Base64 + + :param url: Image url + :return: Base64-encoded image data. + """ + try: + async with aiohttp.ClientSession() as session: + async with session.get(url) as response: + response.raise_for_status() # 如果是 4xx 或 5xx 响应,会引发异常 + image_data = await response.read() + base64_image = base64.b64encode(image_data).decode("utf-8") + return base64_image + + except requests.exceptions.RequestException as e: + logger.error(f"An error occurred:{e}") + return "" + + +# Export +async def oas3_openai_text_to_image(text, size_type: str = "1024x1024", openai_api_key=""): + """Text to image + + :param text: The text used for image conversion. + :param openai_api_key: OpenAI API key, For more details, checkout: `https://platform.openai.com/account/api-keys` + :param size_type: One of ['256x256', '512x512', '1024x1024'] + :return: The image data is returned in Base64 encoding. + """ + if not text: + return "" + if not openai_api_key: + openai_api_key = CONFIG.OPENAI_API_KEY + return await OpenAIText2Image(openai_api_key).text_2_image(text, size_type=size_type) + + +if __name__ == "__main__": + Config() + loop = asyncio.new_event_loop() + task = loop.create_task(oas3_openai_text_to_image("Panda emoji")) + v = loop.run_until_complete(task) + print(v) diff --git a/metagpt/tools/sd_engine.py b/metagpt/tools/sd_engine.py index a84812f7c..479f83c63 100644 --- a/metagpt/tools/sd_engine.py +++ b/metagpt/tools/sd_engine.py @@ -13,7 +13,12 @@ from typing import List from aiohttp import ClientSession from PIL import Image, PngImagePlugin +<<<<<<< HEAD from metagpt.config import CONFIG +======= +from metagpt.config import Config +from metagpt.logs import logger +>>>>>>> send18/dev # from metagpt.const import WORKSPACE_ROOT from metagpt.logs import logger @@ -79,7 +84,11 @@ class SDEngine: return self.payload def _save(self, imgs, save_name=""): +<<<<<<< HEAD save_dir = CONFIG.workspace_path / "resources" / "SD_Output" +======= + save_dir = CONFIG.get_workspace() / "resources" / "SD_Output" +>>>>>>> send18/dev if not os.path.exists(save_dir): os.makedirs(save_dir, exist_ok=True) batch_decode_base64_to_image(imgs, save_dir, save_name=save_name) diff --git a/metagpt/tools/web_browser_engine.py b/metagpt/tools/web_browser_engine.py index 453d87f31..1f1a5ec67 100644 --- a/metagpt/tools/web_browser_engine.py +++ b/metagpt/tools/web_browser_engine.py @@ -1,9 +1,12 @@ #!/usr/bin/env python +""" +@Modified By: mashenquan, 2023/8/20. Remove global configuration `CONFIG`, enable configuration support for business isolation. +""" from __future__ import annotations import importlib -from typing import Any, Callable, Coroutine, Literal, overload +from typing import Any, Callable, Coroutine, Dict, Literal, overload from metagpt.config import CONFIG from metagpt.tools import WebBrowserEngineType @@ -13,18 +16,21 @@ from metagpt.utils.parse_html import WebPage class WebBrowserEngine: def __init__( self, + options: Dict, engine: WebBrowserEngineType | None = None, run_func: Callable[..., Coroutine[Any, Any, WebPage | list[WebPage]]] | None = None, ): - engine = engine or CONFIG.web_browser_engine + engine = engine or options.get("web_browser_engine") + if engine is None: + raise NotImplementedError - if engine == WebBrowserEngineType.PLAYWRIGHT: + if WebBrowserEngineType(engine) is WebBrowserEngineType.PLAYWRIGHT: module = "metagpt.tools.web_browser_engine_playwright" - run_func = importlib.import_module(module).PlaywrightWrapper().run - elif engine == WebBrowserEngineType.SELENIUM: + run_func = importlib.import_module(module).PlaywrightWrapper(options=options).run + elif WebBrowserEngineType(engine) is WebBrowserEngineType.SELENIUM: module = "metagpt.tools.web_browser_engine_selenium" - run_func = importlib.import_module(module).SeleniumWrapper().run - elif engine == WebBrowserEngineType.CUSTOM: + run_func = importlib.import_module(module).SeleniumWrapper(options=options).run + elif WebBrowserEngineType(engine) is WebBrowserEngineType.CUSTOM: run_func = run_func else: raise NotImplementedError @@ -47,6 +53,8 @@ if __name__ == "__main__": import fire async def main(url: str, *urls: str, engine_type: Literal["playwright", "selenium"] = "playwright", **kwargs): - return await WebBrowserEngine(WebBrowserEngineType(engine_type), **kwargs).run(url, *urls) + return await WebBrowserEngine(options=CONFIG.options, engine=WebBrowserEngineType(engine_type), **kwargs).run( + url, *urls + ) fire.Fire(main) diff --git a/metagpt/tools/web_browser_engine_playwright.py b/metagpt/tools/web_browser_engine_playwright.py index 030e7701b..8eecc4f40 100644 --- a/metagpt/tools/web_browser_engine_playwright.py +++ b/metagpt/tools/web_browser_engine_playwright.py @@ -1,4 +1,8 @@ #!/usr/bin/env python +""" +@Modified By: mashenquan, 2023/8/20. Remove global configuration `CONFIG`, enable configuration support for business isolation. +""" + from __future__ import annotations import asyncio @@ -144,6 +148,6 @@ if __name__ == "__main__": import fire async def main(url: str, *urls: str, browser_type: str = "chromium", **kwargs): - return await PlaywrightWrapper(browser_type, **kwargs).run(url, *urls) + return await PlaywrightWrapper(browser_type=browser_type, **kwargs).run(url, *urls) fire.Fire(main) diff --git a/metagpt/tools/web_browser_engine_selenium.py b/metagpt/tools/web_browser_engine_selenium.py index d727709b8..b0fcb3fe1 100644 --- a/metagpt/tools/web_browser_engine_selenium.py +++ b/metagpt/tools/web_browser_engine_selenium.py @@ -1,17 +1,21 @@ #!/usr/bin/env python +""" +@Modified By: mashenquan, 2023/8/20. Remove global configuration `CONFIG`, enable configuration support for business isolation. +""" + from __future__ import annotations import asyncio import importlib from concurrent import futures from copy import deepcopy -from typing import Literal +from typing import Literal, Dict from selenium.webdriver.common.by import By from selenium.webdriver.support import expected_conditions as EC from selenium.webdriver.support.wait import WebDriverWait -from metagpt.config import CONFIG +from metagpt.config import Config from metagpt.utils.parse_html import WebPage @@ -29,6 +33,7 @@ class SeleniumWrapper: def __init__( self, + options: Dict, browser_type: Literal["chrome", "firefox", "edge", "ie"] | None = None, launch_kwargs: dict | None = None, *, @@ -36,11 +41,11 @@ class SeleniumWrapper: executor: futures.Executor | None = None, ) -> None: if browser_type is None: - browser_type = CONFIG.selenium_browser_type + browser_type = options.get("selenium_browser_type") self.browser_type = browser_type launch_kwargs = launch_kwargs or {} - if CONFIG.global_proxy and "proxy-server" not in launch_kwargs: - launch_kwargs["proxy-server"] = CONFIG.global_proxy + if options.get("global_proxy") and "proxy-server" not in launch_kwargs: + launch_kwargs["proxy-server"] = options.get("global_proxy") self.executable_path = launch_kwargs.pop("executable_path", None) self.launch_args = [f"--{k}={v}" for k, v in launch_kwargs.items()] @@ -118,6 +123,8 @@ if __name__ == "__main__": import fire async def main(url: str, *urls: str, browser_type: str = "chrome", **kwargs): - return await SeleniumWrapper(browser_type, **kwargs).run(url, *urls) + return await SeleniumWrapper(options=Config().runtime_options, + browser_type=browser_type, + **kwargs).run(url, *urls) fire.Fire(main) diff --git a/metagpt/utils/common.py b/metagpt/utils/common.py index 8d4d8eaf9..b627316cd 100644 --- a/metagpt/utils/common.py +++ b/metagpt/utils/common.py @@ -18,8 +18,10 @@ import os import platform import re from typing import List, Tuple, Union - from metagpt.const import MESSAGE_ROUTE_TO_ALL +from pathlib import Path +from typing import List, Tuple +import yaml from metagpt.logs import logger @@ -184,7 +186,7 @@ class OutputParser: if start_index != -1 and end_index != -1: # Extract the structure part - structure_text = text[start_index : end_index + 1] + structure_text = text[start_index: end_index + 1] try: # Attempt to convert the text to a Python data type using ast.literal_eval diff --git a/metagpt/utils/cost_manager.py b/metagpt/utils/cost_manager.py new file mode 100644 index 000000000..f0fea44ce --- /dev/null +++ b/metagpt/utils/cost_manager.py @@ -0,0 +1,79 @@ +# -*- coding: utf-8 -*- +""" +@Time : 2023/8/28 +@Author : mashenquan +@File : openai.py +@Desc : mashenquan, 2023/8/28. Separate the `CostManager` class to support user-level cost accounting. +""" + +from pydantic import BaseModel +from metagpt.logs import logger +from metagpt.utils.token_counter import TOKEN_COSTS +from typing import NamedTuple + + +class Costs(NamedTuple): + total_prompt_tokens: int + total_completion_tokens: int + total_cost: float + total_budget: float + + +class CostManager(BaseModel): + """Calculate the overhead of using the interface.""" + + total_prompt_tokens: int = 0 + total_completion_tokens: int = 0 + total_budget: float = 0 + max_budget: float = 10.0 + total_cost: float = 0 + + def update_cost(self, prompt_tokens, completion_tokens, model): + """ + Update the total cost, prompt tokens, and completion tokens. + + Args: + prompt_tokens (int): The number of tokens used in the prompt. + completion_tokens (int): The number of tokens used in the completion. + model (str): The model used for the API call. + """ + self.total_prompt_tokens += prompt_tokens + self.total_completion_tokens += completion_tokens + cost = (prompt_tokens * TOKEN_COSTS[model]["prompt"] + completion_tokens * TOKEN_COSTS[model][ + "completion"]) / 1000 + self.total_cost += cost + logger.info( + f"Total running cost: ${self.total_cost:.3f} | Max budget: ${self.max_budget:.3f} | " + f"Current cost: ${cost:.3f}, prompt_tokens: {prompt_tokens}, completion_tokens: {completion_tokens}" + ) + + def get_total_prompt_tokens(self): + """ + Get the total number of prompt tokens. + + Returns: + int: The total number of prompt tokens. + """ + return self.total_prompt_tokens + + def get_total_completion_tokens(self): + """ + Get the total number of completion tokens. + + Returns: + int: The total number of completion tokens. + """ + return self.total_completion_tokens + + def get_total_cost(self): + """ + Get the total cost of API calls. + + Returns: + float: The total cost of API calls. + """ + return self.total_cost + + def get_costs(self) -> Costs: + """Get all costs""" + return Costs(self.total_prompt_tokens, self.total_completion_tokens, self.total_cost, self.total_budget) diff --git a/metagpt/utils/mermaid.py b/metagpt/utils/mermaid.py index eb85a3f90..bf7e6c4a7 100644 --- a/metagpt/utils/mermaid.py +++ b/metagpt/utils/mermaid.py @@ -4,15 +4,25 @@ @Time : 2023/7/4 10:53 @Author : alexanderwu alitrack @File : mermaid.py +@Modified By: mashenquan, 2023/8/20. Remove global configuration `CONFIG`, enable configuration support for business isolation. """ import asyncio +<<<<<<< HEAD import os from pathlib import Path from metagpt.config import CONFIG from metagpt.const import METAGPT_ROOT +======= +from pathlib import Path + +# from metagpt.utils.common import check_cmd_exists +import aiofiles + +from metagpt.config import CONFIG, Config +from metagpt.const import PROJECT_ROOT +>>>>>>> send18/dev from metagpt.logs import logger -from metagpt.utils.common import check_cmd_exists async def mermaid_to_file(mermaid_code, output_file_without_suffix, width=2048, height=2048) -> int: @@ -29,8 +39,11 @@ async def mermaid_to_file(mermaid_code, output_file_without_suffix, width=2048, if dir_name and not os.path.exists(dir_name): os.makedirs(dir_name) tmp = Path(f"{output_file_without_suffix}.mmd") - tmp.write_text(mermaid_code, encoding="utf-8") + async with aiofiles.open(tmp, "w", encoding="utf-8") as f: + await f.write(mermaid_code) + # tmp.write_text(mermaid_code, encoding="utf-8") +<<<<<<< HEAD engine = CONFIG.mermaid_engine.lower() if engine == "nodejs": if check_cmd_exists(CONFIG.mmdc) != 0: @@ -87,60 +100,93 @@ async def mermaid_to_file(mermaid_code, output_file_without_suffix, width=2048, logger.warning(f"Unsupported mermaid engine: {engine}") return 0 +======= + # if check_cmd_exists("mmdc") != 0: + # logger.warning("RUN `npm install -g @mermaid-js/mermaid-cli` to install mmdc") + # return -1 -MMC1 = """classDiagram - class Main { - -SearchEngine search_engine - +main() str - } - class SearchEngine { - -Index index - -Ranking ranking - -Summary summary - +search(query: str) str - } - class Index { - -KnowledgeBase knowledge_base - +create_index(data: dict) - +query_index(query: str) list - } - class Ranking { - +rank_results(results: list) list - } - class Summary { - +summarize_results(results: list) str - } - class KnowledgeBase { - +update(data: dict) - +fetch_data(query: str) dict - } - Main --> SearchEngine - SearchEngine --> Index - SearchEngine --> Ranking - SearchEngine --> Summary - Index --> KnowledgeBase""" + # for suffix in ["pdf", "svg", "png"]: + for suffix in ["png"]: + output_file = f"{output_file_without_suffix}.{suffix}" + # Call the `mmdc` command to convert the Mermaid code to a PNG + logger.info(f"Generating {output_file}..") + cmds = [CONFIG.mmdc, "-i", str(tmp), "-o", output_file, "-w", str(width), "-H", str(height)] -MMC2 = """sequenceDiagram - participant M as Main - participant SE as SearchEngine - participant I as Index - participant R as Ranking - participant S as Summary - participant KB as KnowledgeBase - M->>SE: search(query) - SE->>I: query_index(query) - I->>KB: fetch_data(query) - KB-->>I: return data - I-->>SE: return results - SE->>R: rank_results(results) - R-->>SE: return ranked_results - SE->>S: summarize_results(ranked_results) - S-->>SE: return summary - SE-->>M: return summary""" + if CONFIG.puppeteer_config: + cmds.extend(["-p", CONFIG.puppeteer_config]) + process = await asyncio.create_subprocess_exec(*cmds) + await process.wait() + return process.returncode +>>>>>>> send18/dev +if __name__ == "__main__": + MMC1 = """classDiagram + class Main { + -SearchEngine search_engine + +main() str + } + class SearchEngine { + -Index index + -Ranking ranking + -Summary summary + +search(query: str) str + } + class Index { + -KnowledgeBase knowledge_base + +create_index(data: dict) + +query_index(query: str) list + } + class Ranking { + +rank_results(results: list) list + } + class Summary { + +summarize_results(results: list) str + } + class KnowledgeBase { + +update(data: dict) + +fetch_data(query: str) dict + } + Main --> SearchEngine + SearchEngine --> Index + SearchEngine --> Ranking + SearchEngine --> Summary + Index --> KnowledgeBase""" + + MMC2 = """sequenceDiagram + participant M as Main + participant SE as SearchEngine + participant I as Index + participant R as Ranking + participant S as Summary + participant KB as KnowledgeBase + M->>SE: search(query) + SE->>I: query_index(query) + I->>KB: fetch_data(query) + KB-->>I: return data + I-->>SE: return results + SE->>R: rank_results(results) + R-->>SE: return ranked_results + SE->>S: summarize_results(ranked_results) + S-->>SE: return summary + SE-->>M: return summary""" + +<<<<<<< HEAD if __name__ == "__main__": loop = asyncio.new_event_loop() result = loop.run_until_complete(mermaid_to_file(MMC1, METAGPT_ROOT / f"{CONFIG.mermaid_engine}/1")) result = loop.run_until_complete(mermaid_to_file(MMC2, METAGPT_ROOT / f"{CONFIG.mermaid_engine}/1")) loop.close() +======= + conf = Config() + asyncio.run( + mermaid_to_file( + options=conf.runtime_options, mermaid_code=MMC1, output_file_without_suffix=PROJECT_ROOT / "tmp/1.png" + ) + ) + asyncio.run( + mermaid_to_file( + options=conf.runtime_options, mermaid_code=MMC2, output_file_without_suffix=PROJECT_ROOT / "tmp/2.png" + ) + ) +>>>>>>> send18/dev diff --git a/metagpt/utils/redis.py b/metagpt/utils/redis.py new file mode 100644 index 000000000..c344b67ac --- /dev/null +++ b/metagpt/utils/redis.py @@ -0,0 +1,219 @@ +# !/usr/bin/python3 +# -*- coding: utf-8 -*- +# @Author: Hui +# @Desc: { redis client } +# @Date: 2022/11/28 10:12 +import json +import traceback +from datetime import timedelta +from enum import Enum +from typing import Awaitable, Callable, Dict, Optional, Union + +from redis import asyncio as aioredis + +from metagpt.config import CONFIG +from metagpt.logs import logger + + +class RedisTypeEnum(Enum): + """Redis 数据类型""" + + String = "String" + List = "List" + Hash = "Hash" + Set = "Set" + ZSet = "ZSet" + + +def make_url( + dialect: str, + *, + user: Optional[str] = None, + password: Optional[str] = None, + host: Optional[str] = None, + port: Optional[Union[str, int]] = None, + name: Optional[Union[str, int]] = None, +) -> str: + url_parts = [f"{dialect}://"] + if user or password: + if user: + url_parts.append(user) + if password: + url_parts.append(f":{password}") + url_parts.append("@") + + if not host and not dialect.startswith("sqlite"): + host = "127.0.0.1" + + if host: + url_parts.append(f"{host}") + if port: + url_parts.append(f":{port}") + + # 比如redis可能传入0 + if name is not None: + url_parts.append(f"/{name}") + return "".join(url_parts) + + +class RedisAsyncClient(aioredis.Redis): + """异步的客户端 + 例子:: + + rdb = RedisAsyncClient() + print(rdb.url) + + Args: + host: 服务器地址 + port: 服务器端口 + user: 用户名 + db: 数据库 + password: 密码 + decode_responses: 字符串输入被编码成utf8存储在Redis里了,而取出来的时候还是被编码后的bytes,需要显示的decode才能变成字符串 + health_check_interval: 定时检测连接,防止出现ConnectionErrors (104, Connection reset by peer) + """ + + def __init__( + self, + host: str = "localhost", + port: int = 6379, + db: int = 0, + password: str = None, + decode_responses=True, + health_check_interval=10, + socket_connect_timeout=5, + retry_on_timeout=True, + socket_keepalive=True, + **kwargs, + ): + super().__init__( + host=host, + port=port, + db=db, + password=password, + decode_responses=decode_responses, + health_check_interval=health_check_interval, + socket_connect_timeout=socket_connect_timeout, + retry_on_timeout=retry_on_timeout, + socket_keepalive=socket_keepalive, + **kwargs, + ) + self.url = make_url("redis", host=host, port=port, name=db, password=password) + + +class RedisCacheInfo(object): + """统一缓存信息类""" + + def __init__(self, key, timeout: Union[int, timedelta] = timedelta(seconds=60), data_type=RedisTypeEnum.String): + """ + 缓存信息类初始化 + Args: + key: 缓存的key + timeout: 缓存过期时间, 单位秒 + data_type: 缓存采用的数据结构 (不传并不影响,用于标记业务采用的是什么数据结构) + """ + self.key = key + self.timeout = timeout + self.data_type = data_type + + def __str__(self): + return f"cache key {self.key} timeout {self.timeout}s" + + +class RedisManager: + client: RedisAsyncClient = None + + @classmethod + def init_redis_conn(cls, host, port, password, db): + """初始化redis 连接""" + if cls.client is None: + cls.client = RedisAsyncClient(host=host, port=port, password=password, db=db) + + @classmethod + async def set_with_cache_info(cls, redis_cache_info: RedisCacheInfo, value): + """ + 根据 RedisCacheInfo 设置 Redis 缓存 + :param redis_cache_info: RedisCacheInfo缓存信息对象 + :param value: 缓存的值 + :return: + """ + await cls.client.setex(redis_cache_info.key, redis_cache_info.timeout, value) + + @classmethod + async def get_with_cache_info(cls, redis_cache_info: RedisCacheInfo): + """ + 根据 RedisCacheInfo 获取 Redis 缓存 + :param redis_cache_info: RedisCacheInfo 缓存信息对象 + :return: + """ + cache_info = await cls.client.get(redis_cache_info.key) + return cache_info + + @classmethod + async def del_with_cache_info(cls, redis_cache_info: RedisCacheInfo): + """ + 根据 RedisCacheInfo 删除 Redis 缓存 + :param redis_cache_info: RedisCacheInfo缓存信息对象 + :return: + """ + await cls.client.delete(redis_cache_info.key) + + @staticmethod + async def get_or_set_cache(cache_info: RedisCacheInfo, fetch_data_func: Callable[[], Awaitable[dict]]) -> dict: + """ + 获取缓存数据,如果缓存不存在,则从提供的函数中获取并设置缓存 + 当前版本仅支持 json 形式的 string 格式数据 + """ + + serialized_data = await RedisManager.get_with_cache_info(cache_info) + + if serialized_data: + return json.loads(serialized_data) + + data = await fetch_data_func() + try: + serialized_data = json.dumps(data) + await RedisManager.set_with_cache_info(cache_info, serialized_data) + except Exception as e: + logger.warning(f"数据 {data} 通过 json 进行序列化缓存失败:{e}") + + return data + + @classmethod + def is_valid(cls): + return cls.client is not None + + +class Redis: + def __init__(self, conf: Dict = None): + try: + host = CONFIG.REDIS_HOST + port = int(CONFIG.REDIS_PORT) + pwd = CONFIG.REDIS_PASSWORD + db = CONFIG.REDIS_DB + RedisManager.init_redis_conn(host=host, port=port, password=pwd, db=db) + except Exception as e: + logger.warning(f"Redis initialization has failed:{e}") + + def is_valid(self): + return RedisManager.is_valid() + + async def get(self, key: str) -> str: + if not self.is_valid() or not key: + return None + try: + v = await RedisManager.get_with_cache_info(redis_cache_info=RedisCacheInfo(key=key)) + return v + except Exception as e: + logger.exception(f"{e}, stack:{traceback.format_exc()}") + return None + + async def set(self, key: str, data: str, timeout_sec: int): + if not self.is_valid() or not key: + return + try: + await RedisManager.set_with_cache_info( + redis_cache_info=RedisCacheInfo(key=key, timeout=timeout_sec), value=data + ) + except Exception as e: + logger.exception(f"{e}, stack:{traceback.format_exc()}") diff --git a/metagpt/utils/s3.py b/metagpt/utils/s3.py new file mode 100644 index 000000000..4c3533d5b --- /dev/null +++ b/metagpt/utils/s3.py @@ -0,0 +1,154 @@ +import base64 +import os.path +import traceback +import uuid +from pathlib import Path +from typing import Optional + +import aioboto3 +import aiofiles + +from metagpt.config import CONFIG +from metagpt.const import BASE64_FORMAT +from metagpt.logs import logger + + +class S3: + """A class for interacting with Amazon S3 storage.""" + + def __init__(self): + self.session = aioboto3.Session() + self.auth_config = { + "service_name": "s3", + "aws_access_key_id": CONFIG.S3_ACCESS_KEY, + "aws_secret_access_key": CONFIG.S3_SECRET_KEY, + "endpoint_url": CONFIG.S3_ENDPOINT_URL, + } + + async def upload_file( + self, + bucket: str, + local_path: str, + object_name: str, + ) -> None: + """Upload a file from the local path to the specified path of the storage bucket specified in s3. + + Args: + bucket: The name of the S3 storage bucket. + local_path: The local file path, including the file name. + object_name: The complete path of the uploaded file to be stored in S3, including the file name. + + Raises: + Exception: If an error occurs during the upload process, an exception is raised. + """ + try: + async with self.session.client(**self.auth_config) as client: + async with aiofiles.open(local_path, mode="rb") as reader: + body = await reader.read() + await client.put_object(Body=body, Bucket=bucket, Key=object_name) + logger.info(f"Successfully uploaded the file to path {object_name} in bucket {bucket} of s3.") + except Exception as e: + logger.error(f"Failed to upload the file to path {object_name} in bucket {bucket} of s3: {e}") + raise e + + async def get_object_url( + self, + bucket: str, + object_name: str, + ) -> str: + """Get the URL for a downloadable or preview file stored in the specified S3 bucket. + + Args: + bucket: The name of the S3 storage bucket. + object_name: The complete path of the file stored in S3, including the file name. + + Returns: + The URL for the downloadable or preview file. + + Raises: + Exception: If an error occurs while retrieving the URL, an exception is raised. + """ + try: + async with self.session.client(**self.auth_config) as client: + file = await client.get_object(Bucket=bucket, Key=object_name) + return str(file["Body"].url) + except Exception as e: + logger.error(f"Failed to get the url for a downloadable or preview file: {e}") + raise e + + async def get_object( + self, + bucket: str, + object_name: str, + ) -> bytes: + """Get the binary data of a file stored in the specified S3 bucket. + + Args: + bucket: The name of the S3 storage bucket. + object_name: The complete path of the file stored in S3, including the file name. + + Returns: + The binary data of the requested file. + + Raises: + Exception: If an error occurs while retrieving the file data, an exception is raised. + """ + try: + async with self.session.client(**self.auth_config) as client: + s3_object = await client.get_object(Bucket=bucket, Key=object_name) + return await s3_object["Body"].read() + except Exception as e: + logger.error(f"Failed to get the binary data of the file: {e}") + raise e + + async def download_file( + self, bucket: str, object_name: str, local_path: str, chunk_size: Optional[int] = 128 * 1024 + ) -> None: + """Download an S3 object to a local file. + + Args: + bucket: The name of the S3 storage bucket. + object_name: The complete path of the file stored in S3, including the file name. + local_path: The local file path where the S3 object will be downloaded. + chunk_size: The size of data chunks to read and write at a time. Default is 128 KB. + + Raises: + Exception: If an error occurs during the download process, an exception is raised. + """ + try: + async with self.session.client(**self.auth_config) as client: + s3_object = await client.get_object(Bucket=bucket, Key=object_name) + stream = s3_object["Body"] + async with aiofiles.open(local_path, mode="wb") as writer: + while True: + file_data = await stream.read(chunk_size) + if not file_data: + break + await writer.write(file_data) + except Exception as e: + logger.error(f"Failed to download the file from S3: {e}") + raise e + + async def cache(self, data: str, file_ext: str, format: str = "") -> str: + """Save data to remote S3 and return url""" + object_name = uuid.uuid4().hex + file_ext + path = Path(__file__).parent + pathname = path / object_name + try: + async with aiofiles.open(str(pathname), mode="wb") as file: + if format == BASE64_FORMAT: + data = base64.b64decode(data) + await file.write(data) + + bucket = CONFIG.S3_BUCKET + object_pathname = CONFIG.S3_BUCKET or "system" + object_pathname += f"/{object_name}" + object_pathname = os.path.normpath(object_pathname) + await self.upload_file(bucket=bucket, local_path=str(pathname), object_name=object_pathname) + pathname.unlink(missing_ok=True) + + return await self.get_object_url(bucket=bucket, object_name=object_pathname) + except Exception as e: + logger.exception(f"{e}, stack:{traceback.format_exc()}") + pathname.unlink(missing_ok=True) + return None diff --git a/requirements-test.txt b/requirements-test.txt new file mode 100644 index 000000000..0a34c35ea --- /dev/null +++ b/requirements-test.txt @@ -0,0 +1,3 @@ +-r requirements.txt +pytest +pytest-asyncio \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index c3a8cb44f..cf7d8d519 100644 --- a/requirements.txt +++ b/requirements.txt @@ -37,6 +37,7 @@ tqdm==4.64.0 anthropic==0.3.6 typing-inspect==0.8.0 typing_extensions==4.5.0 +aiofiles libcst==1.0.1 qdrant-client==1.4.0 pytest-mock==3.11.1 @@ -54,3 +55,4 @@ gitpython==3.1.40 zhipuai==1.0.7 socksio~=1.0.0 gitignore-parser==0.1.9 +connexion[swagger-ui] diff --git a/tests/conftest.py b/tests/conftest.py index b22e43e79..2709b38ae 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -10,9 +10,7 @@ import asyncio import logging import re from unittest.mock import Mock - import pytest - from metagpt.config import CONFIG from metagpt.const import DEFAULT_WORKSPACE_ROOT from metagpt.logs import logger @@ -96,3 +94,8 @@ def setup_and_teardown_git_repo(request): # Register the function for destroying the environment. request.addfinalizer(fin) + +@pytest.fixture(scope="session", autouse=True) +def init_config(): + Config() + diff --git a/tests/metagpt/actions/test_azure_tts.py b/tests/metagpt/actions/test_azure_tts.py deleted file mode 100644 index bcafe10f5..000000000 --- a/tests/metagpt/actions/test_azure_tts.py +++ /dev/null @@ -1,16 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- -""" -@Time : 2023/7/1 22:50 -@Author : alexanderwu -@File : test_azure_tts.py -""" -from metagpt.actions.azure_tts import AzureTTS - - -def test_azure_tts(): - azure_tts = AzureTTS("azure_tts") - azure_tts.synthesize_speech("zh-CN", "zh-CN-YunxiNeural", "Boy", "你好,我是卡卡", "output.wav") - - # 运行需要先配置 SUBSCRIPTION_KEY - # TODO: 这里如果要检验,还要额外加上对应的asr,才能确保前后生成是接近一致的,但现在还没有 diff --git a/tests/metagpt/actions/test_ui_design.py b/tests/metagpt/actions/test_ui_design.py index 83590ec7d..b9c91d21f 100644 --- a/tests/metagpt/actions/test_ui_design.py +++ b/tests/metagpt/actions/test_ui_design.py @@ -101,6 +101,7 @@ body { """ + def test_ui_design_parse_css(): ui_design_work = UIDesign(name="UI design action") diff --git a/tests/metagpt/actions/test_write_code.py b/tests/metagpt/actions/test_write_code.py index 54229089c..0bd6633cd 100644 --- a/tests/metagpt/actions/test_write_code.py +++ b/tests/metagpt/actions/test_write_code.py @@ -7,9 +7,8 @@ @Modifiled By: mashenquan, 2023-12-6. According to RFC 135 """ import pytest - +from metagpt.provider.openai_api import OpenAIGPTAPI as LLM from metagpt.actions.write_code import WriteCode -from metagpt.llm import LLM from metagpt.logs import logger from metagpt.schema import CodingContext, Document from tests.metagpt.actions.mock import TASKS_2, WRITE_CODE_PROMPT_SAMPLE diff --git a/tests/metagpt/actions/test_write_teaching_plan.py b/tests/metagpt/actions/test_write_teaching_plan.py new file mode 100644 index 000000000..6754fe88c --- /dev/null +++ b/tests/metagpt/actions/test_write_teaching_plan.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +@Time : 2023/7/28 17:25 +@Author : mashenquan +@File : test_write_teaching_plan.py +""" + +import asyncio +from typing import Optional +from pydantic import BaseModel +from langchain.llms.base import LLM + +from metagpt.actions.write_teaching_plan import WriteTeachingPlanPart +from metagpt.config import Config +from metagpt.schema import Message + + +class MockWriteTeachingPlanPart(WriteTeachingPlanPart): + def __init__(self, options, name: str = '', context=None, llm: LLM = None, topic="", language="Chinese"): + super().__init__(options, name, context, llm, topic, language) + + async def _aask(self, prompt: str, system_msgs: Optional[list[str]] = None) -> str: + return f"{WriteTeachingPlanPart.DATA_BEGIN_TAG}\nprompt\n{WriteTeachingPlanPart.DATA_END_TAG}" + + +async def mock_write_teaching_plan_part(): + class Inputs(BaseModel): + input: str + name: str + topic: str + language: str + + inputs = [ + { + "input": "AABBCC", + "name": "A", + "topic": WriteTeachingPlanPart.COURSE_TITLE, + "language": "C" + }, + { + "input": "DDEEFFF", + "name": "A1", + "topic": "B1", + "language": "C1" + } + ] + + for i in inputs: + seed = Inputs(**i) + options = Config().runtime_options + act = MockWriteTeachingPlanPart(options=options, name=seed.name, topic=seed.topic, language=seed.language) + await act.run([Message(content="")]) + assert act.topic == seed.topic + assert str(act) == seed.topic + assert act.name == seed.name + assert act.rsp == "# prompt" if seed.topic == WriteTeachingPlanPart.COURSE_TITLE else "prompt" + + +def test_suite(): + loop = asyncio.get_event_loop() + task = loop.create_task(mock_write_teaching_plan_part()) + loop.run_until_complete(task) + + +if __name__ == '__main__': + test_suite() diff --git a/tests/metagpt/learn/__init__.py b/tests/metagpt/learn/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/metagpt/learn/test_google_search.py b/tests/metagpt/learn/test_google_search.py new file mode 100644 index 000000000..da32e8923 --- /dev/null +++ b/tests/metagpt/learn/test_google_search.py @@ -0,0 +1,27 @@ +import asyncio + +from pydantic import BaseModel + +from metagpt.learn.google_search import google_search + + +async def mock_google_search(): + class Input(BaseModel): + input: str + + inputs = [{"input": "ai agent"}] + + for i in inputs: + seed = Input(**i) + result = await google_search(seed.input) + assert result != "" + + +def test_suite(): + loop = asyncio.get_event_loop() + task = loop.create_task(mock_google_search()) + loop.run_until_complete(task) + + +if __name__ == "__main__": + test_suite() diff --git a/tests/metagpt/learn/test_skill_loader.py b/tests/metagpt/learn/test_skill_loader.py new file mode 100644 index 000000000..5bc0e776f --- /dev/null +++ b/tests/metagpt/learn/test_skill_loader.py @@ -0,0 +1,41 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +@Time : 2023/9/19 +@Author : mashenquan +@File : test_skill_loader.py +@Desc : Unit tests. +""" + +from metagpt.config import CONFIG +from metagpt.learn.skill_loader import SkillLoader + + +def test_suite(): + CONFIG.agent_skills = [ + {"id": 1, "name": "text_to_speech", "type": "builtin", "config": {}, "enabled": True}, + {"id": 2, "name": "text_to_image", "type": "builtin", "config": {}, "enabled": True}, + {"id": 3, "name": "ai_call", "type": "builtin", "config": {}, "enabled": True}, + {"id": 3, "name": "data_analysis", "type": "builtin", "config": {}, "enabled": True}, + {"id": 5, "name": "crawler", "type": "builtin", "config": {"engine": "ddg"}, "enabled": True}, + {"id": 6, "name": "knowledge", "type": "builtin", "config": {}, "enabled": True}, + {"id": 6, "name": "web_search", "type": "builtin", "config": {}, "enabled": True}, + ] + loader = SkillLoader() + skills = loader.get_skill_list() + assert skills + assert len(skills) >= 3 + for desc, name in skills.items(): + assert desc + assert name + + entity = loader.get_entity("Assistant") + assert entity + assert entity.skills + for sk in entity.skills: + assert sk + assert sk.arguments + + +if __name__ == "__main__": + test_suite() diff --git a/tests/metagpt/learn/test_text_to_embedding.py b/tests/metagpt/learn/test_text_to_embedding.py new file mode 100644 index 000000000..d81a8ac1c --- /dev/null +++ b/tests/metagpt/learn/test_text_to_embedding.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +@Time : 2023/8/18 +@Author : mashenquan +@File : test_text_to_embedding.py +@Desc : Unit tests. +""" + +import asyncio + +from pydantic import BaseModel + +from metagpt.learn.text_to_embedding import text_to_embedding +from metagpt.tools.openai_text_to_embedding import ResultEmbedding + + +async def mock_text_to_embedding(): + class Input(BaseModel): + input: str + + inputs = [ + {"input": "Panda emoji"} + ] + + for i in inputs: + seed = Input(**i) + data = await text_to_embedding(seed.input) + v = ResultEmbedding(**data) + assert len(v.data) > 0 + + +def test_suite(): + loop = asyncio.get_event_loop() + task = loop.create_task(mock_text_to_embedding()) + loop.run_until_complete(task) + + +if __name__ == '__main__': + test_suite() diff --git a/tests/metagpt/learn/test_text_to_image.py b/tests/metagpt/learn/test_text_to_image.py new file mode 100644 index 000000000..c359797de --- /dev/null +++ b/tests/metagpt/learn/test_text_to_image.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +@Time : 2023/8/18 +@Author : mashenquan +@File : test_text_to_image.py +@Desc : Unit tests. +""" +import asyncio +import base64 + +from pydantic import BaseModel + +from metagpt.learn.text_to_image import text_to_image + + +async def mock_text_to_image(): + class Input(BaseModel): + input: str + size_type: str + + inputs = [ + {"input": "Panda emoji", "size_type": "512x512"} + ] + + for i in inputs: + seed = Input(**i) + base64_data = await text_to_image(seed.input) + assert base64_data != "" + print(f"{seed.input} -> {base64_data}") + flags = ";base64," + assert flags in base64_data + ix = base64_data.find(flags) + len(flags) + declaration = base64_data[0: ix] + assert declaration + data = base64_data[ix:] + assert data + assert base64.b64decode(data, validate=True) + + +def test_suite(): + loop = asyncio.get_event_loop() + task = loop.create_task(mock_text_to_image()) + loop.run_until_complete(task) + + +if __name__ == '__main__': + test_suite() diff --git a/tests/metagpt/learn/test_text_to_speech.py b/tests/metagpt/learn/test_text_to_speech.py new file mode 100644 index 000000000..68de5a3b2 --- /dev/null +++ b/tests/metagpt/learn/test_text_to_speech.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +@Time : 2023/8/18 +@Author : mashenquan +@File : test_text_to_speech.py +@Desc : Unit tests. +""" +import asyncio +import base64 + +from pydantic import BaseModel + +from metagpt.learn.text_to_speech import text_to_speech + + +async def mock_text_to_speech(): + class Input(BaseModel): + input: str + + inputs = [ + {"input": "Panda emoji"} + ] + + for i in inputs: + seed = Input(**i) + base64_data = await text_to_speech(seed.input) + assert base64_data != "" + print(f"{seed.input} -> {base64_data}") + flags = ";base64," + assert flags in base64_data + ix = base64_data.find(flags) + len(flags) + declaration = base64_data[0: ix] + assert declaration + data = base64_data[ix:] + assert data + assert base64.b64decode(data, validate=True) + + +def test_suite(): + loop = asyncio.get_event_loop() + task = loop.create_task(mock_text_to_speech()) + loop.run_until_complete(task) + + +if __name__ == '__main__': + test_suite() \ No newline at end of file diff --git a/tests/metagpt/memory/test_brain_memory.py b/tests/metagpt/memory/test_brain_memory.py new file mode 100644 index 000000000..b5fc942ca --- /dev/null +++ b/tests/metagpt/memory/test_brain_memory.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +@Time : 2023/8/27 +@Author : mashenquan +@File : test_brain_memory.py +""" +import json +from typing import List + +import pydantic + +from metagpt.memory.brain_memory import BrainMemory +from metagpt.schema import Message + + +def test_json(): + class Input(pydantic.BaseModel): + history: List[str] + solution: List[str] + knowledge: List[str] + stack: List[str] + + inputs = [ + { + "history": ["a", "b"], + "solution": ["c"], + "knowledge": ["d", "e"], + "stack": ["f"] + } + ] + + for i in inputs: + v = Input(**i) + bm = BrainMemory() + for h in v.history: + msg = Message(content=h) + bm.history.append(msg.dict()) + for h in v.solution: + msg = Message(content=h) + bm.solution.append(msg.dict()) + for h in v.knowledge: + msg = Message(content=h) + bm.knowledge.append(msg.dict()) + for h in v.stack: + msg = Message(content=h) + bm.stack.append(msg.dict()) + s = bm.json() + m = json.loads(s) + bm = BrainMemory(**m) + assert bm + for v in bm.history: + msg = Message(**v) + assert msg + +if __name__ == '__main__': + test_json() \ No newline at end of file diff --git a/tests/metagpt/memory/test_longterm_memory.py b/tests/metagpt/memory/test_longterm_memory.py index b6ae0ac79..1f07d74e3 100644 --- a/tests/metagpt/memory/test_longterm_memory.py +++ b/tests/metagpt/memory/test_longterm_memory.py @@ -2,11 +2,12 @@ # -*- coding: utf-8 -*- """ @Desc : unittest of `metagpt/memory/longterm_memory.py` +@Modified By: mashenquan, 2023/8/20. Remove global configuration `CONFIG`, enable configuration support for business isolation. """ from metagpt.actions import UserRequirement from metagpt.config import CONFIG -from metagpt.memory import LongTermMemory +from metagpt.memory.longterm_memory import LongTermMemory from metagpt.roles.role import RoleContext from metagpt.schema import Message @@ -28,6 +29,7 @@ def test_ltm_search(): ltm.add(message) sim_idea = "Write a game of cli snake" + sim_message = Message(role="User", content=sim_idea, cause_by=UserRequirement) news = ltm.find_news([sim_message]) assert len(news) == 0 diff --git a/tests/metagpt/provider/test_metagpt_llm_api.py b/tests/metagpt/provider/test_metagpt_llm_api.py new file mode 100644 index 000000000..9c8356ca6 --- /dev/null +++ b/tests/metagpt/provider/test_metagpt_llm_api.py @@ -0,0 +1,17 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +@Time : 2023/8/30 +@Author : mashenquan +@File : test_metagpt_llm_api.py +""" +from metagpt.provider.metagpt_llm_api import MetaGPTLLMAPI + + +def test_metagpt(): + llm = MetaGPTLLMAPI() + assert llm + + +if __name__ == "__main__": + test_metagpt() diff --git a/tests/metagpt/roles/test_teacher.py b/tests/metagpt/roles/test_teacher.py new file mode 100644 index 000000000..8f673d6e0 --- /dev/null +++ b/tests/metagpt/roles/test_teacher.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +@Time : 2023/7/27 13:25 +@Author : mashenquan +@File : test_teacher.py +""" + +from typing import Dict, Optional +from pydantic import BaseModel + +from metagpt.config import Config +from metagpt.provider.openai_api import CostManager +from metagpt.roles.teacher import Teacher + + +def test_init(): + class Inputs(BaseModel): + name: str + profile: str + goal: str + constraints: str + desc: str + kwargs: Optional[Dict] = None + expect_name: str + expect_profile: str + expect_goal: str + expect_constraints: str + expect_desc: str + + inputs = [ + { + "name": "Lily{language}", + "expect_name": "LilyCN", + "profile": "X {teaching_language}", + "expect_profile": "X EN", + "goal": "Do {something_big}, {language}", + "expect_goal": "Do sleep, CN", + "constraints": "Do in {key1}, {language}", + "expect_constraints": "Do in HaHa, CN", + "kwargs": {"language": "CN", "key1": "HaHa", "something_big": "sleep", "teaching_language": "EN"}, + "desc": "aaa{language}", + "expect_desc": "aaaCN" + }, + { + "name": "Lily{language}", + "expect_name": "Lily{language}", + "profile": "X {teaching_language}", + "expect_profile": "X {teaching_language}", + "goal": "Do {something_big}, {language}", + "expect_goal": "Do {something_big}, {language}", + "constraints": "Do in {key1}, {language}", + "expect_constraints": "Do in {key1}, {language}", + "kwargs": {}, + "desc": "aaa{language}", + "expect_desc": "aaa{language}" + }, + ] + + for i in inputs: + seed = Inputs(**i) + options = Config().runtime_options + cost_manager = CostManager(**options) + teacher = Teacher(options=options, cost_manager=cost_manager, name=seed.name, profile=seed.profile, + goal=seed.goal, constraints=seed.constraints, + desc=seed.desc, **seed.kwargs) + assert teacher.name == seed.expect_name + assert teacher.desc == seed.expect_desc + assert teacher.profile == seed.expect_profile + assert teacher.goal == seed.expect_goal + assert teacher.constraints == seed.expect_constraints + assert teacher.course_title == "teaching_plan" + + +def test_new_file_name(): + class Inputs(BaseModel): + lesson_title: str + ext: str + expect: str + + inputs = [ + { + "lesson_title": "# @344\n12", + "ext": ".md", + "expect": "_344_12.md" + }, + { + "lesson_title": "1#@$%!*&\\/:*?\"<>|\n\t \'1", + "ext": ".cc", + "expect": "1_1.cc" + } + ] + for i in inputs: + seed = Inputs(**i) + result = Teacher.new_file_name(seed.lesson_title, seed.ext) + assert result == seed.expect + + +if __name__ == '__main__': + test_init() + test_new_file_name() diff --git a/tests/metagpt/roles/ui_role.py b/tests/metagpt/roles/ui_role.py index 8ac799bf3..5c2d64a58 100644 --- a/tests/metagpt/roles/ui_role.py +++ b/tests/metagpt/roles/ui_role.py @@ -8,8 +8,6 @@ from functools import wraps from importlib import import_module from metagpt.actions import Action, ActionOutput, WritePRD - -# from metagpt.const import WORKSPACE_ROOT from metagpt.config import CONFIG from metagpt.logs import logger from metagpt.roles import Role diff --git a/tests/metagpt/test_environment.py b/tests/metagpt/test_environment.py index b27bc3da7..29ca38f5a 100644 --- a/tests/metagpt/test_environment.py +++ b/tests/metagpt/test_environment.py @@ -4,14 +4,14 @@ @Time : 2023/5/12 00:47 @Author : alexanderwu @File : test_environment.py +@Modified By: mashenquan, 2023/8/20. Remove global configuration `CONFIG`, enable configuration support for business isolation. + """ import pytest - from metagpt.actions import UserRequirement from metagpt.environment import Environment from metagpt.logs import logger -from metagpt.manager import Manager from metagpt.roles import Architect, ProductManager, Role from metagpt.schema import Message @@ -22,35 +22,34 @@ def env(): def test_add_role(env: Environment): - role = ProductManager("Alice", "product manager", "create a new product", "limited resources") + role = ProductManager(name="Alice", + profile="product manager", + goal="create a new product", + constraints="limited resources") env.add_role(role) assert env.get_role(role.profile) == role def test_get_roles(env: Environment): - role1 = Role("Alice", "product manager", "create a new product", "limited resources") - role2 = Role("Bob", "engineer", "develop the new product", "short deadline") + role1 = Role(name="Alice", profile="product manager", + goal="create a new product", constraints="limited resources") + role2 = Role(name="Bob", profile="engineer", + goal="develop the new product", constraints="short deadline") env.add_role(role1) env.add_role(role2) roles = env.get_roles() assert roles == {role1.profile: role1, role2.profile: role2} -def test_set_manager(env: Environment): - manager = Manager() - env.set_manager(manager) - assert env.manager == manager - - @pytest.mark.asyncio async def test_publish_and_process_message(env: Environment): - product_manager = ProductManager("Alice", "Product Manager", "做AI Native产品", "资源有限") - architect = Architect("Bob", "Architect", "设计一个可用、高效、较低成本的系统,包括数据结构与接口", "资源有限,需要节省成本") + product_manager = ProductManager(name="Alice", profile="Product Manager", + goal="做AI Native产品", constraints="资源有限") + architect = Architect(name="Bob", profile="Architect", goal="设计一个可用、高效、较低成本的系统,包括数据结构与接口", + constraints="资源有限,需要节省成本") env.add_roles([product_manager, architect]) - env.set_manager(Manager()) env.publish_message(Message(role="User", content="需要一个基于LLM做总结的搜索引擎", cause_by=UserRequirement)) - await env.run(k=2) logger.info(f"{env.history=}") assert len(env.history) > 10 diff --git a/tests/metagpt/test_llm.py b/tests/metagpt/test_llm.py index 49969a2af..23be82268 100644 --- a/tests/metagpt/test_llm.py +++ b/tests/metagpt/test_llm.py @@ -4,16 +4,19 @@ @Time : 2023/5/11 14:45 @Author : alexanderwu @File : test_llm.py +@Modified By: mashenquan, 2023/8/20. Remove global configuration `CONFIG`, enable configuration support for business isolation. """ import pytest -from metagpt.llm import LLM +from metagpt.config import Config +from metagpt.provider.openai_api import OpenAIGPTAPI as LLM, CostManager @pytest.fixture() def llm(): - return LLM() + options = Config().runtime_options + return LLM(options=options, cost_manager=CostManager(**options)) @pytest.mark.asyncio diff --git a/tests/metagpt/tools/test_azure_tts.py b/tests/metagpt/tools/test_azure_tts.py new file mode 100644 index 000000000..b7f94a19c --- /dev/null +++ b/tests/metagpt/tools/test_azure_tts.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +@Time : 2023/7/1 22:50 +@Author : alexanderwu +@File : test_azure_tts.py +@Modified By: mashenquan, 2023-8-9, add more text formatting options +@Modified By: mashenquan, 2023-8-17, move to `tools` folder. +""" +import asyncio + +from metagpt.config import CONFIG +from metagpt.tools.azure_tts import AzureTTS + + +def test_azure_tts(): + azure_tts = AzureTTS(subscription_key="", region="") + text = """ + 女儿看见父亲走了进来,问道: + + “您来的挺快的,怎么过来的?” + + 父亲放下手提包,说: + + “Writing a binary file in Python is similar to writing a regular text file, but you'll work with bytes instead of strings.” + + """ + path = CONFIG.workspace / "tts" + path.mkdir(exist_ok=True, parents=True) + filename = path / "girl.wav" + loop = asyncio.new_event_loop() + v = loop.create_task( + azure_tts.synthesize_speech(lang="zh-CN", voice="zh-CN-XiaomoNeural", text=text, output_file=str(filename)) + ) + result = loop.run_until_complete(v) + + print(result) + + # 运行需要先配置 SUBSCRIPTION_KEY + # TODO: 这里如果要检验,还要额外加上对应的asr,才能确保前后生成是接近一致的,但现在还没有 + + +if __name__ == "__main__": + test_azure_tts() diff --git a/tests/metagpt/tools/test_sd_tool.py b/tests/metagpt/tools/test_sd_tool.py index e457101a9..9003dbe9c 100644 --- a/tests/metagpt/tools/test_sd_tool.py +++ b/tests/metagpt/tools/test_sd_tool.py @@ -24,3 +24,4 @@ async def test_sd_engine_run_t2i(): await sd_engine.run_t2i(prompts=["test"]) img_path = CONFIG.workspace_path / "resources" / "SD_Output" / "output_0.png" assert os.path.exists(img_path) + diff --git a/tests/metagpt/tools/test_web_browser_engine.py b/tests/metagpt/tools/test_web_browser_engine.py index 28dd0e15c..1e4e956f2 100644 --- a/tests/metagpt/tools/test_web_browser_engine.py +++ b/tests/metagpt/tools/test_web_browser_engine.py @@ -1,5 +1,10 @@ +""" +@Modified By: mashenquan, 2023/8/20. Remove global configuration `CONFIG`, enable configuration support for business isolation. +""" + import pytest +from metagpt.config import Config from metagpt.tools import WebBrowserEngineType, web_browser_engine @@ -13,7 +18,8 @@ from metagpt.tools import WebBrowserEngineType, web_browser_engine ids=["playwright", "selenium"], ) async def test_scrape_web_page(browser_type, url, urls): - browser = web_browser_engine.WebBrowserEngine(browser_type) + conf = Config() + browser = web_browser_engine.WebBrowserEngine(options=conf.runtime_options, engine=browser_type) result = await browser.run(url) assert isinstance(result, str) assert "深度赋智" in result diff --git a/tests/metagpt/tools/test_web_browser_engine_playwright.py b/tests/metagpt/tools/test_web_browser_engine_playwright.py index e9ea80b10..5ebd7394e 100644 --- a/tests/metagpt/tools/test_web_browser_engine_playwright.py +++ b/tests/metagpt/tools/test_web_browser_engine_playwright.py @@ -1,6 +1,10 @@ +""" +@Modified By: mashenquan, 2023/8/20. Remove global configuration `CONFIG`, enable configuration support for business isolation. +""" + import pytest -from metagpt.config import CONFIG +from metagpt.config import Config from metagpt.tools import web_browser_engine_playwright @@ -15,22 +19,24 @@ from metagpt.tools import web_browser_engine_playwright ids=["chromium-normal", "firefox-normal", "webkit-normal"], ) async def test_scrape_web_page(browser_type, use_proxy, kwagrs, url, urls, proxy, capfd): + conf = Config() + global_proxy = conf.global_proxy try: - global_proxy = CONFIG.global_proxy if use_proxy: - CONFIG.global_proxy = proxy - browser = web_browser_engine_playwright.PlaywrightWrapper(browser_type, **kwagrs) + conf.global_proxy = proxy + browser = web_browser_engine_playwright.PlaywrightWrapper(options=conf.runtime_options, + browser_type=browser_type, **kwagrs) result = await browser.run(url) result = result.inner_text assert isinstance(result, str) - assert "Deepwisdom" in result + assert "DeepWisdom" in result if urls: results = await browser.run(url, *urls) assert isinstance(results, list) assert len(results) == len(urls) + 1 - assert all(("Deepwisdom" in i) for i in results) + assert all(("DeepWisdom" in i) for i in results) if use_proxy: assert "Proxy:" in capfd.readouterr().out finally: - CONFIG.global_proxy = global_proxy + conf.global_proxy = global_proxy diff --git a/tests/metagpt/tools/test_web_browser_engine_selenium.py b/tests/metagpt/tools/test_web_browser_engine_selenium.py index ac6eafee7..77f4d8592 100644 --- a/tests/metagpt/tools/test_web_browser_engine_selenium.py +++ b/tests/metagpt/tools/test_web_browser_engine_selenium.py @@ -1,6 +1,10 @@ +""" +@Modified By: mashenquan, 2023/8/20. Remove global configuration `CONFIG`, enable configuration support for business isolation. +""" + import pytest -from metagpt.config import CONFIG +from metagpt.config import Config from metagpt.tools import web_browser_engine_selenium @@ -15,11 +19,12 @@ from metagpt.tools import web_browser_engine_selenium ids=["chrome-normal", "firefox-normal", "edge-normal"], ) async def test_scrape_web_page(browser_type, use_proxy, url, urls, proxy, capfd): + conf = Config() + global_proxy = conf.global_proxy try: - global_proxy = CONFIG.global_proxy if use_proxy: - CONFIG.global_proxy = proxy - browser = web_browser_engine_selenium.SeleniumWrapper(browser_type) + conf.global_proxy = proxy + browser = web_browser_engine_selenium.SeleniumWrapper(options=conf.runtime_options, browser_type=browser_type) result = await browser.run(url) result = result.inner_text assert isinstance(result, str) @@ -33,4 +38,4 @@ async def test_scrape_web_page(browser_type, use_proxy, url, urls, proxy, capfd) if use_proxy: assert "Proxy:" in capfd.readouterr().out finally: - CONFIG.global_proxy = global_proxy + conf.global_proxy = global_proxy diff --git a/tests/metagpt/utils/test_config.py b/tests/metagpt/utils/test_config.py index b68a535f9..f38cddb0d 100644 --- a/tests/metagpt/utils/test_config.py +++ b/tests/metagpt/utils/test_config.py @@ -4,19 +4,15 @@ @Time : 2023/5/1 11:19 @Author : alexanderwu @File : test_config.py +@Modified By: mashenquan, 2013/8/20, Add `test_options`; remove global configuration `CONFIG`, enable configuration support for business isolation. """ +from pathlib import Path import pytest from metagpt.config import Config -def test_config_class_is_singleton(): - config_1 = Config() - config_2 = Config() - assert config_1 == config_2 - - def test_config_class_get_key_exception(): with pytest.raises(Exception) as exc_info: config = Config() @@ -28,4 +24,15 @@ def test_config_yaml_file_not_exists(): config = Config("wtf.yaml") with pytest.raises(Exception) as exc_info: config.get("OPENAI_BASE_URL") - assert str(exc_info.value) == "Key 'OPENAI_BASE_URL' not found in environment variables or in the YAML file" + assert str(exc_info.value) == "Set OPENAI_API_KEY or Anthropic_API_KEY first" + + +def test_options(): + filename = Path(__file__).resolve().parent.parent.parent.parent / "config/config.yaml" + config = Config(filename) + assert config.options + + +if __name__ == '__main__': + test_options() +