diff --git a/README.md b/README.md index 5e67483d6..ded8fa1b5 100644 --- a/README.md +++ b/README.md @@ -12,16 +12,17 @@ # MetaGPT: The Multi-Agent Framework CN doc EN doc JA doc -Discord Follow -License: MIT +Discord Follow +License: MIT roadmap Twitter Follow

+ AgentStore Waitlist Open in Dev Containers Open in GitHub Codespaces - Hugging Face + Hugging Face

1. MetaGPT takes a **one line requirement** as input and outputs **user stories / competitive analysis / requirements / data structures / APIs / documents, etc.** diff --git a/docs/README_CN.md b/docs/README_CN.md index a5e4c6879..4ee4c7408 100644 --- a/docs/README_CN.md +++ b/docs/README_CN.md @@ -9,19 +9,20 @@ # MetaGPT: 多智能体框架

-CN doc -EN doc -JA doc -Discord Follow -License: MIT -roadmap -roadmap +CN doc +EN doc +JA doc +Discord Follow +License: MIT +roadmap Twitter Follow

+

+ AgentStore Waitlist Open in Dev Containers Open in GitHub Codespaces - Hugging Face + Hugging Face

1. MetaGPT输入**一句话的老板需求**,输出**用户故事 / 竞品分析 / 需求 / 数据结构 / APIs / 文件等** diff --git a/docs/README_JA.md b/docs/README_JA.md index f930c0cc2..158ad8ceb 100644 --- a/docs/README_JA.md +++ b/docs/README_JA.md @@ -9,19 +9,20 @@ # MetaGPT: マルチエージェントフレームワーク

-CN doc -EN doc -JA doc -Discord Follow -License: MIT +CN doc +EN doc +JA doc +Discord Follow +License: MIT roadmap -roadmap Twitter Follow

+

+ AgentStore Waitlist Open in Dev Containers Open in GitHub Codespaces - Hugging Face + Hugging Face

1. MetaGPT は、**1 行の要件** を入力とし、**ユーザーストーリー / 競合分析 / 要件 / データ構造 / API / 文書など** を出力します。 diff --git a/examples/write_tutorial.py b/examples/write_tutorial.py new file mode 100644 index 000000000..167f3eb7c --- /dev/null +++ b/examples/write_tutorial.py @@ -0,0 +1,20 @@ +#!/usr/bin/env python3 +# _*_ coding: utf-8 _*_ +""" +@Time : 2023/9/4 21:40:57 +@Author : Stitch-z +@File : tutorial_assistant.py +""" +import asyncio + +from metagpt.roles.tutorial_assistant import TutorialAssistant + + +async def main(): + topic = "Write a tutorial about MySQL" + role = TutorialAssistant(language="Chinese") + await role.run(topic) + + +if __name__ == '__main__': + asyncio.run(main()) \ No newline at end of file diff --git a/metagpt/actions/write_tutorial.py b/metagpt/actions/write_tutorial.py new file mode 100644 index 000000000..b23fc2ad4 --- /dev/null +++ b/metagpt/actions/write_tutorial.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +# _*_ coding: utf-8 _*_ +""" +@Time : 2023/9/4 15:40:40 +@Author : Stitch-z +@File : tutorial_assistant.py +@Describe : Actions of the tutorial assistant, including writing directories and document content. +""" +import json +from typing import Dict + +from metagpt.actions import Action +from metagpt.logs import logger +from metagpt.prompts.tutorial_assistant import DIRECTORY_PROMPT, CONTENT_PROMPT + + +class WriteDirectory(Action): + """Action class for writing tutorial directories. + + Args: + name: The name of the action. + language: The language to output, default is "Chinese". + """ + + def __init__(self, name: str = "", language: str = "Chinese", *args, **kwargs): + super().__init__(name, *args, **kwargs) + self.language = language + + @staticmethod + async def _handle_resp(resp: str) -> Dict: + """Process string results and convert them to JSON format. + + Args: + resp: The directory results returned by gpt. + + Returns: + The parsed dictionary, such as {"title": "xxx", "directory": [{"dir 1": ["sub dir 1", "sub dir 2"]}]}. + + Raises: + Exception: If no matching dictionary section is found. + json.JSONDecodeError: If the dictionary part cannot be parsed as JSON. + """ + start = resp.find('{') + end = resp.rfind('}') + if start != -1 and end != -1 and end > start: + directory_str = resp[start:end + 1] + logger.info(f"Successfully parsed json: {str(directory_str)}") + try: + return json.loads(directory_str) + except json.JSONDecodeError as e: + logger.error(f"Json parsing error: {e}") + raise e + else: + raise Exception("No matching dictionary section found.") + + async def run(self, topic: str, *args, **kwargs) -> Dict: + """Execute the action to generate a tutorial directory according to the topic. + + Args: + topic: The tutorial topic. + + Returns: + the tutorial directory information, including {"title": "xxx", "directory": [{"dir 1": ["sub dir 1", "sub dir 2"]}]}. + """ + prompt = DIRECTORY_PROMPT.format(topic=topic, language=self.language) + resp = await self._aask(prompt=prompt) + return await self._handle_resp(resp) + + +class WriteContent(Action): + """Action class for writing tutorial content. + + Args: + name: The name of the action. + directory: The content to write. + language: The language to output, default is "Chinese". + """ + + def __init__(self, name: str = "", directory: str = "", language: str = "Chinese", *args, **kwargs): + super().__init__(name, *args, **kwargs) + self.language = language + self.directory = directory + + async def run(self, topic: str, *args, **kwargs) -> str: + """Execute the action to write document content according to the directory and topic. + + Args: + topic: The tutorial topic. + + Returns: + The written tutorial content. + """ + prompt = CONTENT_PROMPT.format(topic=topic, language=self.language, directory=self.directory) + return await self._aask(prompt=prompt) + diff --git a/metagpt/const.py b/metagpt/const.py index 16f652186..35b4c9fa7 100644 --- a/metagpt/const.py +++ b/metagpt/const.py @@ -33,5 +33,6 @@ API_QUESTIONS_PATH = UT_PATH / "files/question/" YAPI_URL = "http://yapi.deepwisdomai.com/" TMP = PROJECT_ROOT / 'tmp' RESEARCH_PATH = DATA_PATH / "research" +TUTORIAL_PATH = DATA_PATH / "tutorial_docx" MEM_TTL = 24 * 30 * 3600 diff --git a/metagpt/prompts/tutorial_assistant.py b/metagpt/prompts/tutorial_assistant.py new file mode 100644 index 000000000..d690aad83 --- /dev/null +++ b/metagpt/prompts/tutorial_assistant.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 +# _*_ coding: utf-8 _*_ +""" +@Time : 2023/9/4 15:40:40 +@Author : Stitch-z +@File : tutorial_assistant.py +@Describe : Tutorial Assistant's prompt templates. +""" + +COMMON_PROMPT = """ +You are now a seasoned technical professional in the field of the internet. +We need you to write a technical tutorial with the topic "{topic}". +""" + +DIRECTORY_PROMPT = COMMON_PROMPT + """ +Please provide the specific table of contents for this tutorial, strictly following the following requirements: +1. The output must be strictly in the specified language, {language}. +2. Answer strictly in the dictionary format like {{"title": "xxx", "directory": [{{"dir 1": ["sub dir 1", "sub dir 2"]}}, {{"dir 2": ["sub dir 3", "sub dir 4"]}}]}}. +3. The directory should be as specific and sufficient as possible, with a primary and secondary directory.The secondary directory is in the array. +4. Do not have extra spaces or line breaks. +5. Each directory title has practical significance. +""" + +CONTENT_PROMPT = COMMON_PROMPT + """ +Now I will give you the module directory titles for the topic. +Please output the detailed principle content of this title in detail. +If there are code examples, please provide them according to standard code specifications. +Without a code example, it is not necessary. + +The module directory titles for the topic is as follows: +{directory} + +Strictly limit output according to the following requirements: +1. Follow the Markdown syntax format for layout. +2. If there are code examples, they must follow standard syntax specifications, have document annotations, and be displayed in code blocks. +3. The output must be strictly in the specified language, {language}. +4. Do not have redundant output, including concluding remarks. +5. Strict requirement not to output the topic "{topic}". +""" \ No newline at end of file diff --git a/metagpt/roles/tutorial_assistant.py b/metagpt/roles/tutorial_assistant.py new file mode 100644 index 000000000..9a7df4f4d --- /dev/null +++ b/metagpt/roles/tutorial_assistant.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 +# _*_ coding: utf-8 _*_ +""" +@Time : 2023/9/4 15:40:40 +@Author : Stitch-z +@File : tutorial_assistant.py +""" + +from datetime import datetime +from typing import Dict + +from metagpt.actions.write_tutorial import WriteDirectory, WriteContent +from metagpt.const import TUTORIAL_PATH +from metagpt.logs import logger +from metagpt.roles import Role +from metagpt.schema import Message +from metagpt.utils.file import File + + +class TutorialAssistant(Role): + """Tutorial assistant, input one sentence to generate a tutorial document in markup format. + + Args: + name: The name of the role. + profile: The role profile description. + goal: The goal of the role. + constraints: Constraints or requirements for the role. + language: The language in which the tutorial documents will be generated. + """ + + def __init__( + self, + name: str = "Stitch", + profile: str = "Tutorial Assistant", + goal: str = "Generate tutorial documents", + constraints: str = "Strictly follow Markdown's syntax, with neat and standardized layout", + language: str = "Chinese", + ): + super().__init__(name, profile, goal, constraints) + self._init_actions([WriteDirectory(language=language)]) + self.topic = "" + self.main_title = "" + self.total_content = "" + self.language = language + + async def _think(self) -> None: + """Determine the next action to be taken by the role.""" + if self._rc.todo is None: + self._set_state(0) + return + + if self._rc.state + 1 < len(self._states): + self._set_state(self._rc.state + 1) + else: + self._rc.todo = None + + async def _handle_directory(self, titles: Dict) -> Message: + """Handle the directories for the tutorial document. + + Args: + titles: A dictionary containing the titles and directory structure, + such as {"title": "xxx", "directory": [{"dir 1": ["sub dir 1", "sub dir 2"]}]} + + Returns: + A message containing information about the directory. + """ + self.main_title = titles.get("title") + directory = f"{self.main_title}\n" + self.total_content += f"# {self.main_title}" + actions = list() + for first_dir in titles.get("directory"): + actions.append(WriteContent(language=self.language, directory=first_dir)) + key = list(first_dir.keys())[0] + directory += f"- {key}\n" + for second_dir in first_dir[key]: + directory += f" - {second_dir}\n" + self._init_actions(actions) + self._rc.todo = None + return Message(content=directory) + + async def _act(self) -> Message: + """Perform an action as determined by the role. + + Returns: + A message containing the result of the action. + """ + todo = self._rc.todo + if type(todo) is WriteDirectory: + msg = self._rc.memory.get(k=1)[0] + self.topic = msg.content + resp = await todo.run(topic=self.topic) + logger.info(resp) + return await self._handle_directory(resp) + resp = await todo.run(topic=self.topic) + logger.info(resp) + if self.total_content != "": + self.total_content += "\n\n\n" + self.total_content += resp + return Message(content=resp, role=self.profile) + + async def _react(self) -> Message: + """Execute the assistant's think and actions. + + Returns: + A message containing the final result of the assistant's actions. + """ + while True: + await self._think() + if self._rc.todo is None: + break + msg = await self._act() + root_path = TUTORIAL_PATH / datetime.now().strftime("%Y-%m-%d_%H-%M-%S") + await File.write(root_path, f"{self.main_title}.md", self.total_content.encode('utf-8')) + return msg diff --git a/metagpt/utils/file.py b/metagpt/utils/file.py new file mode 100644 index 000000000..5aca2a0e5 --- /dev/null +++ b/metagpt/utils/file.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 +# _*_ coding: utf-8 _*_ +""" +@Time : 2023/9/4 15:40:40 +@Author : Stitch-z +@File : file.py +@Describe : General file operations. +""" +import aiofiles +from pathlib import Path + +from metagpt.logs import logger + + +class File: + """A general util for file operations.""" + + @classmethod + async def write(cls, root_path: Path, filename: str, content: bytes) -> Path: + """Write the file content to the local specified path. + + Args: + root_path: The root path of file, such as "/data". + filename: The name of file, such as "test.txt". + content: The binary content of file. + + Returns: + The full filename of file, such as "/data/test.txt". + + Raises: + Exception: If an unexpected error occurs during the file writing process. + """ + try: + root_path.mkdir(parents=True, exist_ok=True) + full_path = root_path / filename + async with aiofiles.open(full_path, mode="wb") as writer: + await writer.write(content) + logger.info(f"Successfully write file: {full_path}") + return full_path + except Exception as e: + logger.error(f"Error writing file: {e}") + raise e \ No newline at end of file diff --git a/tests/metagpt/actions/test_write_tutorial.py b/tests/metagpt/actions/test_write_tutorial.py new file mode 100644 index 000000000..683fee082 --- /dev/null +++ b/tests/metagpt/actions/test_write_tutorial.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 +# _*_ coding: utf-8 _*_ +""" +@Time : 2023/9/6 21:41:34 +@Author : Stitch-z +@File : test_write_tutorial.py +""" +from typing import Dict + +import pytest + +from metagpt.actions.write_tutorial import WriteDirectory, WriteContent + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("language", "topic"), + [("English", "Write a tutorial about Python")] +) +async def test_write_directory(language: str, topic: str): + ret = await WriteDirectory(language=language).run(topic=topic) + assert isinstance(ret, dict) + assert "title" in ret + assert "directory" in ret + assert isinstance(ret["directory"], list) + assert len(ret["directory"]) + assert isinstance(ret["directory"][0], dict) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("language", "topic", "directory"), + [("English", "Write a tutorial about Python", {"Introduction": ["What is Python?", "Why learn Python?"]})] +) +async def test_write_content(language: str, topic: str, directory: Dict): + ret = await WriteContent(language=language, directory=directory).run(topic=topic) + assert isinstance(ret, str) + assert list(directory.keys())[0] in ret + for value in list(directory.values())[0]: + assert value in ret diff --git a/tests/metagpt/roles/test_tutorial_assistant.py b/tests/metagpt/roles/test_tutorial_assistant.py new file mode 100644 index 000000000..945620cfc --- /dev/null +++ b/tests/metagpt/roles/test_tutorial_assistant.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python3 +# _*_ coding: utf-8 _*_ +""" +@Time : 2023/9/6 23:11:27 +@Author : Stitch-z +@File : test_tutorial_assistant.py +""" +import aiofiles +import pytest + +from metagpt.roles.tutorial_assistant import TutorialAssistant + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("language", "topic"), + [("Chinese", "Write a tutorial about Python")] +) +async def test_tutorial_assistant(language: str, topic: str): + topic = "Write a tutorial about MySQL" + role = TutorialAssistant(language=language) + msg = await role.run(topic) + filename = msg.content + title = filename.split("/")[-1].split(".")[0] + async with aiofiles.open(filename, mode="r") as reader: + content = await reader.read() + assert content.startswith(f"# {title}") \ No newline at end of file diff --git a/tests/metagpt/utils/test_file.py b/tests/metagpt/utils/test_file.py new file mode 100644 index 000000000..a9f1a353d --- /dev/null +++ b/tests/metagpt/utils/test_file.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python3 +# _*_ coding: utf-8 _*_ +""" +@Time : 2023/9/4 15:40:40 +@Author : Stitch-z +@File : test_file.py +""" +from pathlib import Path + +import aiofiles +import pytest + +from metagpt.utils.file import File + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("root_path", "filename", "content"), + [(Path("/code/MetaGPT/data/tutorial_docx/2023-09-07_17-05-20"), "test.md", "Hello World!")] +) +async def test_write_file(root_path: Path, filename: str, content: bytes): + full_file_name = await File.write(root_path=root_path, filename=filename, content=content.encode('utf-8')) + assert isinstance(full_file_name, Path) + assert root_path / filename == full_file_name + async with aiofiles.open(full_file_name, mode="r") as reader: + body = await reader.read() + assert body == content \ No newline at end of file