diff --git a/.gitignore b/.gitignore index c4c79c733..3ec71f8b6 100644 --- a/.gitignore +++ b/.gitignore @@ -163,3 +163,6 @@ workspace/* *.mmd tmp output.wav + +# output folder +output diff --git a/metagpt/actions/write_teaching_plan.py b/metagpt/actions/write_teaching_plan.py new file mode 100644 index 000000000..1f0167df3 --- /dev/null +++ b/metagpt/actions/write_teaching_plan.py @@ -0,0 +1,134 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +@Time : 2023/5/11 14:43 +@Author : mashenquan +@File : write_teaching_plan.py +""" +from langchain.llms.base import LLM +from metagpt.logs import logger +from metagpt.actions import Action +from metagpt.schema import Message + + +class TeachingPlanRequirement(Action): + """Teaching Plan Requirement without any implementation details""" + + async def run(self, *args, **kwargs): + raise NotImplementedError + + +class WriteTeachingPlanPart(Action): + """Write Teaching Plan Part""" + + def __init__(self, name: str = '', context=None, llm: LLM = None, topic="", language="Chinese"): + """ + + Args: + name: action name + context: context + llm: object of :class:`LLM` + topic: topic part of teaching plan + """ + super().__init__(name, context, llm) + self.topic = topic + self.language = language + self.rsp = None + + async def run(self, *args, **kwargs): + if len(args) < 1 or len(args[0]) < 1 or not isinstance(args[0][0], Message): + raise ValueError("Invalid args, a tuple of List[Message] is expected") + + statements = self.TOPIC_STATEMENTS.get(self.topic, []) + formatter = self.PROMPT_TITLE_TEMPLATE if self.topic == self.COURSE_TITLE else self.PROMPT_TEMPLATE + prompt = formatter.format(formation=self.FORMATION, + role=self.prefix, + statements="\n".join(statements), + lesson=args[0][0].content, + topic=self.topic, + language=self.language) + + logger.debug(prompt) + rsp = await self._aask(prompt=prompt) + logger.debug(rsp) + self._set_result(rsp) + return self.rsp + + def _set_result(self, rsp): + if self.DATA_BEGIN_TAG in rsp: + ix = rsp.index(self.DATA_BEGIN_TAG) + rsp = rsp[ix + len(self.DATA_BEGIN_TAG):] + if self.DATA_END_TAG in rsp: + ix = rsp.index(self.DATA_END_TAG) + rsp = rsp[0:ix] + self.rsp = rsp.strip() + if self.topic != self.COURSE_TITLE: + return + if '#' not in self.rsp or self.rsp.index('#') != 0: + self.rsp = "# " + self.rsp + + def __str__(self): + """str()时返回`topic`""" + return self.topic + + def __repr__(self): + """调试时返回`topic`""" + return self.topic + + FORMATION = """ + "\tCapacity and role" defines the role you are currently playing; + "\t[LESSON_BEGIN]" and "[LESSON_END]" tags enclose the content of textbook; + "\tStatement" defines the work detail you need to complete at this stage; + "\tAnswer options" defines the format requirements for your responses; + "\tConstraint" defines the conditions that your responses must comply with. + """ + COURSE_TITLE = "Title" + TOPICS = [COURSE_TITLE, "Teaching Hours", "Teaching Objectives", "Teaching Content", + "Teaching Methods and Strategies", "Learning Activities", + "Teaching Time Allocation", "Assessment and Feedback", "Teaching Summary and Improvement"] + + TOPIC_STATEMENTS = { + COURSE_TITLE: ["Statement: Find and return the title of the lesson only in markdown first-level header format, " + "without anything else."], + "Teaching Content": [ + "Statement: \"Teaching Content\" must include vocabulary, analysis, and examples of various grammar " + "structures that appear in the textbook, as well as the listening materials and key points.", + "Statement: \"Teaching Content\" must include more examples."], + "Teaching Time Allocation": [ + "Statement: \"Teaching Time Allocation\" must include how much time is allocated to each " + "part of the textbook content."], + "Teaching Methods and Strategies": [ + "Statement: \"Teaching Methods and Strategies\" must include teaching focus, difficulties, materials, " + "procedures, in detail." + ] + } + + # Teaching plan title + PROMPT_TITLE_TEMPLATE = """ + Do not refer to the context of the previous conversation records, start the conversation anew.\n\n + Formation: {formation}\n\n + {statements}\n + Constraint: Writing in {language}.\n + Answer options: Encloses the lesson title with "[TEACHING_PLAN_BEGIN]" and "[TEACHING_PLAN_END]" tags.\n + [LESSON_BEGIN]\n + {lesson}\n + [LESSON_END] + """ + + # Teaching plan parts: + PROMPT_TEMPLATE = """ + Do not refer to the context of the previous conversation records, start the conversation anew.\n\n + Formation: {formation}\n\n + Capacity and role: {role}\n + Statement: Write the "{topic}" part of teaching plan, WITHOUT ANY content unrelated to "{topic}"!!\n + {statements}\n + Answer options: Enclose the teaching plan content with "[TEACHING_PLAN_BEGIN]" and "[TEACHING_PLAN_END]" tags.\n + Answer options: Using proper markdown format from second-level header format.\n + Constraint: Writing in {language}.\n + [LESSON_BEGIN]\n + {lesson}\n + [LESSON_END] + """ + + DATA_BEGIN_TAG = "[TEACHING_PLAN_BEGIN]" + DATA_END_TAG = "[TEACHING_PLAN_END]" diff --git a/metagpt/provider/openai_api.py b/metagpt/provider/openai_api.py index f6499c643..ba5a655d3 100644 --- a/metagpt/provider/openai_api.py +++ b/metagpt/provider/openai_api.py @@ -4,12 +4,13 @@ @Time : 2023/5/5 23:08 @Author : alexanderwu @File : openai.py +@Modified By: mashenquan, 2023-07-27, + try except. """ import asyncio import time from functools import wraps from typing import NamedTuple - +import traceback import openai from metagpt.config import CONFIG @@ -30,7 +31,9 @@ def retry(max_retries): for i in range(max_retries): try: return await f(*args, **kwargs) - except Exception: + except Exception as e: + error_str = traceback.format_exc() + logger.warning(f"Exception occurred: {str(e)}, stack:{error_str}. Retrying...") if i == max_retries - 1: raise await asyncio.sleep(2 ** i) @@ -148,10 +151,15 @@ class OpenAIGPTAPI(BaseGPTAPI, RateLimiter): self.rpm = int(config.get("RPM", 10)) async def _achat_completion_stream(self, messages: list[dict]) -> str: - response = await openai.ChatCompletion.acreate( - **self._cons_kwargs(messages), - stream=True - ) + try: + response = await openai.ChatCompletion.acreate( + **self._cons_kwargs(messages), + stream=True + ) + except Exception as e: + error_str = traceback.format_exc() + logger.error(f"Exception:{e}, stack:{error_str}") + raise e # create variables to collect the stream of chunks collected_chunks = [] diff --git a/metagpt/roles/role.py b/metagpt/roles/role.py index 1681586cc..3e18257ed 100644 --- a/metagpt/roles/role.py +++ b/metagpt/roles/role.py @@ -4,9 +4,11 @@ @Time : 2023/5/11 14:42 @Author : alexanderwu @File : role.py +@Modified By: mashenquan, 2023-07-27, :class:`Role` + properties. """ from __future__ import annotations +import traceback from typing import Iterable, Type from pydantic import BaseModel, Field @@ -92,13 +94,22 @@ class RoleContext(BaseModel): class Role: """角色/代理""" - def __init__(self, name="", profile="", goal="", constraints="", desc=""): + def __init__(self, name="", profile="", goal="", constraints="", desc="", *args, **kwargs): + # Enable parameter configurability + name = Role.format_value(name, kwargs) + profile = Role.format_value(profile, kwargs) + goal = Role.format_value(goal, kwargs) + constraints = Role.format_value(constraints, kwargs) + desc = Role.format_value(desc, kwargs) + + # Initialize self._llm = LLM() self._setting = RoleSetting(name=name, profile=profile, goal=goal, constraints=constraints, desc=desc) self._states = [] self._actions = [] self._role_id = str(self._setting) self._rc = RoleContext() + self._options = Role.supply_options(kwargs) def _reset(self): self._states = [] @@ -136,6 +147,26 @@ class Role: """获取角色描述(职位)""" return self._setting.profile + @property + def name(self): + """Return role `name`, read only""" + return self._setting.name + + @property + def desc(self): + """Return role `desc`, read only""" + return self._setting.desc + + @property + def goal(self): + """Return role `goal`, read only""" + return self._setting.goal + + @property + def constraints(self): + """Return role `constraints`, read only""" + return self._setting.constraints + def _get_prefix(self): """获取角色前缀""" if self._setting.desc: @@ -164,7 +195,8 @@ class Role: # history=self.history) logger.info(f"{self._setting}: ready to {self._rc.todo}") - response = await self._rc.todo.run(self._rc.important_memory) + requirement = self._rc.important_memory + response = await self._rc.todo.run(requirement) # logger.info(response) if isinstance(response, ActionOutput): msg = Message(content=response.content, instruct_content=response.instruct_content, @@ -238,3 +270,33 @@ class Role: # 将回复发布到环境,等待下一个订阅者处理 self._publish_message(rsp) return rsp + + @staticmethod + def supply_options(options): + """Supply missing options""" + ret = Role.__DEFAULT_OPTIONS__.copy() + if not options: + return ret + ret.update(options) + return ret + + @staticmethod + def format_value(value, options): + """Fill parameters inside `value` with `options`. + """ + if "{" not in value: + return value + + options = Role.supply_options(options) + try: + return value.format(**options) + except KeyError as e: + logger.warning(f"Parameter is missing:{e}") + for k, v in options.items(): + value = value.replace("{" + f"{k}" + "}", v) + return value + + __DEFAULT_OPTIONS__ = { + "teaching_language": "English", + "language": "Chinese" + } \ No newline at end of file diff --git a/metagpt/roles/teacher.py b/metagpt/roles/teacher.py new file mode 100644 index 000000000..a007926be --- /dev/null +++ b/metagpt/roles/teacher.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +@Time : 2023/5/23 17:25 +@Author : mashenquan +@File : teacher.py +""" +from pathlib import Path + +import aiofiles + +from metagpt.actions.write_teaching_plan import WriteTeachingPlanPart, TeachingPlanRequirement +from metagpt.roles import Role +from metagpt.schema import Message +from metagpt.logs import logger +import re + + +class Teacher(Role): + """Support configurable teacher roles, + with native and teaching languages being replaceable through configurations.""" + def __init__(self, name='Lily', profile='{teaching_language} Teacher', + goal='writing a {language} teaching plan part by part', + constraints='writing in {language}', desc="", *args, **kwargs): + super().__init__(name=name, profile=profile, goal=goal, constraints=constraints, desc=desc, *args, **kwargs) + actions = [] + for topic in WriteTeachingPlanPart.TOPICS: + act = WriteTeachingPlanPart(topic=topic, llm=self._llm) + actions.append(act) + self._init_actions(actions) + self._watch({TeachingPlanRequirement}) + + async def _think(self) -> None: + """Everything will be done part by part.""" + 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 _react(self) -> Message: + ret = Message(content="") + while True: + await self._think() + if self._rc.todo is None: + break + logger.debug(f"{self._setting}: {self._rc.state=}, will do {self._rc.todo}") + msg = await self._act() + if ret.content != '': + ret.content += "\n\n\n" + ret.content += msg.content + logger.info(ret.content) + await self.save(ret.content) + return ret + + async def save(self, content): + """Save teaching plan""" + filename = Teacher.new_file_name(self.course_title) + pathname = Path(__file__).resolve().parent.parent.parent / "output" + pathname.mkdir(exist_ok=True) + pathname = pathname / filename + try: + async with aiofiles.open(str(pathname), mode='w', encoding='utf-8') as writer: + await writer.write(content) + except Exception as e: + logger.error(f'Save failed:{e}') + logger.info(f"Save to:{pathname}") + + @staticmethod + def new_file_name(lesson_title, ext=".md"): + """Create a related file name based on `lesson_title` and `ext`.""" + # 定义需要替换的特殊字符 + illegal_chars = r'[#@$%!*&\\/:*?"<>|\n\t \']' + # 将特殊字符替换为下划线 + filename = re.sub(illegal_chars, '_', lesson_title) + ext + return re.sub(r'_+', '_', filename) + + @property + def course_title(self): + """Return course title of teaching plan""" + default_title = "teaching_plan" + for act in self._actions: + if act.topic != WriteTeachingPlanPart.COURSE_TITLE: + continue + if act.rsp is None: + return default_title + title = act.rsp.lstrip("# \n") + if '\n' in title: + ix = title.index('\n') + title = title[0: ix] + return title + + return default_title diff --git a/metagpt/software_company.py b/metagpt/software_company.py index 8f173ebf3..10fb025d6 100644 --- a/metagpt/software_company.py +++ b/metagpt/software_company.py @@ -4,6 +4,7 @@ @Time : 2023/5/12 00:30 @Author : alexanderwu @File : software_company.py +@Modified By: mashenquan, 2023-07-27, Add `role` & `cause_by` parameters to `start_project()`. """ from pydantic import BaseModel, Field @@ -42,10 +43,10 @@ class SoftwareCompany(BaseModel): if CONFIG.total_cost > CONFIG.max_budget: raise NoMoneyException(CONFIG.total_cost, f'Insufficient funds: {CONFIG.max_budget}') - def start_project(self, idea): + def start_project(self, idea, role="BOSS", cause_by=BossRequirement): """Start a project from publishing boss requirement.""" self.idea = idea - self.environment.publish_message(Message(role="BOSS", content=idea, cause_by=BossRequirement)) + self.environment.publish_message(Message(role=role, content=idea, cause_by=cause_by)) def _save(self): logger.info(self.json()) diff --git a/requirements.txt b/requirements.txt index 32a436962..4d5856c20 100644 --- a/requirements.txt +++ b/requirements.txt @@ -35,3 +35,4 @@ tqdm==4.64.0 anthropic==0.3.6 typing-inspect==0.8.0 typing_extensions==4.5.0 +aiofiles \ No newline at end of file diff --git a/startup.py b/startup.py index e062babb5..17f55fb0a 100644 --- a/startup.py +++ b/startup.py @@ -1,15 +1,23 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- +""" +@Modified By: mashenquan, 2023-07-27, +industry concept +""" + import asyncio - +from pathlib import Path +import aiofiles import fire - +from metagpt.logs import logger +from metagpt.actions.write_teaching_plan import TeachingPlanRequirement from metagpt.roles import Architect, Engineer, ProductManager, ProjectManager +from metagpt.roles.teacher import Teacher from metagpt.software_company import SoftwareCompany -async def startup(idea: str, investment: float = 3.0, n_round: int = 5, code_review: bool = False): - """Run a startup. Be a boss.""" +async def software_startup(investment: float = 3.0, n_round: int = 5, code_review: bool = False, *args, **kwargs): + """Run a startup. Be a boss in software industry.""" + idea = kwargs['idea'] # Your innovative idea, such as "Creating a snake game." company = SoftwareCompany() company.hire([ProductManager(), Architect(), @@ -20,16 +28,90 @@ async def startup(idea: str, investment: float = 3.0, n_round: int = 5, code_rev await company.run(n_round=n_round) -def main(idea: str, investment: float = 3.0, n_round: int = 5, code_review: bool = False): +async def education_startup(investment: float = 3.0, n_round: int = 5, code_review: bool = False, *args, **kwargs): + """Run a startup. Be a teacher in education industry.""" + + demo_lesson = """ + UNIT 1 Making New Friends + TOPIC 1 Welcome to China! + Section A + + 1a Listen and number the following names. + Jane Mari Kangkang Michael + Look, listen and understand. Then practice the conversation. + Work in groups. Introduce yourself using + I ’m ... Then practice 1a + with your own hometown or the following places. + + 1b Listen and number the following names + Jane Michael Maria Kangkang + 1c Work in groups. Introduce yourself using I ’m ... Then practice 1a with your own hometown or the following places. + China the USA the UK Hong Kong Beijing + + 2a Look, listen and understand. Then practice the conversation + Hello! + Hello! + Hello! + Hello! Are you Maria? + No, I’m not. I’m Jane. + Oh, nice to meet you, Jane + Nice to meet you, too. + Hi, Maria! + Hi, Kangkang! + Welcome to China! + Thanks. + + 2b Work in groups. Make up a conversation with your own name and the + following structures. + A: Hello! / Good morning! / Hi! I’m ... Are you ... ? + B: ... + + 3a Listen, say and trace + Aa Bb Cc Dd Ee Ff Gg + + 3b Listen and number the following letters. Then circle the letters withthe same sound as Bb. + Aa Bb Cc Dd Ee Ff Gg + + 3c Match the big letters with the small ones. Then write them on the lines. + """ + + lesson = "" + lesson_file = kwargs.get('lesson_file') + if lesson_file is not None and Path(lesson_file).exists(): + async with aiofiles.open(lesson_file, mode="r", encoding="utf-8") as reader: + lesson = await reader.read() + logger.info(f"Course content: {lesson}") + if not lesson: + logger.info("No course content provided, using the demo course.") + lesson = demo_lesson + + company = SoftwareCompany() + company.hire([Teacher(*args, **kwargs)]) + company.invest(investment) + company.start_project(lesson, role="Teacher", cause_by=TeachingPlanRequirement) + await company.run(n_round=1) + + +def main(investment: float = 3.0, n_round: int = 5, code_review: bool = False, *args, **kwargs): """ We are a software startup comprised of AI. By investing in us, you are empowering a future filled with limitless possibilities. - :param idea: Your innovative idea, such as "Creating a snake game." :param investment: As an investor, you have the opportunity to contribute a certain dollar amount to this AI company. :param n_round: :param code_review: Whether to use code review. + :param args: Parameters passed in format: `python your_script.py arg1 arg2 arg3` + :param kwargs: Parameters passed in format: `python your_script.py a--param1=value1 --param2=value2` :return: """ - asyncio.run(startup(idea, investment, n_round, code_review)) + industry = kwargs.get("industry", "software") + industries = { + "software": software_startup, + "education": education_startup, + } + startup = industries.get(industry) + if startup is None: + print(f"Available industries:{list(industries.keys())}") + return + asyncio.run(startup(investment, n_round, code_review, *args, **kwargs)) if __name__ == '__main__': diff --git a/tests/metagpt/roles/test_teacher.py b/tests/metagpt/roles/test_teacher.py new file mode 100644 index 000000000..0dddff3ac --- /dev/null +++ b/tests/metagpt/roles/test_teacher.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +@Time : 2023/5/23 17:25 +@Author : mashenquan +@File : teacher.py +""" + +from typing import Dict, Optional +from pydantic import BaseModel + +from metagpt.roles.teacher import Teacher + + +def test_init(): + class Inputs(BaseModel): + name: str + profile: str + goal: str + constraints: str + desc: str + options: Optional[Dict] = None + expect_name: str + expect_profile: str + expect_goal: str + expect_constraints: str + expect_desc: str + + inputs = [ + { + "name": "Lily{language}", + "expect_name": "LilyCN", + "profile": "X {teaching_language}", + "expect_profile": "X EN", + "goal": "Do {something_big}, {language}", + "expect_goal": "Do sleep, CN", + "constraints": "Do in {key1}, {language}", + "expect_constraints": "Do in HaHa, CN", + "options": {"language": "CN", "key1": "HaHa", "something_big": "sleep", "teaching_language": "EN"}, + "desc": "aaa{language}", + "expect_desc": "aaaCN" + }, + { + "name": "Lily{language}", + "expect_name": "LilyChinese", + "profile": "X {teaching_language}", + "expect_profile": "X English", + "goal": "Do {something_big}, {language}", + "expect_goal": "Do {something_big}, Chinese", + "constraints": "Do in {key1}, {language}", + "expect_constraints": "Do in {key1}, Chinese", + "desc": "aaa{language}", + "expect_desc": "aaaChinese" + }, + ] + + for i in inputs: + seed = Inputs(**i) + teacher = Teacher(name=seed.name, profile=seed.profile, goal=seed.goal, constraints=seed.constraints, + desc=seed.desc, options=seed.options) + assert teacher.name == seed.expect_name + assert teacher.desc == seed.expect_desc + assert teacher.profile == seed.expect_profile + assert teacher.goal == seed.expect_goal + assert teacher.constraints == seed.expect_constraints + assert teacher.course_title == "teaching_plan" + + +def test_new_file_name(): + class Inputs(BaseModel): + lesson_title: str + ext: str + expect: str + + inputs = [ + { + "lesson_title": "# @344\n12", + "ext": ".md", + "expect": "_344_12.md" + }, + { + "lesson_title": "1#@$%!*&\\/:*?\"<>|\n\t \'1", + "ext": ".cc", + "expect": "1_1.cc" + } + ] + for i in inputs: + seed = Inputs(**i) + result = Teacher.new_file_name(seed.lesson_title, seed.ext) + assert result == seed.expect + + +if __name__ == '__main__': + test_init()