diff --git a/.env.example b/.env.example index 855954d4..4e9181f6 100644 --- a/.env.example +++ b/.env.example @@ -6,4 +6,6 @@ PRODUCT_CATALOG=examples/sample_product_catalog.txt PRODUCT_PRICE_MAPPING=examples/example_product_price_id_mapping.json GPT_MODEL=gpt-3.5-turbo-0613 USE_TOOLS_IN_API=True - +AWS_ACCESS_KEY_ID=xx +AWS_SECRET_ACCESS_KEY=xx +AWS_REGION_NAME=xx diff --git a/.github/workflows/poetry_unit_tests.yml b/.github/workflows/poetry_unit_tests.yml index ffb5f120..06657049 100644 --- a/.github/workflows/poetry_unit_tests.yml +++ b/.github/workflows/poetry_unit_tests.yml @@ -57,7 +57,15 @@ jobs: - name: Run Unit Tests env: OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + AWS_REGION_NAME: ${{ secrets.AWS_REGION_NAME }} + AWS_DEFAULT_REGION: ${{ secrets.AWS_DEFAULT_REGION }} run: | export OPENAI_API_KEY=$OPENAI_API_KEY + export AWS_ACCESS_KEY_ID=$AWS_ACCESS_KEY_ID + export AWS_SECRET_ACCESS_KEY=$AWS_SECRET_ACCESS_KEY + export AWS_DEFAULT_REGION=$AWS_DEFAULT_REGION + export AWS_REGION_NAME=$AWS_REGION_NAME make test # Executing tests with Poetry diff --git a/poetry.lock b/poetry.lock index 1102f5e5..c7045f56 100644 --- a/poetry.lock +++ b/poetry.lock @@ -325,6 +325,47 @@ d = ["aiohttp (>=3.7.4)"] jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] uvloop = ["uvloop (>=0.15.2)"] +[[package]] +name = "boto3" +version = "1.34.70" +description = "The AWS SDK for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "boto3-1.34.70-py3-none-any.whl", hash = "sha256:8d7902e2c0c62837457ba18146e3feaf1dec62018617edc5c0336b65b305b682"}, + {file = "boto3-1.34.70.tar.gz", hash = "sha256:54150a52eb93028b8e09df00319e8dcb68be7459333d5da00d706d75ba5130d6"}, +] + +[package.dependencies] +botocore = ">=1.34.70,<1.35.0" +jmespath = ">=0.7.1,<2.0.0" +s3transfer = ">=0.10.0,<0.11.0" + +[package.extras] +crt = ["botocore[crt] (>=1.21.0,<2.0a0)"] + +[[package]] +name = "botocore" +version = "1.34.70" +description = "Low-level, data-driven core of boto 3." +optional = false +python-versions = ">=3.8" +files = [ + {file = "botocore-1.34.70-py3-none-any.whl", hash = "sha256:c86944114e85c8a8d5da06fb84f2609ed3bd23cd2fc06b30250bef7e37e8c589"}, + {file = "botocore-1.34.70.tar.gz", hash = "sha256:fa03d4972cd57d505e6c0eb5d7c7a1caeb7dd49e84f963f7ebeca41fe8ab736e"}, +] + +[package.dependencies] +jmespath = ">=0.7.1,<2.0.0" +python-dateutil = ">=2.1,<3.0.0" +urllib3 = [ + {version = ">=1.25.4,<1.27", markers = "python_version < \"3.10\""}, + {version = ">=1.25.4,<2.2.0 || >2.2.0,<3", markers = "python_version >= \"3.10\""}, +] + +[package.extras] +crt = ["awscrt (==0.19.19)"] + [[package]] name = "cachetools" version = "5.3.2" @@ -1512,6 +1553,17 @@ MarkupSafe = ">=2.0" [package.extras] i18n = ["Babel (>=2.7)"] +[[package]] +name = "jmespath" +version = "1.0.1" +description = "JSON Matching Expressions" +optional = false +python-versions = ">=3.7" +files = [ + {file = "jmespath-1.0.1-py3-none-any.whl", hash = "sha256:02e2e4cc71b5bcab88332eebf907519190dd9e6e82107fa7f83b1003a6252980"}, + {file = "jmespath-1.0.1.tar.gz", hash = "sha256:90261b206d6defd58fdd5e85f478bf633a2901798906be2ad389150c5c60edbe"}, +] + [[package]] name = "jsonpatch" version = "1.33" @@ -3273,6 +3325,23 @@ files = [ [package.dependencies] pyasn1 = ">=0.1.3" +[[package]] +name = "s3transfer" +version = "0.10.1" +description = "An Amazon S3 Transfer Manager" +optional = false +python-versions = ">= 3.8" +files = [ + {file = "s3transfer-0.10.1-py3-none-any.whl", hash = "sha256:ceb252b11bcf87080fb7850a224fb6e05c8a776bab8f2b64b7f25b969464839d"}, + {file = "s3transfer-0.10.1.tar.gz", hash = "sha256:5683916b4c724f799e600f41dd9e10a9ff19871bf87623cc8f491cb4f5fa0a19"}, +] + +[package.dependencies] +botocore = ">=1.33.2,<2.0a.0" + +[package.extras] +crt = ["botocore[crt] (>=1.33.2,<2.0a.0)"] + [[package]] name = "setuptools" version = "69.0.2" @@ -4236,4 +4305,4 @@ testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "p [metadata] lock-version = "2.0" python-versions = "^3.8.1" -content-hash = "5017f21f1b8104f21cfa8d987e4499fcc6e4deb88d81d15fd5a396630f498fc4" +content-hash = "ca471f41a00b45e4611c0f29a3423096caf51274c091157423040e8a5435d472" diff --git a/pyproject.toml b/pyproject.toml index ddf41029..81ded888 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,6 +36,7 @@ pytest-cov = "^4.1.0" pytest-asyncio = "^0.23.1" langchain-openai = "0.0.2" tokenizers = "^0.15.2" +boto3 = "^1.34.70" [tool.poetry.group.dev.dependencies] black = "^23.11.0" diff --git a/requirements.txt b/requirements.txt index 745dc9ab..90df681b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,4 +5,5 @@ tiktoken>=0.5.2 pydantic>=2.5.2 litellm>=1.10.2 ipykernel>=6.27.1 -langchain-openai==0.0.2 \ No newline at end of file +langchain-openai==0.0.2 +boto3 \ No newline at end of file diff --git a/run_api.py b/run_api.py index 8390b658..9fb0a523 100644 --- a/run_api.py +++ b/run_api.py @@ -47,15 +47,17 @@ class MessageList(BaseModel): @app.get("/botname") async def get_bot_name(): + load_dotenv() sales_api = SalesGPTAPI( config_path=os.getenv("CONFIG_PATH", "examples/example_agent_setup.json"), product_catalog=os.getenv( "PRODUCT_CATALOG", "examples/sample_product_catalog.txt" ), verbose=True, + model_name=os.getenv("GPT_MODEL", "gpt-3.5-turbo-0613"), ) name = sales_api.sales_agent.salesperson_name - return {"name": name} + return {"name": name, "model": sales_api.sales_agent.model_name} @app.post("/chat") diff --git a/salesgpt/agents.py b/salesgpt/agents.py index 6c5dba51..fbbac84b 100644 --- a/salesgpt/agents.py +++ b/salesgpt/agents.py @@ -1,13 +1,18 @@ from copy import deepcopy from typing import Any, Callable, Dict, List, Union -from langchain.agents import (AgentExecutor, LLMSingleActionAgent, - create_openai_tools_agent) +from langchain.agents import ( + AgentExecutor, + LLMSingleActionAgent, + create_openai_tools_agent, +) from langchain.chains import LLMChain, RetrievalQA from langchain.chains.base import Chain from langchain_community.chat_models import ChatLiteLLM -from langchain_core.agents import (_convert_agent_action_to_messages, - _convert_agent_observation_to_messages) +from langchain_core.agents import ( + _convert_agent_action_to_messages, + _convert_agent_observation_to_messages, +) from langchain_core.language_models.llms import create_base_retry_decorator from litellm import acompletion from pydantic import Field diff --git a/salesgpt/chains.py b/salesgpt/chains.py index 7aef9b3c..cad87468 100644 --- a/salesgpt/chains.py +++ b/salesgpt/chains.py @@ -3,8 +3,10 @@ from langchain_community.chat_models import ChatLiteLLM from salesgpt.logger import time_logger -from salesgpt.prompts import (SALES_AGENT_INCEPTION_PROMPT, - STAGE_ANALYZER_INCEPTION_PROMPT) +from salesgpt.prompts import ( + SALES_AGENT_INCEPTION_PROMPT, + STAGE_ANALYZER_INCEPTION_PROMPT, +) class StageAnalyzerChain(LLMChain): diff --git a/salesgpt/models.py b/salesgpt/models.py new file mode 100644 index 00000000..eac5acb4 --- /dev/null +++ b/salesgpt/models.py @@ -0,0 +1,73 @@ +from typing import Any, AsyncIterator, Dict, Iterator, List, Optional + +from langchain_core.callbacks import ( + AsyncCallbackManagerForLLMRun, + CallbackManagerForLLMRun, +) +from langchain_core.language_models import BaseChatModel, SimpleChatModel +from langchain_core.messages import AIMessage, AIMessageChunk, BaseMessage, HumanMessage +from langchain_core.outputs import ChatGeneration, ChatGenerationChunk, ChatResult +from langchain_core.runnables import run_in_executor +from langchain_openai import ChatOpenAI + +from salesgpt.tools import completion_bedrock + + +class BedrockCustomModel(ChatOpenAI): + """A custom chat model that echoes the first `n` characters of the input. + + When contributing an implementation to LangChain, carefully document + the model including the initialization parameters, include + an example of how to initialize the model and include any relevant + links to the underlying models documentation or API. + + Example: + + .. code-block:: python + + model = CustomChatModel(n=2) + result = model.invoke([HumanMessage(content="hello")]) + result = model.batch([[HumanMessage(content="hello")], + [HumanMessage(content="world")]]) + """ + + model: str + system_prompt: str + """The number of characters from the last message of the prompt to be echoed.""" + + def _generate( + self, + messages: List[BaseMessage], + stop: Optional[List[str]] = None, + run_manager: Optional[CallbackManagerForLLMRun] = None, + **kwargs: Any, + ) -> ChatResult: + """Override the _generate method to implement the chat model logic. + + This can be a call to an API, a call to a local model, or any other + implementation that generates a response to the input prompt. + + Args: + messages: the prompt composed of a list of messages. + stop: a list of strings on which the model should stop generating. + If generation stops due to a stop token, the stop token itself + SHOULD BE INCLUDED as part of the output. This is not enforced + across models right now, but it's a good practice to follow since + it makes it much easier to parse the output of the model + downstream and understand why generation stopped. + run_manager: A run manager with callbacks for the LLM. + """ + last_message = messages[-1] + + print(messages) + response = completion_bedrock( + model_id=self.model, + system_prompt=self.system_prompt, + messages=[{"content": last_message.content, "role": "user"}], + max_tokens=1000, + ) + print("output", response) + content = response["content"][0]["text"] + message = AIMessage(content=content) + generation = ChatGeneration(message=message) + return ChatResult(generations=[generation]) diff --git a/salesgpt/salesgptapi.py b/salesgpt/salesgptapi.py index cb4b605b..7ae90fed 100644 --- a/salesgpt/salesgptapi.py +++ b/salesgpt/salesgptapi.py @@ -2,9 +2,11 @@ import json import re -from langchain_community.chat_models import ChatLiteLLM +from langchain_community.chat_models import BedrockChat, ChatLiteLLM +from langchain_openai import ChatOpenAI from salesgpt.agents import SalesGPT +from salesgpt.models import BedrockCustomModel class SalesGPTAPI: @@ -20,7 +22,15 @@ def __init__( self.config_path = config_path self.verbose = verbose self.max_num_turns = max_num_turns - self.llm = ChatLiteLLM(temperature=0.2, model_name=model_name) + self.model_name = model_name + if "anthropic" in model_name: + self.llm = BedrockCustomModel( + type="bedrock-model", + model=model_name, + system_prompt="You are a helpful assistant.", + ) + else: + self.llm = ChatLiteLLM(temperature=0.2, model=model_name) self.product_catalog = product_catalog self.conversation_history = [] self.use_tools = use_tools @@ -131,6 +141,7 @@ def do(self, human_input=None): "tool_input": tool_input, "action_output": action_output, "action_input": action_input, + "model_name": self.model_name, } return payload diff --git a/salesgpt/tools.py b/salesgpt/tools.py index de63c3f6..8deab2bb 100644 --- a/salesgpt/tools.py +++ b/salesgpt/tools.py @@ -1,10 +1,12 @@ import json import os +import boto3 import requests from langchain.agents import Tool from langchain.chains import RetrievalQA from langchain.text_splitter import CharacterTextSplitter +from langchain_community.chat_models import BedrockChat from langchain_community.vectorstores import Chroma from langchain_openai import ChatOpenAI, OpenAIEmbeddings from litellm import completion @@ -23,7 +25,8 @@ def setup_knowledge_base( text_splitter = CharacterTextSplitter(chunk_size=10, chunk_overlap=0) texts = text_splitter.split_text(product_catalog) - llm = ChatOpenAI(model_name=model_name, temperature=0) + llm = ChatOpenAI(model_name="gpt-4-turbo-preview", temperature=0) + embeddings = OpenAIEmbeddings() docsearch = Chroma.from_texts( texts, embeddings, collection_name="product-knowledge-base" @@ -35,18 +38,43 @@ def setup_knowledge_base( return knowledge_base +def completion_bedrock(model_id, system_prompt, messages, max_tokens=1000): + """ + High-level API call to generate a message with Anthropic Claude. + """ + bedrock_runtime = boto3.client( + service_name="bedrock-runtime", region_name=os.environ.get("AWS_REGION_NAME") + ) + + body = json.dumps( + { + "anthropic_version": "bedrock-2023-05-31", + "max_tokens": max_tokens, + "system": system_prompt, + "messages": messages, + } + ) + + response = bedrock_runtime.invoke_model(body=body, modelId=model_id) + response_body = json.loads(response.get("body").read()) + + return response_body + + def get_product_id_from_query(query, product_price_id_mapping_path): # Load product_price_id_mapping from a JSON file - with open(product_price_id_mapping_path, 'r') as f: + with open(product_price_id_mapping_path, "r") as f: product_price_id_mapping = json.load(f) - + # Serialize the product_price_id_mapping to a JSON string for inclusion in the prompt product_price_id_mapping_json_str = json.dumps(product_price_id_mapping) - + # Dynamically create the enum list from product_price_id_mapping keys - enum_list = list(product_price_id_mapping.values()) + ["No relevant product id found"] + enum_list = list(product_price_id_mapping.values()) + [ + "No relevant product id found" + ] enum_list_str = json.dumps(enum_list) - + prompt = f""" You are an expert data scientist and you are working on a project to recommend products to customers based on their needs. Given the following query: @@ -70,15 +98,27 @@ def get_product_id_from_query(query, product_price_id_mapping_path): }} Return a valid directly parsable json, dont return in it within a code snippet or add any kind of explanation!! """ - prompt+='{' - response = completion( - model=os.getenv("GPT_MODEL", "gpt-3.5-turbo-1106"), - messages=[{"content": prompt, "role": "user"}], - max_tokens=1000, - temperature=0 - ) - - product_id = response.choices[0].message.content.strip() + prompt += "{" + model_name = os.getenv("GPT_MODEL", "gpt-3.5-turbo-1106") + + if "anthropic" in model_name: + response = completion_bedrock( + model_id=model_name, + system_prompt="You are a helpful assistant.", + messages=[{"content": prompt, "role": "user"}], + max_tokens=1000, + ) + + product_id = response["content"][0]["text"] + + else: + response = completion( + model=model_name, + messages=[{"content": prompt, "role": "user"}], + max_tokens=1000, + temperature=0, + ) + product_id = response.choices[0].message.content.strip() return product_id @@ -86,21 +126,26 @@ def generate_stripe_payment_link(query: str) -> str: """Generate a stripe payment link for a customer based on a single query string.""" # example testing payment gateway url - PAYMENT_GATEWAY_URL = os.getenv("PAYMENT_GATEWAY_URL", "https://agent-payments-gateway.vercel.app/payment") - PRODUCT_PRICE_MAPPING = os.getenv("PRODUCT_PRICE_MAPPING","example_product_price_id_mapping.json") - + PAYMENT_GATEWAY_URL = os.getenv( + "PAYMENT_GATEWAY_URL", "https://agent-payments-gateway.vercel.app/payment" + ) + PRODUCT_PRICE_MAPPING = os.getenv( + "PRODUCT_PRICE_MAPPING", "example_product_price_id_mapping.json" + ) + # use LLM to get the price_id from query price_id = get_product_id_from_query(query, PRODUCT_PRICE_MAPPING) price_id = json.loads(price_id) - payload = json.dumps({"prompt": query, - **price_id, - 'stripe_key': os.getenv("STRIPE_API_KEY") - }) + payload = json.dumps( + {"prompt": query, **price_id, "stripe_key": os.getenv("STRIPE_API_KEY")} + ) headers = { - 'Content-Type': 'application/json', + "Content-Type": "application/json", } - - response = requests.request("POST", PAYMENT_GATEWAY_URL, headers=headers, data=payload) + + response = requests.request( + "POST", PAYMENT_GATEWAY_URL, headers=headers, data=payload + ) return response.text diff --git a/tests/test_api.py b/tests/test_api.py index 0f1cb452..b9819791 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -43,6 +43,16 @@ def test_do_method_with_human_input(self, mock_salesgpt_step): payload["response"] == "Hello " ), "The payload response should match the mock response. {}".format(payload) + def test_do_method_with_human_input_anthropic(self, mock_salesgpt_step): + api = SalesGPTAPI(config_path="", use_tools=False, model_name="anthropic.claude-3-sonnet-20240229-v1:0") + payload = api.do(human_input="Hello") + assert ( + "User: Hello " in api.sales_agent.conversation_history + ), "Human input should be added to the conversation history." + assert ( + payload["response"] == "Hello " + ), "The payload response should match the mock response. {}".format(payload) + def test_do_method_without_human_input(self, mock_salesgpt_step): api = SalesGPTAPI(config_path="", use_tools=False) payload = api.do() diff --git a/tests/test_salesgpt.py b/tests/test_salesgpt.py index 0903d090..68f2907d 100644 --- a/tests/test_salesgpt.py +++ b/tests/test_salesgpt.py @@ -5,6 +5,7 @@ import pytest from dotenv import load_dotenv from langchain_community.chat_models import ChatLiteLLM +from salesgpt.models import BedrockCustomModel from salesgpt.agents import SalesGPT @@ -129,10 +130,48 @@ def test_valid_inference_with_tools(self, load_env): assert isinstance(agent_output, str), "Agent output needs to be of type str" assert len(agent_output) > 0, "Length of output needs to be greater than 0." + def test_valid_inference_with_tools_anthropic(self, load_env): + """Test that the agent will start and generate the first utterance.""" + + + llm = BedrockCustomModel(type='bedrock-model', model="anthropic.claude-3-sonnet-20240229-v1:0", system_prompt="You are a helpful assistant.") + + data_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "test_data") + + sales_agent = SalesGPT.from_llm( + llm, + verbose=False, + use_tools="True", + product_catalog=f"{data_dir}/sample_product_catalog.txt", + salesperson_name="Ted Lasso", + salesperson_role="Sales Representative", + company_name="Sleep Haven", + company_business="""Sleep Haven + is a premium mattress company that provides + customers with the most comfortable and + supportive sleeping experience possible. + We offer a range of high-quality mattresses, + pillows, and bedding accessories + that are designed to meet the unique + needs of our customers.""", + ) + + sales_agent.seed_agent() + sales_agent.determine_conversation_stage() # optional for demonstration, built into the prompt + + # agent output sample + sales_agent.step() + + agent_output = sales_agent.conversation_history[-1] + assert agent_output is not None, "Agent output cannot be None." + assert isinstance(agent_output, str), "Agent output needs to be of type str" + assert len(agent_output) > 0, "Length of output needs to be greater than 0." + + def test_valid_inference_stream(self, load_env): """Test that the agent will start and generate the first utterance when streaming.""" - llm = ChatLiteLLM(temperature=0.9, model_name="gpt-3.5-turbo") + llm = ChatLiteLLM(temperature=0.9) sales_agent = SalesGPT.from_llm( llm,