diff --git a/conftest.py b/conftest.py index 18e63cfa..c6f16d5a 100644 --- a/conftest.py +++ b/conftest.py @@ -138,3 +138,15 @@ def clear_db(redis): redis.flushall() yield redis.flushall() + +@pytest.fixture +def app_name(): + return "test_app" + +@pytest.fixture +def session_tag(): + return "123" + +@pytest.fixture +def user_tag(): + return "abc" diff --git a/docs/user_guide/index.md b/docs/user_guide/index.md index 8cab09fc..6f0d0b5f 100644 --- a/docs/user_guide/index.md +++ b/docs/user_guide/index.md @@ -17,5 +17,6 @@ llmcache_03 vectorizers_04 hash_vs_json_05 rerankers_06 +session_manager_07 ``` diff --git a/docs/user_guide/session_manager_07.ipynb b/docs/user_guide/session_manager_07.ipynb new file mode 100644 index 00000000..d47624e1 --- /dev/null +++ b/docs/user_guide/session_manager_07.ipynb @@ -0,0 +1,615 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# LLM Session Memory" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Large Language Models are inherently stateless and have no knowledge of previous interactions with a user, or even of previous parts of the current conversation. While this may not be noticable when asking simple questions, it becomes a hinderance when engaging in long running conversations that rely on conversational context.\n", + "\n", + "The solution to this problem is to append the previous conversation history to each subsequent call to the LLM.\n", + "\n", + "This notebook will show how to use Redis to structure and store and retrieve this conversational session memory." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [], + "source": [ + "from typing import Dict, List\n", + "import cohere\n", + "import os\n", + "\n", + "class CohereClient():\n", + " def __init__(self, api_key: str = None, model: str = 'command-r-plus'):\n", + " api_key = api_key or os.getenv(\"COHERE_API_KEY\")\n", + " self.client = cohere.Client(api_key)\n", + " self._model = model\n", + "\n", + " def converse(self, prompt: str, context: List[Dict]) -> str:\n", + " context = self.remap(context)\n", + " response = self.client.chat(\n", + " model=self._model,\n", + " chat_history = context,\n", + " message=prompt,\n", + " )\n", + " return response.text\n", + "\n", + " def remap(self, context) -> List[Dict]:\n", + " ''' re-index the chat history to match the Cohere API requirements '''\n", + " new_context = []\n", + " for statement in context:\n", + " if statement[\"role\"] == \"user\":\n", + " new_statement = {\"role\": \"USER\", \"message\": statement[\"content\"]}\n", + " elif statement[\"role\"] == \"llm\":\n", + " new_statement = {\"role\": \"CHATBOT\", \"message\": statement[\"content\"]}\n", + " elif statement[\"role\"] == \"system\":\n", + " new_statement = {\"role\": \"SYSTEM\", \"message\": statement[\"content\"]}\n", + " else:\n", + " raise ValueError(f'Unknown chat role {statement[\"role\"]}')\n", + " new_context.append(new_statement)\n", + " return new_context" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "15:47:49 redisvl.index.index INFO Index already exists, not overwriting.\n" + ] + } + ], + "source": [ + "from redisvl.extensions.session_manager import SemanticSessionManager\n", + "user_session = SemanticSessionManager(name='llm_chef', session_tag='123', user_tag='abc')\n", + "user_session.add_message({\"role\":\"system\", \"content\":\"You are a helpful chef, assisting people in making delicious meals\"})\n", + "\n", + "client = CohereClient()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "On each call to the LLM we can first retrieve the recent history of the conversation. On the first call it will just be the preamble we set." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "USER: can you give me some ideas for breakfast?\n", + "\n", + "LLM: Certainly! Here are some delicious breakfast ideas to start your day off right:\n", + "\n", + "**1. Avocado Toast:**\n", + " - Spread mashed avocado on a slice of toasted bread. Top it with a sprinkle of salt and pepper, a squeeze of lemon or lime juice, and a drizzle of extra virgin olive oil. You can also add sliced tomatoes, chopped onions, or a fried egg on top.\n", + "\n", + "**2. Smoothie Bowl:**\n", + " - Blend your favorite fruits (such as bananas, strawberries, blueberries, and mango) with a liquid base (like milk, yogurt, or juice) to create a thick and creamy smoothie. Pour it into a bowl and top it with granola, nuts, seeds, shredded coconut, or fresh fruit slices.\n", + "\n", + "**3. Pancakes or Waffles:**\n", + " - Whip up a batch of fluffy pancakes or waffles and serve them with a variety of toppings. Some options include fresh fruit, maple syrup, butter, whipped cream, chocolate chips, or a dusting of powdered sugar.\n", + "\n", + "**4. Breakfast Burrito:**\n", + " - Fill a flour tortilla with scrambled eggs, sautéed vegetables (such as bell peppers, onions, and mushrooms), shredded cheese, and your choice of breakfast meat (bacon, sausage, or ham). Roll it up and enjoy it as a handheld breakfast.\n", + "\n", + "**5. Overnight Oats:**\n", + " - Combine rolled oats, milk, yogurt, and your choice of mix-ins (such as chia seeds, flaxseeds, nuts, dried fruit, or fresh fruit) in a jar or container. Stir well, cover, and refrigerate overnight. In the morning, you'll have a delicious, creamy, and nutritious breakfast ready to go.\n", + "\n", + "**6. Eggs Benedict:**\n", + " - Poach an egg and place it on top of a toasted English muffin halved. Add a slice of Canadian bacon or ham and top it with a generous drizzle of hollandaise sauce. This classic breakfast dish is indulgent and satisfying.\n", + "\n", + "**7. Yogurt Parfait:**\n", + " - Layer yogurt, granola, and fresh fruit in a glass or bowl to create a beautiful and tasty parfait. Drizzle with honey and top with a sprinkle of chopped nuts or seeds for some crunch.\n", + "\n", + "**8. Breakfast Sandwich:**\n", + " - Toast two slices of bread and fill them with scrambled or fried eggs, cheese, and your choice of breakfast meat. You can also add vegetables like spinach, tomatoes, or avocado slices.\n", + "\n", + "**9. Fruit and Nut Butter:**\n", + " - Spread your favorite nut butter (such as peanut butter, almond butter, or cashew butter) on a slice of toast or a bagel. Top it with sliced bananas, apples, or berries for a nutritious and filling breakfast.\n", + "\n", + "**10. Quiche:**\n", + " - Bake a quiche filled with eggs, milk or cream, cheese, and your choice of vegetables and/or meat. It's a delicious and elegant breakfast option that can be served warm or at room temperature.\n", + "\n", + "These breakfast ideas offer a variety of flavors, textures, and nutritional profiles to suit different tastes and preferences. Feel free to experiment, get creative, and customize these dishes to your liking!\n" + ] + } + ], + "source": [ + "prompt = \"can you give me some ideas for breakfast?\"\n", + "context = user_session.get_recent()\n", + "response = client.converse(prompt=prompt, context=context)\n", + "user_session.store(prompt, response)\n", + "print('USER: ', prompt)\n", + "print('\\nLLM: ', response)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Let's continue this conversation and inspect the context we're passing to the LLM as we go." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "USER: can you give me the recipe for those pancakes?\n", + "\n", + "LLM: Of course! Here is a classic recipe for fluffy and delicious pancakes:\n", + "\n", + "**Ingredients:**\n", + "\n", + "- 2 cups (250 grams) all-purpose flour\n", + "- 2 teaspoons baking powder\n", + "- 1 teaspoon salt\n", + "- 2 tablespoons sugar\n", + "- 2 cups milk\n", + "- 2 eggs\n", + "- 3 tablespoons melted butter, plus more for the pan\n", + "- 1 teaspoon vanilla extract (optional)\n", + "\n", + "**Instructions:**\n", + "\n", + "1. Whisk together the dry ingredients: In a large mixing bowl, combine the flour, baking powder, salt, and sugar. Whisk until well blended.\n", + "\n", + "2. Combine the wet ingredients: In a separate mixing bowl, whisk together the milk, eggs, melted butter, and vanilla extract (if using). Make sure the melted butter has cooled down a bit so it doesn't cook the eggs.\n", + "\n", + "3. Mix dry and wet ingredients: Slowly pour the wet ingredients into the dry ingredients while continuously whisking until you have a smooth batter. Be careful not to overmix; a few small lumps are okay.\n", + "\n", + "4. Heat your griddle or pan: Place a nonstick pan or griddle over medium heat and add a small amount of butter to grease the surface. Let the butter melt and coat the cooking surface.\n", + "\n", + "5. Pour the batter: Once the pan is hot, pour about 1/4 cup of batter for each pancake onto the griddle or pan. Cook until the edges look dry and small bubbles appear on the surface, about 2-3 minutes.\n", + "\n", + "6. Flip the pancakes: Use a spatula to carefully flip the pancakes. Cook the other side for another 1-2 minutes until lightly browned.\n", + "\n", + "7. Repeat: Repeat this process until you've cooked all your batter, adding more butter to the pan as needed.\n", + "\n", + "8. Serve: Stack the pancakes on a plate and serve them with your favorite toppings. Some popular options include butter, maple syrup, fresh fruit, whipped cream, or a dusting of powdered sugar.\n", + "\n", + "**Variations and Tips:**\n", + "- For extra fluffy pancakes, you can separate the eggs and gently fold in the whipped egg whites after combining the other ingredients.\n", + "- Feel free to add mix-ins to your batter, such as chocolate chips, blueberries, or chopped nuts.\n", + "- If you like thicker pancakes, you can add a little less milk to the batter. For thinner pancakes, add a bit more.\n", + "- To keep pancakes warm while you cook a large batch, preheat your oven to its lowest setting and place the cooked pancakes on a baking sheet or oven-safe plate.\n", + "\n", + "Enjoy your homemade pancakes! They are a classic breakfast treat and can be tailored to your taste preferences.\n" + ] + } + ], + "source": [ + "prompt = \"can you give me the recipe for those pancakes?\"\n", + "context = user_session.get_recent()\n", + "response = client.converse(prompt=prompt, context=context)\n", + "user_session.store(prompt, response)\n", + "print('USER: ', prompt)\n", + "print('\\nLLM: ', response)" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "{'role': 'user', 'content': 'I changed my mind. Can you give me the first recipe from your list?'}\n", + "{'role': 'llm', 'content': 'Sure! Here is the recipe for \"Easy One-Pot Pasta\":\\n\\n**Ingredients:**\\n- 2 cups (500 mL) of short pasta (such as penne, macaroni, or fusilli)\\n- 1 tablespoon (15 mL) of olive oil\\n- 1 onion, diced\\n- 2 cloves of garlic, minced\\n- 1 red or green bell pepper, diced\\n- 1 can (28 ounces/796 mL) of diced tomatoes\\n- 1 teaspoon (5 mL) of dried basil\\n- 1 teaspoon (5 mL) of dried oregano\\n- Salt and pepper to taste\\n- 4 cups (1 L) of vegetable broth\\n- 1 cup (250 mL) of grated mozzarella cheese (optional)\\n\\n**Instructions:**\\n\\n1. Heat the olive oil in a large pot over medium heat. Add the onion and garlic, and cook until softened, about 3-4 minutes.\\n\\n2. Add the bell pepper and cook for another 2-3 minutes.\\n\\n3. Stir in the diced tomatoes, basil, oregano, salt, and pepper. Let the mixture simmer for 5 minutes.\\n\\n4. Pour in the vegetable broth and bring the mixture to a boil.\\n\\n5. Add the pasta to the pot and stir well. Ensure that the pasta is fully submerged in the liquid.\\n\\n6. Reduce the heat to medium-low and let the pasta simmer uncovered for about 10-12 minutes, or until the pasta is al dente and most of the liquid has been absorbed. Stir occasionally to prevent the pasta from sticking to the bottom of the pot.\\n\\n7. If desired, sprinkle the grated mozzarella cheese over the pasta and cover the pot until the cheese has melted.\\n\\n8. Serve the pasta hot and enjoy!\\n\\nThis recipe is simple, quick, and perfect for a busy weeknight meal. You can also easily customize it by adding your favorite vegetables or spices. Enjoy the delicious, hearty comfort of this one-pot pasta!'}\n", + "{'role': 'system', 'content': 'You are a helpful chef, assisting people in making delicious meals'}\n", + "{'role': 'user', 'content': 'can you give me some ideas for breakfast?'}\n", + "{'role': 'llm', 'content': \"Certainly! Here are some delicious breakfast ideas to start your day off right:\\n\\n**1. Avocado Toast:**\\n - Spread mashed avocado on a slice of toasted bread. Top it with a sprinkle of salt and pepper, a squeeze of lemon or lime juice, and a drizzle of extra virgin olive oil. You can also add sliced tomatoes, chopped onions, or a fried egg on top.\\n\\n**2. Smoothie Bowl:**\\n - Blend your favorite fruits (such as bananas, strawberries, blueberries, and mango) with a liquid base (like milk, yogurt, or juice) to create a thick and creamy smoothie. Pour it into a bowl and top it with granola, nuts, seeds, shredded coconut, or fresh fruit slices.\\n\\n**3. Pancakes or Waffles:**\\n - Whip up a batch of fluffy pancakes or waffles and serve them with a variety of toppings. Some options include fresh fruit, maple syrup, butter, whipped cream, chocolate chips, or a dusting of powdered sugar.\\n\\n**4. Breakfast Burrito:**\\n - Fill a flour tortilla with scrambled eggs, sautéed vegetables (such as bell peppers, onions, and mushrooms), shredded cheese, and your choice of breakfast meat (bacon, sausage, or ham). Roll it up and enjoy it as a handheld breakfast.\\n\\n**5. Overnight Oats:**\\n - Combine rolled oats, milk, yogurt, and your choice of mix-ins (such as chia seeds, flaxseeds, nuts, dried fruit, or fresh fruit) in a jar or container. Stir well, cover, and refrigerate overnight. In the morning, you'll have a delicious, creamy, and nutritious breakfast ready to go.\\n\\n**6. Eggs Benedict:**\\n - Poach an egg and place it on top of a toasted English muffin halved. Add a slice of Canadian bacon or ham and top it with a generous drizzle of hollandaise sauce. This classic breakfast dish is indulgent and satisfying.\\n\\n**7. Yogurt Parfait:**\\n - Layer yogurt, granola, and fresh fruit in a glass or bowl to create a beautiful and tasty parfait. Drizzle with honey and top with a sprinkle of chopped nuts or seeds for some crunch.\\n\\n**8. Breakfast Sandwich:**\\n - Toast two slices of bread and fill them with scrambled or fried eggs, cheese, and your choice of breakfast meat. You can also add vegetables like spinach, tomatoes, or avocado slices.\\n\\n**9. Fruit and Nut Butter:**\\n - Spread your favorite nut butter (such as peanut butter, almond butter, or cashew butter) on a slice of toast or a bagel. Top it with sliced bananas, apples, or berries for a nutritious and filling breakfast.\\n\\n**10. Quiche:**\\n - Bake a quiche filled with eggs, milk or cream, cheese, and your choice of vegetables and/or meat. It's a delicious and elegant breakfast option that can be served warm or at room temperature.\\n\\nThese breakfast ideas offer a variety of flavors, textures, and nutritional profiles to suit different tastes and preferences. Feel free to experiment, get creative, and customize these dishes to your liking!\"}\n" + ] + } + ], + "source": [ + "for ctx in context:\n", + " print(ctx)" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "USER: I am vegetarian. Can you remove the eggs?\n", + "\n", + "LLM: Absolutely! You can make delicious vegetarian pancakes by omitting the eggs. Here's the modified recipe:\n", + "\n", + "**Ingredients:**\n", + "\n", + "- 2 cups (250 grams) all-purpose flour\n", + "- 2 teaspoons baking powder\n", + "- 1 teaspoon salt\n", + "- 2 tablespoons sugar\n", + "- 2 cups milk (dairy or plant-based alternative)\n", + "- 3 tablespoons melted butter, plus more for the pan\n", + "- 1 teaspoon vanilla extract (optional)\n", + "\n", + "**Instructions:**\n", + "\n", + "1. Whisk together the dry ingredients: In a large mixing bowl, combine the flour, baking powder, salt, and sugar. Whisk until well blended.\n", + "\n", + "2. Combine the wet ingredients: In a separate mixing bowl, whisk together the milk, melted butter, and vanilla extract (if using). Make sure the melted butter has cooled down a bit so it doesn't curdle the milk.\n", + "\n", + "3. Mix dry and wet ingredients: Slowly pour the wet ingredients into the dry ingredients while continuously whisking until you have a smooth batter. Again, don't overmix; a few small lumps are fine.\n", + "\n", + "4. Heat your griddle or pan: Place a nonstick pan or griddle over medium heat and add a small amount of butter to grease the surface.\n", + "\n", + "5. Pour the batter: Once the pan is hot, pour about 1/4 cup of batter for each pancake onto the griddle or pan. Cook until you see small bubbles appear on the surface and the edges look dry, which should take around 2-3 minutes.\n", + "\n", + "6. Flip the pancakes: Use a spatula to gently flip the pancakes. Cook the other side for an additional 1-2 minutes until lightly browned.\n", + "\n", + "7. Repeat: Continue this process until you've cooked all your batter, adding more butter to the pan as needed.\n", + "\n", + "8. Serve: Stack the pancakes on a plate and serve with your favorite toppings. Maple syrup, butter, fresh fruit, whipped cream, or a sprinkle of powdered sugar are all great choices.\n", + "\n", + "**Tips for Eggless Pancakes:**\n", + "- The eggs in the original recipe act as a binding agent and provide structure to the pancakes. By omitting them, your pancakes may be a bit more delicate, but they will still taste delicious.\n", + "- You can add a little extra milk (1-2 tablespoons) if you feel the batter is too thick. This will help compensate for the absence of eggs.\n", + "- For extra leavening, you can increase the amount of baking powder by 1/2 teaspoon to ensure your pancakes still rise nicely.\n", + "\n", + "Enjoy your vegetarian pancakes! They are a tasty and versatile breakfast option that can be customized with various toppings and mix-ins.\n" + ] + } + ], + "source": [ + "prompt = \"I am vegetarian. Can you remove the eggs?\"\n", + "context = user_session.get_recent()\n", + "response = client.converse(prompt=prompt, context=context)\n", + "user_session.store(prompt, response)\n", + "print('USER: ', prompt)\n", + "print('\\nLLM: ', response)" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "{'role': 'system', 'content': 'You are a helpful chef, assisting people in making delicious meals'}\n", + "{'role': 'user', 'content': 'can you give me some ideas for breakfast?'}\n", + "{'role': 'llm', 'content': \"Certainly! Here are some delicious breakfast ideas to start your day off right:\\n\\n**1. Avocado Toast:**\\n - Spread mashed avocado on a slice of toasted bread. Top it with a sprinkle of salt and pepper, a squeeze of lemon or lime juice, and a drizzle of extra virgin olive oil. You can also add sliced tomatoes, chopped onions, or a fried egg on top.\\n\\n**2. Smoothie Bowl:**\\n - Blend your favorite fruits (such as bananas, strawberries, blueberries, and mango) with a liquid base (like milk, yogurt, or juice) to create a thick and creamy smoothie. Pour it into a bowl and top it with granola, nuts, seeds, shredded coconut, or fresh fruit slices.\\n\\n**3. Pancakes or Waffles:**\\n - Whip up a batch of fluffy pancakes or waffles and serve them with a variety of toppings. Some options include fresh fruit, maple syrup, butter, whipped cream, chocolate chips, or a dusting of powdered sugar.\\n\\n**4. Breakfast Burrito:**\\n - Fill a flour tortilla with scrambled eggs, sautéed vegetables (such as bell peppers, onions, and mushrooms), shredded cheese, and your choice of breakfast meat (bacon, sausage, or ham). Roll it up and enjoy it as a handheld breakfast.\\n\\n**5. Overnight Oats:**\\n - Combine rolled oats, milk, yogurt, and your choice of mix-ins (such as chia seeds, flaxseeds, nuts, dried fruit, or fresh fruit) in a jar or container. Stir well, cover, and refrigerate overnight. In the morning, you'll have a delicious, creamy, and nutritious breakfast ready to go.\\n\\n**6. Eggs Benedict:**\\n - Poach an egg and place it on top of a toasted English muffin halved. Add a slice of Canadian bacon or ham and top it with a generous drizzle of hollandaise sauce. This classic breakfast dish is indulgent and satisfying.\\n\\n**7. Yogurt Parfait:**\\n - Layer yogurt, granola, and fresh fruit in a glass or bowl to create a beautiful and tasty parfait. Drizzle with honey and top with a sprinkle of chopped nuts or seeds for some crunch.\\n\\n**8. Breakfast Sandwich:**\\n - Toast two slices of bread and fill them with scrambled or fried eggs, cheese, and your choice of breakfast meat. You can also add vegetables like spinach, tomatoes, or avocado slices.\\n\\n**9. Fruit and Nut Butter:**\\n - Spread your favorite nut butter (such as peanut butter, almond butter, or cashew butter) on a slice of toast or a bagel. Top it with sliced bananas, apples, or berries for a nutritious and filling breakfast.\\n\\n**10. Quiche:**\\n - Bake a quiche filled with eggs, milk or cream, cheese, and your choice of vegetables and/or meat. It's a delicious and elegant breakfast option that can be served warm or at room temperature.\\n\\nThese breakfast ideas offer a variety of flavors, textures, and nutritional profiles to suit different tastes and preferences. Feel free to experiment, get creative, and customize these dishes to your liking!\"}\n", + "{'role': 'user', 'content': 'can you give me the recipe for those pancakes?'}\n", + "{'role': 'llm', 'content': \"Of course! Here is a classic recipe for fluffy and delicious pancakes:\\n\\n**Ingredients:**\\n\\n- 2 cups (250 grams) all-purpose flour\\n- 2 teaspoons baking powder\\n- 1 teaspoon salt\\n- 2 tablespoons sugar\\n- 2 cups milk\\n- 2 eggs\\n- 3 tablespoons melted butter, plus more for the pan\\n- 1 teaspoon vanilla extract (optional)\\n\\n**Instructions:**\\n\\n1. Whisk together the dry ingredients: In a large mixing bowl, combine the flour, baking powder, salt, and sugar. Whisk until well blended.\\n\\n2. Combine the wet ingredients: In a separate mixing bowl, whisk together the milk, eggs, melted butter, and vanilla extract (if using). Make sure the melted butter has cooled down a bit so it doesn't cook the eggs.\\n\\n3. Mix dry and wet ingredients: Slowly pour the wet ingredients into the dry ingredients while continuously whisking until you have a smooth batter. Be careful not to overmix; a few small lumps are okay.\\n\\n4. Heat your griddle or pan: Place a nonstick pan or griddle over medium heat and add a small amount of butter to grease the surface. Let the butter melt and coat the cooking surface.\\n\\n5. Pour the batter: Once the pan is hot, pour about 1/4 cup of batter for each pancake onto the griddle or pan. Cook until the edges look dry and small bubbles appear on the surface, about 2-3 minutes.\\n\\n6. Flip the pancakes: Use a spatula to carefully flip the pancakes. Cook the other side for another 1-2 minutes until lightly browned.\\n\\n7. Repeat: Repeat this process until you've cooked all your batter, adding more butter to the pan as needed.\\n\\n8. Serve: Stack the pancakes on a plate and serve them with your favorite toppings. Some popular options include butter, maple syrup, fresh fruit, whipped cream, or a dusting of powdered sugar.\\n\\n**Variations and Tips:**\\n- For extra fluffy pancakes, you can separate the eggs and gently fold in the whipped egg whites after combining the other ingredients.\\n- Feel free to add mix-ins to your batter, such as chocolate chips, blueberries, or chopped nuts.\\n- If you like thicker pancakes, you can add a little less milk to the batter. For thinner pancakes, add a bit more.\\n- To keep pancakes warm while you cook a large batch, preheat your oven to its lowest setting and place the cooked pancakes on a baking sheet or oven-safe plate.\\n\\nEnjoy your homemade pancakes! They are a classic breakfast treat and can be tailored to your taste preferences.\"}\n" + ] + } + ], + "source": [ + "for ctx in context:\n", + " print(ctx)" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "USER: I am also vegan. Can you replace the butter too?\n", + "\n", + "LLM: Certainly! For a vegan version of fluffy pancakes, you can replace both the eggs and butter with plant-based alternatives. Here's the recipe tailored for vegan dietary preferences:\n", + "\n", + "**Ingredients:**\n", + "\n", + "- 2 cups (250 grams) all-purpose flour\n", + "- 2 teaspoons baking powder\n", + "- 1 teaspoon salt\n", + "- 2 tablespoons sugar\n", + "- 2 cups plant-based milk (such as soy, almond, or oat milk)\n", + "- 2 tablespoons neutral-flavored oil (such as canola or vegetable oil), plus more for the pan\n", + "- 1 teaspoon vanilla extract (optional)\n", + "\n", + "**Instructions:**\n", + "\n", + "1. Whisk together the dry ingredients: In a large mixing bowl, combine the flour, baking powder, salt, and sugar. Mix well to ensure there are no lumps.\n", + "\n", + "2. Combine the wet ingredients: In a separate mixing bowl, whisk together the plant-based milk, oil, and vanilla extract (if using).\n", + "\n", + "3. Mix dry and wet ingredients: Slowly pour the wet ingredients into the dry ingredients, whisking continuously until you have a smooth batter. It's okay if there are a few small lumps.\n", + "\n", + "4. Heat your griddle or pan: Place a nonstick pan or griddle over medium heat. Add a small amount of oil to grease the surface, ensuring it coats the cooking area evenly.\n", + "\n", + "5. Pour the batter: Once the pan is hot, pour about 1/4 cup of batter for each pancake onto the griddle or pan. Cook until you notice small bubbles appearing on the surface and the edges look dry, which should take about 2-3 minutes.\n", + "\n", + "6. Flip the pancakes: Use a spatula to carefully flip the pancakes. Cook the other side for an additional 1-2 minutes until lightly browned.\n", + "\n", + "7. Repeat: Repeat this process until you've cooked all your batter, adding more oil to the pan as needed to prevent sticking.\n", + "\n", + "8. Serve: Stack the pancakes on a plate and serve them with your favorite vegan toppings. Some options include maple syrup, vegan butter, fresh fruit, or a dustable amount of powdered sugar.\n", + "\n", + "**Tips for Vegan Pancakes:**\n", + "- The oil replaces the butter in this recipe, providing moisture and helping to create a crispy edge on the pancakes.\n", + "- Feel free to experiment with different plant-based milk alternatives to find your preferred taste and texture. Soy milk tends to be creamier, while almond or oat milk can lend a subtle nutty flavor.\n", + "- If you like your pancakes with a hint of sweetness, you can add a bit more sugar to the batter or top them with sweet vegan toppings like agave nectar or fruit compote.\n", + "\n", + "Enjoy your vegan pancakes! They are a delicious and inclusive breakfast option that can be tailored to suit your dietary needs and preferences.\n" + ] + } + ], + "source": [ + "prompt = \"I am also vegan. Can you replace the butter too?\"\n", + "context = user_session.get_recent()\n", + "response = client.converse(prompt=prompt, context=context)\n", + "user_session.store(prompt, response)\n", + "print('USER: ', prompt)\n", + "print('\\nLLM: ', response)" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "{'role': 'llm', 'content': \"Certainly! Here are some delicious breakfast ideas to start your day off right:\\n\\n**1. Avocado Toast:**\\n - Spread mashed avocado on a slice of toasted bread. Top it with a sprinkle of salt and pepper, a squeeze of lemon or lime juice, and a drizzle of extra virgin olive oil. You can also add sliced tomatoes, chopped onions, or a fried egg on top.\\n\\n**2. Smoothie Bowl:**\\n - Blend your favorite fruits (such as bananas, strawberries, blueberries, and mango) with a liquid base (like milk, yogurt, or juice) to create a thick and creamy smoothie. Pour it into a bowl and top it with granola, nuts, seeds, shredded coconut, or fresh fruit slices.\\n\\n**3. Pancakes or Waffles:**\\n - Whip up a batch of fluffy pancakes or waffles and serve them with a variety of toppings. Some options include fresh fruit, maple syrup, butter, whipped cream, chocolate chips, or a dusting of powdered sugar.\\n\\n**4. Breakfast Burrito:**\\n - Fill a flour tortilla with scrambled eggs, sautéed vegetables (such as bell peppers, onions, and mushrooms), shredded cheese, and your choice of breakfast meat (bacon, sausage, or ham). Roll it up and enjoy it as a handheld breakfast.\\n\\n**5. Overnight Oats:**\\n - Combine rolled oats, milk, yogurt, and your choice of mix-ins (such as chia seeds, flaxseeds, nuts, dried fruit, or fresh fruit) in a jar or container. Stir well, cover, and refrigerate overnight. In the morning, you'll have a delicious, creamy, and nutritious breakfast ready to go.\\n\\n**6. Eggs Benedict:**\\n - Poach an egg and place it on top of a toasted English muffin halved. Add a slice of Canadian bacon or ham and top it with a generous drizzle of hollandaise sauce. This classic breakfast dish is indulgent and satisfying.\\n\\n**7. Yogurt Parfait:**\\n - Layer yogurt, granola, and fresh fruit in a glass or bowl to create a beautiful and tasty parfait. Drizzle with honey and top with a sprinkle of chopped nuts or seeds for some crunch.\\n\\n**8. Breakfast Sandwich:**\\n - Toast two slices of bread and fill them with scrambled or fried eggs, cheese, and your choice of breakfast meat. You can also add vegetables like spinach, tomatoes, or avocado slices.\\n\\n**9. Fruit and Nut Butter:**\\n - Spread your favorite nut butter (such as peanut butter, almond butter, or cashew butter) on a slice of toast or a bagel. Top it with sliced bananas, apples, or berries for a nutritious and filling breakfast.\\n\\n**10. Quiche:**\\n - Bake a quiche filled with eggs, milk or cream, cheese, and your choice of vegetables and/or meat. It's a delicious and elegant breakfast option that can be served warm or at room temperature.\\n\\nThese breakfast ideas offer a variety of flavors, textures, and nutritional profiles to suit different tastes and preferences. Feel free to experiment, get creative, and customize these dishes to your liking!\"}\n", + "{'role': 'user', 'content': 'can you give me the recipe for those pancakes?'}\n", + "{'role': 'llm', 'content': \"Of course! Here is a classic recipe for fluffy and delicious pancakes:\\n\\n**Ingredients:**\\n\\n- 2 cups (250 grams) all-purpose flour\\n- 2 teaspoons baking powder\\n- 1 teaspoon salt\\n- 2 tablespoons sugar\\n- 2 cups milk\\n- 2 eggs\\n- 3 tablespoons melted butter, plus more for the pan\\n- 1 teaspoon vanilla extract (optional)\\n\\n**Instructions:**\\n\\n1. Whisk together the dry ingredients: In a large mixing bowl, combine the flour, baking powder, salt, and sugar. Whisk until well blended.\\n\\n2. Combine the wet ingredients: In a separate mixing bowl, whisk together the milk, eggs, melted butter, and vanilla extract (if using). Make sure the melted butter has cooled down a bit so it doesn't cook the eggs.\\n\\n3. Mix dry and wet ingredients: Slowly pour the wet ingredients into the dry ingredients while continuously whisking until you have a smooth batter. Be careful not to overmix; a few small lumps are okay.\\n\\n4. Heat your griddle or pan: Place a nonstick pan or griddle over medium heat and add a small amount of butter to grease the surface. Let the butter melt and coat the cooking surface.\\n\\n5. Pour the batter: Once the pan is hot, pour about 1/4 cup of batter for each pancake onto the griddle or pan. Cook until the edges look dry and small bubbles appear on the surface, about 2-3 minutes.\\n\\n6. Flip the pancakes: Use a spatula to carefully flip the pancakes. Cook the other side for another 1-2 minutes until lightly browned.\\n\\n7. Repeat: Repeat this process until you've cooked all your batter, adding more butter to the pan as needed.\\n\\n8. Serve: Stack the pancakes on a plate and serve them with your favorite toppings. Some popular options include butter, maple syrup, fresh fruit, whipped cream, or a dusting of powdered sugar.\\n\\n**Variations and Tips:**\\n- For extra fluffy pancakes, you can separate the eggs and gently fold in the whipped egg whites after combining the other ingredients.\\n- Feel free to add mix-ins to your batter, such as chocolate chips, blueberries, or chopped nuts.\\n- If you like thicker pancakes, you can add a little less milk to the batter. For thinner pancakes, add a bit more.\\n- To keep pancakes warm while you cook a large batch, preheat your oven to its lowest setting and place the cooked pancakes on a baking sheet or oven-safe plate.\\n\\nEnjoy your homemade pancakes! They are a classic breakfast treat and can be tailored to your taste preferences.\"}\n", + "{'role': 'user', 'content': 'I am vegetarian. Can you remove the eggs?'}\n", + "{'role': 'llm', 'content': \"Absolutely! You can make delicious vegetarian pancakes by omitting the eggs. Here's the modified recipe:\\n\\n**Ingredients:**\\n\\n- 2 cups (250 grams) all-purpose flour\\n- 2 teaspoons baking powder\\n- 1 teaspoon salt\\n- 2 tablespoons sugar\\n- 2 cups milk (dairy or plant-based alternative)\\n- 3 tablespoons melted butter, plus more for the pan\\n- 1 teaspoon vanilla extract (optional)\\n\\n**Instructions:**\\n\\n1. Whisk together the dry ingredients: In a large mixing bowl, combine the flour, baking powder, salt, and sugar. Whisk until well blended.\\n\\n2. Combine the wet ingredients: In a separate mixing bowl, whisk together the milk, melted butter, and vanilla extract (if using). Make sure the melted butter has cooled down a bit so it doesn't curdle the milk.\\n\\n3. Mix dry and wet ingredients: Slowly pour the wet ingredients into the dry ingredients while continuously whisking until you have a smooth batter. Again, don't overmix; a few small lumps are fine.\\n\\n4. Heat your griddle or pan: Place a nonstick pan or griddle over medium heat and add a small amount of butter to grease the surface.\\n\\n5. Pour the batter: Once the pan is hot, pour about 1/4 cup of batter for each pancake onto the griddle or pan. Cook until you see small bubbles appear on the surface and the edges look dry, which should take around 2-3 minutes.\\n\\n6. Flip the pancakes: Use a spatula to gently flip the pancakes. Cook the other side for an additional 1-2 minutes until lightly browned.\\n\\n7. Repeat: Continue this process until you've cooked all your batter, adding more butter to the pan as needed.\\n\\n8. Serve: Stack the pancakes on a plate and serve with your favorite toppings. Maple syrup, butter, fresh fruit, whipped cream, or a sprinkle of powdered sugar are all great choices.\\n\\n**Tips for Eggless Pancakes:**\\n- The eggs in the original recipe act as a binding agent and provide structure to the pancakes. By omitting them, your pancakes may be a bit more delicate, but they will still taste delicious.\\n- You can add a little extra milk (1-2 tablespoons) if you feel the batter is too thick. This will help compensate for the absence of eggs.\\n- For extra leavening, you can increase the amount of baking powder by 1/2 teaspoon to ensure your pancakes still rise nicely.\\n\\nEnjoy your vegetarian pancakes! They are a tasty and versatile breakfast option that can be customized with various toppings and mix-ins.\"}\n" + ] + } + ], + "source": [ + "for ctx in context:\n", + " print(ctx)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "This works, but our context keeps growing, increasing our LLM token count, which increases latency and cost.\n", + "Conversation histories can be truncated, but that can break quickly." + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "USER: I changed my mind. Can you give me the first recipe from your list?\n", + "\n", + "LLM: Sure! Here's the recipe for classic fluffy pancakes with eggs and butter:\n", + "\n", + "**Ingredients:**\n", + "\n", + "- 2 cups (250 grams) all-purpose flour\n", + "- 2 teaspoons baking powder\n", + "- 1 teaspoon salt\n", + "- 2 tablespoons sugar\n", + "- 2 cups milk\n", + "- 2 eggs\n", + "- 3 tablespoons melted butter, plus more for the pan\n", + "- 1 teaspoon vanilla extract (optional)\n", + "\n", + "**Instructions:**\n", + "\n", + "1. Whisk together the dry ingredients: In a large mixing bowl, combine the flour, baking powder, salt, and sugar. Whisk until the ingredients are well incorporated and there are no lumps.\n", + "\n", + "2. Combine the wet ingredients: In a separate mixing bowl, whisk together the milk, eggs, melted butter, and vanilla extract (if using). Ensure the melted butter has cooled down slightly so it doesn't cook the eggs.\n", + "\n", + "3. Mix dry and wet ingredients: Slowly pour the wet ingredients into the dry ingredients while continuously whisking. Mix until you have a smooth batter, but be careful not to overmix. A few small lumps are okay.\n", + "\n", + "4. Heat your griddle or pan: Place a nonstick pan or griddle over medium heat. Add a small amount of butter to grease the surface, spreading it evenly across the cooking area.\n", + "\n", + "5. Pour the batter: Once the pan is hot, pour about 1/4 cup of batter for each pancake onto the griddle or pan. Cook until you see the edges looking dry and small bubbles appearing on the surface, which should take around 2-3 minutes.\n", + "\n", + "6. Flip the pancakes: Use a spatula to gently flip the pancakes over. Cook the other side for an additional 1-2 minutes until lightly browned.\n", + "\n", + "7. Repeat: Continue this process until you've cooked all your batter, adding more butter to the pan as needed between batches.\n", + "\n", + "8. Serve: Stack the pancakes on a plate and serve them with your choice of toppings. Classic options include butter, maple syrup, fresh fruit, or a dusting of powdered sugar.\n", + "\n", + "**Tips for Classic Fluffy Pancakes:**\n", + "- For extra fluffy pancakes, you can separate the eggs and gently fold in the whipped egg whites after combining the other ingredients. This will give them an even lighter texture.\n", + "- If you prefer thicker pancakes, you can add a little less milk to the batter. For thinner, crepe-like pancakes, add a bit more milk.\n", + "- Feel free to experiment with mix-ins like chocolate chips, blueberries, or chopped nuts. Simply sprinkle them onto the pancakes as soon as you pour the batter onto the griddle or pan.\n", + "\n", + "Enjoy your classic fluffy pancakes! They are a breakfast staple and can be tailored to your taste preferences with various toppings and add-ins.\n" + ] + } + ], + "source": [ + "prompt = \"I changed my mind. Can you give me the first recipe from your list?\"\n", + "context = user_session.get_recent(top_k=5)\n", + "response = client.converse(prompt=prompt, context=context)\n", + "user_session.store(prompt, response)\n", + "print('USER: ', prompt)\n", + "print('\\nLLM: ', response)" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "{'role': 'llm', 'content': \"Of course! Here is a classic recipe for fluffy and delicious pancakes:\\n\\n**Ingredients:**\\n\\n- 2 cups (250 grams) all-purpose flour\\n- 2 teaspoons baking powder\\n- 1 teaspoon salt\\n- 2 tablespoons sugar\\n- 2 cups milk\\n- 2 eggs\\n- 3 tablespoons melted butter, plus more for the pan\\n- 1 teaspoon vanilla extract (optional)\\n\\n**Instructions:**\\n\\n1. Whisk together the dry ingredients: In a large mixing bowl, combine the flour, baking powder, salt, and sugar. Whisk until well blended.\\n\\n2. Combine the wet ingredients: In a separate mixing bowl, whisk together the milk, eggs, melted butter, and vanilla extract (if using). Make sure the melted butter has cooled down a bit so it doesn't cook the eggs.\\n\\n3. Mix dry and wet ingredients: Slowly pour the wet ingredients into the dry ingredients while continuously whisking until you have a smooth batter. Be careful not to overmix; a few small lumps are okay.\\n\\n4. Heat your griddle or pan: Place a nonstick pan or griddle over medium heat and add a small amount of butter to grease the surface. Let the butter melt and coat the cooking surface.\\n\\n5. Pour the batter: Once the pan is hot, pour about 1/4 cup of batter for each pancake onto the griddle or pan. Cook until the edges look dry and small bubbles appear on the surface, about 2-3 minutes.\\n\\n6. Flip the pancakes: Use a spatula to carefully flip the pancakes. Cook the other side for another 1-2 minutes until lightly browned.\\n\\n7. Repeat: Repeat this process until you've cooked all your batter, adding more butter to the pan as needed.\\n\\n8. Serve: Stack the pancakes on a plate and serve them with your favorite toppings. Some popular options include butter, maple syrup, fresh fruit, whipped cream, or a dusting of powdered sugar.\\n\\n**Variations and Tips:**\\n- For extra fluffy pancakes, you can separate the eggs and gently fold in the whipped egg whites after combining the other ingredients.\\n- Feel free to add mix-ins to your batter, such as chocolate chips, blueberries, or chopped nuts.\\n- If you like thicker pancakes, you can add a little less milk to the batter. For thinner pancakes, add a bit more.\\n- To keep pancakes warm while you cook a large batch, preheat your oven to its lowest setting and place the cooked pancakes on a baking sheet or oven-safe plate.\\n\\nEnjoy your homemade pancakes! They are a classic breakfast treat and can be tailored to your taste preferences.\"}\n", + "{'role': 'user', 'content': 'I am vegetarian. Can you remove the eggs?'}\n", + "{'role': 'llm', 'content': \"Absolutely! You can make delicious vegetarian pancakes by omitting the eggs. Here's the modified recipe:\\n\\n**Ingredients:**\\n\\n- 2 cups (250 grams) all-purpose flour\\n- 2 teaspoons baking powder\\n- 1 teaspoon salt\\n- 2 tablespoons sugar\\n- 2 cups milk (dairy or plant-based alternative)\\n- 3 tablespoons melted butter, plus more for the pan\\n- 1 teaspoon vanilla extract (optional)\\n\\n**Instructions:**\\n\\n1. Whisk together the dry ingredients: In a large mixing bowl, combine the flour, baking powder, salt, and sugar. Whisk until well blended.\\n\\n2. Combine the wet ingredients: In a separate mixing bowl, whisk together the milk, melted butter, and vanilla extract (if using). Make sure the melted butter has cooled down a bit so it doesn't curdle the milk.\\n\\n3. Mix dry and wet ingredients: Slowly pour the wet ingredients into the dry ingredients while continuously whisking until you have a smooth batter. Again, don't overmix; a few small lumps are fine.\\n\\n4. Heat your griddle or pan: Place a nonstick pan or griddle over medium heat and add a small amount of butter to grease the surface.\\n\\n5. Pour the batter: Once the pan is hot, pour about 1/4 cup of batter for each pancake onto the griddle or pan. Cook until you see small bubbles appear on the surface and the edges look dry, which should take around 2-3 minutes.\\n\\n6. Flip the pancakes: Use a spatula to gently flip the pancakes. Cook the other side for an additional 1-2 minutes until lightly browned.\\n\\n7. Repeat: Continue this process until you've cooked all your batter, adding more butter to the pan as needed.\\n\\n8. Serve: Stack the pancakes on a plate and serve with your favorite toppings. Maple syrup, butter, fresh fruit, whipped cream, or a sprinkle of powdered sugar are all great choices.\\n\\n**Tips for Eggless Pancakes:**\\n- The eggs in the original recipe act as a binding agent and provide structure to the pancakes. By omitting them, your pancakes may be a bit more delicate, but they will still taste delicious.\\n- You can add a little extra milk (1-2 tablespoons) if you feel the batter is too thick. This will help compensate for the absence of eggs.\\n- For extra leavening, you can increase the amount of baking powder by 1/2 teaspoon to ensure your pancakes still rise nicely.\\n\\nEnjoy your vegetarian pancakes! They are a tasty and versatile breakfast option that can be customized with various toppings and mix-ins.\"}\n", + "{'role': 'user', 'content': 'I am also vegan. Can you replace the butter too?'}\n", + "{'role': 'llm', 'content': \"Certainly! For a vegan version of fluffy pancakes, you can replace both the eggs and butter with plant-based alternatives. Here's the recipe tailored for vegan dietary preferences:\\n\\n**Ingredients:**\\n\\n- 2 cups (250 grams) all-purpose flour\\n- 2 teaspoons baking powder\\n- 1 teaspoon salt\\n- 2 tablespoons sugar\\n- 2 cups plant-based milk (such as soy, almond, or oat milk)\\n- 2 tablespoons neutral-flavored oil (such as canola or vegetable oil), plus more for the pan\\n- 1 teaspoon vanilla extract (optional)\\n\\n**Instructions:**\\n\\n1. Whisk together the dry ingredients: In a large mixing bowl, combine the flour, baking powder, salt, and sugar. Mix well to ensure there are no lumps.\\n\\n2. Combine the wet ingredients: In a separate mixing bowl, whisk together the plant-based milk, oil, and vanilla extract (if using).\\n\\n3. Mix dry and wet ingredients: Slowly pour the wet ingredients into the dry ingredients, whisking continuously until you have a smooth batter. It's okay if there are a few small lumps.\\n\\n4. Heat your griddle or pan: Place a nonstick pan or griddle over medium heat. Add a small amount of oil to grease the surface, ensuring it coats the cooking area evenly.\\n\\n5. Pour the batter: Once the pan is hot, pour about 1/4 cup of batter for each pancake onto the griddle or pan. Cook until you notice small bubbles appearing on the surface and the edges look dry, which should take about 2-3 minutes.\\n\\n6. Flip the pancakes: Use a spatula to carefully flip the pancakes. Cook the other side for an additional 1-2 minutes until lightly browned.\\n\\n7. Repeat: Repeat this process until you've cooked all your batter, adding more oil to the pan as needed to prevent sticking.\\n\\n8. Serve: Stack the pancakes on a plate and serve them with your favorite vegan toppings. Some options include maple syrup, vegan butter, fresh fruit, or a dustable amount of powdered sugar.\\n\\n**Tips for Vegan Pancakes:**\\n- The oil replaces the butter in this recipe, providing moisture and helping to create a crispy edge on the pancakes.\\n- Feel free to experiment with different plant-based milk alternatives to find your preferred taste and texture. Soy milk tends to be creamier, while almond or oat milk can lend a subtle nutty flavor.\\n- If you like your pancakes with a hint of sweetness, you can add a bit more sugar to the batter or top them with sweet vegan toppings like agave nectar or fruit compote.\\n\\nEnjoy your vegan pancakes! They are a delicious and inclusive breakfast option that can be tailored to suit your dietary needs and preferences.\"}\n" + ] + } + ], + "source": [ + "for ctx in context:\n", + " print(ctx)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The LLM has forgotten about the avocado toast it mentioned because that was too far back in it's conversation history!" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Semantic session memory" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Rather than naively pass the most recent conversation we can use RedisVL to find only the semantically relevant sections of our conversation based on our new prompt. We can do this with the `fetch_relevant()` method." + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "USER: Can you give me the avocado one?\n", + "\n", + "LLM: Sure! Here's the first breakfast idea, Avocado Toast, with a simple recipe and some optional toppings:\n", + "\n", + "**Avocado Toast**\n", + "\n", + "**Ingredients:**\n", + "- 1 ripe avocado\n", + "- 2 slices of bread (preferably a hearty variety like sourdough, rye, or multigrain)\n", + "- Extra virgin olive oil\n", + "- Lemon or lime juice\n", + "- Salt and pepper, to taste\n", + "- Optional toppings: sliced tomatoes, chopped onions, chili flakes, or a fried egg\n", + "\n", + "**Instructions:**\n", + "\n", + "1. Toast the bread to your desired level of doneness.\n", + "2. While the bread is toasting, prepare the avocado. Cut the avocado in half, remove the pit, and scoop out the flesh into a small bowl.\n", + "3. Using a fork, mash the avocado until it reaches your desired consistency. You can leave it chunkier or make it smoother, depending on your preference.\n", + "4. Add a drizzle of extra virgin olive oil and a squeeze of lemon or lime juice to the mashed avocado. This will enhance the flavor and prevent the avocado from turning brown too quickly. Stir to combine.\n", + "5. Spread the mashed avocado mixture onto the toasted bread slices. Be generous!\n", + "6. Sprinkle salt and pepper on top to taste.\n", + "7. If desired, add your choice of optional toppings. Sliced tomatoes and a sprinkle of chopped onions go well with avocado toast. You could also add some chili flakes for a spicy kick.\n", + "8. For an even heartier breakfast, top the avocado toast with a fried egg. Simply fry an egg in a pan until the whites are set and the yolk is still runny (or cook it to your preferred doneness). Place the fried egg on top of the avocado toast.\n", + "9. Enjoy your delicious and nutritious avocado toast! It's a quick, easy, and healthy breakfast option.\n", + "\n", + "Feel free to experiment with different types of bread and toppings to create your own unique version of avocado toast. It's a simple dish that can be easily customized to your taste preferences. Enjoy!\n" + ] + } + ], + "source": [ + "prompt = \"Can you give me the avocado one?\"\n", + "user_session.set_distance_threshold(0.75)\n", + "context = user_session.get_relevant(prompt=prompt)\n", + "response = client.converse(prompt=prompt, context=context)\n", + "user_session.store(prompt, response)\n", + "print('USER: ', prompt)\n", + "print('\\nLLM: ', response)" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "{'role': 'user', 'content': 'can you give me some ideas for breakfast?'}\n", + "{'role': 'llm', 'content': \"Certainly! Here are some delicious breakfast ideas to start your day off right:\\n\\n**1. Avocado Toast:**\\n - Spread mashed avocado on a slice of toasted bread. Top it with a sprinkle of salt and pepper, a squeeze of lemon or lime juice, and a drizzle of extra virgin olive oil. You can also add sliced tomatoes, chopped onions, or a fried egg on top.\\n\\n**2. Smoothie Bowl:**\\n - Blend your favorite fruits (such as bananas, strawberries, blueberries, and mango) with a liquid base (like milk, yogurt, or juice) to create a thick and creamy smoothie. Pour it into a bowl and top it with granola, nuts, seeds, shredded coconut, or fresh fruit slices.\\n\\n**3. Pancakes or Waffles:**\\n - Whip up a batch of fluffy pancakes or waffles and serve them with a variety of toppings. Some options include fresh fruit, maple syrup, butter, whipped cream, chocolate chips, or a dusting of powdered sugar.\\n\\n**4. Breakfast Burrito:**\\n - Fill a flour tortilla with scrambled eggs, sautéed vegetables (such as bell peppers, onions, and mushrooms), shredded cheese, and your choice of breakfast meat (bacon, sausage, or ham). Roll it up and enjoy it as a handheld breakfast.\\n\\n**5. Overnight Oats:**\\n - Combine rolled oats, milk, yogurt, and your choice of mix-ins (such as chia seeds, flaxseeds, nuts, dried fruit, or fresh fruit) in a jar or container. Stir well, cover, and refrigerate overnight. In the morning, you'll have a delicious, creamy, and nutritious breakfast ready to go.\\n\\n**6. Eggs Benedict:**\\n - Poach an egg and place it on top of a toasted English muffin halved. Add a slice of Canadian bacon or ham and top it with a generous drizzle of hollandaise sauce. This classic breakfast dish is indulgent and satisfying.\\n\\n**7. Yogurt Parfait:**\\n - Layer yogurt, granola, and fresh fruit in a glass or bowl to create a beautiful and tasty parfait. Drizzle with honey and top with a sprinkle of chopped nuts or seeds for some crunch.\\n\\n**8. Breakfast Sandwich:**\\n - Toast two slices of bread and fill them with scrambled or fried eggs, cheese, and your choice of breakfast meat. You can also add vegetables like spinach, tomatoes, or avocado slices.\\n\\n**9. Fruit and Nut Butter:**\\n - Spread your favorite nut butter (such as peanut butter, almond butter, or cashew butter) on a slice of toast or a bagel. Top it with sliced bananas, apples, or berries for a nutritious and filling breakfast.\\n\\n**10. Quiche:**\\n - Bake a quiche filled with eggs, milk or cream, cheese, and your choice of vegetables and/or meat. It's a delicious and elegant breakfast option that can be served warm or at room temperature.\\n\\nThese breakfast ideas offer a variety of flavors, textures, and nutritional profiles to suit different tastes and preferences. Feel free to experiment, get creative, and customize these dishes to your liking!\"}\n" + ] + } + ], + "source": [ + "for ctx in context:\n", + " print(ctx)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Looking at the context we see that only the conversation section that had to do with avocados was retrieved. We don't need to know about pancakes to make avocado toast." + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "metadata": {}, + "outputs": [], + "source": [ + "user_session.clear()" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.2" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/redisvl/extensions/session_manager/__init__.py b/redisvl/extensions/session_manager/__init__.py new file mode 100644 index 00000000..53afbf8b --- /dev/null +++ b/redisvl/extensions/session_manager/__init__.py @@ -0,0 +1,5 @@ +from redisvl.extensions.session_manager.base_session import BaseSessionManager +from redisvl.extensions.session_manager.semantic_session import SemanticSessionManager +from redisvl.extensions.session_manager.standard_session import StandardSessionManager + +__all__ = ["BaseSessionManager", "StandardSessionManager", "SemanticSessionManager"] diff --git a/redisvl/extensions/session_manager/base_session.py b/redisvl/extensions/session_manager/base_session.py new file mode 100644 index 00000000..4d09c2cd --- /dev/null +++ b/redisvl/extensions/session_manager/base_session.py @@ -0,0 +1,173 @@ +from typing import Any, Dict, List, Optional, Union + +from redis import Redis + + +class BaseSessionManager: + id_field_name: str = "id_field" + role_field_name: str = "role" + content_field_name: str = "content" + tool_field_name: str = "tool_call_id" + timestamp_field_name: str = "timestamp" + + def __init__( + self, + name: str, + session_tag: str, + user_tag: str, + ): + """Initialize session memory with index + + Session Manager stores the current and previous user text prompts and + LLM responses to allow for enriching future prompts with session + context. Session history is stored in individual user or LLM prompts and + responses. + + Args: + name (str): The name of the session manager index. + session_tag (str): Tag to be added to entries to link to a specific + session. + user_tag (str): Tag to be added to entries to link to a specific user. + """ + self._name = name + self._user_tag = user_tag + self._session_tag = session_tag + + def set_scope( + self, + session_tag: Optional[str] = None, + user_tag: Optional[str] = None, + ) -> None: + """Set the filter to apply to querries based on the desired scope. + + This new scope persists until another call to set_scope is made, or if + scope specified in calls to get_recent. + + Args: + session_tag (str): Id of the specific session to filter to. Default is + None. + user_tag (str): Id of the specific user to filter to. Default is None. + """ + raise NotImplementedError + + def clear(self) -> None: + """Clears the chat session history.""" + raise NotImplementedError + + def delete(self) -> None: + """Clear all conversation history and remove any search indices.""" + raise NotImplementedError + + def drop(self, id_field: Optional[str] = None) -> None: + """Remove a specific exchange from the conversation history. + + Args: + id_field (Optional[str]): The id_field of the entry to delete. + If None then the last entry is deleted. + """ + raise NotImplementedError + + @property + def messages(self) -> Union[List[str], List[Dict[str, str]]]: + """Returns the full chat history.""" + raise NotImplementedError + + def get_recent( + self, + top_k: int = 5, + session_tag: Optional[str] = None, + user_tag: Optional[str] = None, + as_text: bool = False, + raw: bool = False, + ) -> Union[List[str], List[Dict[str, str]]]: + """Retreive the recent conversation history in sequential order. + + Args: + top_k (int): The number of previous exchanges to return. Default is 5. + Note that one exchange contains both a prompt and response. + session_tag (str): Tag to be added to entries to link to a specific + session. + user_tag (str): Tag to be added to entries to link to a specific user. + as_text (bool): Whether to return the conversation as a single string, + or list of alternating prompts and responses. + raw (bool): Whether to return the full Redis hash entry or just the + prompt and response + + Returns: + Union[str, List[str]]: A single string transcription of the session + or list of strings if as_text is false. + + Raises: + ValueError: If top_k is not an integer greater than or equal to 0. + """ + raise NotImplementedError + + def _format_context( + self, hits: List[Dict[str, Any]], as_text: bool + ) -> Union[List[str], List[Dict[str, str]]]: + """Extracts the prompt and response fields from the Redis hashes and + formats them as either flat dictionaries or strings. + + Args: + hits (List): The hashes containing prompt & response pairs from + recent conversation history. + as_text (bool): Whether to return the conversation as a single string, + or list of alternating prompts and responses. + Returns: + Union[str, List[str]]: A single string transcription of the session + or list of strings if as_text is false. + """ + if as_text: + text_statements = [] + for hit in hits: + text_statements.append(hit[self.content_field_name]) + return text_statements + else: + statements = [] + for hit in hits: + statements.append( + { + self.role_field_name: hit[self.role_field_name], + self.content_field_name: hit[self.content_field_name], + } + ) + if ( + hasattr(hit, self.tool_field_name) + or isinstance(hit, dict) + and self.tool_field_name in hit + ): + statements[-1].update( + {self.tool_field_name: hit[self.tool_field_name]} + ) + return statements + + def store(self, prompt: str, response: str) -> None: + """Insert a prompt:response pair into the session memory. A timestamp + is associated with each exchange so that they can be later sorted + in sequential ordering after retrieval. + + Args: + prompt (str): The user prompt to the LLM. + response (str): The corresponding LLM response. + """ + raise NotImplementedError + + def add_messages(self, messages: List[Dict[str, str]]) -> None: + """Insert a list of prompts and responses into the session memory. + A timestamp is associated with each so that they can be later sorted + in sequential ordering after retrieval. + + Args: + messages (List[Dict[str, str]]): The list of user prompts and LLM responses. + """ + raise NotImplementedError + + def add_message(self, message: Dict[str, str]) -> None: + """Insert a single prompt or response into the session memory. + A timestamp is associated with it so that it can be later sorted + in sequential ordering after retrieval. + + Args: + message (Dict[str,str]): The user prompt or LLM response. + """ + raise NotImplementedError diff --git a/redisvl/extensions/session_manager/semantic_session.py b/redisvl/extensions/session_manager/semantic_session.py new file mode 100644 index 00000000..6cfa9dc6 --- /dev/null +++ b/redisvl/extensions/session_manager/semantic_session.py @@ -0,0 +1,365 @@ +from time import time +from typing import Dict, List, Optional, Union + +from redis import Redis + +from redisvl.extensions.session_manager import BaseSessionManager +from redisvl.index import SearchIndex +from redisvl.query import FilterQuery, RangeQuery +from redisvl.query.filter import Tag +from redisvl.redis.utils import array_to_buffer +from redisvl.schema.schema import IndexSchema +from redisvl.utils.vectorize import BaseVectorizer, HFTextVectorizer + + +class SemanticSessionManager(BaseSessionManager): + session_field_name: str = "session_tag" + user_field_name: str = "user_tag" + vector_field_name: str = "vector_field" + + def __init__( + self, + name: str, + session_tag: str, + user_tag: str, + prefix: Optional[str] = None, + vectorizer: Optional[BaseVectorizer] = None, + distance_threshold: float = 0.3, + redis_client: Optional[Redis] = None, + redis_url: str = "redis://localhost:6379", + ): + """Initialize session memory with index + + Session Manager stores the current and previous user text prompts and + LLM responses to allow for enriching future prompts with session + context. Session history is stored in individual user or LLM prompts and + responses. + + + Args: + name (str): The name of the session manager index. + session_tag (str): Tag to be added to entries to link to a specific + session. + user_tag (str): Tag to be added to entries to link to a specific user. + prefix (Optional[str]): Prefix for the keys for this session data. + Defaults to None and will be replaced with the index name. + vectorizer (Vectorizer): The vectorizer to create embeddings with. + distance_threshold (float): The maximum semantic distance to be + included in the context. Defaults to 0.3. + redis_client (Optional[Redis]): A Redis client instance. Defaults to + None. + redis_url (str): The URL of the Redis instance. Defaults to 'redis://localhost:6379'. + + The proposed schema will support a single vector embedding constructed + from either the prompt or response in a single string. + + """ + super().__init__(name, session_tag, user_tag) + + prefix = prefix or name + + self._vectorizer = vectorizer or HFTextVectorizer( + model="sentence-transformers/msmarco-distilbert-cos-v5" + ) + + self.set_distance_threshold(distance_threshold) + + schema = IndexSchema.from_dict({"index": {"name": name, "prefix": prefix}}) + + schema.add_fields( + [ + {"name": "role", "type": "text"}, + {"name": "content", "type": "text"}, + {"name": "tool_call_id", "type": "text"}, + {"name": "timestamp", "type": "numeric"}, + {"name": "session_tag", "type": "tag"}, + {"name": "user_tag", "type": "tag"}, + { + "name": "vector_field", + "type": "vector", + "attrs": { + "dims": self._vectorizer.dims, + "datatype": "float32", + "distance_metric": "cosine", + "algorithm": "flat", + }, + }, + ] + ) + + self._index = SearchIndex(schema=schema) + + if redis_client: + self._index.set_client(redis_client) + else: + self._index.connect(redis_url=redis_url) + + self._index.create(overwrite=False) + + self.set_scope(session_tag, user_tag) + + def set_scope( + self, + session_tag: Optional[str] = None, + user_tag: Optional[str] = None, + ) -> None: + """Set the tag filter to apply to querries based on the desired scope. + + This new scope persists until another call to set_scope is made, or if + scope specified in calls to get_recent or get_relevant. + + Args: + session_tag (str): Id of the specific session to filter to. Default is + None, which means all sessions will be in scope. + user_tag (str): Id of the specific user to filter to. Default is None, + which means all users will be in scope. + """ + if not (session_tag or user_tag): + return + self._session_tag = session_tag or self._session_tag + self._user_tag = user_tag or self._user_tag + tag_filter = Tag(self.user_field_name) == [] + if user_tag: + tag_filter = tag_filter & (Tag(self.user_field_name) == self._user_tag) + if session_tag: + tag_filter = tag_filter & ( + Tag(self.session_field_name) == self._session_tag + ) + + self._tag_filter = tag_filter + + def clear(self) -> None: + """Clears the chat session history.""" + with self._index.client.pipeline(transaction=False) as pipe: # type: ignore + for key in self._index.client.scan_iter(match=f"{self._index.prefix}:*"): # type: ignore + pipe.delete(key) + pipe.execute() + + def delete(self) -> None: + """Clear all conversation keys and remove the search index.""" + self._index.delete(drop=True) + + def drop(self, id_field: Optional[str] = None) -> None: + """Remove a specific exchange from the conversation history. + + Args: + id_field (Optional[str]): The id_field of the entry to delete. + If None then the last entry is deleted. + """ + if id_field: + sep = self._index.key_separator + key = sep.join([self._index.schema.index.name, id_field]) + else: + key = self.get_recent(top_k=1, raw=True)[0]["id"] # type: ignore + self._index.client.delete(key) # type: ignore + + @property + def messages(self) -> Union[List[str], List[Dict[str, str]]]: + """Returns the full chat history.""" + # TODO raw or as_text? + return_fields = [ + self.id_field_name, + self.session_field_name, + self.user_field_name, + self.role_field_name, + self.content_field_name, + self.tool_field_name, + self.timestamp_field_name, + ] + + query = FilterQuery( + filter_expression=self._tag_filter, + return_fields=return_fields, + ) + + sorted_query = query.query + sorted_query.sort_by(self.timestamp_field_name, asc=True) + hits = self._index.search(sorted_query, query.params).docs + + return self._format_context(hits, as_text=False) + + def get_relevant( + self, + prompt: str, + as_text: bool = False, + top_k: int = 5, + fall_back: bool = False, + session_tag: Optional[str] = None, + user_tag: Optional[str] = None, + raw: bool = False, + ) -> Union[List[str], List[Dict[str, str]]]: + """Searches the chat history for information semantically related to + the specified prompt. + + This method uses vector similarity search with a text prompt as input. + It checks for semantically similar prompts and responses and gets + the top k most relevant previous prompts or responses to include as + context to the next LLM call. + + Args: + prompt (str): The message text to search for in session memory + as_text (bool): Whether to return the prompts and responses as text + or as JSON + top_k (int): The number of previous messages to return. Default is 5. + fallback (bool): Whether to drop back to recent conversation history + if no relevant context is found. + session_tag (str): Tag of entries linked to a specific session. + user_tag (str): Tag of entries linked to a specific user. + raw (bool): Whether to return the full Redis hash entry or just the + message. + + Returns: + Union[List[str], List[Dict[str,str]]: Either a list of strings, or a + list of prompts and responses in JSON containing the most relevant. + + Raises ValueError: if top_k is not an integer greater or equal to 0. + """ + if type(top_k) != int or top_k < 0: + raise ValueError("top_k must be an integer greater than or equal to -1") + if top_k == 0: + return [] + self.set_scope(session_tag, user_tag) + return_fields = [ + self.session_field_name, + self.user_field_name, + self.role_field_name, + self.content_field_name, + self.timestamp_field_name, + self.tool_field_name, + self.vector_field_name, + ] + + query = RangeQuery( + vector=self._vectorizer.embed(prompt), + vector_field_name=self.vector_field_name, + return_fields=return_fields, + distance_threshold=self._distance_threshold, + num_results=top_k, + return_score=True, + filter_expression=self._tag_filter, + ) + hits = self._index.query(query) + + # if we don't find semantic matches fallback to returning recent context + if not hits and fall_back: + return self.get_recent(as_text=as_text, top_k=top_k, raw=raw) + if raw: + return hits + return self._format_context(hits, as_text) + + def get_recent( + self, + top_k: int = 5, + session_tag: Optional[str] = None, + user_tag: Optional[str] = None, + as_text: bool = False, + raw: bool = False, + ) -> Union[List[str], List[Dict[str, str]]]: + """Retreive the recent conversation history in sequential order. + + Args: + as_text (bool): Whether to return the conversation as a single string, + or list of alternating prompts and responses. + top_k (int): The number of previous exchanges to return. Default is 5. + Note that one exchange contains both a prompt and a respoonse. + session_tag (str): Tag to be added to entries to link to a specific + session. + user_tag (str): Tag to be added to entries to link to a specific user. + raw (bool): Whether to return the full Redis hash entry or just the + prompt and response + + Returns: + Union[str, List[str]]: A single string transcription of the session + or list of strings if as_text is false. + + Raises: + ValueError: if top_k is not an integer greater than or equal to 0. + """ + if type(top_k) != int or top_k < 0: + raise ValueError("top_k must be an integer greater than or equal to 0") + + self.set_scope(session_tag, user_tag) + return_fields = [ + self.id_field_name, + self.session_field_name, + self.user_field_name, + self.role_field_name, + self.content_field_name, + self.tool_field_name, + self.timestamp_field_name, + ] + + query = FilterQuery( + filter_expression=self._tag_filter, + return_fields=return_fields, + num_results=top_k, + ) + + sorted_query = query.query + sorted_query.sort_by(self.timestamp_field_name, asc=False) + hits = self._index.search(sorted_query, query.params).docs + + if raw: + return hits[::-1] + return self._format_context(hits[::-1], as_text) + + @property + def distance_threshold(self): + return self._distance_threshold + + def set_distance_threshold(self, threshold): + self._distance_threshold = threshold + + def store(self, prompt: str, response: str) -> None: + """Insert a prompt:response pair into the session memory. A timestamp + is associated with each message so that they can be later sorted + in sequential ordering after retrieval. + + Args: + prompt (str): The user prompt to the LLM. + response (str): The corresponding LLM response. + """ + self.add_messages( + [ + {self.role_field_name: "user", self.content_field_name: prompt}, + {self.role_field_name: "llm", self.content_field_name: response}, + ] + ) + + def add_messages(self, messages: List[Dict[str, str]]) -> None: + """Insert a list of prompts and responses into the session memory. + A timestamp is associated with each so that they can be later sorted + in sequential ordering after retrieval. + + Args: + messages (List[Dict[str, str]]): The list of user prompts and LLM responses. + """ + sep = self._index.key_separator + payloads = [] + for message in messages: + vector = self._vectorizer.embed(message[self.content_field_name]) + timestamp = time() + id_field = sep.join([self._user_tag, self._session_tag, str(timestamp)]) + payload = { + self.id_field_name: id_field, + self.role_field_name: message[self.role_field_name], + self.content_field_name: message[self.content_field_name], + self.timestamp_field_name: timestamp, + self.session_field_name: self._session_tag, + self.user_field_name: self._user_tag, + self.vector_field_name: array_to_buffer(vector), + } + if self.tool_field_name in message: + payload.update({self.tool_field_name: message[self.tool_field_name]}) + payloads.append(payload) + self._index.load(data=payloads, id_field=self.id_field_name) + + def add_message(self, message: Dict[str, str]) -> None: + """Insert a single prompt or response into the session memory. + A timestamp is associated with it so that it can be later sorted + in sequential ordering after retrieval. + + Args: + message (Dict[str,str]): The user prompt or LLM response. + """ + self.add_messages([message]) diff --git a/redisvl/extensions/session_manager/standard_session.py b/redisvl/extensions/session_manager/standard_session.py new file mode 100644 index 00000000..d0b76898 --- /dev/null +++ b/redisvl/extensions/session_manager/standard_session.py @@ -0,0 +1,193 @@ +import json +from time import time +from typing import Dict, List, Optional, Union + +from redis import Redis + +from redisvl.extensions.session_manager import BaseSessionManager + + +class StandardSessionManager(BaseSessionManager): + + def __init__( + self, + name: str, + session_tag: str, + user_tag: str, + redis_client: Optional[Redis] = None, + redis_url: str = "redis://localhost:6379", + ): + """Initialize session memory + + Session Manager stores the current and previous user text prompts and + LLM responses to allow for enriching future prompts with session + context.Session history is stored in individual user or LLM prompts and + responses. + + Args: + name (str): The name of the session manager index. + session_tag (str): Tag to be added to entries to link to a specific + session. + user_tag (str): Tag to be added to entries to link to a specific user. + redis_client (Optional[Redis]): A Redis client instance. Defaults to + None. + redis_url (str): The URL of the Redis instance. Defaults to 'redis://localhost:6379'. + + The proposed schema will support a single combined vector embedding + constructed from the prompt & response in a single string. + + """ + super().__init__(name, session_tag, user_tag) + + if redis_client: + self._client = redis_client + else: + self._client = Redis.from_url(redis_url) + + self.set_scope(session_tag, user_tag) + + def set_scope( + self, + session_tag: Optional[str] = None, + user_tag: Optional[str] = None, + ) -> None: + """Set the filter to apply to querries based on the desired scope. + + This new scope persists until another call to set_scope is made, or if + scope is specified in calls to get_recent. + + Args: + session_tag (str): Id of the specific session to filter to. Default is + None, which means session_tag will be unchanged. + user_tag (str): Id of the specific user to filter to. Default is None, + which means user_tag will be unchanged. + """ + if not (session_tag or user_tag): + return + + self._session_tag = session_tag or self._session_tag + self._user_tag = user_tag or self._user_tag + + def clear(self) -> None: + """Clears the chat session history.""" + self._client.delete(self.key) + + def delete(self) -> None: + """Clears the chat session history.""" + self._client.delete(self.key) + + def drop(self, id_field: Optional[str] = None) -> None: + """Remove a specific exchange from the conversation history. + + Args: + id_field (Optional[str]): The id_field of the entry to delete. + If None then the last entry is deleted. + """ + if id_field: + messages = self._client.lrange(self.key, 0, -1) + messages = [json.loads(msg) for msg in messages] + messages = [msg for msg in messages if msg["id_field"] != id_field] + messages = [json.dumps(msg) for msg in messages] + self.clear() + self._client.rpush(self.key, *messages) + else: + self._client.rpop(self.key) + + @property + def messages(self) -> Union[List[str], List[Dict[str, str]]]: + """Returns the full chat history.""" + return self.get_recent(top_k=-1) + + def get_recent( + self, + top_k: int = 5, + session_tag: Optional[str] = None, + user_tag: Optional[str] = None, + as_text: bool = False, + raw: bool = False, + ) -> Union[List[str], List[Dict[str, str]]]: + """Retreive the recent conversation history in sequential order. + + Args: + top_k (int): The number of previous messages to return. Default is 5. + To get all messages set top_k = -1. + session_tag (str): Tag to be added to entries to link to a specific + session. + user_tag (str): Tag to be added to entries to link to a specific user. + as_text (bool): Whether to return the conversation as a single string, + or list of alternating prompts and responses. + raw (bool): Whether to return the full Redis hash entry or just the + prompt and response + + Returns: + Union[str, List[str]]: A single string transcription of the session + or list of strings if as_text is false. + + Raises: + ValueError: if top_k is not an integer greater than or equal to -1. + """ + if type(top_k) != int or top_k < -1: + raise ValueError("top_k must be an integer greater than or equal to -1") + if top_k == 0: + return [] + elif top_k == -1: + top_k = 0 + self.set_scope(session_tag, user_tag) + messages = self._client.lrange(self.key, -top_k, -1) + messages = [json.loads(msg) for msg in messages] + if raw: + return messages + return self._format_context(messages, as_text) + + @property + def key(self): + return ":".join([self._name, self._user_tag, self._session_tag]) + + def store(self, prompt: str, response: str) -> None: + """Insert a prompt:response pair into the session memory. A timestamp + is associated with each exchange so that they can be later sorted + in sequential ordering after retrieval. + + Args: + prompt (str): The user prompt to the LLM. + response (str): The corresponding LLM response. + """ + self.add_messages( + [{"role": "user", "content": prompt}, {"role": "llm", "content": response}] + ) + + def add_messages(self, messages: List[Dict[str, str]]) -> None: + """Insert a list of prompts and responses into the session memory. + A timestamp is associated with each so that they can be later sorted + in sequential ordering after retrieval. + + Args: + messages (List[Dict[str, str]]): The list of user prompts and LLM responses. + """ + payloads = [] + for message in messages: + timestamp = time() + payload = { + self.id_field_name: ":".join( + [self._user_tag, self._session_tag, str(timestamp)] + ), + self.role_field_name: message[self.role_field_name], + self.content_field_name: message[self.content_field_name], + self.timestamp_field_name: timestamp, + } + if self.tool_field_name in message: + payload.update({self.tool_field_name: message[self.tool_field_name]}) + + payloads.append(json.dumps(payload)) + + self._client.rpush(self.key, *payloads) + + def add_message(self, message: Dict[str, str]) -> None: + """Insert a single prompt or response into the session memory. + A timestamp is associated with it so that it can be later sorted + in sequential ordering after retrieval. + + Args: + message (Dict[str,str]): The user prompt or LLM response. + """ + self.add_messages([message]) diff --git a/tests/integration/test_session_manager.py b/tests/integration/test_session_manager.py new file mode 100644 index 00000000..0a236aaf --- /dev/null +++ b/tests/integration/test_session_manager.py @@ -0,0 +1,611 @@ +import json +import time + +import pytest + +from redisvl.extensions.session_manager import ( + SemanticSessionManager, + StandardSessionManager, +) + + +@pytest.fixture +def standard_session(app_name, user_tag, session_tag): + session = StandardSessionManager( + app_name, session_tag=session_tag, user_tag=user_tag + ) + yield session + session.clear() + session.delete() + + +@pytest.fixture +def semantic_session(app_name, user_tag, session_tag): + session = SemanticSessionManager( + app_name, session_tag=session_tag, user_tag=user_tag + ) + yield session + session.clear() + session.delete() + + +# test standard session manager +def test_key_creation(): + # test default key creation + session = StandardSessionManager(name="test_app", session_tag="123", user_tag="abc") + assert session.key == "test_app:abc:123" + + +def test_specify_redis_client(client): + session = StandardSessionManager( + name="test_app", session_tag="abc", user_tag="123", redis_client=client + ) + assert isinstance(session._client, type(client)) + + +def test_standard_store_and_get(standard_session): + context = standard_session.get_recent() + assert len(context) == 0 + + standard_session.store(prompt="first prompt", response="first response") + standard_session.store(prompt="second prompt", response="second response") + standard_session.store(prompt="third prompt", response="third response") + standard_session.store(prompt="fourth prompt", response="fourth response") + standard_session.store(prompt="fifth prompt", response="fifth response") + + # test default context history size + default_context = standard_session.get_recent() + assert len(default_context) == 5 # default is 5 + + # test specified context history size + partial_context = standard_session.get_recent(top_k=2) + assert len(partial_context) == 2 + assert partial_context == [ + {"role": "user", "content": "fifth prompt"}, + {"role": "llm", "content": "fifth response"}, + ] + + # test larger context history returns full history + too_large_context = standard_session.get_recent(top_k=100) + assert len(too_large_context) == 10 + + # test no context is returned when top_k is 0 + no_context = standard_session.get_recent(top_k=0) + assert len(no_context) == 0 + + # test that the full context is returned when top_k is -1 + full_context = standard_session.get_recent(top_k=-1) + assert len(full_context) == 10 + + # test that order is maintained + assert full_context == [ + {"role": "user", "content": "first prompt"}, + {"role": "llm", "content": "first response"}, + {"role": "user", "content": "second prompt"}, + {"role": "llm", "content": "second response"}, + {"role": "user", "content": "third prompt"}, + {"role": "llm", "content": "third response"}, + {"role": "user", "content": "fourth prompt"}, + {"role": "llm", "content": "fourth response"}, + {"role": "user", "content": "fifth prompt"}, + {"role": "llm", "content": "fifth response"}, + ] + + # test that a ValueError is raised when top_k is invalid + with pytest.raises(ValueError): + bad_context = standard_session.get_recent(top_k=-2) + + with pytest.raises(ValueError): + bad_context = standard_session.get_recent(top_k=-2.0) + + with pytest.raises(ValueError): + bad_context = standard_session.get_recent(top_k=1.3) + + with pytest.raises(ValueError): + bad_context = standard_session.get_recent(top_k="3") + + +def test_standard_add_and_get(standard_session): + context = standard_session.get_recent() + assert len(context) == 0 + + standard_session.add_message({"role": "user", "content": "first prompt"}) + standard_session.add_message({"role": "llm", "content": "first response"}) + standard_session.add_message({"role": "user", "content": "second prompt"}) + standard_session.add_message({"role": "llm", "content": "second response"}) + standard_session.add_message( + { + "role": "tool", + "content": "tool result 1", + "tool_call_id": "tool call one", + } + ) + standard_session.add_message( + { + "role": "tool", + "content": "tool result 2", + "tool_call_id": "tool call two", + } + ) + standard_session.add_message({"role": "user", "content": "fourth prompt"}) + standard_session.add_message({"role": "llm", "content": "fourth response"}) + + # test default context history size + default_context = standard_session.get_recent() + assert len(default_context) == 5 # default is 5 + + # test specified context history size + partial_context = standard_session.get_recent(top_k=3) + assert len(partial_context) == 3 + assert partial_context == [ + {"role": "tool", "content": "tool result 2", "tool_call_id": "tool call two"}, + {"role": "user", "content": "fourth prompt"}, + {"role": "llm", "content": "fourth response"}, + ] + + # test that order is maintained + full_context = standard_session.get_recent(top_k=-1) + assert full_context == [ + {"role": "user", "content": "first prompt"}, + {"role": "llm", "content": "first response"}, + {"role": "user", "content": "second prompt"}, + {"role": "llm", "content": "second response"}, + {"role": "tool", "content": "tool result 1", "tool_call_id": "tool call one"}, + {"role": "tool", "content": "tool result 2", "tool_call_id": "tool call two"}, + {"role": "user", "content": "fourth prompt"}, + {"role": "llm", "content": "fourth response"}, + ] + + +def test_standard_add_messages(standard_session): + context = standard_session.get_recent() + assert len(context) == 0 + + standard_session.add_messages( + [ + {"role": "user", "content": "first prompt"}, + {"role": "llm", "content": "first response"}, + {"role": "user", "content": "second prompt"}, + {"role": "llm", "content": "second response"}, + { + "role": "tool", + "content": "tool result 1", + "tool_call_id": "tool call one", + }, + { + "role": "tool", + "content": "tool resuilt 2", + "tool_call_id": "tool call two", + }, + {"role": "user", "content": "fourth prompt"}, + {"role": "llm", "content": "fourth response"}, + ] + ) + + # test default context history size + default_context = standard_session.get_recent() + assert len(default_context) == 5 # default is 5 + + # test specified context history size + partial_context = standard_session.get_recent(top_k=2) + assert len(partial_context) == 2 + assert partial_context == [ + {"role": "user", "content": "fourth prompt"}, + {"role": "llm", "content": "fourth response"}, + ] + + # test that order is maintained + full_context = standard_session.get_recent(top_k=-1) + assert full_context == [ + {"role": "user", "content": "first prompt"}, + {"role": "llm", "content": "first response"}, + {"role": "user", "content": "second prompt"}, + {"role": "llm", "content": "second response"}, + {"role": "tool", "content": "tool result 1", "tool_call_id": "tool call one"}, + {"role": "tool", "content": "tool resuilt 2", "tool_call_id": "tool call two"}, + {"role": "user", "content": "fourth prompt"}, + {"role": "llm", "content": "fourth response"}, + ] + + +def test_standard_messages_property(standard_session): + standard_session.add_messages( + [ + {"role": "user", "content": "first prompt"}, + {"role": "llm", "content": "first response"}, + {"role": "user", "content": "second prompt"}, + {"role": "llm", "content": "second response"}, + {"role": "user", "content": "third prompt"}, + ] + ) + + assert standard_session.messages == [ + {"role": "user", "content": "first prompt"}, + {"role": "llm", "content": "first response"}, + {"role": "user", "content": "second prompt"}, + {"role": "llm", "content": "second response"}, + {"role": "user", "content": "third prompt"}, + ] + + +def test_standard_set_scope(standard_session, app_name, user_tag, session_tag): + # test calling set_scope with no params does not change scope + current_key = standard_session.key + standard_session.set_scope() + assert standard_session.key == current_key + + # test passing either user_tag or session_tag only changes corresponding value + new_user = "def" + standard_session.set_scope(user_tag=new_user) + assert standard_session.key == f"{app_name}:{new_user}:{session_tag}" + + new_session = "456" + standard_session.set_scope(session_tag=new_session) + assert standard_session.key == f"{app_name}:{new_user}:{new_session}" + + # test that changing user and session id does indeed change access scope + standard_session.store("new user prompt", "new user response") + + standard_session.set_scope(session_tag="789", user_tag="ghi") + no_context = standard_session.get_recent() + assert no_context == [] + + # change scope back to read previously stored entries + standard_session.set_scope(session_tag="456", user_tag="def") + previous_context = standard_session.get_recent() + assert previous_context == [ + {"role": "user", "content": "new user prompt"}, + {"role": "llm", "content": "new user response"}, + ] + + +def test_standard_get_recent_with_scope(standard_session, session_tag): + # test that passing user or session id to get_recent(...) changes scope + standard_session.store("first prompt", "first response") + + context = standard_session.get_recent() + assert context == [ + {"role": "user", "content": "first prompt"}, + {"role": "llm", "content": "first response"}, + ] + + context = standard_session.get_recent(session_tag="456") + assert context == [] + + # test that scope change persists after being updated via get_recent(...) + standard_session.store("new session prompt", "new session response") + context = standard_session.get_recent() + assert context == [ + {"role": "user", "content": "new session prompt"}, + {"role": "llm", "content": "new session response"}, + ] + + # clean up lingering sessions + standard_session.clear() + standard_session.set_scope(session_tag=session_tag) + + +def test_standard_get_text(standard_session): + standard_session.store("first prompt", "first response") + text = standard_session.get_recent(as_text=True) + assert text == ["first prompt", "first response"] + + standard_session.add_message({"role": "system", "content": "system level prompt"}) + text = standard_session.get_recent(as_text=True) + assert text == ["first prompt", "first response", "system level prompt"] + + +def test_standard_get_raw(standard_session): + current_time = int(time.time()) + standard_session.store("first prompt", "first response") + standard_session.store("second prompt", "second response") + raw = standard_session.get_recent(raw=True) + assert len(raw) == 4 + assert raw[0].keys() == { + "id_field", + "role", + "content", + "timestamp", + } + assert raw[0]["role"] == "user" + assert raw[0]["content"] == "first prompt" + assert current_time <= raw[0]["timestamp"] <= time.time() + assert raw[1]["role"] == "llm" + assert raw[1]["content"] == "first response" + assert raw[1]["timestamp"] > raw[0]["timestamp"] + + +def test_standard_drop(standard_session): + standard_session.store("first prompt", "first response") + standard_session.store("second prompt", "second response") + standard_session.store("third prompt", "third response") + standard_session.store("fourth prompt", "fourth response") + + # test drop() with no arguments removes the last element + standard_session.drop() + context = standard_session.get_recent(top_k=3) + assert context == [ + {"role": "user", "content": "third prompt"}, + {"role": "llm", "content": "third response"}, + {"role": "user", "content": "fourth prompt"}, + ] + + # test drop(id) removes the specified element + context = standard_session.get_recent(top_k=-1, raw=True) + middle_id = context[3]["id_field"] + standard_session.drop(middle_id) + context = standard_session.get_recent(top_k=6) + assert context == [ + {"role": "user", "content": "first prompt"}, + {"role": "llm", "content": "first response"}, + {"role": "user", "content": "second prompt"}, + {"role": "user", "content": "third prompt"}, + {"role": "llm", "content": "third response"}, + {"role": "user", "content": "fourth prompt"}, + ] + + +def test_standard_clear(standard_session): + standard_session.store("some prompt", "some response") + standard_session.clear() + empty_context = standard_session.get_recent(top_k=-1) + assert empty_context == [] + + +def test_standard_delete(standard_session): + standard_session.store("some prompt", "some response") + standard_session.delete() + empty_context = standard_session.get_recent(top_k=-1) + assert empty_context == [] + + +# test semantic session manager +def test_semantic_specify_client(client): + session = SemanticSessionManager( + name="test_app", session_tag="abc", user_tag="123", redis_client=client + ) + assert isinstance(session._index.client, type(client)) + + +def test_semantic_set_scope(semantic_session, app_name, user_tag, session_tag): + # test calling set_scope with no params does not change scope + semantic_session.store("some prompt", "some response") + semantic_session.set_scope() + context = semantic_session.get_recent() + assert context == [ + {"role": "user", "content": "some prompt"}, + {"role": "llm", "content": "some response"}, + ] + + # test that changing user and session id does indeed change access scope + new_user = "def" + semantic_session.set_scope(user_tag=new_user) + semantic_session.store("new user prompt", "new user response") + context = semantic_session.get_recent() + assert context == [ + {"role": "user", "content": "new user prompt"}, + {"role": "llm", "content": "new user response"}, + ] + + # test that previous user and session data is still accessible + previous_user = "abc" + semantic_session.set_scope(user_tag=previous_user) + context = semantic_session.get_recent() + assert context == [ + {"role": "user", "content": "some prompt"}, + {"role": "llm", "content": "some response"}, + ] + + semantic_session.set_scope(session_tag="789", user_tag="ghi") + no_context = semantic_session.get_recent() + assert no_context == [] + + +def test_semantic_store_and_get_recent(semantic_session): + context = semantic_session.get_recent() + assert len(context) == 0 + + semantic_session.store(prompt="first prompt", response="first response") + semantic_session.store(prompt="second prompt", response="second response") + semantic_session.store(prompt="third prompt", response="third response") + semantic_session.store(prompt="fourth prompt", response="fourth response") + semantic_session.add_message( + {"role": "tool", "content": "tool result", "tool_call_id": "tool id"} + ) + # test default context history size + default_context = semantic_session.get_recent() + assert len(default_context) == 5 # 5 is default + + # test specified context history size + partial_context = semantic_session.get_recent(top_k=4) + assert len(partial_context) == 4 + + # test larger context history returns full history + too_large_context = semantic_session.get_recent(top_k=100) + assert len(too_large_context) == 9 + + # test that order is maintained + full_context = semantic_session.get_recent(top_k=9) + assert full_context == [ + {"role": "user", "content": "first prompt"}, + {"role": "llm", "content": "first response"}, + {"role": "user", "content": "second prompt"}, + {"role": "llm", "content": "second response"}, + {"role": "user", "content": "third prompt"}, + {"role": "llm", "content": "third response"}, + {"role": "user", "content": "fourth prompt"}, + {"role": "llm", "content": "fourth response"}, + {"role": "tool", "content": "tool result", "tool_call_id": "tool id"}, + ] + + # test that more recent entries are returned + context = semantic_session.get_recent(top_k=4) + assert context == [ + {"role": "llm", "content": "third response"}, + {"role": "user", "content": "fourth prompt"}, + {"role": "llm", "content": "fourth response"}, + {"role": "tool", "content": "tool result", "tool_call_id": "tool id"}, + ] + + # test no entries are returned and no error is raised if top_k == 0 + context = semantic_session.get_recent(top_k=0) + assert context == [] + + # test that a ValueError is raised when top_k is invalid + with pytest.raises(ValueError): + bad_context = semantic_session.get_recent(top_k=0.5) + + with pytest.raises(ValueError): + bad_context = semantic_session.get_recent(top_k=-1) + + with pytest.raises(ValueError): + bad_context = semantic_session.get_recent(top_k=-2.0) + + with pytest.raises(ValueError): + bad_context = semantic_session.get_recent(top_k=1.3) + + with pytest.raises(ValueError): + bad_context = semantic_session.get_recent(top_k="3") + + +def test_semantic_messages_property(semantic_session): + semantic_session.add_messages( + [ + {"role": "user", "content": "first prompt"}, + {"role": "llm", "content": "first response"}, + { + "role": "tool", + "content": "tool result 1", + "tool_call_id": "tool call one", + }, + { + "role": "tool", + "content": "tool result 2", + "tool_call_id": "tool call two", + }, + {"role": "user", "content": "second prompt"}, + {"role": "llm", "content": "second response"}, + {"role": "user", "content": "third prompt"}, + ] + ) + + assert semantic_session.messages == [ + {"role": "user", "content": "first prompt"}, + {"role": "llm", "content": "first response"}, + {"role": "tool", "content": "tool result 1", "tool_call_id": "tool call one"}, + {"role": "tool", "content": "tool result 2", "tool_call_id": "tool call two"}, + {"role": "user", "content": "second prompt"}, + {"role": "llm", "content": "second response"}, + {"role": "user", "content": "third prompt"}, + ] + + +def test_semantic_add_and_get_relevant(semantic_session): + semantic_session.add_message( + {"role": "system", "content": "discussing common fruits and vegetables"} + ) + semantic_session.store( + prompt="list of common fruits", + response="apples, oranges, bananas, strawberries", + ) + semantic_session.store( + prompt="list of common vegetables", + response="carrots, broccoli, onions, spinach", + ) + semantic_session.store( + prompt="winter sports in the olympics", + response="downhill skiing, ice skating, luge", + ) + semantic_session.add_message( + { + "role": "tool", + "content": "skiing, skating, luge", + "tool_call_id": "winter_sports()", + } + ) + + # test default distance metric + default_context = semantic_session.get_relevant( + "set of common fruits like apples and bananas" + ) + assert len(default_context) == 2 + assert default_context[0] == {"role": "user", "content": "list of common fruits"} + assert default_context[1] == { + "role": "llm", + "content": "apples, oranges, bananas, strawberries", + } + + # test increasing distance metric broadens results + semantic_session.set_distance_threshold(0.5) + default_context = semantic_session.get_relevant("list of fruits and vegetables") + assert len(default_context) == 5 # 2 pairs of prompt:response, and system + + # test tool calls can also be returned + context = semantic_session.get_relevant("winter sports like skiing") + assert context == [ + { + "role": "user", + "content": "winter sports in the olympics", + }, + { + "role": "tool", + "content": "skiing, skating, luge", + "tool_call_id": "winter_sports()", + }, + { + "role": "llm", + "content": "downhill skiing, ice skating, luge", + }, + ] + + # test that a ValueError is raised when top_k is invalid + with pytest.raises(ValueError): + bad_context = semantic_session.get_relevant("test prompt", top_k=-1) + + with pytest.raises(ValueError): + bad_context = semantic_session.get_relevant("test prompt", top_k=-2.0) + + with pytest.raises(ValueError): + bad_context = semantic_session.get_relevant("test prompt", top_k=1.3) + + with pytest.raises(ValueError): + bad_context = semantic_session.get_relevant("test prompt", top_k="3") + + +def test_semantic_get_raw(semantic_session): + current_time = int(time.time()) + semantic_session.store("first prompt", "first response") + semantic_session.store("second prompt", "second response") + raw = semantic_session.get_recent(raw=True) + assert len(raw) == 4 + assert raw[0]["content"] == "first prompt" + assert raw[1]["content"] == "first response" + assert current_time <= float(raw[0]["timestamp"]) <= time.time() + + +def test_semantic_drop(semantic_session): + semantic_session.store("first prompt", "first response") + semantic_session.store("second prompt", "second response") + semantic_session.store("third prompt", "third response") + semantic_session.store("fourth prompt", "fourth response") + + # test drop() with no arguments removes the last element + semantic_session.drop() + context = semantic_session.get_recent(top_k=3) + assert context == [ + {"role": "user", "content": "third prompt"}, + {"role": "llm", "content": "third response"}, + {"role": "user", "content": "fourth prompt"}, + ] + + # test drop(id) removes the specified element + context = semantic_session.get_recent(top_k=5, raw=True) + middle_id = context[2]["id_field"] + semantic_session.drop(middle_id) + context = semantic_session.get_recent(top_k=4) + assert context == [ + {"role": "user", "content": "second prompt"}, + {"role": "llm", "content": "second response"}, + {"role": "llm", "content": "third response"}, + {"role": "user", "content": "fourth prompt"}, + ]