diff --git a/clarifai_datautils/text/prompt_factory.py b/clarifai_datautils/text/prompt_factory.py new file mode 100644 index 0000000..ab7b868 --- /dev/null +++ b/clarifai_datautils/text/prompt_factory.py @@ -0,0 +1,589 @@ +# This was inspired from litellm + +import json +from enum import Enum +from typing import Any, Optional + +import requests +from jinja2.sandbox import ImmutableSandboxedEnvironment + + +def default_pt(messages): + return " ".join(message["content"] for message in messages) + + +# alpaca prompt template - for models like mythomax, etc. +def alpaca_pt(messages): + prompt = custom_prompt( + role_dict={ + "system": { + "pre_message": "### Instruction:\n", + "post_message": "\n\n", + }, + "user": { + "pre_message": "### Instruction:\n", + "post_message": "\n\n", + }, + "assistant": { + "pre_message": "### Response:\n", + "post_message": "\n\n" + }, + }, + bos_token="", + eos_token="", + messages=messages, + ) + return prompt + + +# Llama2 prompt template +def llama_2_chat_pt(messages): + prompt = custom_prompt( + role_dict={ + "system": { + "pre_message": "[INST] <>\n", + "post_message": "\n<>\n [/INST]\n", + }, + "user": { # follow this format https://github.com/facebookresearch/llama/blob/77062717054710e352a99add63d160274ce670c6/llama/generation.py#L348 + "pre_message": "[INST] ", + "post_message": " [/INST]\n", + }, + "assistant": { + "post_message": "\n" # follows this - https://replicate.com/blog/how-to-prompt-llama + }, + }, + messages=messages, + bos_token="", + eos_token="", + ) + return prompt + + +def ollama_pt( + model, messages +): # https://github.com/jmorganca/ollama/blob/af4cf55884ac54b9e637cd71dadfe9b7a5685877/docs/modelfile.md#template + if "instruct" in model: + prompt = custom_prompt( + role_dict={ + "system": { + "pre_message": "### System:\n", + "post_message": "\n" + }, + "user": { + "pre_message": "### User:\n", + "post_message": "\n", + }, + "assistant": { + "pre_message": "### Response:\n", + "post_message": "\n", + }, + }, + final_prompt_value="### Response:", + messages=messages, + ) + elif "llava" in model: + prompt = "" + images = [] + for message in messages: + if isinstance(message["content"], str): + prompt += message["content"] + elif isinstance(message["content"], list): + for element in message["content"]: + if isinstance(element, dict): + if element["type"] == "text": + prompt += element["text"] + elif element["type"] == "image_url": + image_url = element["image_url"]["url"] + images.append(image_url) + return {"prompt": prompt, "images": images} + else: + prompt = "".join(m["content"] if isinstance(m["content"], str) is str else "".join( + m["content"]) for m in messages) + return prompt + + +def mistral_instruct_pt(messages): + # Following the Mistral example's https://huggingface.co/docs/transformers/main/chat_templating + prompt = custom_prompt( + initial_prompt_value="", + role_dict={ + "system": { + "pre_message": "[INST] \n", + "post_message": " [/INST]\n", + }, + "user": { + "pre_message": "[INST] ", + "post_message": " [/INST]\n" + }, + "assistant": { + "pre_message": " ", + "post_message": " " + }, + }, + final_prompt_value="", + messages=messages, + ) + return prompt + + +# Falcon prompt template - from https://github.com/lm-sys/FastChat/blob/main/fastchat/conversation.py#L110 +def falcon_instruct_pt(messages): + prompt = "" + for message in messages: + if message["role"] == "system": + prompt += message["content"] + else: + prompt += ( + message["role"] + ":" + message["content"].replace("\r\n", "\n").replace("\n\n", "\n")) + prompt += "\n\n" + + return prompt + + +def falcon_chat_pt(messages): + prompt = "" + for message in messages: + if message["role"] == "system": + prompt += "System: " + message["content"] + elif message["role"] == "assistant": + prompt += "Falcon: " + message["content"] + elif message["role"] == "user": + prompt += "User: " + message["content"] + + return prompt + + +# MPT prompt template - from https://github.com/lm-sys/FastChat/blob/main/fastchat/conversation.py#L110 +def mpt_chat_pt(messages): + prompt = "" + for message in messages: + if message["role"] == "system": + prompt += "<|im_start|>system" + message["content"] + "<|im_end|>" + "\n" + elif message["role"] == "assistant": + prompt += "<|im_start|>assistant" + message["content"] + "<|im_end|>" + "\n" + elif message["role"] == "user": + prompt += "<|im_start|>user" + message["content"] + "<|im_end|>" + "\n" + return prompt + + +# WizardCoder prompt template - https://huggingface.co/WizardLM/WizardCoder-Python-34B-V1.0#prompt-format +def wizardcoder_pt(messages): + prompt = "" + for message in messages: + if message["role"] == "system": + prompt += message["content"] + "\n\n" + elif message["role"] == "user": # map to 'Instruction' + prompt += "### Instruction:\n" + message["content"] + "\n\n" + elif message["role"] == "assistant": # map to 'Response' + prompt += "### Response:\n" + message["content"] + "\n\n" + return prompt + + +# Phind-CodeLlama prompt template - https://huggingface.co/Phind/Phind-CodeLlama-34B-v2#how-to-prompt-the-model +def phind_codellama_pt(messages): + prompt = "" + for message in messages: + if message["role"] == "system": + prompt += "### System Prompt\n" + message["content"] + "\n\n" + elif message["role"] == "user": + prompt += "### User Message\n" + message["content"] + "\n\n" + elif message["role"] == "assistant": + prompt += "### Assistant\n" + message["content"] + "\n\n" + return prompt + + +# Anthropic template +def claude_2_1_pt( + messages: list,): # format - https://docs.anthropic.com/claude/docs/how-to-use-system-prompts + """ + Claude v2.1 allows system prompts (no Human: needed), but requires it be followed by Human: + - you can't just pass a system message + - you can't pass a system message and follow that with an assistant message + if system message is passed in, you can only do system, human, assistant or system, human + + if a system message is passed in and followed by an assistant message, insert a blank human message between them. + + Additionally, you can "put words in Claude's mouth" by ending with an assistant message. + See: https://docs.anthropic.com/claude/docs/put-words-in-claudes-mouth + """ + + class AnthropicConstants(Enum): + HUMAN_PROMPT = "\n\nHuman: " + AI_PROMPT = "\n\nAssistant: " + + prompt = "" + for idx, message in enumerate(messages): + if message["role"] == "user": + prompt += f"{AnthropicConstants.HUMAN_PROMPT.value}{message['content']}" + elif message["role"] == "system": + prompt += f"{message['content']}" + elif message["role"] == "assistant": + if idx > 0 and messages[idx - 1]["role"] == "system": + prompt += f"{AnthropicConstants.HUMAN_PROMPT.value}" # Insert a blank human message + prompt += f"{AnthropicConstants.AI_PROMPT.value}{message['content']}" + if messages[-1]["role"] != "assistant": + prompt += f"{AnthropicConstants.AI_PROMPT.value}" # prompt must end with \"\n\nAssistant: " turn + return prompt + + +def anthropic_pt( + messages: list,): # format - https://docs.anthropic.com/claude/reference/complete_post + """ + You can "put words in Claude's mouth" by ending with an assistant message. + See: https://docs.anthropic.com/claude/docs/put-words-in-claudes-mouth + """ + + class AnthropicConstants(Enum): + HUMAN_PROMPT = "\n\nHuman: " + AI_PROMPT = "\n\nAssistant: " + + prompt = "" + for idx, message in enumerate( + messages): # needs to start with `\n\nHuman: ` and end with `\n\nAssistant: ` + if message["role"] == "user": + prompt += f"{AnthropicConstants.HUMAN_PROMPT.value}{message['content']}" + elif message["role"] == "system": + prompt += f"{AnthropicConstants.HUMAN_PROMPT.value}{message['content']}" + else: + prompt += f"{AnthropicConstants.AI_PROMPT.value}{message['content']}" + if (idx == 0 and + message["role"] == "assistant"): # ensure the prompt always starts with `\n\nHuman: ` + prompt = f"{AnthropicConstants.HUMAN_PROMPT.value}" + prompt + if messages[-1]["role"] != "assistant": + prompt += f"{AnthropicConstants.AI_PROMPT.value}" + return prompt + + +def _load_image_from_url(image_url): + # try: + # except: + # raise Exception( + # "gemini image conversion failed please run `pip install Pillow`" + # ) + from io import BytesIO + + from PIL import Image + + try: + # Send a GET request to the image URL + response = requests.get(image_url) + response.raise_for_status() # Raise an exception for HTTP errors + + # Check the response's content type to ensure it is an image + content_type = response.headers.get("content-type") + if not content_type or "image" not in content_type: + raise ValueError(f"URL does not point to a valid image (content-type: {content_type})") + + # Load the image from the response content + return Image.open(BytesIO(response.content)) + + except requests.RequestException as e: + raise Exception(f"Request failed: {e}") + except Exception as e: + raise e + + +def _gemini_vision_convert_messages(messages: list): + """ + Converts given messages for GPT-4 Vision to Gemini format. + + Args: + messages (list): The messages to convert. Each message can be a dictionary with a "content" key. The content can be a string or a list of elements. If it is a string, it will be concatenated to the prompt. If it is a list, each element will be processed based on its type: + - If the element is a dictionary with a "type" key equal to "text", its "text" value will be concatenated to the prompt. + - If the element is a dictionary with a "type" key equal to "image_url", its "image_url" value will be added to the list of images. + + Returns: + tuple: A tuple containing the prompt (a string) and the processed images (a list of objects representing the images). + """ + # try: + from PIL import Image + + # except: + # raise Exception( + # "gemini image conversion failed please run `pip install Pillow`" + # ) + + try: + # given messages for gpt-4 vision, convert them for gemini + # https://github.com/GoogleCloudPlatform/generative-ai/blob/main/gemini/getting-started/intro_gemini_python.ipynb + prompt = "" + images = [] + for message in messages: + if isinstance(message["content"], str): + prompt += message["content"] + elif isinstance(message["content"], list): + for element in message["content"]: + if isinstance(element, dict): + if element["type"] == "text": + prompt += element["text"] + elif element["type"] == "image_url": + image_url = element["image_url"]["url"] + images.append(image_url) + # processing images passed to gemini + processed_images = [] + for img in images: + if "https:/" in img: + # Case 1: Image from URL + image = _load_image_from_url(img) + processed_images.append(image) + else: + # Case 2: Image filepath (e.g. temp.jpeg) given + image = Image.open(img) + processed_images.append(image) + content = [prompt] + processed_images + return content + except Exception as e: + raise e + + +def gemini_text_image_pt(messages: list): + """ + { + "contents":[ + { + "parts":[ + {"text": "What is this picture?"}, + { + "inline_data": { + "mime_type":"image/jpeg", + "data": "'$(base64 -w0 image.jpg)'" + } + } + ] + } + ] + } + """ + # try: + # pass + # except: + # raise Exception( + # "Importing google.generativeai failed, please run 'pip install -q google-generativeai" + # ) + + prompt = "" + images = [] + for message in messages: + if isinstance(message["content"], str): + prompt += message["content"] + elif isinstance(message["content"], list): + for element in message["content"]: + if isinstance(element, dict): + if element["type"] == "text": + prompt += element["text"] + elif element["type"] == "image_url": + image_url = element["image_url"]["url"] + images.append(image_url) + + content = [prompt] + images + return content + + +def hf_chat_template(model: str, + messages: list, + hf_token: Optional[str] = None, + chat_template: Optional[Any] = None): + ## get the tokenizer config from huggingface + bos_token = "" + eos_token = "" + if chat_template is None: + + def _get_tokenizer_config(hf_model_name): + headers = {"Authorization": f"Bearer {hf_token}"} + url = (f"https://huggingface.co/{hf_model_name}/raw/main/tokenizer_config.json") + # Make a GET request to fetch the JSON data + response = requests.get(url, headers=headers) + # print(response) + if response.status_code == 200: + # Parse the JSON data + tokenizer_config = json.loads(response.content) + return {"status": "success", "tokenizer": tokenizer_config} + else: + return {"status": "failure"} + + tokenizer_config = _get_tokenizer_config(model) + if (tokenizer_config["status"] == "failure" or + "chat_template" not in tokenizer_config["tokenizer"]): + raise Exception("No chat template found") + ## read the bos token, eos token and chat template from the json + tokenizer_config = tokenizer_config["tokenizer"] + bos_token = tokenizer_config["bos_token"] + eos_token = tokenizer_config["eos_token"] + chat_template = tokenizer_config["chat_template"] + + def raise_exception(message): + raise Exception(f"Error message - {message}") + + # Create a template object from the template text + env = ImmutableSandboxedEnvironment() + env.globals["raise_exception"] = raise_exception + try: + template = env.from_string(chat_template) + except Exception as e: + raise e + + def _is_system_in_template(): + try: + # Try rendering the template with a system message + template.render( + messages=[{ + "role": "system", + "content": "test" + }], + eos_token="", + bos_token="", + ) + return True + + # This will be raised if Jinja attempts to render the system message and it can't + except Exception: + return False + + try: + # Render the template with the provided values + if _is_system_in_template(): + rendered_text = template.render(bos_token=bos_token, eos_token=eos_token, messages=messages) + else: + # treat a system message as a user message, if system not in template + try: + reformatted_messages = [] + for message in messages: + if message["role"] == "system": + reformatted_messages.append({"role": "user", "content": message["content"]}) + else: + reformatted_messages.append(message) + rendered_text = template.render( + bos_token=bos_token, + eos_token=eos_token, + messages=reformatted_messages, + ) + except Exception as e: + if "Conversation roles must alternate user/assistant" in str(e): + # reformat messages to ensure user/assistant are alternating, if there's either 2 consecutive 'user' messages or 2 consecutive 'assistant' message, add a blank 'user' or 'assistant' message to ensure compatibility + new_messages = [] + for i in range(len(reformatted_messages) - 1): + new_messages.append(reformatted_messages[i]) + if (reformatted_messages[i]["role"] == reformatted_messages[i + 1]["role"]): + if reformatted_messages[i]["role"] == "user": + new_messages.append({"role": "assistant", "content": ""}) + else: + new_messages.append({"role": "user", "content": ""}) + new_messages.append(reformatted_messages[-1]) + rendered_text = template.render( + bos_token=bos_token, eos_token=eos_token, messages=new_messages) + return rendered_text, chat_template + except Exception as e: + raise Exception(f"Error rendering template - {str(e)}") + + +# Function call template +def function_call_prompt(messages: list, functions: list): + function_prompt = ("Produce JSON OUTPUT ONLY! The following functions are available to you:") + for function in functions: + function_prompt += f"""\n{function}\n""" + + function_added_to_prompt = False + for message in messages: + if "system" in message["role"]: + message["content"] += f"""{function_prompt}""" + function_added_to_prompt = True + + if function_added_to_prompt is False: + messages.append({"role": "system", "content": f"""{function_prompt}"""}) + + return messages + + +# Custom prompt template +def custom_prompt( + role_dict: dict, + messages: list, + initial_prompt_value: str = "", + final_prompt_value: str = "", + bos_token: str = "", + eos_token: str = "", +): + prompt = bos_token + initial_prompt_value + bos_open = True + ## a bos token is at the start of a system / human message + ## an eos token is at the end of the assistant response to the message + for message in messages: + role = message["role"] + + if role in ["system", "human"] and not bos_open: + prompt += bos_token + bos_open = True + + pre_message_str = (role_dict[role]["pre_message"] + if role in role_dict and "pre_message" in role_dict[role] else "") + post_message_str = (role_dict[role]["post_message"] + if role in role_dict and "post_message" in role_dict[role] else "") + prompt += pre_message_str + message["content"] + post_message_str + + if role == "assistant": + prompt += eos_token + bos_open = False + + prompt += final_prompt_value + return prompt + + +def prompt_factory( + model: str, + messages: list, + custom_llm_provider: Optional[str] = None, + hf_token: Optional[str] = None, +): + original_model_name = model + model = model.lower() + if custom_llm_provider == "ollama": + return ollama_pt(model=model, messages=messages) + elif custom_llm_provider == "anthropic": + if any(_ in model for _ in ["claude-2.1", "claude-v2:1"]): + return claude_2_1_pt(messages=messages) + else: + return anthropic_pt(messages=messages) + elif custom_llm_provider == "gemini": + if model == "gemini-pro-vision": + return _gemini_vision_convert_messages(messages=messages) + else: + return gemini_text_image_pt(messages=messages) + try: + if "meta-llama/llama-2" in model and "chat" in model: + return llama_2_chat_pt(messages=messages) + elif "llama3" in model and "instruct" in model: + return hf_chat_template( + model="meta-llama/Meta-Llama-3-8B-Instruct", + messages=messages, + ) + elif ( + "tiiuae/falcon" in + model): # Note: for the instruct models, it's best to use a User: .., Assistant:.. approach in your prompt template. + if model == "tiiuae/falcon-180B-chat": + return falcon_chat_pt(messages=messages) + elif "instruct" in model: + return falcon_instruct_pt(messages=messages) + elif "mosaicml/mpt" in model: + if "chat" in model: + return mpt_chat_pt(messages=messages) + elif "codellama/codellama" in model: + if "instruct" in model: + return llama_2_chat_pt( + messages=messages) # https://huggingface.co/blog/codellama#conversational-instructions + elif "wizardlm/wizardcoder" in model: + return wizardcoder_pt(messages=messages) + elif "phind/phind-codellama" in model: + return phind_codellama_pt(messages=messages) + elif model in [ + "gryphe/mythomax-l2-13b", + "gryphe/mythomix-l2-13b", + "gryphe/mythologic-l2-13b", + ]: + return alpaca_pt(messages=messages) + else: + messages, _ = hf_chat_template(original_model_name, messages, hf_token=hf_token) + return messages + except Exception: + return default_pt( + messages=messages + ) # default that covers Bloom, T-5, any non-chat tuned model (e.g. base Llama2) diff --git a/requirements.txt b/requirements.txt index fe04d6d..9342f60 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,3 +4,4 @@ pi_heif==0.18.0 markdown==3.7 python-docx==1.1.2 schema==0.7.5 +jinja2==3.1.6 diff --git a/tests/test_prompt_factory.py b/tests/test_prompt_factory.py new file mode 100644 index 0000000..bd73735 --- /dev/null +++ b/tests/test_prompt_factory.py @@ -0,0 +1,73 @@ +# This was inspired from litellm + +from clarifai_datautils.text.prompt_factory import claude_2_1_pt, llama_2_chat_pt + + +def test_codellama_prompt_format(): + messages = [ + { + "role": "system", + "content": "You are a good bot" + }, + { + "role": "user", + "content": "Hey, how's it going?" + }, + ] + expected_prompt = "[INST] <>\nYou are a good bot\n<>\n [/INST]\n[INST] Hey, how's it going? [/INST]\n" + assert llama_2_chat_pt(messages) == expected_prompt + + +def test_claude_2_1_pt_formatting(): + # Test case: User only, should add Assistant + messages = [{"role": "user", "content": "Hello"}] + expected_prompt = "\n\nHuman: Hello\n\nAssistant: " + assert claude_2_1_pt(messages) == expected_prompt + + # Test case: System, User, and Assistant "pre-fill" sequence, + # Should return pre-fill + messages = [ + { + "role": "system", + "content": "You are a helpful assistant." + }, + { + "role": "user", + "content": 'Please return "Hello World" as a JSON object.' + }, + { + "role": "assistant", + "content": "{" + }, + ] + expected_prompt = 'You are a helpful assistant.\n\nHuman: Please return "Hello World" as a JSON object.\n\nAssistant: {' + assert claude_2_1_pt(messages) == expected_prompt + + # Test case: System, Assistant sequence, should insert blank Human message + # before Assistant pre-fill + messages = [ + { + "role": "system", + "content": "You are a storyteller." + }, + { + "role": "assistant", + "content": "Once upon a time, there " + }, + ] + expected_prompt = ("You are a storyteller.\n\nHuman: \n\nAssistant: Once upon a time, there ") + assert claude_2_1_pt(messages) == expected_prompt + + # Test case: System, User sequence + messages = [ + { + "role": "system", + "content": "System reboot" + }, + { + "role": "user", + "content": "Is everything okay?" + }, + ] + expected_prompt = "System reboot\n\nHuman: Is everything okay?\n\nAssistant: " + assert claude_2_1_pt(messages) == expected_prompt