diff --git a/src/apis/gpt.py b/src/apis/gpt.py index 63fbd03..038ec85 100644 --- a/src/apis/gpt.py +++ b/src/apis/gpt.py @@ -7,20 +7,23 @@ from openai.error import RateLimitError, Timeout from src.constants import PRICING_GPT4_PROMPT, PRICING_GPT4_GENERATION, PRICING_GPT3_5_TURBO_PROMPT, \ PRICING_GPT3_5_TURBO_GENERATION -from src.options.generate.prompt_system import system_base_definition +from src.options.generate.prompt_system import system_base_definition, executor_example, docarray_example, client_example from src.utils.io import timeout_generator_wrapper, GenerationTimeoutError from src.utils.string_tools import print_colored + class GPTSession: - def __init__(self): + def __init__(self, model: str = 'gpt-4'): self.configure_openai_api_key() - if self.is_gpt4_available(): + if model == 'gpt-4' and self.is_gpt4_available(): self.supported_model = 'gpt-4' self.pricing_prompt = PRICING_GPT4_PROMPT self.pricing_generation = PRICING_GPT4_GENERATION else: - raise Exception('The OPENAI_API_KEY does not have access to GPT-4. We are working on 3.5-turbo - support') - self.supported_model = 'gpt-3.5-turbo' + if model == 'gpt-4': + print_colored('GPT-4 is not available. Using GPT-3.5-turbo instead.', 'yellow') + model = 'gpt-3.5-turbo' + self.supported_model = model self.pricing_prompt = PRICING_GPT3_5_TURBO_PROMPT self.pricing_generation = PRICING_GPT3_5_TURBO_GENERATION self.chars_prompt_so_far = 0 @@ -58,26 +61,25 @@ If you have updated it already, please restart your terminal. self.chars_prompt_so_far += chars_prompt self.chars_generation_so_far += chars_generation print('\n') - money_prompt = round(self.chars_prompt_so_far / 3.4 * self.pricing_prompt / 1000, 2) - money_generation = round(self.chars_generation_so_far / 3.4 * self.pricing_generation / 1000, 2) + money_prompt = round(self.chars_prompt_so_far / 3.4 * self.pricing_prompt / 1000, 3) + money_generation = round(self.chars_generation_so_far / 3.4 * self.pricing_generation / 1000, 3) print('Estimated costs on openai.com:') # print('money prompt:', f'${money_prompt}') # print('money generation:', f'${money_generation}') print('total money spent so far:', f'${money_prompt + money_generation}') print('\n') - def get_conversation(self): - return _GPTConversation(self.supported_model, self.cost_callback) + def get_conversation(self, system_definition_examples: List[str] = ['executor', 'docarray', 'client']): + return _GPTConversation(self.supported_model, self.cost_callback, system_definition_examples) class _GPTConversation: - def __init__(self, model: str, cost_callback, prompt_list: List[Tuple[str, str]] = None): + def __init__(self, model: str, cost_callback, system_definition_examples: List[str] = ['executor', 'docarray', 'client']): self.model = model - if prompt_list is None: - prompt_list = [('system', system_base_definition)] - self.prompt_list = prompt_list self.cost_callback = cost_callback - print_colored('system', system_base_definition, 'magenta') + self.prompt_list = [None] + self.set_system_definition(system_definition_examples) + print_colored('system', self.prompt_list[0][1], 'magenta') def query(self, prompt: str): print_colored('user', prompt, 'blue') @@ -86,6 +88,16 @@ class _GPTConversation: self.prompt_list.append(('assistant', response)) return response + def set_system_definition(self, system_definition_examples: List[str] = []): + system_message = system_base_definition + if 'executor' in system_definition_examples: + system_message += f'\n{executor_example}' + if 'docarray' in system_definition_examples: + system_message += f'\n{docarray_example}' + if 'client' in system_definition_examples: + system_message += f'\n{client_example}' + self.prompt_list[0] = ('system', system_message) + def get_response_from_stream(self, response_generator): response_generator_with_timeout = timeout_generator_wrapper(response_generator, 10) complete_string = '' @@ -102,7 +114,7 @@ class _GPTConversation: try: response_generator = openai.ChatCompletion.create( temperature=0, - max_tokens=2_000, + max_tokens=2_000 if self.model == 'gpt-4' else None, model=self.model, stream=True, messages=[ diff --git a/src/apis/jina_cloud.py b/src/apis/jina_cloud.py index 2f618ef..0a688c0 100644 --- a/src/apis/jina_cloud.py +++ b/src/apis/jina_cloud.py @@ -250,7 +250,7 @@ def process_error_message(error_message): if last_matching_line_index is not None: relevant_lines = lines[last_matching_line_index:] - return '\n'.join(relevant_lines[-25:]) + return '\n'.join(relevant_lines[-25:]).strip() def build_docker(path): diff --git a/src/cli.py b/src/cli.py index 2a5234a..9f2173a 100644 --- a/src/cli.py +++ b/src/cli.py @@ -45,10 +45,12 @@ def main(ctx): @click.option('--description', required=True, help='Description of the microservice.') @click.option('--test', required=True, help='Test scenario for the microservice.') @path_param +@click.option('--model', default='gpt-4', help='GPT model to use (default: gpt-4).') def generate( description, test, path, + model='gpt-4' ): from src.options.generate.generator import Generator path = os.path.expanduser(path) @@ -57,7 +59,7 @@ def generate( if os.listdir(path): click.echo(f"Error: The path {path} you provided via --path is not empty. Please choose a directory that does not exist or is empty.") return - generator = Generator() + generator = Generator(model=model) generator.generate(description, test, path) @main.command() diff --git a/src/options/generate/generator.py b/src/options/generate/generator.py index e3680f5..73b7b19 100644 --- a/src/options/generate/generator.py +++ b/src/options/generate/generator.py @@ -6,22 +6,28 @@ from src.apis import gpt from src.constants import FILE_AND_TAG_PAIRS, NUM_IMPLEMENTATION_STRATEGIES, MAX_DEBUGGING_ITERATIONS from src.apis.jina_cloud import process_error_message, push_executor from src.options.generate.prompt_tasks import general_guidelines, chain_of_thought_creation, executor_file_task, \ - not_allowed, chain_of_thought_optimization, test_executor_file_task, requirements_file_task, docker_file_task + not_allowed_executor, chain_of_thought_optimization, test_executor_file_task, requirements_file_task, docker_file_task from src.utils.io import persist_file, get_all_microservice_files_with_content, get_microservice_path from src.utils.string_tools import print_colored class Generator: - def __init__(self): - self.gpt_session = gpt.GPTSession() + def __init__(self, model='gpt-4'): + self.gpt_session = gpt.GPTSession(model=model) - def extract_content_from_result(self, plain_text, file_name): + def extract_content_from_result(self, plain_text, file_name, match_single_block=False): pattern = fr"^\*\*{file_name}\*\*\n```(?:\w+\n)?([\s\S]*?)```" match = re.search(pattern, plain_text, re.MULTILINE) if match: return match.group(1).strip() else: - return '' + # Check for a single code block + single_code_block_pattern = r"^```(?:\w+\n)?([\s\S]*?)```" + single_code_block_match = re.findall(single_code_block_pattern, plain_text, re.MULTILINE) + if match_single_block and len(single_code_block_match) == 1: + return single_code_block_match[0].strip() + else: + return '' def write_config_yml(self, microservice_name, dest_folder): config_content = f''' @@ -41,7 +47,7 @@ class Generator: all_microservice_files_string += f'**{file_name}**\n' all_microservice_files_string += f'```{tag}\n' all_microservice_files_string += file_name_to_content[file_name] - all_microservice_files_string += '\n```\n\n' + all_microservice_files_string += '\n```' return all_microservice_files_string def wrap_content_in_code_block(self, microservice_content, file_name, tag): @@ -64,15 +70,19 @@ class Generator: user_query = ( general_guidelines() + executor_file_task(microservice_name, description, test, package) - + chain_of_thought_creation() + + '\n\n' + chain_of_thought_creation() ) conversation = self.gpt_session.get_conversation() microservice_content_raw = conversation.query(user_query) if is_chain_of_thought: microservice_content_raw = conversation.query( - f"General rules: " + not_allowed() + chain_of_thought_optimization('python', 'microservice.py')) - microservice_content = self.extract_content_from_result(microservice_content_raw, 'microservice.py') - + f"General rules: " + not_allowed_executor() + chain_of_thought_optimization('python', 'microservice.py')) + microservice_content = self.extract_content_from_result(microservice_content_raw, 'microservice.py', match_single_block=True) + if microservice_content == '': + microservice_content_raw = conversation.query('You must add the executor code.') + microservice_content = self.extract_content_from_result( + microservice_content_raw, 'microservice.py', match_single_block=True + ) persist_file(microservice_content, os.path.join(MICROSERVICE_FOLDER_v1, 'microservice.py')) print_colored('', '############# Test Microservice #############', 'red') @@ -85,12 +95,14 @@ class Generator: test_microservice_content_raw = conversation.query(user_query) if is_chain_of_thought: test_microservice_content_raw = conversation.query( - f"General rules: " + not_allowed() + + f"General rules: " + not_allowed_executor() + chain_of_thought_optimization('python', 'test_microservice.py') + "Don't add any additional tests. " ) - test_microservice_content = self.extract_content_from_result(test_microservice_content_raw, 'test_microservice.py') - persist_file(test_microservice_content, os.path.join(MICROSERVICE_FOLDER_v1, 'test_microservice.py')) + microservice_content = self.extract_content_from_result( + microservice_content_raw, 'microservice.py', match_single_block=True + ) + persist_file(microservice_content, os.path.join(MICROSERVICE_FOLDER_v1, 'test_microservice.py')) print_colored('', '############# Requirements #############', 'red') requirements_path = os.path.join(MICROSERVICE_FOLDER_v1, 'requirements.txt') @@ -106,7 +118,7 @@ class Generator: requirements_content_raw = conversation.query( chain_of_thought_optimization('', requirements_path) + "Keep the same version of jina ") - requirements_content = self.extract_content_from_result(requirements_content_raw, 'requirements.txt') + requirements_content = self.extract_content_from_result(requirements_content_raw, 'requirements.txt', match_single_block=True) persist_file(requirements_content, requirements_path) print_colored('', '############# Dockerfile #############', 'red') @@ -121,8 +133,8 @@ class Generator: dockerfile_content_raw = conversation.query(user_query) if is_chain_of_thought: dockerfile_content_raw = conversation.query( - f"General rules: " + not_allowed() + chain_of_thought_optimization('dockerfile', 'Dockerfile')) - dockerfile_content = self.extract_content_from_result(dockerfile_content_raw, 'Dockerfile') + f"General rules: " + not_allowed_executor() + chain_of_thought_optimization('dockerfile', 'Dockerfile')) + dockerfile_content = self.extract_content_from_result(dockerfile_content_raw, 'Dockerfile', match_single_block=True) persist_file(dockerfile_content, os.path.join(MICROSERVICE_FOLDER_v1, 'Dockerfile')) self.write_config_yml(microservice_name, MICROSERVICE_FOLDER_v1) @@ -141,24 +153,24 @@ class Generator: Create a playground for the executor {microservice_name} using streamlit. The playground must look like it was made by a professional designer. All the ui elements are well thought out to make them visually appealing and easy to use. - This is an example how you can connect to the executor assuming the document (d) is already defined: ``` from jina import Client, Document, DocumentArray client = Client(host=host) response = client.post('/', inputs=DocumentArray([d])) # always use '/' -print(response[0].text) +print(response[0].text) # can also be blob in case of image/audio..., this should be visualized in the streamlit app ``` +Note that the response will always be in response[0].text +You must provide the complete file with the exact same syntax to wrap the code. The playground (app.py) must read the host from sys.argv because it will be started with a custom host: streamlit run app.py -- --host grpc://... The playground (app.py) must not let the user configure the host on the ui. ''' ) - conversation = self.gpt_session.get_conversation() + conversation = self.gpt_session.get_conversation([]) conversation.query(user_query) - playground_content_raw = conversation.query( - f"General rules: " + not_allowed() + chain_of_thought_optimization('python', 'app.py')) - playground_content = self.extract_content_from_result(playground_content_raw, 'app.py') - persist_file(playground_content, os.path.join(microservice_path, 'app.py')) + playground_content_raw = conversation.query(chain_of_thought_optimization('python', 'app.py', 'the playground')) + playground_content = self.extract_content_from_result(playground_content_raw, 'app.py', match_single_block=True) + persist_file(playground_content, os.path.join(miicroservice_path, 'app.py')) def debug_microservice(self, path, microservice_name, num_approach, packages, description, test): @@ -172,36 +184,48 @@ The playground (app.py) must not let the user configure the host on the ui. error = process_error_message(log_hubble) if error: os.makedirs(next_microservice_path) - file_name_to_content = get_all_microservice_files_with_content(previous_microservice_path) - all_files_string = self.files_to_string(file_name_to_content) - user_query = ( - f"General rules: " + not_allowed() - + 'Here is the description of the task the executor must solve:\n' - + description - + '\n\nHere is the test scenario the executor must pass:\n' - + test - + 'Here are all the files I use:\n' - + all_files_string - + (('This is an error that is already fixed before:\n' - + error_before) if error_before else '') - + '\n\nNow, I get the following error:\n' - + error + '\n' - + 'Think quickly about possible reasons the error might caused by. ' - 'Decide which files need to be changed. ' - 'Then output the files that need change. ' - "Don't output files that don't need change. " - "If you output a file, then write the complete file. " - "Use the exact same syntax to wrap the code:\n" - f"**...**\n" - f"```...\n" - f"...code...\n" - f"```\n\n" - ) + file_name_to_content = self.get_all_microservice_files_with_content(previous_executor_path) + + is_dependency_issue = self.is_dependency_issue(error, file_name_to_content['Dockerfile']) + + if is_dependency_issue: + all_files_string = self.files_to_string({ + key: val for key, val in file_name_to_content.items() if key in ['requirements.txt', 'Dockerfile'] + }) + user_query = ( + f"Your task is to provide guidance on how to solve an error that occurred during the Docker " + f"build process. The error message is:\n{error}\nTo solve this error, you should first " + f"identify the type of error by examining the stack trace. Once you have identified the " + f"error, you should suggest how to solve it. Your response should include the files that " + f"need to be changed, but not files that don't need to be changed. For files that need to " + f"be changed, you must provide the complete file with the exact same syntax to wrap the code.\n\n" + f"You are given the following files:\n\n{all_files_string}" + ) + else: + all_files_string = self.files_to_string(file_name_to_content) + user_query = ( + f"General rules: " + not_allowed_executor() + + f'Here is the description of the task the executor must solve:\n{description}' + + f'\n\nHere is the test scenario the executor must pass:\n{test}' + + f'Here are all the files I use:\n{all_files_string}' + + ((f'This is an error that I already fixed before:\n{error_before}\n\n') if error_before else '') + + f'\n\nThis is the error I encounter currently during the docker build process:\n{error}\n\n' + + 'Look at the stack trace of the current error. First, think about what kind of error is this? ' + 'Then think about possible reasons which might have caused it. Then suggest how to ' + 'solve it. Output the files that need change. ' + "Don't output files that don't need change. If you output a file, then write the " + "complete file. Use the exact same syntax to wrap the code:\n" + f"**...**\n" + f"```...\n" + f"...code...\n" + f"```" + ) + conversation = self.gpt_session.get_conversation() returned_files_raw = conversation.query(user_query) for file_name, tag in FILE_AND_TAG_PAIRS: updated_file = self.extract_content_from_result(returned_files_raw, file_name) - if updated_file: + if updated_file and (not is_dependency_issue or file_name in ['requirements.txt', 'Dockerfile']): file_name_to_content[file_name] = updated_file for file_name, content in file_name_to_content.items(): @@ -218,6 +242,20 @@ The playground (app.py) must not let the user configure the host on the ui. class MaxDebugTimeReachedException(BaseException): pass + def is_dependency_issue(self, error, docker_file: str): + # a few heuristics to quickly jump ahead + if any([error_message in error for error_message in ['AttributeError', 'NameError', 'AssertionError']]): + return False + + conversation = self.gpt_session.get_conversation([]) + answer = conversation.query( + f'Your task is to assist in identifying the root cause of a Docker build error for a python application. ' + f'The error message is as follows::\n\n{error}\n\n' + f'The docker file is as follows:\n\n{docker_file}\n\n' + f'Is this a dependency installation failure? Answer with "yes" or "no".' + ) + return 'yes' in answer.lower() + def generate_microservice_name(self, description): conversation = self.gpt_session.get_conversation() user_query = f''' @@ -250,7 +288,7 @@ For each subtask: For each package: Write down some non-obvious thoughts about the challenges you might face for the task and give multiple approaches on how you handle them. For example, there might be some packages you must not use because they do not obay the rules: - {not_allowed()} + {not_allowed_executor()} Discuss the pros and cons for all of these packages. Create a list of package subsets that you could use to solve the task. The list is sorted in a way that the most promising subset of packages is at the top. diff --git a/src/options/generate/prompt_system.py b/src/options/generate/prompt_system.py index fe3eebb..02a38ee 100644 --- a/src/options/generate/prompt_system.py +++ b/src/options/generate/prompt_system.py @@ -1,7 +1,6 @@ from src.constants import FLOW_URL_PLACEHOLDER -executor_example = ''' -Using the Jina framework, users can define executors. +executor_example = '''Using the Jina framework, users can define executors. Here is an example of how an executor can be defined. It always starts with a comment: **microservice.py** @@ -24,18 +23,19 @@ class MyInfoExecutor(Executor): An Executor gets a DocumentArray as input and returns a DocumentArray as output. ''' -docarray_example = f''' -A DocumentArray is a python class that can be seen as a list of Documents. +docarray_example = f'''A DocumentArray is a python class that can be seen as a list of Documents. A Document is a python class that represents a single document. Here is the protobuf definition of a Document: - +``` message DocumentProto {{ // used to store json data the executor gets and returns string text = 1; }} +``` Here are examples of how a DocumentArray can be defined: +``` from jina import DocumentArray, Document import json @@ -53,11 +53,11 @@ array_list = array.tolist() d3 = Document(text=json.dumps(array_list)) d4 = Document() d4.text = '{{"uri": "https://.../logo.png"}}' +``` ''' -client_example = f''' -After the executor is deployed, it can be called via Jina Client. +client_example = f'''After the executor is deployed, it can be called via Jina Client. Here is an example of a client file: **client.py** @@ -68,13 +68,7 @@ d = Document(uri='...') d.load_uri_to_blob() response = client.post('/', inputs=DocumentArray([d])) # the client must be called on '/' print(response[0].text) -``` -''' +```''' -system_base_definition = f''' -You are a principal engineer working at Jina - an open source company." -{executor_example} -{docarray_example} -{client_example} -''' \ No newline at end of file +system_base_definition = f'''You are a principal engineer working at Jina - an open source company. You accurately satisfy all of the user's requirements.''' \ No newline at end of file diff --git a/src/options/generate/prompt_tasks.py b/src/options/generate/prompt_tasks.py index ed67486..239f952 100644 --- a/src/options/generate/prompt_tasks.py +++ b/src/options/generate/prompt_tasks.py @@ -17,24 +17,29 @@ def general_guidelines(): ) -def _task(task, tag_name, file_name): +def _task(task, tag_name, file_name, purpose=None): + into_string = file_name + if purpose: + into_string += f"/{purpose}" + return ( - task + f"The code will go into {file_name}. Wrap the code into:\n" + task + f"The code will go into {into_string}. Make sure to wrap the code into ``` marks even if you only " + f"output code:\n" f"**{file_name}**\n" f"```{tag_name}\n" f"...code...\n" - f"```\n\n" + f"```\nYou must provide the complete file with the exact same syntax to wrap the code." ) def executor_file_task(executor_name, executor_description, test_scenario, package): return _task(f''' -Write the executor called '{executor_name}'. +Write the executor called '{executor_name}'. The name is very important to keep. It matches the following description: '{executor_description}'. It will be tested with the following scenario: '{test_scenario}'. For the implementation use the following package: '{package}'. Have in mind that d.uri is never a path to a local file. It is always a url. -''' + not_allowed(), +''' + not_allowed_executor(), EXECUTOR_FILE_TAG, EXECUTOR_FILE_NAME ) @@ -50,8 +55,8 @@ def test_executor_file_task(executor_name, test_scenario): if test_scenario else "" ) + "Use the following import to import the executor: " - f"from microservice import {executor_name} " - + not_allowed() + f"```\nfrom microservice import {executor_name}\n```" + + not_allowed_executor() + "The test must not open local files. " + "The test must not mock a function of the executor. " + "The test must not use other data than the one provided in the test scenario. ", @@ -73,15 +78,15 @@ def requirements_file_task(): def docker_file_task(): return _task( "Write the Dockerfile that defines the environment with all necessary dependencies that the executor uses. " - "The Dockerfile runs the test during the build process. " "It is important to make sure that all libs are installed that are required by the python packages. " "Usually libraries are installed with apt-get. " "Be aware that the machine the docker container is running on does not have a GPU - only CPU. " - "Add the config.yml file to the Dockerfile. " + "Add the config.yml file to the Dockerfile. Note that the Dockerfile only has access to the files: " + "executor.py, requirements.txt, config.yml, test_executor.py. " "The base image of the Dockerfile is FROM jinaai/jina:3.14.1-py39-standard. " 'The entrypoint is ENTRYPOINT ["jina", "executor", "--uses", "config.yml"]. ' 'Make sure the all files are in the /workdir. ' - "The Dockerfile runs the test during the build process. " + not_allowed(), + "The Dockerfile runs the test during the build process. " + not_allowed_docker(), DOCKER_FILE_TAG, DOCKER_FILE_NAME ) @@ -104,28 +109,33 @@ def streamlit_file_task(): def chain_of_thought_creation(): - return ( - "First, write down some non-obvious thoughts about the challenges of the task and give multiple approaches on how you handle them. " - "For example, the given package you could used in different ways and not all of them obay the rules: " - + "Discuss the pros and cons for all of these approaches and then decide for one of the approaches. " - "Then write as I told you. " + return (f''' +First, write down some non-obvious thoughts about the challenges of the task and give multiple approaches on how you handle them. +For example, the given package you could used in different ways and not all of them obey the instructions. +Discuss the pros and cons for all of these approaches and then decide for one of the approaches. +Then write the code. +''' ) -def chain_of_thought_optimization(tag_name, file_name): +def chain_of_thought_optimization(tag_name, file_name, file_name_function=None): + file_name_or_function = file_name + if file_name_function: + file_name_or_function += f"/{file_name_function}" return _task( - f'First, write down an extensive list of obvious and non-obvious observations about {file_name} that could need an adjustment. Explain why. ' + f'First, write down an extensive list of obvious and non-obvious observations about {file_name_or_function} that could need an adjustment. Explain why. ' f"Think if all the changes are required and finally decide for the changes you want to make, " f"but you are not allowed disregard the instructions in the previous message. " f"Be very hesitant to change the code. Only make a change if you are sure that it is necessary. " - f"Output only {file_name} " - f"Write the whole content of {file_name} - even if you decided to change only a small thing or even nothing. ", + f"Output only {file_name_or_function} " + f"Write the whole content of {file_name_or_function} - even if you decided to change only a small thing or even nothing. ", tag_name, - file_name + file_name, + file_name_function ) -def not_allowed(): +def not_allowed_executor(): return ''' The executor must not use the GPU. The executor must not access a database. @@ -135,4 +145,10 @@ The executor must not load data from the local file system unless it was created The executor must not use a pre-trained model unless it is explicitly mentioned in the description. The executor must not train a model. The executor must not use any attribute of Document accept Document.text. -''' \ No newline at end of file +''' + +def not_allowed_docker(): + return ''' +Note that the Dockerfile only has access to the files: executor.py, requirements.txt, config.yml, test_executor.py. +Note that the Dockerfile runs the test_microservice.py during the build process. +'''