From 9d84c8f047b60455bbf9c679af3cfe4cf1b11b27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=8E=98=E6=9D=83=20=E9=A9=AC?= Date: Mon, 4 Dec 2023 23:04:07 +0800 Subject: [PATCH] feat: +SummarizeCode, refactor project_name --- metagpt/actions/design_api.py | 65 ++---- metagpt/actions/prepare_documents.py | 7 +- metagpt/actions/project_management.py | 10 +- metagpt/actions/summarize_code.py | 9 +- metagpt/actions/write_code.py | 20 +- metagpt/actions/write_code_review.py | 3 +- metagpt/actions/write_prd.py | 48 ++++- metagpt/actions/write_test.py | 7 +- metagpt/const.py | 3 + metagpt/provider/base_gpt_api.py | 4 +- metagpt/roles/engineer.py | 132 +++++++++--- metagpt/roles/qa_engineer.py | 12 +- metagpt/roles/role.py | 3 +- metagpt/schema.py | 20 +- metagpt/startup.py | 11 +- metagpt/utils/dependency_file.py | 5 +- metagpt/utils/file_repository.py | 33 +++ metagpt/utils/git_repository.py | 41 +++- tests/conftest.py | 16 ++ tests/metagpt/actions/mock.py | 2 +- tests/metagpt/actions/test_debug_error.py | 90 ++++---- tests/metagpt/actions/test_design_api.py | 26 +-- .../metagpt/actions/test_prepare_documents.py | 30 +++ tests/metagpt/actions/test_run_code.py | 62 +++--- tests/metagpt/actions/test_summarize_code.py | 195 ++++++++++++++++++ tests/metagpt/actions/test_write_code.py | 17 +- .../metagpt/actions/test_write_code_review.py | 12 +- tests/metagpt/actions/test_write_prd.py | 7 +- tests/metagpt/actions/test_write_test.py | 22 +- tests/metagpt/roles/mock.py | 2 +- tests/metagpt/utils/test_file_repository.py | 4 + 31 files changed, 672 insertions(+), 246 deletions(-) create mode 100644 tests/metagpt/actions/test_prepare_documents.py create mode 100644 tests/metagpt/actions/test_summarize_code.py diff --git a/metagpt/actions/design_api.py b/metagpt/actions/design_api.py index c5787ba20..557ebcbbd 100644 --- a/metagpt/actions/design_api.py +++ b/metagpt/actions/design_api.py @@ -7,6 +7,7 @@ @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. +@Modified By: mashenquan, 2023/12/5. Move the generation logic of the project name to WritePRD. """ import json from pathlib import Path @@ -23,7 +24,6 @@ ) from metagpt.logs import logger from metagpt.schema import Document, Documents -from metagpt.utils.common import CodeParser from metagpt.utils.file_repository import FileRepository from metagpt.utils.get_template import get_template from metagpt.utils.mermaid import mermaid_to_file @@ -43,7 +43,7 @@ ## Implementation approach: Provide as Plain text. Analyze the difficult points of the requirements, select appropriate open-source frameworks. -## project_name: Provide as Plain text, concise and clear, characters only use a combination of all lowercase and underscores +## Project name: Constant text. ## File list: Provided as Python list[str], the list of files needed (including HTML & CSS IF NEEDED) to write the program. Only need relative paths. ALWAYS write a main.py or app.py here @@ -58,15 +58,15 @@ """, "FORMAT_EXAMPLE": """ [CONTENT] -{ +{{ "Implementation approach": "We will ...", - "project_name": "snake_game", + "Project name": "{project_name}", "File list": ["main.py"], "Data structures and interfaces": ' classDiagram - class Game{ + class Game{{ +int score - } + }} ... Game "1" -- "1" Food: has ', @@ -77,7 +77,7 @@ class Game{ G->>M: end game ', "Anything UNCLEAR": "The requirement is clear to me." -} +}} [/CONTENT] """, }, @@ -96,7 +96,7 @@ class Game{ ## Implementation approach: Provide as Plain text. Analyze the difficult points of the requirements, select the appropriate open-source framework. -## project_name: Provide as Plain text, concise and clear, characters only use a combination of all lowercase and underscores +## Project name: Constant text. ## File list: Provided as Python list[str], the list of code files (including HTML & CSS IF NEEDED) to write the program. Only need relative paths. ALWAYS write a main.py or app.py here @@ -112,9 +112,9 @@ class Game{ ## Implementation approach We will ... -## project_name +## Project name ```python -"snake_game" +"{project_name}" ``` ## File list @@ -151,7 +151,7 @@ class Game{ OUTPUT_MAPPING = { "Implementation approach": (str, ...), - "project_name": (str, ...), + "Project name": (str, ...), "File list": (List[str], ...), "Data structures and interfaces": (str, ...), "Program call flow": (str, ...), @@ -173,7 +173,7 @@ class Game{ ## Implementation approach: Provide as Plain text. Analyze the difficult points of the requirements, select the appropriate open-source framework. -## project_name: Provide as Plain text, concise and clear, characters only use a combination of all lowercase and underscores +## Project name: Constant text "{project_name}". ## File list: Provided as Python list[str], the list of code files (including HTML & CSS IF NEEDED) to write the program. Only need relative paths. ALWAYS write a main.py or app.py here @@ -229,50 +229,21 @@ async def run(self, with_messages, format=CONFIG.prompt_format): async def _new_system_design(self, context, format=CONFIG.prompt_format): prompt_template, format_example = get_template(templates, format) + 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) - self._rename_project_name(system_design=system_design) - await self._rename_workspace(system_design) return system_design async def _merge(self, prd_doc, system_design_doc, format=CONFIG.prompt_format): - prompt = MERGE_PROMPT.format(old_design=system_design_doc.content, context=prd_doc.content) + prompt = MERGE_PROMPT.format( + old_design=system_design_doc.content, context=prd_doc.content, project_name=CONFIG.project_name + ) system_design = await self._aask_v1(prompt, "system_design", OUTPUT_MAPPING, format=format) # fix Python package name, we can't system_design.instruct_content.python_package_name = "xxx" since "Python # package name" contain space, have to use setattr - self._rename_project_name(system_design=system_design) system_design_doc.content = system_design.instruct_content.json(ensure_ascii=False) return system_design_doc - @staticmethod - def _rename_project_name(system_design): - # fix project_name, we can't system_design.instruct_content.python_package_name = "xxx" since "project_name" - # contain space, have to use setattr - if CONFIG.project_name: - setattr( - system_design.instruct_content, - "project_name", - CONFIG.project_name, - ) - return - setattr( - system_design.instruct_content, - "project_name", - system_design.instruct_content.dict()["project_name"].strip().strip("'").strip('"'), - ) - - @staticmethod - async def _rename_workspace(system_design): - if CONFIG.project_path: # Updating on the old version has already been specified if it's valid. According to - # Section 2.2.3.10 of RFC 135 - return - - if isinstance(system_design, ActionOutput): - ws_name = system_design.instruct_content.dict()["project_name"] - else: - ws_name = CodeParser.parse_str(block="project_name", text=system_design) - CONFIG.git_repo.rename_root(ws_name) - async def _update_system_design(self, filename, prds_file_repo, system_design_file_repo) -> Document: prd = await prds_file_repo.get(filename) old_system_design_doc = await system_design_file_repo.get(filename) @@ -296,10 +267,10 @@ async def _update_system_design(self, filename, prds_file_repo, system_design_fi @staticmethod async def _save_data_api_design(design_doc): m = json.loads(design_doc.content) - data_api_design = m.get("Data structures and interface definitions") + data_api_design = m.get("Data structures and interfaces") if not data_api_design: return - pathname = CONFIG.git_repo.workdir / Path(DATA_API_DESIGN_FILE_REPO) / Path(design_doc.filename).with_suffix("") + pathname = CONFIG.git_repo.workdir / DATA_API_DESIGN_FILE_REPO / Path(design_doc.filename).with_suffix("") await WriteDesign._save_mermaid_file(data_api_design, pathname) logger.info(f"Save class view to {str(pathname)}") diff --git a/metagpt/actions/prepare_documents.py b/metagpt/actions/prepare_documents.py index b751dc970..05255dcc5 100644 --- a/metagpt/actions/prepare_documents.py +++ b/metagpt/actions/prepare_documents.py @@ -3,7 +3,7 @@ """ @Time : 2023/11/20 @Author : mashenquan -@File : git_repository.py +@File : prepare_documents.py @Desc: PrepareDocuments Action: initialize project folder and add new requirements to docs/requirements.txt. RFC 135 2.2.3.5.1. """ @@ -26,7 +26,10 @@ async def run(self, with_messages, **kwargs): if not CONFIG.git_repo: # Create and initialize the workspace folder, initialize the Git environment. project_name = CONFIG.project_name or FileRepository.new_filename() - workdir = Path(CONFIG.project_path or DEFAULT_WORKSPACE_ROOT / project_name) + workdir = CONFIG.project_path + if not workdir and CONFIG.workspace: + workdir = Path(CONFIG.workspace) / project_name + workdir = Path(workdir or DEFAULT_WORKSPACE_ROOT / project_name) if not CONFIG.inc and workdir.exists(): shutil.rmtree(workdir) CONFIG.git_repo = GitRepository() diff --git a/metagpt/actions/project_management.py b/metagpt/actions/project_management.py index 3d59daeed..95da0d65a 100644 --- a/metagpt/actions/project_management.py +++ b/metagpt/actions/project_management.py @@ -183,6 +183,10 @@ ## Old Tasks {old_tasks} ----- + +## Format example +{format_example} +----- Role: You are a project manager; The goal is to merge the new PRD/technical design content from 'Context' into 'Old Tasks.' Based on this merged result, break down tasks, give a task list, and analyze task dependencies to start with the prerequisite modules. Requirements: Based on the context, fill in the following missing information, each section name is a key in json. Here the granularity of the task is a file, if there are any missing files, you can supplement them Attention: Use '##' to split sections, not '#', and '## ' SHOULD WRITE BEFORE the code and triple quote. @@ -201,7 +205,7 @@ ## Anything UNCLEAR: Provide as Plain text. Make clear here. For example, don't forget a main entry. don't forget to init 3rd party libs. -output a properly formatted JSON, wrapped inside [CONTENT][/CONTENT] like "Old Tasks" format, +output a properly formatted JSON, wrapped inside [CONTENT][/CONTENT] like "Format example" format, and only output the json inside this tag, nothing else """ @@ -264,7 +268,9 @@ async def _run_new_tasks(self, context, format=CONFIG.prompt_format): return rsp async def _merge(self, system_design_doc, task_doc, format=CONFIG.prompt_format) -> Document: - prompt = MERGE_PROMPT.format(context=system_design_doc.content, old_tasks=task_doc.content) + _, format_example = get_template(templates, format) + prompt = MERGE_PROMPT.format(context=system_design_doc.content, old_tasks=task_doc.content, + format_example=format_example) rsp = await self._aask_v1(prompt, "task", OUTPUT_MAPPING, format=format) task_doc.content = rsp.instruct_content.json(ensure_ascii=False) return task_doc diff --git a/metagpt/actions/summarize_code.py b/metagpt/actions/summarize_code.py index 88a37536b..d10cd6c55 100644 --- a/metagpt/actions/summarize_code.py +++ b/metagpt/actions/summarize_code.py @@ -3,12 +3,15 @@ """ @Author : alexanderwu @File : summarize_code.py +@Modified By: mashenquan, 2023/12/5. Archive the summarization content of issue discovery for use in WriteCode. """ +from pathlib import Path from tenacity import retry, stop_after_attempt, wait_fixed from metagpt.actions.action import Action from metagpt.config import CONFIG +from metagpt.const import SYSTEM_DESIGN_FILE_REPO, TASK_FILE_REPO from metagpt.logs import logger from metagpt.utils.file_repository import FileRepository @@ -95,8 +98,10 @@ async def summarize_code(self, prompt): return code_rsp async def run(self): - design_doc = await FileRepository.get_file(self.context.design_filename) - task_doc = await FileRepository.get_file(self.context.task_filename) + design_pathname = Path(self.context.design_filename) + design_doc = await FileRepository.get_file(filename=design_pathname.name, relative_path=SYSTEM_DESIGN_FILE_REPO) + task_pathname = Path(self.context.task_filename) + task_doc = await FileRepository.get_file(filename=task_pathname.name, relative_path=TASK_FILE_REPO) src_file_repo = CONFIG.git_repo.new_file_repository(relative_path=CONFIG.src_workspace) code_blocks = [] for filename in self.context.codes_filenames: diff --git a/metagpt/actions/write_code.py b/metagpt/actions/write_code.py index 59ccb49a5..9b20843c7 100644 --- a/metagpt/actions/write_code.py +++ b/metagpt/actions/write_code.py @@ -15,13 +15,13 @@ RunCodeResult to standardize and unify parameter passing between WriteCode, RunCode, and DebugError. """ - from tenacity import retry, stop_after_attempt, wait_fixed from metagpt.actions.action import Action -from metagpt.const import TEST_OUTPUTS_FILE_REPO +from metagpt.config import CONFIG +from metagpt.const import CODE_SUMMARIES_FILE_REPO, TEST_OUTPUTS_FILE_REPO from metagpt.logs import logger -from metagpt.schema import CodingContext, RunCodeResult +from metagpt.schema import CodingContext, Document, RunCodeResult from metagpt.utils.common import CodeParser from metagpt.utils.file_repository import FileRepository @@ -50,6 +50,8 @@ # Debug logs ```text {logs} + +{summary_log} ``` ----- @@ -90,18 +92,26 @@ async def run(self, *args, **kwargs) -> CodingContext: test_doc = await FileRepository.get_file( filename="test_" + coding_context.filename + ".json", relative_path=TEST_OUTPUTS_FILE_REPO ) + summary_doc = None + if coding_context.design_doc.filename: + summary_doc = await FileRepository.get_file( + filename=coding_context.design_doc.filename, relative_path=CODE_SUMMARIES_FILE_REPO + ) logs = "" if test_doc: test_detail = RunCodeResult.loads(test_doc.content) logs = test_detail.stderr prompt = PROMPT_TEMPLATE.format( design=coding_context.design_doc.content, - tasks=coding_context.task_doc.content, - code=coding_context.code_doc.content, + tasks=coding_context.task_doc.content if coding_context.task_doc else "", + code=coding_context.code_doc.content if coding_context.code_doc else "", logs=logs, filename=self.context.filename, + summary_log=summary_doc.content if summary_doc else "", ) logger.info(f"Writing {coding_context.filename}..") 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) coding_context.code_doc.content = code return coding_context diff --git a/metagpt/actions/write_code_review.py b/metagpt/actions/write_code_review.py index 364f6af57..f7c6845d2 100644 --- a/metagpt/actions/write_code_review.py +++ b/metagpt/actions/write_code_review.py @@ -108,10 +108,11 @@ async def run(self, *args, **kwargs) -> CodingContext: k = CONFIG.code_review_k_times or 1 for i in range(k): format_example = FORMAT_EXAMPLE.format(filename=self.context.code_doc.filename) + task_content = self.context.task_doc.content if self.context.task_doc else "" context = "\n----------\n".join( [ "```text\n" + self.context.design_doc.content + "```\n", - "```text\n" + self.context.task_doc.content + "```\n", + "```text\n" + task_content + "```\n", "```python\n" + self.context.code_doc.content + "```\n", ] ) diff --git a/metagpt/actions/write_prd.py b/metagpt/actions/write_prd.py index 3967a0578..530a22def 100644 --- a/metagpt/actions/write_prd.py +++ b/metagpt/actions/write_prd.py @@ -8,6 +8,7 @@ 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.2 of RFC 135, add incremental iteration functionality. 3. Move the document storage operations related to WritePRD from the save operation of WriteDesign. +@Modified By: mashenquan, 2023/12/5. Move the generation logic of the project name to WritePRD. """ from __future__ import annotations @@ -27,6 +28,7 @@ ) from metagpt.logs import logger from metagpt.schema import Document, Documents +from metagpt.utils.common import CodeParser from metagpt.utils.file_repository import FileRepository from metagpt.utils.get_template import get_template from metagpt.utils.mermaid import mermaid_to_file @@ -53,7 +55,7 @@ {{ "Language": "", # str, use the same language as the user requirement. en_us / zh_cn etc. "Original Requirements": "", # str, place the polished complete original requirements here - "project_name": "", # str, name it like game_2048 / web_2048 / simple_crm etc. + "Project Name": "{project_name}", # str, if it's empty, name it with snake case style, like game_2048 / web_2048 / simple_crm etc. "Search Information": "", "Requirements": "", "Product Goals": [], # Provided as Python list[str], up to 3 clear, orthogonal product goals. @@ -85,9 +87,10 @@ """, "FORMAT_EXAMPLE": """ [CONTENT] -{ +{{ "Language": "", "Original Requirements": "", + "Project Name": "{project_name}", "Search Information": "", "Requirements": "", "Product Goals": [], @@ -111,7 +114,7 @@ "Requirement Pool": [["P0","P0 requirement"],["P1","P1 requirement"]], "UI Design draft": "", "Anything UNCLEAR": "", -} +}} [/CONTENT] """, }, @@ -228,6 +231,7 @@ OUTPUT_MAPPING = { "Language": (str, ...), "Original Requirements": (str, ...), + "Project Name": (str, ...), "Product Goals": (List[str], ...), "User Stories": (List[str], ...), "Competitive Analysis": (List[str], ...), @@ -270,7 +274,7 @@ {{ "Language": "", # str, use the same language as the user requirement. en_us / zh_cn etc. "Original Requirements": "", # str, place the polished complete original requirements here - "project_name": "", # str, name it like game_2048 / web_2048 / simple_crm etc. + "Project Name": "{project_name}", # str, if it's empty, name it with snake case style, like game_2048 / web_2048 / simple_crm etc. "Search Information": "", "Requirements": "", "Product Goals": [], # Provided as Python list[str], up to 3 clear, orthogonal product goals. @@ -320,6 +324,7 @@ async def run(self, with_messages, format=CONFIG.prompt_format, *args, **kwargs) if not prd_doc: continue change_files.docs[prd_doc.filename] = prd_doc + logger.info(f"REWRITE PRD:{prd_doc.filename}") # If there is no existing PRD, generate one using 'docs/requirement.txt'. if not change_files.docs: prd_doc = await self._update_prd( @@ -327,6 +332,7 @@ async def run(self, with_messages, format=CONFIG.prompt_format, *args, **kwargs) ) if prd_doc: change_files.docs[prd_doc.filename] = prd_doc + logger.info(f"NEW PRD:{prd_doc.filename}") # Once all files under 'docs/prds/' have been compared with the newly added requirements, trigger the # 'publish' message to transition the workflow to the next stage. This design allows room for global # optimization in subsequent steps. @@ -343,32 +349,36 @@ async def _run_new_requirement(self, requirements, format=CONFIG.prompt_format, # logger.info(format) prompt_template, format_example = get_template(templates, format) + project_name = CONFIG.project_name if CONFIG.project_name else "" + format_example = format_example.format(project_name=project_name) # logger.info(prompt_template) # logger.info(format_example) prompt = prompt_template.format( - requirements=requirements, search_information=info, format_example=format_example + requirements=requirements, search_information=info, format_example=format_example, project_name=project_name ) # logger.info(prompt) # prd = await self._aask_v1(prompt, "prd", OUTPUT_MAPPING) prd = await self._aask_v1(prompt, "prd", OUTPUT_MAPPING, format=format) + await self._rename_workspace(prd) return prd async def _is_relative_to(self, new_requirement_doc, old_prd_doc) -> bool: - m = json.loads(old_prd_doc.content) - if m.get("Original Requirements") == new_requirement_doc.content: - # There have been no changes in the requirements, so they are considered unrelated. - return False prompt = IS_RELATIVE_PROMPT.format(old_prd=old_prd_doc.content, requirements=new_requirement_doc.content) res = await self._aask(prompt=prompt) - logger.info(f"[{new_requirement_doc.root_relative_path}, {old_prd_doc.root_relative_path}]: {res}") + logger.info(f"REQ-RELATIVE:[{new_requirement_doc.root_relative_path}, {old_prd_doc.root_relative_path}]: {res}") if "YES" in res: return True return False async def _merge(self, new_requirement_doc, prd_doc, format=CONFIG.prompt_format) -> Document: - prompt = MERGE_PROMPT.format(requirements=new_requirement_doc.content, old_prd=prd_doc.content) + if not CONFIG.project_name: + CONFIG.project_name = Path(CONFIG.project_path).name + prompt = MERGE_PROMPT.format( + requirements=new_requirement_doc.content, old_prd=prd_doc.content, project_name=CONFIG.project_name + ) prd = await self._aask_v1(prompt, "prd", OUTPUT_MAPPING, format=format) prd_doc.content = prd.instruct_content.json(ensure_ascii=False) + await self._rename_workspace(prd) return prd_doc async def _update_prd(self, requirement_doc, prd_doc, prds_file_repo, *args, **kwargs) -> Document | None: @@ -404,3 +414,19 @@ async def _save_competitive_analysis(prd_doc): @staticmethod async def _save_pdf(prd_doc): await FileRepository.save_as(doc=prd_doc, with_suffix=".md", relative_path=PRD_PDF_FILE_REPO) + + @staticmethod + async def _rename_workspace(prd): + if CONFIG.project_path: # Updating on the old version has already been specified if it's valid. According to + # Section 2.2.3.10 of RFC 135 + if not CONFIG.project_name: + CONFIG.project_name = Path(CONFIG.project_path).name + return + + if not CONFIG.project_name: + if isinstance(prd, ActionOutput): + ws_name = prd.instruct_content.dict()["Project Name"] + else: + ws_name = CodeParser.parse_str(block="Project Name", text=prd) + CONFIG.project_name = ws_name + CONFIG.git_repo.rename_root(CONFIG.project_name) diff --git a/metagpt/actions/write_test.py b/metagpt/actions/write_test.py index 7cbb42e1d..65673807f 100644 --- a/metagpt/actions/write_test.py +++ b/metagpt/actions/write_test.py @@ -9,8 +9,9 @@ """ from metagpt.actions.action import Action from metagpt.config import CONFIG +from metagpt.const import TEST_CODES_FILE_REPO from metagpt.logs import logger -from metagpt.schema import TestingContext +from metagpt.schema import Document, TestingContext from metagpt.utils.common import CodeParser PROMPT_TEMPLATE = """ @@ -52,6 +53,10 @@ async def write_code(self, prompt): return code async def run(self, *args, **kwargs) -> TestingContext: + if not self.context.test_doc: + self.context.test_doc = Document( + filename="test_" + self.context.code_doc.filename, root_path=TEST_CODES_FILE_REPO + ) prompt = PROMPT_TEMPLATE.format( code_to_test=self.context.code_doc.content, test_file_name=self.context.test_doc.filename, diff --git a/metagpt/const.py b/metagpt/const.py index a646cea7a..bd735a5e1 100644 --- a/metagpt/const.py +++ b/metagpt/const.py @@ -7,6 +7,7 @@ @Modified By: mashenquan, 2023-11-1. According to Section 2.2.1 and 2.2.2 of RFC 116, added key definitions for common properties in the Message. @Modified By: mashenquan, 2023-11-27. Defines file repository paths according to Section 2.2.3.4 of RFC 135. +@Modified By: mashenquan, 2023/12/5. Add directories for code summarization.. """ import contextvars import os @@ -87,5 +88,7 @@ def get_metagpt_root(): TASK_PDF_FILE_REPO = "resources/api_spec_and_tasks" TEST_CODES_FILE_REPO = "tests" TEST_OUTPUTS_FILE_REPO = "test_outputs" +CODE_SUMMARIES_FILE_REPO = "docs/code_summaries" +CODE_SUMMARIES_PDF_FILE_REPO = "resources/code_summaries" YAPI_URL = "http://yapi.deepwisdomai.com/" diff --git a/metagpt/provider/base_gpt_api.py b/metagpt/provider/base_gpt_api.py index 565ae94f7..6c1dc8338 100644 --- a/metagpt/provider/base_gpt_api.py +++ b/metagpt/provider/base_gpt_api.py @@ -38,7 +38,7 @@ def ask(self, msg: str) -> str: rsp = self.completion(message) return self.get_choice_text(rsp) - async def aask(self, msg: str, system_msgs: Optional[list[str]] = None) -> str: + async def aask(self, msg: str, system_msgs: Optional[list[str]] = None, stream=True) -> str: if system_msgs: message = ( self._system_msgs(system_msgs) + [self._user_msg(msg)] @@ -49,7 +49,7 @@ async def aask(self, msg: str, system_msgs: Optional[list[str]] = None) -> str: message = ( [self._default_system_msg(), self._user_msg(msg)] if self.use_system_prompt else [self._user_msg(msg)] ) - rsp = await self.acompletion_text(message, stream=True) + rsp = await self.acompletion_text(message, stream=stream) logger.debug(message) # logger.debug(rsp) return rsp diff --git a/metagpt/roles/engineer.py b/metagpt/roles/engineer.py index d42835a1b..9f8eb6482 100644 --- a/metagpt/roles/engineer.py +++ b/metagpt/roles/engineer.py @@ -13,17 +13,25 @@ @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.5 of RFC 135, add incremental iteration functionality. +@Modified By: mashenquan, 2023-12-5. Enhance the workflow to navigate to WriteCode or QaEngineer based on the results + of SummarizeCode. """ from __future__ import annotations import json +from collections import defaultdict from pathlib import Path from typing import Set from metagpt.actions import Action, WriteCode, WriteCodeReview, WriteTasks from metagpt.actions.summarize_code import SummarizeCode from metagpt.config import CONFIG -from metagpt.const import MESSAGE_ROUTE_TO_NONE, SYSTEM_DESIGN_FILE_REPO, TASK_FILE_REPO +from metagpt.const import ( + CODE_SUMMARIES_FILE_REPO, + CODE_SUMMARIES_PDF_FILE_REPO, + SYSTEM_DESIGN_FILE_REPO, + TASK_FILE_REPO, +) from metagpt.logs import logger from metagpt.roles import Role from metagpt.schema import ( @@ -33,6 +41,16 @@ Documents, Message, ) +from metagpt.utils.common import any_to_str, any_to_str_set + +IS_PASS_PROMPT = """ +{context} + +---- +Does the above log indicate anything that needs to be done? +If there are any tasks to be completed, please answer 'NO' along with the to-do list in JSON format; +otherwise, answer 'YES' in JSON format. +""" class Engineer(Role): @@ -60,7 +78,7 @@ def __init__( """Initializes the Engineer role with given attributes.""" super().__init__(name, profile, goal, constraints) self.use_code_review = use_code_review - self._watch([WriteTasks]) + self._watch([WriteTasks, SummarizeCode, WriteCode, WriteCodeReview]) self.code_todos = [] self.summarize_todos = [] self.n_borg = n_borg @@ -105,39 +123,88 @@ async def _act(self) -> Message | None: if self._rc.todo is None: return None if isinstance(self._rc.todo, WriteCode): - changed_files = await self._act_sp_with_cr(review=self.use_code_review) - # Unit tests only. - if CONFIG.REQA_FILENAME and CONFIG.REQA_FILENAME not in changed_files: - changed_files.add(CONFIG.REQA_FILENAME) - return Message( - content="\n".join(changed_files), - role=self.profile, - cause_by=WriteCodeReview if self.use_code_review else WriteCode, - send_to="Edward", # The name of QaEngineer - ) + return await self._act_write_code() if isinstance(self._rc.todo, SummarizeCode): - summaries = [] - for todo in self.summarize_todos: - summary = await todo.run() - summaries.append(summary.json(ensure_ascii=False)) + return await self._act_summarize() + return None + + async def _act_write_code(self): + changed_files = await self._act_sp_with_cr(review=self.use_code_review) + return Message( + content="\n".join(changed_files), + role=self.profile, + cause_by=WriteCodeReview if self.use_code_review else WriteCode, + send_to=self, + sent_from=self, + ) + + 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) + tasks = [] + src_relative_path = CONFIG.src_workspace.relative_to(CONFIG.git_repo.workdir) + for todo in self.summarize_todos: + summary = await todo.run() + summary_filename = Path(todo.context.design_filename).with_suffix(".md").name + dependencies = {todo.context.design_filename, todo.context.task_filename} + for filename in todo.context.codes_filenames: + rpath = src_relative_path / filename + dependencies.add(str(rpath)) + await code_summaries_pdf_file_repo.save( + filename=summary_filename, content=summary, dependencies=dependencies + ) + is_pass, reason = await self._is_pass(summary) + if not is_pass: + todo.context.reason = reason + tasks.append(todo.context.dict()) + await code_summaries_file_repo.save( + filename=Path(todo.context.design_filename).name, + content=todo.context.json(), + dependencies=dependencies, + ) + else: + await code_summaries_file_repo.delete(filename=Path(todo.context.design_filename).name) + + logger.info(f"--max-auto-summarize-code={CONFIG.max_auto_summarize_code}") + if not tasks or CONFIG.max_auto_summarize_code == 0: return Message( - content="\n".join(summaries), + content="", role=self.profile, cause_by=SummarizeCode, - send_to=MESSAGE_ROUTE_TO_NONE, + sent_from=self, + send_to="Edward", # The name of QaEngineer ) - return None + # The maximum number of times the 'SummarizeCode' action is automatically invoked, with -1 indicating unlimited. + # This parameter is used for debugging the workflow. + CONFIG.max_auto_summarize_code -= 1 if CONFIG.max_auto_summarize_code > 0 else 0 + return Message( + content=json.dumps(tasks), role=self.profile, cause_by=SummarizeCode, send_to=self, sent_from=self + ) + + async def _is_pass(self, summary) -> (str, str): + rsp = await self._llm.aask(msg=IS_PASS_PROMPT.format(context=summary), stream=False) + logger.info(rsp) + if "YES" in rsp: + return True, rsp + return False, rsp async def _think(self) -> Action | None: if not CONFIG.src_workspace: CONFIG.src_workspace = CONFIG.git_repo.workdir / CONFIG.git_repo.workdir.name - if not self.code_todos: + write_code_filters = any_to_str_set([WriteTasks, SummarizeCode]) + summarize_code_filters = any_to_str_set([WriteCode, WriteCodeReview]) + if not self._rc.news: + return None + msg = self._rc.news[0] + if msg.cause_by in write_code_filters: + logger.info(f"TODO WriteCode:{msg.json()}") await self._new_code_actions() - elif not self.summarize_todos: + return self._rc.todo + if msg.cause_by in summarize_code_filters and msg.sent_from == any_to_str(self): + logger.info(f"TODO SummarizeCode:{msg.json()}") await self._new_summarize_actions() - else: - return None - return self._rc.todo # For agent store + return self._rc.todo + return None @staticmethod async def _new_coding_context( @@ -151,9 +218,9 @@ async def _new_coding_context( design_doc = None for i in dependencies: if str(i.parent) == TASK_FILE_REPO: - task_doc = task_file_repo.get(i.filename) + task_doc = await task_file_repo.get(i.name) elif str(i.parent) == SYSTEM_DESIGN_FILE_REPO: - design_doc = design_file_repo.get(i.filename) + design_doc = await design_file_repo.get(i.name) context = CodingContext(filename=filename, design_doc=design_doc, task_doc=task_doc, code_doc=old_code_doc) return context @@ -216,16 +283,13 @@ async def _new_code_actions(self): async def _new_summarize_actions(self): src_file_repo = CONFIG.git_repo.new_file_repository(CONFIG.src_workspace) - changed_src_files = src_file_repo.changed_files + src_files = src_file_repo.all_files # Generate a SummarizeCode action for each pair of (system_design_doc, task_doc). - summarizations = {} - for filename in changed_src_files: - dependencies = src_file_repo.get_dependency(filename=filename) + summarizations = defaultdict(list) + for filename in src_files: + dependencies = await src_file_repo.get_dependency(filename=filename) ctx = CodeSummarizeContext.loads(filenames=dependencies) - if ctx not in summarizations: - summarizations[ctx] = set() - srcs = summarizations.get(ctx) - srcs.add(filename) + summarizations[ctx].append(filename) for ctx, filenames in summarizations.items(): ctx.codes_filenames = filenames self.summarize_todos.append(SummarizeCode(context=ctx, llm=self._llm)) diff --git a/metagpt/roles/qa_engineer.py b/metagpt/roles/qa_engineer.py index 41a3213dc..15a01b9e9 100644 --- a/metagpt/roles/qa_engineer.py +++ b/metagpt/roles/qa_engineer.py @@ -11,10 +11,13 @@ WriteTest/RunCode/DebugError object, rather than passing them in when calling the run function. 2. According to Section 2.2.3.5.7 of RFC 135, change the method of transferring files from using the Message to using file references. +@Modified By: mashenquan, 2023-12-5. Enhance the workflow to navigate to WriteCode or QaEngineer based on the results + of SummarizeCode. """ from metagpt.actions import DebugError, RunCode, WriteCode, WriteCodeReview, WriteTest # from metagpt.const import WORKSPACE_ROOT +from metagpt.actions.summarize_code import SummarizeCode from metagpt.config import CONFIG from metagpt.const import ( MESSAGE_ROUTE_TO_NONE, @@ -40,13 +43,16 @@ def __init__( self._init_actions( [WriteTest] ) # FIXME: a bit hack here, only init one action to circumvent _think() logic, will overwrite _think() in future updates - self._watch([WriteCode, WriteCodeReview, WriteTest, RunCode, DebugError]) + self._watch([SummarizeCode, WriteTest, RunCode, DebugError]) self.test_round = 0 self.test_round_allowed = test_round_allowed async def _write_test(self, message: Message) -> None: - changed_files = message.content.splitlines() src_file_repo = CONFIG.git_repo.new_file_repository(CONFIG.src_workspace) + changed_files = set(src_file_repo.changed_files.keys()) + # Unit tests only. + if CONFIG.reqa_file and CONFIG.reqa_file not in changed_files: + changed_files.add(CONFIG.reqa_file) tests_file_repo = CONFIG.git_repo.new_file_repository(TEST_CODES_FILE_REPO) for filename in changed_files: # write tests @@ -146,7 +152,7 @@ async def _act(self) -> Message: ) return result_msg - code_filters = any_to_str_set({WriteCode, WriteCodeReview}) + code_filters = any_to_str_set({SummarizeCode}) test_filters = any_to_str_set({WriteTest, DebugError}) run_filters = any_to_str_set({RunCode}) for msg in self._rc.news: diff --git a/metagpt/roles/role.py b/metagpt/roles/role.py index 1e99cc1ff..2651be7eb 100644 --- a/metagpt/roles/role.py +++ b/metagpt/roles/role.py @@ -284,9 +284,10 @@ async def _act(self) -> Message: instruct_content=response.instruct_content, role=self.profile, cause_by=self._rc.todo, + sent_from=self, ) else: - msg = Message(content=response, role=self.profile, cause_by=self._rc.todo) + msg = Message(content=response, role=self.profile, cause_by=self._rc.todo, sent_from=self) self._rc.memory.add(msg) return msg diff --git a/metagpt/schema.py b/metagpt/schema.py index d1174799a..a8c1b7726 100644 --- a/metagpt/schema.py +++ b/metagpt/schema.py @@ -48,9 +48,9 @@ class Document(BaseModel): Represents a document. """ - root_path: str - filename: str - content: Optional[str] = None + root_path: str = "" + filename: str = "" + content: str = "" def get_meta(self) -> Document: """Get metadata of the document. @@ -260,8 +260,8 @@ def load(self, v) -> "MessageQueue": class CodingContext(BaseModel): filename: str design_doc: Document - task_doc: Document - code_doc: Document + task_doc: Optional[Document] + code_doc: Optional[Document] @staticmethod def loads(val: str) -> CodingContext | None: @@ -275,7 +275,7 @@ def loads(val: str) -> CodingContext | None: class TestingContext(BaseModel): filename: str code_doc: Document - test_doc: Document + test_doc: Optional[Document] @staticmethod def loads(val: str) -> TestingContext | None: @@ -324,10 +324,11 @@ def loads(val: str) -> RunCodeResult | None: class CodeSummarizeContext(BaseModel): design_filename: str = "" task_filename: str = "" - codes_filenames: Set[str] = Field(default_factory=set) + codes_filenames: List[str] = Field(default_factory=list) + reason: str = "" @staticmethod - def loads(filenames: Set) -> CodeSummarizeContext: + def loads(filenames: List) -> CodeSummarizeContext: ctx = CodeSummarizeContext() for filename in filenames: if Path(filename).is_relative_to(SYSTEM_DESIGN_FILE_REPO): @@ -337,3 +338,6 @@ def loads(filenames: Set) -> CodeSummarizeContext: ctx.task_filename = str(filename) continue return ctx + + def __hash__(self): + return hash((self.design_filename, self.task_filename)) diff --git a/metagpt/startup.py b/metagpt/startup.py index 78f32d556..f930c386b 100644 --- a/metagpt/startup.py +++ b/metagpt/startup.py @@ -1,6 +1,7 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- import asyncio +from pathlib import Path import typer @@ -24,6 +25,10 @@ def startup( help="Specify the directory path of the old version project to fulfill the " "incremental requirements.", ), reqa_file: str = typer.Option(default="", help="Specify the source file name for rewriting the quality test code."), + max_auto_summarize_code: int = typer.Option( + default=-1, + help="The maximum number of times the 'SummarizeCode' action is automatically invoked, with -1 indicating unlimited. This parameter is used for debugging the workflow.", + ), ): """Run a startup. Be a boss.""" from metagpt.roles import ( @@ -36,10 +41,14 @@ def startup( from metagpt.team import Team # Use in the PrepareDocuments action according to Section 2.2.3.5.1 of RFC 135. + CONFIG.project_path = project_path + if project_path: + inc = True + project_name = project_name or Path(project_path).name CONFIG.project_name = project_name CONFIG.inc = inc - CONFIG.project_path = project_path CONFIG.reqa_file = reqa_file + CONFIG.max_auto_summarize_code = max_auto_summarize_code company = Team() company.hire( diff --git a/metagpt/utils/dependency_file.py b/metagpt/utils/dependency_file.py index 653e07ef9..e8347d567 100644 --- a/metagpt/utils/dependency_file.py +++ b/metagpt/utils/dependency_file.py @@ -14,6 +14,7 @@ import aiofiles +from metagpt.config import CONFIG from metagpt.logs import logger @@ -81,7 +82,7 @@ async def update(self, filename: Path | str, dependencies: Set[Path | str], pers if persist: await self.save() - async def get(self, filename: Path | str, persist=False): + async def get(self, filename: Path | str, persist=True): """Get dependencies for a file asynchronously. :param filename: The filename or path. @@ -91,7 +92,7 @@ async def get(self, filename: Path | str, persist=False): if persist: await self.load() - root = self._filename.parent + root = CONFIG.git_repo.workdir try: key = Path(filename).relative_to(root) except ValueError: diff --git a/metagpt/utils/file_repository.py b/metagpt/utils/file_repository.py index 0815bf90a..2cace7232 100644 --- a/metagpt/utils/file_repository.py +++ b/metagpt/utils/file_repository.py @@ -151,6 +151,17 @@ def changed_files(self) -> Dict[str, str]: relative_files[str(rf)] = ct return relative_files + @property + def all_files(self) -> List: + """Get a dictionary of all files in the repository. + + The dictionary includes file paths relative to the current FileRepository. + + :return: A dictionary where keys are file paths and values are file information. + :rtype: List + """ + return self._git_repo.get_files(relative_path=self._relative_path) + def get_change_dir_files(self, dir: Path | str) -> List: """Get the files in a directory that have changed. @@ -259,3 +270,25 @@ async def save_as( """ file_repo = CONFIG.git_repo.new_file_repository(relative_path=relative_path) return await file_repo.save_doc(doc=doc, with_suffix=with_suffix, dependencies=dependencies) + + async def delete(self, filename: Path | str): + """Delete a file from the file repository. + + This method deletes a file from the file repository based on the provided filename. + + :param filename: The name or path of the file to be deleted. + :type filename: Path or str + """ + pathname = self.workdir / filename + if not pathname.exists(): + return + pathname.unlink(missing_ok=True) + + dependency_file = await self._git_repo.get_dependency() + await dependency_file.update(filename=pathname, dependencies=None) + logger.info(f"remove dependency key: {str(pathname)}") + + @staticmethod + async def delete_file(filename: Path | str, relative_path: Path | str = "."): + file_repo = CONFIG.git_repo.new_file_repository(relative_path=relative_path) + await file_repo.delete(filename=filename) diff --git a/metagpt/utils/git_repository.py b/metagpt/utils/git_repository.py index 7c9ec645f..5aec4509c 100644 --- a/metagpt/utils/git_repository.py +++ b/metagpt/utils/git_repository.py @@ -8,10 +8,11 @@ """ from __future__ import annotations +import os import shutil from enum import Enum from pathlib import Path -from typing import Dict +from typing import Dict, List from git.repo import Repo from git.repo.fun import is_git_dir @@ -196,10 +197,46 @@ def rename_root(self, new_dir_name): if new_path.exists(): logger.info(f"Delete directory {str(new_path)}") shutil.rmtree(new_path) - self.workdir.rename(new_path) + try: + shutil.move(src=str(self.workdir), dst=str(new_path)) + except Exception as e: + logger.warning(f"Move {str(self.workdir)} to {str(new_path)} error: {e}") logger.info(f"Rename directory {str(self.workdir)} to {str(new_path)}") self._repository = Repo(new_path) + def get_files(self, relative_path: Path | str, root_relative_path: Path | str = None) -> List: + """Retrieve a list of files in the specified relative path. + + The method returns a list of file paths relative to the current FileRepository. + + :param relative_path: The relative path within the repository. + :type relative_path: Path or str + :param root_relative_path: The root relative path within the repository. + :type root_relative_path: Path or str + :return: A list of file paths in the specified directory. + :rtype: List[str] + """ + try: + relative_path = Path(relative_path).relative_to(self.workdir) + except ValueError: + relative_path = Path(relative_path) + + if not root_relative_path: + root_relative_path = Path(self.workdir) / relative_path + files = [] + try: + directory_path = Path(self.workdir) / relative_path + for file_path in directory_path.iterdir(): + if file_path.is_file(): + rpath = file_path.relative_to(root_relative_path) + files.append(str(rpath)) + else: + subfolder_files = self.get_files(relative_path=file_path, root_relative_path=root_relative_path) + files.extend(subfolder_files) + except Exception as e: + logger.error(f"Error: {e}") + return files + if __name__ == "__main__": path = DEFAULT_WORKSPACE_ROOT / "git" diff --git a/tests/conftest.py b/tests/conftest.py index d2ac8304f..8e4422700 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -12,8 +12,11 @@ import pytest +from metagpt.config import CONFIG +from metagpt.const import DEFAULT_WORKSPACE_ROOT from metagpt.logs import logger from metagpt.provider.openai_api import OpenAIGPTAPI as GPTAPI +from metagpt.utils.git_repository import GitRepository class Context: @@ -68,3 +71,16 @@ async def handle_client(reader, writer): server = asyncio.get_event_loop().run_until_complete(asyncio.start_server(handle_client, "127.0.0.1", 0)) return "http://{}:{}".format(*server.sockets[0].getsockname()) + + +# init & dispose git repo +@pytest.fixture(scope="session", autouse=True) +def setup_and_teardown_git_repo(request): + CONFIG.git_repo = GitRepository(local_path=DEFAULT_WORKSPACE_ROOT / "unittest") + + # Destroy git repo at the end of the test session. + def fin(): + CONFIG.git_repo.delete_repository() + + # Register the function for destroying the environment. + request.addfinalizer(fin) diff --git a/tests/metagpt/actions/mock.py b/tests/metagpt/actions/mock.py index c48913755..f6602a82b 100644 --- a/tests/metagpt/actions/mock.py +++ b/tests/metagpt/actions/mock.py @@ -90,7 +90,7 @@ For testing, we can use the PyTest framework. This is a mature full-featured Python testing tool that helps you write better programs. -## project_name: +## Project Name: ```python "adventure_game" ``` diff --git a/tests/metagpt/actions/test_debug_error.py b/tests/metagpt/actions/test_debug_error.py index 2393d2cc9..8289fe41b 100644 --- a/tests/metagpt/actions/test_debug_error.py +++ b/tests/metagpt/actions/test_debug_error.py @@ -4,17 +4,19 @@ @Time : 2023/5/11 17:46 @Author : alexanderwu @File : test_debug_error.py +@Modifiled By: mashenquan, 2023-12-6. According to RFC 135 """ +import uuid + import pytest from metagpt.actions.debug_error import DebugError +from metagpt.config import CONFIG +from metagpt.const import TEST_CODES_FILE_REPO, TEST_OUTPUTS_FILE_REPO +from metagpt.schema import RunCodeContext, RunCodeResult +from metagpt.utils.file_repository import FileRepository -EXAMPLE_MSG_CONTENT = ''' ---- -## Development Code File Name -player.py -## Development Code -```python +CODE_CONTENT = ''' from typing import List from deck import Deck from card import Card @@ -58,12 +60,9 @@ def calculate_score(self) -> int: if self.score > 21 and any(card.rank == 'A' for card in self.hand): self.score -= 10 return self.score +''' -``` -## Test File Name -test_player.py -## Test Code -```python +TEST_CONTENT = """ import unittest from blackjack_game.player import Player from blackjack_game.deck import Deck @@ -114,42 +113,41 @@ def test_player_calculate_score_with_multiple_aces(self): if __name__ == '__main__': unittest.main() -``` -## Running Command -python tests/test_player.py -## Running Output -standard output: ; -standard errors: ..F.. -====================================================================== -FAIL: test_player_calculate_score_with_multiple_aces (__main__.TestPlayer) ----------------------------------------------------------------------- -Traceback (most recent call last): - File "tests/test_player.py", line 46, in test_player_calculate_score_with_multiple_aces - self.assertEqual(player.score, 12) -AssertionError: 22 != 12 - ----------------------------------------------------------------------- -Ran 5 tests in 0.007s - -FAILED (failures=1) -; -## instruction: -The error is in the development code, specifically in the calculate_score method of the Player class. The method is not correctly handling the case where there are multiple Aces in the player's hand. The current implementation only subtracts 10 from the score once if the score is over 21 and there's an Ace in the hand. However, in the case of multiple Aces, it should subtract 10 for each Ace until the score is 21 or less. -## File To Rewrite: -player.py -## Status: -FAIL -## Send To: -Engineer ---- -''' +""" @pytest.mark.asyncio async def test_debug_error(): - debug_error = DebugError("debug_error") - - file_name, rewritten_code = await debug_error.run(context=EXAMPLE_MSG_CONTENT) - - assert "class Player" in rewritten_code # rewrite the same class - assert "while self.score > 21" in rewritten_code # a key logic to rewrite to (original one is "if self.score > 12") + CONFIG.src_workspace = CONFIG.git_repo.workdir / uuid.uuid4().hex + ctx = RunCodeContext( + code_filename="player.py", + test_filename="test_player.py", + command=["python", "tests/test_player.py"], + output_filename="output.log", + ) + + await FileRepository.save_file(filename=ctx.code_filename, content=CODE_CONTENT, relative_path=CONFIG.src_workspace) + await FileRepository.save_file(filename=ctx.test_filename, content=TEST_CONTENT, relative_path=TEST_CODES_FILE_REPO) + output_data = RunCodeResult( + stdout=";", + stderr="", + summary="======================================================================\n" + "FAIL: test_player_calculate_score_with_multiple_aces (__main__.TestPlayer)\n" + "----------------------------------------------------------------------\n" + "Traceback (most recent call last):\n" + ' File "tests/test_player.py", line 46, in test_player_calculate_score_' + "with_multiple_aces\n" + " self.assertEqual(player.score, 12)\nAssertionError: 22 != 12\n\n" + "----------------------------------------------------------------------\n" + "Ran 5 tests in 0.007s\n\nFAILED (failures=1)\n;\n", + ) + await FileRepository.save_file( + filename=ctx.output_filename, content=output_data.json(), relative_path=TEST_OUTPUTS_FILE_REPO + ) + debug_error = DebugError(context=ctx) + + rsp = await debug_error.run() + + assert "class Player" in rsp # rewrite the same class + # a key logic to rewrite to (original one is "if self.score > 12") + assert "while self.score > 21" in rsp diff --git a/tests/metagpt/actions/test_design_api.py b/tests/metagpt/actions/test_design_api.py index 0add8fb74..e90707d1a 100644 --- a/tests/metagpt/actions/test_design_api.py +++ b/tests/metagpt/actions/test_design_api.py @@ -4,33 +4,27 @@ @Time : 2023/5/11 19:26 @Author : alexanderwu @File : test_design_api.py +@Modifiled By: mashenquan, 2023-12-6. According to RFC 135 """ import pytest from metagpt.actions.design_api import WriteDesign +from metagpt.const import PRDS_FILE_REPO from metagpt.logs import logger from metagpt.schema import Message +from metagpt.utils.file_repository import FileRepository from tests.metagpt.actions.mock import PRD_SAMPLE @pytest.mark.asyncio async def test_design_api(): - prd = "我们需要一个音乐播放器,它应该有播放、暂停、上一曲、下一曲等功能。" + inputs = ["我们需要一个音乐播放器,它应该有播放、暂停、上一曲、下一曲等功能。", PRD_SAMPLE] + for prd in inputs: + await FileRepository.save_file("new_prd.txt", content=prd, relative_path=PRDS_FILE_REPO) - design_api = WriteDesign("design_api") + design_api = WriteDesign("design_api") - result = await design_api.run([Message(content=prd, instruct_content=None)]) - logger.info(result) + result = await design_api.run([Message(content=prd, instruct_content=None)]) + logger.info(result) - assert result - - -@pytest.mark.asyncio -async def test_design_api_calculator(): - prd = PRD_SAMPLE - - design_api = WriteDesign("design_api") - result = await design_api.run([Message(content=prd, instruct_content=None)]) - logger.info(result) - - assert result + assert result diff --git a/tests/metagpt/actions/test_prepare_documents.py b/tests/metagpt/actions/test_prepare_documents.py new file mode 100644 index 000000000..31c8bcb80 --- /dev/null +++ b/tests/metagpt/actions/test_prepare_documents.py @@ -0,0 +1,30 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +@Time : 2023/12/6 +@Author : mashenquan +@File : test_prepare_documents.py +@Desc: Unit test for prepare_documents.py +""" +import pytest + +from metagpt.actions.prepare_documents import PrepareDocuments +from metagpt.config import CONFIG +from metagpt.const import DOCS_FILE_REPO, REQUIREMENT_FILENAME +from metagpt.schema import Message +from metagpt.utils.file_repository import FileRepository + + +@pytest.mark.asyncio +async def test_prepare_documents(): + msg = Message(content="New user requirements balabala...") + + if CONFIG.git_repo: + CONFIG.git_repo.delete_repository() + CONFIG.git_repo = None + + await PrepareDocuments().run(with_messages=[msg]) + assert CONFIG.git_repo + doc = await FileRepository.get_file(filename=REQUIREMENT_FILENAME, relative_path=DOCS_FILE_REPO) + assert doc + assert doc.content == msg.content diff --git a/tests/metagpt/actions/test_run_code.py b/tests/metagpt/actions/test_run_code.py index 1e451cb14..888418974 100644 --- a/tests/metagpt/actions/test_run_code.py +++ b/tests/metagpt/actions/test_run_code.py @@ -4,10 +4,12 @@ @Time : 2023/5/11 17:46 @Author : alexanderwu @File : test_run_code.py +@Modifiled By: mashenquan, 2023-12-6. According to RFC 135 """ import pytest from metagpt.actions.run_code import RunCode +from metagpt.schema import RunCodeContext @pytest.mark.asyncio @@ -35,37 +37,29 @@ async def test_run_script(): @pytest.mark.asyncio async def test_run(): - action = RunCode() - result = await action.run(mode="text", code="print('Hello, World')") - assert "PASS" in result - - result = await action.run( - mode="script", - code="echo 'Hello World'", - code_file_name="", - test_code="", - test_file_name="", - command=["echo", "Hello World"], - working_directory=".", - additional_python_paths=[], - ) - assert "PASS" in result - - -@pytest.mark.asyncio -async def test_run_failure(): - action = RunCode() - result = await action.run(mode="text", code="result = 1 / 0") - assert "FAIL" in result - - result = await action.run( - mode="script", - code='python -c "print(1/0)"', - code_file_name="", - test_code="", - test_file_name="", - command=["python", "-c", "print(1/0)"], - working_directory=".", - additional_python_paths=[], - ) - assert "FAIL" in result + inputs = [ + (RunCodeContext(mode="text", code_filename="a.txt", code="print('Hello, World')"), "PASS"), + ( + RunCodeContext( + mode="script", + code_filename="a.sh", + code="echo 'Hello World'", + command=["echo", "Hello World"], + working_directory=".", + ), + "PASS", + ), + ( + RunCodeContext( + mode="script", + code_filename="a.py", + code='python -c "print(1/0)"', + command=["python", "-c", "print(1/0)"], + working_directory=".", + ), + "FAIL", + ), + ] + for ctx, result in inputs: + rsp = await RunCode(context=ctx).run() + assert result in rsp.summary diff --git a/tests/metagpt/actions/test_summarize_code.py b/tests/metagpt/actions/test_summarize_code.py new file mode 100644 index 000000000..7ecb67afd --- /dev/null +++ b/tests/metagpt/actions/test_summarize_code.py @@ -0,0 +1,195 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +@Time : 2023/5/11 17:46 +@Author : mashenquan +@File : test_summarize_code.py +@Modifiled By: mashenquan, 2023-12-6. Unit test for summarize_code.py +""" +import pytest + +from metagpt.actions.summarize_code import SummarizeCode +from metagpt.config import CONFIG +from metagpt.const import SYSTEM_DESIGN_FILE_REPO, TASK_FILE_REPO +from metagpt.logs import logger +from metagpt.schema import CodeSummarizeContext +from metagpt.utils.file_repository import FileRepository + +DESIGN_CONTENT = """ +{"Implementation approach": "To develop this snake game, we will use the Python language and choose the Pygame library. Pygame is an open-source Python module collection specifically designed for writing video games. It provides functionalities such as displaying images and playing sounds, making it suitable for creating intuitive and responsive user interfaces. We will ensure efficient game logic to prevent any delays during gameplay. The scoring system will be simple, with the snake gaining points for each food it eats. We will use Pygame's event handling system to implement pause and resume functionality, as well as high-score tracking. The difficulty will increase by speeding up the snake's movement. In the initial version, we will focus on single-player mode and consider adding multiplayer mode and customizable skins in future updates. Based on the new requirement, we will also add a moving obstacle that appears randomly. If the snake eats this obstacle, the game will end. If the snake does not eat the obstacle, it will disappear after 5 seconds. For this, we need to add mechanisms for obstacle generation, movement, and disappearance in the game logic.", "Project_name": "snake_game", "File list": ["main.py", "game.py", "snake.py", "food.py", "obstacle.py", "scoreboard.py", "constants.py", "assets/styles.css", "assets/index.html"], "Data structures and interfaces": "```mermaid\n classDiagram\n class Game{\n +int score\n +int speed\n +bool game_over\n +bool paused\n +Snake snake\n +Food food\n +Obstacle obstacle\n +Scoreboard scoreboard\n +start_game() void\n +pause_game() void\n +resume_game() void\n +end_game() void\n +increase_difficulty() void\n +update() void\n +render() void\n Game()\n }\n class Snake{\n +list body_parts\n +str direction\n +bool grow\n +move() void\n +grow() void\n +check_collision() bool\n Snake()\n }\n class Food{\n +tuple position\n +spawn() void\n Food()\n }\n class Obstacle{\n +tuple position\n +int lifetime\n +bool active\n +spawn() void\n +move() void\n +check_collision() bool\n +disappear() void\n Obstacle()\n }\n class Scoreboard{\n +int high_score\n +update_score(int) void\n +reset_score() void\n +load_high_score() void\n +save_high_score() void\n Scoreboard()\n }\n class Constants{\n }\n Game \"1\" -- \"1\" Snake: has\n Game \"1\" -- \"1\" Food: has\n Game \"1\" -- \"1\" Obstacle: has\n Game \"1\" -- \"1\" Scoreboard: has\n ```", "Program call flow": "```sequenceDiagram\n participant M as Main\n participant G as Game\n participant S as Snake\n participant F as Food\n participant O as Obstacle\n participant SB as Scoreboard\n M->>G: start_game()\n loop game loop\n G->>S: move()\n G->>S: check_collision()\n G->>F: spawn()\n G->>O: spawn()\n G->>O: move()\n G->>O: check_collision()\n G->>O: disappear()\n G->>SB: update_score(score)\n G->>G: update()\n G->>G: render()\n alt if paused\n M->>G: pause_game()\n M->>G: resume_game()\n end\n alt if game_over\n G->>M: end_game()\n end\n end\n```", "Anything UNCLEAR": "There is no need for further clarification as the requirements are already clear."} +""" + +TASK_CONTENT = """ +{"Required Python third-party packages": ["pygame==2.0.1"], "Required Other language third-party packages": ["No third-party packages required for other languages."], "Full API spec": "\n openapi: 3.0.0\n info:\n title: Snake Game API\n version: \"1.0.0\"\n paths:\n /start:\n get:\n summary: Start the game\n responses:\n '200':\n description: Game started successfully\n /pause:\n get:\n summary: Pause the game\n responses:\n '200':\n description: Game paused successfully\n /resume:\n get:\n summary: Resume the game\n responses:\n '200':\n description: Game resumed successfully\n /end:\n get:\n summary: End the game\n responses:\n '200':\n description: Game ended successfully\n /score:\n get:\n summary: Get the current score\n responses:\n '200':\n description: Current score retrieved successfully\n /highscore:\n get:\n summary: Get the high score\n responses:\n '200':\n description: High score retrieved successfully\n components: {}\n ", "Logic Analysis": [["constants.py", "Contains all the constant values like screen size, colors, game speeds, etc. This should be implemented first as it provides the base values for other components."], ["snake.py", "Contains the Snake class with methods for movement, growth, and collision detection. It is dependent on constants.py for configuration values."], ["food.py", "Contains the Food class responsible for spawning food items on the screen. It is dependent on constants.py for configuration values."], ["obstacle.py", "Contains the Obstacle class with methods for spawning, moving, and disappearing of obstacles, as well as collision detection with the snake. It is dependent on constants.py for configuration values."], ["scoreboard.py", "Contains the Scoreboard class for updating, resetting, loading, and saving high scores. It may use constants.py for configuration values and depends on the game's scoring logic."], ["game.py", "Contains the main Game class which includes the game loop and methods for starting, pausing, resuming, and ending the game. It is dependent on snake.py, food.py, obstacle.py, and scoreboard.py."], ["main.py", "The entry point of the game that initializes the game and starts the game loop. It is dependent on game.py."]], "Task list": ["constants.py", "snake.py", "food.py", "obstacle.py", "scoreboard.py", "game.py", "main.py"], "Shared Knowledge": "\n 'constants.py' should contain all the necessary configurations for the game, such as screen dimensions, color definitions, and speed settings. These constants will be used across multiple files, ensuring consistency and ease of updates. Ensure that the Pygame library is initialized correctly in 'main.py' before starting the game loop. Also, make sure that the game's state is managed properly when pausing and resuming the game.\n ", "Anything UNCLEAR": "The interaction between the 'obstacle.py' and the game loop needs to be clearly defined to ensure obstacles appear and disappear correctly. The lifetime of the obstacle and its random movement should be implemented in a way that does not interfere with the game's performance."} +""" + +FOOD_PY = """ +## food.py +import random + +class Food: + def __init__(self): + self.position = (0, 0) + + def generate(self): + x = random.randint(0, 9) + y = random.randint(0, 9) + self.position = (x, y) + + def get_position(self): + return self.position + +""" + +GAME_PY = """ +## game.py +import pygame +from snake import Snake +from food import Food + +class Game: + def __init__(self): + self.score = 0 + self.level = 1 + self.snake = Snake() + self.food = Food() + + def start_game(self): + pygame.init() + self.initialize_game() + self.game_loop() + + def initialize_game(self): + self.score = 0 + self.level = 1 + self.snake.reset() + self.food.generate() + + def game_loop(self): + game_over = False + + while not game_over: + self.update() + self.draw() + self.handle_events() + self.check_collision() + self.increase_score() + self.increase_level() + + if self.snake.is_collision(): + game_over = True + self.game_over() + + def update(self): + self.snake.move() + + def draw(self): + self.snake.draw() + self.food.draw() + + def handle_events(self): + for event in pygame.event.get(): + if event.type == pygame.QUIT: + pygame.quit() + quit() + elif event.type == pygame.KEYDOWN: + if event.key == pygame.K_UP: + self.snake.change_direction("UP") + elif event.key == pygame.K_DOWN: + self.snake.change_direction("DOWN") + elif event.key == pygame.K_LEFT: + self.snake.change_direction("LEFT") + elif event.key == pygame.K_RIGHT: + self.snake.change_direction("RIGHT") + + def check_collision(self): + if self.snake.get_head() == self.food.get_position(): + self.snake.grow() + self.food.generate() + + def increase_score(self): + self.score += 1 + + def increase_level(self): + if self.score % 10 == 0: + self.level += 1 + + def game_over(self): + print("Game Over") + self.initialize_game() + +""" + +MAIN_PY = """ +## main.py +import pygame +from game import Game + +def main(): + pygame.init() + game = Game() + game.start_game() + +if __name__ == "__main__": + main() + +""" + +SNAKE_PY = """ +## snake.py +import pygame + +class Snake: + def __init__(self): + self.body = [(0, 0)] + self.direction = (1, 0) + + def move(self): + head = self.body[0] + dx, dy = self.direction + new_head = (head[0] + dx, head[1] + dy) + self.body.insert(0, new_head) + self.body.pop() + + def change_direction(self, direction): + if direction == "UP": + self.direction = (0, -1) + elif direction == "DOWN": + self.direction = (0, 1) + elif direction == "LEFT": + self.direction = (-1, 0) + elif direction == "RIGHT": + self.direction = (1, 0) + + def grow(self): + tail = self.body[-1] + dx, dy = self.direction + new_tail = (tail[0] - dx, tail[1] - dy) + self.body.append(new_tail) + + def get_head(self): + return self.body[0] + + def get_body(self): + return self.body[1:] + +""" + + +@pytest.mark.asyncio +async def test_summarize_code(): + CONFIG.src_workspace = CONFIG.git_repo.workdir / "src" + await FileRepository.save_file(filename="1.json", relative_path=SYSTEM_DESIGN_FILE_REPO, content=DESIGN_CONTENT) + await FileRepository.save_file(filename="1.json", relative_path=TASK_FILE_REPO, content=TASK_CONTENT) + await FileRepository.save_file(filename="food.py", relative_path=CONFIG.src_workspace, content=FOOD_PY) + await FileRepository.save_file(filename="game.py", relative_path=CONFIG.src_workspace, content=GAME_PY) + await FileRepository.save_file(filename="main.py", relative_path=CONFIG.src_workspace, content=MAIN_PY) + await FileRepository.save_file(filename="snake.py", relative_path=CONFIG.src_workspace, content=SNAKE_PY) + + src_file_repo = CONFIG.git_repo.new_file_repository(relative_path=CONFIG.src_workspace) + all_files = src_file_repo.all_files + ctx = CodeSummarizeContext(design_filename="1.json", task_filename="1.json", codes_filenames=all_files) + action = SummarizeCode(context=ctx) + rsp = await action.run() + assert rsp + logger.info(rsp) diff --git a/tests/metagpt/actions/test_write_code.py b/tests/metagpt/actions/test_write_code.py index eb5e3de91..54229089c 100644 --- a/tests/metagpt/actions/test_write_code.py +++ b/tests/metagpt/actions/test_write_code.py @@ -4,26 +4,31 @@ @Time : 2023/5/11 17:45 @Author : alexanderwu @File : test_write_code.py +@Modifiled By: mashenquan, 2023-12-6. According to RFC 135 """ import pytest 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 @pytest.mark.asyncio async def test_write_code(): - api_design = "设计一个名为'add'的函数,该函数接受两个整数作为输入,并返回它们的和。" - write_code = WriteCode("write_code") + context = CodingContext( + filename="task_filename.py", design_doc=Document(content="设计一个名为'add'的函数,该函数接受两个整数作为输入,并返回它们的和。") + ) + doc = Document(content=context.json()) + write_code = WriteCode(context=doc) - code = await write_code.run(api_design) - logger.info(code) + code = await write_code.run() + logger.info(code.json()) # 我们不能精确地预测生成的代码,但我们可以检查某些关键字 - assert "def add" in code - assert "return" in code + assert "def add" in code.code_doc.content + assert "return" in code.code_doc.content @pytest.mark.asyncio diff --git a/tests/metagpt/actions/test_write_code_review.py b/tests/metagpt/actions/test_write_code_review.py index 21bc563ec..e16eb7348 100644 --- a/tests/metagpt/actions/test_write_code_review.py +++ b/tests/metagpt/actions/test_write_code_review.py @@ -8,6 +8,8 @@ import pytest from metagpt.actions.write_code_review import WriteCodeReview +from metagpt.document import Document +from metagpt.schema import CodingContext @pytest.mark.asyncio @@ -16,13 +18,15 @@ async def test_write_code_review(capfd): def add(a, b): return a + """ - # write_code_review = WriteCodeReview("write_code_review") + context = CodingContext( + filename="math.py", design_doc=Document(content="编写一个从a加b的函数,返回a+b"), code_doc=Document(content=code) + ) - code = await WriteCodeReview().run(context="编写一个从a加b的函数,返回a+b", code=code, filename="math.py") + context = await WriteCodeReview(context=context).run() # 我们不能精确地预测生成的代码评审,但我们可以检查返回的是否为字符串 - assert isinstance(code, str) - assert len(code) > 0 + assert isinstance(context.code_doc.content, str) + assert len(context.code_doc.content) > 0 captured = capfd.readouterr() print(f"输出内容: {captured.out}") diff --git a/tests/metagpt/actions/test_write_prd.py b/tests/metagpt/actions/test_write_prd.py index 8f8ef84f5..08be3cf75 100644 --- a/tests/metagpt/actions/test_write_prd.py +++ b/tests/metagpt/actions/test_write_prd.py @@ -9,19 +9,24 @@ import pytest from metagpt.actions import UserRequirement +from metagpt.config import CONFIG +from metagpt.const import DOCS_FILE_REPO, PRDS_FILE_REPO, REQUIREMENT_FILENAME from metagpt.logs import logger from metagpt.roles.product_manager import ProductManager from metagpt.schema import Message +from metagpt.utils.file_repository import FileRepository @pytest.mark.asyncio async def test_write_prd(): product_manager = ProductManager() requirements = "开发一个基于大语言模型与私有知识库的搜索引擎,希望可以基于大语言模型进行搜索总结" + await FileRepository.save_file(filename=REQUIREMENT_FILENAME, content=requirements, relative_path=DOCS_FILE_REPO) prd = await product_manager.run(Message(content=requirements, cause_by=UserRequirement)) logger.info(requirements) logger.info(prd) # Assert the prd is not None or empty assert prd is not None - assert prd != "" + assert prd.content != "" + assert CONFIG.git_repo.new_file_repository(relative_path=PRDS_FILE_REPO).changed_files diff --git a/tests/metagpt/actions/test_write_test.py b/tests/metagpt/actions/test_write_test.py index e5acdff44..a3190fb0e 100644 --- a/tests/metagpt/actions/test_write_test.py +++ b/tests/metagpt/actions/test_write_test.py @@ -9,6 +9,7 @@ from metagpt.actions.write_test import WriteTest from metagpt.logs import logger +from metagpt.schema import Document, TestingContext @pytest.mark.asyncio @@ -24,22 +25,17 @@ def __init__(self, position: Tuple[int, int]): def generate(self, max_y: int, max_x: int): self.position = (random.randint(1, max_y - 1), random.randint(1, max_x - 1)) """ + context = TestingContext(filename="food.py", code_doc=Document(filename="food.py", content=code)) + write_test = WriteTest(context=context) - write_test = WriteTest() - - test_code = await write_test.run( - code_to_test=code, - test_file_name="test_food.py", - source_file_path="/some/dummy/path/cli_snake_game/cli_snake_game/food.py", - workspace="/some/dummy/path/cli_snake_game", - ) - logger.info(test_code) + context = await write_test.run() + logger.info(context.json()) # We cannot exactly predict the generated test cases, but we can check if it is a string and if it is not empty - assert isinstance(test_code, str) - assert "from cli_snake_game.food import Food" in test_code - assert "class TestFood(unittest.TestCase)" in test_code - assert "def test_generate" in test_code + assert isinstance(context.test_doc.content, str) + assert "from food import Food" in context.test_doc.content + assert "class TestFood(unittest.TestCase)" in context.test_doc.content + assert "def test_generate" in context.test_doc.content @pytest.mark.asyncio diff --git a/tests/metagpt/roles/mock.py b/tests/metagpt/roles/mock.py index 5500b69f7..75f6b3b43 100644 --- a/tests/metagpt/roles/mock.py +++ b/tests/metagpt/roles/mock.py @@ -71,7 +71,7 @@ ``` ''' -SYSTEM_DESIGN = """## project_name +SYSTEM_DESIGN = """## Project name ```python "smart_search_engine" ``` diff --git a/tests/metagpt/utils/test_file_repository.py b/tests/metagpt/utils/test_file_repository.py index a830b58aa..92e5204c5 100644 --- a/tests/metagpt/utils/test_file_repository.py +++ b/tests/metagpt/utils/test_file_repository.py @@ -43,6 +43,10 @@ async def test_file_repo(): assert {"a.txt"} == await file_repo.get_changed_dependency("b.txt") await file_repo.save("d/e.txt", "EEE") assert ["d/e.txt"] == file_repo.get_change_dir_files("d") + assert set(file_repo.all_files) == {"a.txt", "b.txt", "d/e.txt"} + await file_repo.delete("d/e.txt") + await file_repo.delete("d/e.txt") # delete twice + assert set(file_repo.all_files) == {"a.txt", "b.txt"} git_repo.delete_repository()