diff --git a/metagpt/actions/design_api.py b/metagpt/actions/design_api.py index 2e84cc463..8bf11356a 100644 --- a/metagpt/actions/design_api.py +++ b/metagpt/actions/design_api.py @@ -10,8 +10,9 @@ @Modified By: mashenquan, 2023/12/5. Move the generation logic of the project name to WritePRD. """ import json +import uuid from pathlib import Path -from typing import Optional +from typing import List, Optional from pydantic import BaseModel, Field @@ -27,6 +28,7 @@ from metagpt.actions.design_api_an import ( from metagpt.const import DATA_API_DESIGN_FILE_REPO, SEQ_FLOW_FILE_REPO from metagpt.logs import logger from metagpt.schema import AIMessage, Document, Documents, Message +from metagpt.utils.common import aread, awrite, to_markdown_code_block from metagpt.utils.mermaid import mermaid_to_file from metagpt.utils.project_repo import ProjectRepo from metagpt.utils.report import DocsReporter, GalleryReporter @@ -51,7 +53,116 @@ class WriteDesign(Action): repo: Optional[ProjectRepo] = Field(default=None, exclude=True) input_args: Optional[BaseModel] = Field(default=None, exclude=True) - async def run(self, with_messages: Message, schema: str = None): + async def run( + self, + with_messages: List[Message] = None, + *, + user_requirement: str = "", + prd_filename: str = "", + exists_design_filename: str = "", + extra_info: str = "", + output_path: str = "", + **kwargs, + ) -> AIMessage: + """ + Write a system design. + + Args: + user_requirement (str): The user's requirements for the system design. + prd_filename (str, optional): The filename of the Product Requirement Document (PRD). + exists_design_filename (str, optional): The filename of the existing design document. + extra_info (str, optional): Additional information to be included in the system design. + output_path (str, optional): The output path where the system design should be saved. + + Returns: + AIMessage: An AIMessage object containing the system design. + + Example: + # Write a new system design. + >>> user_requirement = "Your user requirements" + >>> extra_info = "Your extra information" + >>> action = WriteDesign() + >>> result = await action.run(user_requirement=user_requirement, extra_info=extra_info) + >>> print(result.content) + The design is balabala... + + # Modify an exists system design. + >>> user_requirement = "Your user requirements" + >>> extra_info = "Your extra information" + >>> exists_design_filename = "/path/to/exists/design/filename" + >>> action = WriteDesign() + >>> result = await action.run(user_requirement=user_requirement, extra_info=extra_info, exists_design_filename=exists_design_filename) + >>> print(result.content) + The design is balabala... + + # Write a new system design with the given PRD(Product Requirement Document). + >>> user_requirement = "Your user requirements" + >>> extra_info = "Your extra information" + >>> prd_filename = "/path/to/prd/filename" + >>> action = WriteDesign() + >>> result = await action.run(user_requirement=user_requirement, extra_info=extra_info, prd_filename=prd_filename) + >>> print(result.content) + The design is balabala... + + # Modify an exists system design with the given PRD(Product Requirement Document). + >>> user_requirement = "Your user requirements" + >>> extra_info = "Your extra information" + >>> prd_filename = "/path/to/prd/filename" + >>> exists_design_filename = "/path/to/exists/design/filename" + >>> action = WriteDesign() + >>> result = await action.run(user_requirement=user_requirement, extra_info=extra_info, exists_design_filename=exists_design_filename, prd_filename=prd_filename) + >>> print(result.content) + The design is balabala... + + # Write a new system design and save to the directory. + >>> user_requirement = "Your user requirements" + >>> extra_info = "Your extra information" + >>> output_path = "/path/to/save/" + >>> action = WriteDesign() + >>> result = await action.run(user_requirement=user_requirement, extra_info=extra_info, output_path=output_path) + >>> print(result.content) + System Design filename: "/path/to/design/filename" + + # Modify an exists system design and save to the directory. + >>> user_requirement = "Your user requirements" + >>> extra_info = "Your extra information" + >>> exists_design_filename = "/path/to/exists/design/filename" + >>> output_path = "/path/to/save/" + >>> action = WriteDesign() + >>> result = await action.run(user_requirement=user_requirement, extra_info=extra_info, exists_design_filename=exists_design_filename) + >>> print(result.content) + System Design filename: "/path/to/design/filename" + + # Write a new system design with the given PRD(Product Requirement Document) and save to the directory. + >>> user_requirement = "Your user requirements" + >>> extra_info = "Your extra information" + >>> prd_filename = "/path/to/prd/filename" + >>> output_path = "/path/to/save/" + >>> action = WriteDesign() + >>> result = await action.run(user_requirement=user_requirement, extra_info=extra_info, prd_filename=prd_filename) + >>> print(result.content) + System Design filename: "/path/to/design/filename" + + # Modify an exists system design with the given PRD(Product Requirement Document) and save to the directory. + >>> user_requirement = "Your user requirements" + >>> extra_info = "Your extra information" + >>> prd_filename = "/path/to/prd/filename" + >>> exists_design_filename = "/path/to/exists/design/filename" + >>> output_path = "/path/to/save/" + >>> action = WriteDesign() + >>> result = await action.run(user_requirement=user_requirement, extra_info=extra_info, exists_design_filename=exists_design_filename, prd_filename=prd_filename) + >>> print(result.content) + System Design filename: "/path/to/design/filename" + """ + if not with_messages: + return await self._execute_api( + user_requirement=user_requirement, + prd_filename=prd_filename, + exists_design_filename=exists_design_filename, + extra_info=extra_info, + output_path=output_path, + ) + self.input_args = with_messages[0].instruct_content self.repo = ProjectRepo(self.input_args.project_path) changed_prds = self.input_args.changed_prd_filenames @@ -147,3 +258,32 @@ class WriteDesign(Action): image_path = pathname.parent / f"{pathname.name}.png" if image_path.exists(): await GalleryReporter().async_report(image_path, "path") + + async def _execute_api( + self, + user_requirement: str = "", + prd_filename: str = "", + exists_design_filename: str = "", + extra_info: str = "", + output_path: str = "", + ) -> AIMessage: + context = to_markdown_code_block(user_requirement) + if extra_info: + context = to_markdown_code_block(extra_info) + if prd_filename: + prd_content = await aread(filename=prd_filename) + context += to_markdown_code_block(prd_content) + if not exists_design_filename: + node = await self._new_system_design(context=context) + design = Document(content=node.instruct_content.model_dump_json()) + else: + old_design_content = await aread(filename=exists_design_filename) + design = await self._merge( + prd_doc=Document(content=context), system_design_doc=Document(content=old_design_content) + ) + + if not output_path: + return AIMessage(content=design.instruct_content.model_dump_json()) + output_filename = Path(output_path) / f"{uuid.uuid4().hex}.json" + await awrite(filename=output_filename, data=design.content) + return AIMessage(content=f'System Design filename: "{str(output_filename)}"') diff --git a/metagpt/actions/project_management.py b/metagpt/actions/project_management.py index 55356f58b..9880a10f3 100644 --- a/metagpt/actions/project_management.py +++ b/metagpt/actions/project_management.py @@ -12,7 +12,7 @@ import json from pathlib import Path -from typing import Optional +from typing import List, Optional from pydantic import BaseModel, Field @@ -20,7 +20,8 @@ from metagpt.actions.action import Action from metagpt.actions.project_management_an import PM_NODE, REFINED_PM_NODE from metagpt.const import PACKAGE_REQUIREMENTS_FILENAME from metagpt.logs import logger -from metagpt.schema import AIMessage, Document, Documents +from metagpt.schema import AIMessage, Document, Documents, Message +from metagpt.utils.common import aread, to_markdown_code_block from metagpt.utils.project_repo import ProjectRepo from metagpt.utils.report import DocsReporter @@ -39,7 +40,39 @@ class WriteTasks(Action): repo: Optional[ProjectRepo] = Field(default=None, exclude=True) input_args: Optional[BaseModel] = Field(default=None, exclude=True) - async def run(self, with_messages): + async def run( + self, with_messages: List[Message] = None, *, user_requirement: str = "", design_filename: str = "", **kwargs + ) -> AIMessage: + """ + Write a project schedule given a project system design file. + + Args: + user_requirement (str, optional): A string specifying the user's requirements. Defaults to an empty string. + design_filename (str): The filename of the project system design file. Defaults to an empty string. + **kwargs: Additional keyword arguments. + + Returns: + AIMessage: The generated project schedule. + + Example: + # Write a new project schedule. + >>> design_filename = "/path/to/design/filename" + >>> action = WriteTasks() + >>> result = await action.run(design_filename=design_filename) + >>> print(result.content) + The project schedule is balabala... + + # Write a new project schedule with the user requirement. + >>> design_filename = "/path/to/design/filename" + >>> user_requirement = "Your user requirements" + >>> action = WriteTasks() + >>> result = await action.run(design_filename=design_filename, user_requirement=user_requirement) + >>> print(result.content) + The project schedule is balabala... + """ + if not with_messages: + return await self._execute_api(user_requirement=user_requirement, design_filename=design_filename) + self.input_args = with_messages[0].instruct_content self.repo = ProjectRepo(self.input_args.project_path) changed_system_designs = self.input_args.changed_system_design_filenames @@ -99,7 +132,7 @@ class WriteTasks(Action): await reporter.async_report(self.repo.workdir / md.root_relative_path, "path") return task_doc - async def _run_new_tasks(self, context): + async def _run_new_tasks(self, context: str): node = await PM_NODE.fill(context, self.llm, schema=self.prompt_schema) return node @@ -121,3 +154,11 @@ class WriteTasks(Action): continue packages.add(pkg) await self.repo.save(filename=PACKAGE_REQUIREMENTS_FILENAME, content="\n".join(packages)) + + async def _execute_api(self, user_requirement: str = "", design_filename: str = ""): + context = to_markdown_code_block(user_requirement) + if not design_filename: + content = await aread(filename=design_filename) + context += to_markdown_code_block(content) + node = await self._run_new_tasks(context) + return AIMessage(content=node.instruct_content.model_dump_json()) diff --git a/metagpt/actions/write_prd.py b/metagpt/actions/write_prd.py index 3275619f7..0584a247f 100644 --- a/metagpt/actions/write_prd.py +++ b/metagpt/actions/write_prd.py @@ -14,8 +14,9 @@ from __future__ import annotations import json +import uuid from pathlib import Path -from typing import Optional +from typing import List, Optional from pydantic import BaseModel, Field @@ -37,7 +38,7 @@ from metagpt.const import ( ) from metagpt.logs import logger from metagpt.schema import AIMessage, Document, Documents, Message -from metagpt.utils.common import CodeParser +from metagpt.utils.common import CodeParser, aread, awrite, to_markdown_code_block from metagpt.utils.file_repository import FileRepository from metagpt.utils.mermaid import mermaid_to_file from metagpt.utils.project_repo import ProjectRepo @@ -73,8 +74,75 @@ class WritePRD(Action): repo: Optional[ProjectRepo] = Field(default=None, exclude=True) input_args: Optional[BaseModel] = Field(default=None, exclude=True) - async def run(self, with_messages, *args, **kwargs) -> Message: - """Run the action.""" + async def run( + self, + with_messages: List[Message] = None, + *, + user_requirement: str = "", + output_path: str = "", + exists_prd_filename: str = "", + extra_info: str = "", + **kwargs, + ) -> AIMessage: + """ + Write a Product Requirement Document. + + Args: + user_requirement (str): A string detailing the user's requirements. + output_path (str, optional): The file path where the output document should be saved. Defaults to "". + exists_prd_filename (str, optional): The file path of an existing Product Requirement Document to use as a reference. Defaults to "". + extra_info (str, optional): Additional information to include in the document. Defaults to "". + **kwargs: Additional keyword arguments. + + Returns: + AIMessage: The resulting message after generating the Product Requirement Document. + + Example: + # Write a new PRD(Product Requirement Document) + >>> user_requirement = "YOUR REQUIREMENTS" + >>> extra_info = "YOUR EXTRA INFO" + >>> write_prd = WritePRD() + >>> result = await write_prd.run(user_requirement=user_requirement, extra_info=extra_info) + >>> print(result.content) + The PRD is about balabala... + + # Modify a exists PRD(Product Requirement Document) + >>> user_requirement = "YOUR REQUIREMENTS" + >>> extra_info = "YOUR EXTRA INFO" + >>> exists_prd_filename = "/path/to/exists/prd_filename" + >>> write_prd = WritePRD() + >>> result = await write_prd.run(user_requirement=user_requirement, extra_info=extra_info, exists_prd_filename=exists_prd_filename) + >>> print(result.content) + The PRD is about balabala... + + # Write and save a new PRD(Product Requirement Document) to the directory. + >>> user_requirement = "YOUR REQUIREMENTS" + >>> extra_info = "YOUR EXTRA INFO" + >>> output_path = "/path/to/prd/directory/" + >>> write_prd = WritePRD() + >>> result = await write_prd.run(user_requirement=user_requirement, extra_info=extra_info, output_path=output_path) + >>> print(result.content) + PRD filename: "/path/to/prd/directory/213434ad.json" + + # Modify a exists PRD(Product Requirement Document) and save to the directory. + >>> user_requirement = "YOUR REQUIREMENTS" + >>> extra_info = "YOUR EXTRA INFO" + >>> exists_prd_filename = "/path/to/exists/prd_filename" + >>> output_path = "/path/to/prd/directory/" + >>> write_prd = WritePRD() + >>> result = await write_prd.run(user_requirement=user_requirement, extra_info=extra_info, exists_prd_filename=exists_prd_filename, output_path=output_path) + >>> print(result.content) + PRD filename: "/path/to/prd/directory/213434ad.json" + + """ + if not with_messages: + return await self._execute_api( + user_requirement=user_requirement, + output_path=output_path, + exists_prd_filename=exists_prd_filename, + extra_info=extra_info, + ) + self.input_args = with_messages[-1].instruct_content if not self.input_args: self.repo = ProjectRepo(self.config.project_path) @@ -110,6 +178,7 @@ class WritePRD(Action): else: logger.info(f"New requirement detected: {req.content}") await self._handle_new_requirement(req) + kvs = self.input_args.model_dump() kvs["changed_prd_filenames"] = [ str(self.repo.docs.prd.workdir / i) for i in list(self.repo.docs.prd.changed_files.keys()) @@ -143,16 +212,20 @@ class WritePRD(Action): send_to="Alex", # the name of Engineer ) + async def _new_prd(self, requirement: str) -> ActionNode: + project_name = self.project_name + context = CONTEXT_TEMPLATE.format(requirements=requirement, project_name=project_name) + exclude = [PROJECT_NAME.key] if project_name else [] + node = await WRITE_PRD_NODE.fill( + context=context, llm=self.llm, exclude=exclude, schema=self.prompt_schema + ) # schema=schema + return node + async def _handle_new_requirement(self, req: Document) -> ActionOutput: """handle new requirement""" async with DocsReporter(enable_llm_stream=True) as reporter: await reporter.async_report({"type": "prd"}, "meta") - project_name = self.project_name - context = CONTEXT_TEMPLATE.format(requirements=req, project_name=project_name) - exclude = [PROJECT_NAME.key] if project_name else [] - node = await WRITE_PRD_NODE.fill( - context=context, llm=self.llm, exclude=exclude, schema=self.prompt_schema - ) # schema=schema + node = await self._new_prd(req.content) await self._rename_workspace(node) new_prd_doc = await self.repo.docs.prd.save( filename=FileRepository.new_filename() + ".json", content=node.instruct_content.model_dump_json() @@ -223,4 +296,28 @@ class WritePRD(Action): ws_name = CodeParser.parse_str(block="Project Name", text=prd) if ws_name: self.project_name = ws_name - self.repo.git_repo.rename_root(self.project_name) + if self.repo: + self.repo.git_repo.rename_root(self.project_name) + + async def _execute_api( + self, user_requirement: str, output_path: str, exists_prd_filename: str, extra_info: str + ) -> AIMessage: + content = to_markdown_code_block(val=user_requirement) + if extra_info: + content += to_markdown_code_block(val=extra_info) + + req = Document(content=content) + if not exists_prd_filename: + node = await self._new_prd(requirement=req.content) + new_prd = Document(content=node.instruct_content.model_dump_json()) + else: + content = await aread(filename=exists_prd_filename) + old_prd = Document(content=content) + new_prd = await self._merge(req=req, related_doc=old_prd) + + if not output_path: + return AIMessage(content=new_prd.content) + + output_filename = Path(output_path) / f"{uuid.uuid4().hex}.json" + await awrite(filename=output_filename, data=new_prd.content) + return AIMessage(content=f'PRD filename: "{str(output_filename)}"') diff --git a/metagpt/utils/common.py b/metagpt/utils/common.py index 6d40828af..9e9bb034c 100644 --- a/metagpt/utils/common.py +++ b/metagpt/utils/common.py @@ -667,6 +667,8 @@ def role_raise_decorator(func): @handle_exception async def aread(filename: str | Path, encoding="utf-8") -> str: """Read file asynchronously.""" + if not filename or not Path(filename).exists(): + return "" try: async with aiofiles.open(str(filename), mode="r", encoding=encoding) as reader: content = await reader.read() @@ -940,3 +942,8 @@ def get_markdown_code_block_type(filename: str) -> str: # Add more file extensions and corresponding code block types as needed } return types.get(ext, "") + + +def to_markdown_code_block(val: str, type_: str = "") -> str: + val = val.replace("```", "\\`\\`\\`") + return f"\n```{type_}\n{val}\n```\n"