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
-
-
+
+
+
-
+
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: 多智能体框架
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
-
+
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: マルチエージェントフレームワーク
-
-
-
-
-
+
+
+
+
+
-
+
+
-
+
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