From 6b8fba2e233ce6968c720dc117d5f3d818f57df1 Mon Sep 17 00:00:00 2001 From: Surya Prakash Pathak Date: Mon, 4 Nov 2024 07:32:59 -0800 Subject: [PATCH] Added react-agent supporting files Signed-off-by: Surya Prakash Pathak --- .../agents/Makefile | 9 + .../agents/README.md | 182 +++++++++ .../agents/ai-lab.yaml | 27 ++ .../agents/app/Containerfile | 8 + .../agents/app/react-agent-app.py | 386 ++++++++++++++++++ .../agents/app/requirements.txt | 10 + 6 files changed, 622 insertions(+) create mode 100644 recipes/natural_language_processing/agents/Makefile create mode 100644 recipes/natural_language_processing/agents/README.md create mode 100644 recipes/natural_language_processing/agents/ai-lab.yaml create mode 100644 recipes/natural_language_processing/agents/app/Containerfile create mode 100644 recipes/natural_language_processing/agents/app/react-agent-app.py create mode 100644 recipes/natural_language_processing/agents/app/requirements.txt diff --git a/recipes/natural_language_processing/agents/Makefile b/recipes/natural_language_processing/agents/Makefile new file mode 100644 index 00000000..673576d7 --- /dev/null +++ b/recipes/natural_language_processing/agents/Makefile @@ -0,0 +1,9 @@ +SHELL := /bin/bash +APP ?= react_agent +PORT ?= 8501 + +include ../../common/Makefile.common + +RECIPE_BINARIES_PATH := $(shell realpath ../../common/bin) +RELATIVE_MODELS_PATH := ../../../models +RELATIVE_TESTS_PATH := ../tests diff --git a/recipes/natural_language_processing/agents/README.md b/recipes/natural_language_processing/agents/README.md new file mode 100644 index 00000000..b843483a --- /dev/null +++ b/recipes/natural_language_processing/agents/README.md @@ -0,0 +1,182 @@ +# ReAct Agent Application + +This recipe demonstrates the ReAct (Reasoning and Acting) framework in action through a music exploration application. ReAct enables AI to think step-by-step about tasks, take appropriate actions, and provide reasoned responses. The application shows how ReAct can be used to create an intelligent music discovery assistant that combines reasoning with Spotify API interactions. + +The application utilizes [`llama-cpp-python`](https://github.com/abetlen/llama-cpp-python) for the Model Service and integrates with Spotify's API for music data. The recipe uses [Langchain](https://python.langchain.com/docs/get_started/introduction) for the ReAct implementation and [Streamlit](https://streamlit.io/) for the UI layer. + +## Spotify API Access +To use this application, you'll need Spotify API credentials: +- Create a Spotify Developer account +- Create an application in the Spotify Developer Dashboard +- Get your Client ID and Client Secret + +These can be provided through environment variables or the application's UI. + +## Try the ReAct Agent Application +The [Podman Desktop](https://podman-desktop.io) [AI Lab Extension](https://github.com/containers/podman-desktop-extension-ai-lab) includes this recipe among others. To try it out, open `Recipes Catalog` -> `ReAct Agent` and follow the instructions to start the application. + +# Build the Application + +The rest of this document will explain how to build and run the application from the terminal, and will +go into greater detail on how each container in the Pod above is built, run, and +what purpose it serves in the overall application. All the recipes use a central [Makefile](../../common/Makefile.common) that includes variables populated with default values to simplify getting started. Please review the [Makefile docs](../../common/README.md), to learn about further customizing your application. + + +This application requires a model, a model service and an AI inferencing application. + +* [Quickstart](#quickstart) +* [Download a model](#download-a-model) +* [Build the Model Service](#build-the-model-service) +* [Deploy the Model Service](#deploy-the-model-service) +* [Build the AI Application](#build-the-ai-application) +* [Deploy the AI Application](#deploy-the-ai-application) +* [Interact with the AI Application](#interact-with-the-ai-application) +* [Embed the AI Application in a Bootable Container Image](#embed-the-ai-application-in-a-bootable-container-image) + + +## Quickstart +To run the application with pre-built images from `quay.io/ai-lab`, use `make quadlet`. This command +builds the application's metadata and generates Kubernetes YAML at `./build/chatbot.yaml` to spin up a Pod that can then be launched locally. +Try it with: + +``` +make quadlet +podman kube play build/chatbot.yaml +``` + +This will take a few minutes if the model and model-server container images need to be downloaded. +The Pod is named `chatbot`, so you may use [Podman](https://podman.io) to manage the Pod and its containers: + +``` +podman pod list +podman ps +``` + +Once the Pod and its containers are running, the application can be accessed at `http://localhost:8501`. However, if you started the app via the podman desktop UI, a random port will be assigned instead of `8501`. Please use the AI App Details `Open AI App` button to access it instead. +Please refer to the section below for more details about [interacting with the chatbot application](#interact-with-the-ai-application). + +To stop and remove the Pod, run: + +``` +podman pod stop chatbot +podman pod rm chatbot +``` + +## Download a model + +If you are just getting started, we recommend using [granite-7b-lab](https://huggingface.co/instructlab/granite-7b-lab). This is a well +performant mid-sized model with an apache-2.0 license. In order to use it with our Model Service we need it converted +and quantized into the [GGUF format](https://github.com/ggerganov/ggml/blob/master/docs/gguf.md). There are a number of +ways to get a GGUF version of granite-7b-lab, but the simplest is to download a pre-converted one from +[huggingface.co](https://huggingface.co) here: https://huggingface.co/instructlab/granite-7b-lab-GGUF. + +The recommended model can be downloaded using the code snippet below: + +```bash +cd ../../../models +curl -sLO https://huggingface.co/instructlab/granite-7b-lab-GGUF/resolve/main/granite-7b-lab-Q4_K_M.gguf +cd ../recipes/natural_language_processing/chatbot +``` + +_A full list of supported open models is forthcoming._ + + +## Build the Model Service + +The complete instructions for building and deploying the Model Service can be found in the +[llamacpp_python model-service document](../../../model_servers/llamacpp_python/README.md). + +The Model Service can be built from make commands from the [llamacpp_python directory](../../../model_servers/llamacpp_python/). + +```bash +# from path model_servers/llamacpp_python from repo containers/ai-lab-recipes +make build +``` +Checkout the [Makefile](../../../model_servers/llamacpp_python/Makefile) to get more details on different options for how to build. + +## Deploy the Model Service + +The local Model Service relies on a volume mount to the localhost to access the model files. It also employs environment variables to dictate the model used and where its served. You can start your local Model Service using the following `make` command from `model_servers/llamacpp_python` set with reasonable defaults: + +```bash +# from path model_servers/llamacpp_python from repo containers/ai-lab-recipes +make run +``` + +## Build the AI Application + +The AI Application can be built from the make command: + +```bash +# Run this from the current directory (path recipes/natural_language_processing/chatbot from repo containers/ai-lab-recipes) +make build +``` + +## Deploy the AI Application + +Make sure the Model Service is up and running before starting this container image. When starting the AI Application container image we need to direct it to the correct `MODEL_ENDPOINT`. This could be any appropriately hosted Model Service (running locally or in the cloud) using an OpenAI compatible API. In our case the Model Service is running inside the Podman machine so we need to provide it with the appropriate address `10.88.0.1`. To deploy the AI application use the following: + +```bash +# Run this from the current directory (path recipes/natural_language_processing/chatbot from repo containers/ai-lab-recipes) +make run +``` + +## Interact with the AI Application + +Everything should now be up an running with the chat application available at [`http://localhost:8501`](http://localhost:8501). By using this recipe and getting this starting point established, users should now have an easier time customizing and building their own LLM enabled chatbot applications. + +## Embed the AI Application in a Bootable Container Image + +To build a bootable container image that includes this sample chatbot workload as a service that starts when a system is booted, run: `make -f Makefile bootc`. You can optionally override the default image / tag you want to give the make command by specifying it as follows: `make -f Makefile BOOTC_IMAGE= bootc`. + +Substituting the bootc/Containerfile FROM command is simple using the Makefile FROM option. + +```bash +make FROM=registry.redhat.io/rhel9/rhel-bootc:9.4 bootc +``` + +Selecting the ARCH for the bootc/Containerfile is simple using the Makefile ARCH= variable. + +``` +make ARCH=x86_64 bootc +``` + +The magic happens when you have a bootc enabled system running. If you do, and you'd like to update the operating system to the OS you just built +with the chatbot application, it's as simple as ssh-ing into the bootc system and running: + +```bash +bootc switch quay.io/ai-lab/chatbot-bootc:latest +``` + +Upon a reboot, you'll see that the chatbot service is running on the system. Check on the service with: + +```bash +ssh user@bootc-system-ip +sudo systemctl status chatbot +``` + +### What are bootable containers? + +What's a [bootable OCI container](https://containers.github.io/bootc/) and what's it got to do with AI? + +That's a good question! We think it's a good idea to embed AI workloads (or any workload!) into bootable images at _build time_ rather than +at _runtime_. This extends the benefits, such as portability and predictability, that containerizing applications provides to the operating system. +Bootable OCI images bake exactly what you need to run your workloads into the operating system at build time by using your favorite containerization +tools. Might I suggest [podman](https://podman.io/)? + +Once installed, a bootc enabled system can be updated by providing an updated bootable OCI image from any OCI +image registry with a single `bootc` command. This works especially well for fleets of devices that have fixed workloads - think +factories or appliances. Who doesn't want to add a little AI to their appliance, am I right? + +Bootable images lend toward immutable operating systems, and the more immutable an operating system is, the less that can go wrong at runtime! + +#### Creating bootable disk images + +You can convert a bootc image to a bootable disk image using the +[quay.io/centos-bootc/bootc-image-builder](https://github.com/osbuild/bootc-image-builder) container image. + +This container image allows you to build and deploy [multiple disk image types](../../common/README_bootc_image_builder.md) from bootc container images. + +Default image types can be set via the DISK_TYPE Makefile variable. + +`make bootc-image-builder DISK_TYPE=ami` diff --git a/recipes/natural_language_processing/agents/ai-lab.yaml b/recipes/natural_language_processing/agents/ai-lab.yaml new file mode 100644 index 00000000..5898cdad --- /dev/null +++ b/recipes/natural_language_processing/agents/ai-lab.yaml @@ -0,0 +1,27 @@ +version: v1.0 +application: + type: language + name: ReAct_Agent_Streamlit + description: ReAct framework implementation with Spotify API integration in a web frontend + containers: + - name: llamacpp-server + contextdir: ../../../model_servers/llamacpp_python + containerfile: ./base/Containerfile + model-service: true + backend: + - llama-cpp + arch: + - arm64 + - amd64 + ports: + - 8001 + image: quay.io/ai-lab/llamacpp_python:latest + - name: streamlit-react-agent-app + contextdir: app + containerfile: Containerfile + arch: + - arm64 + - amd64 + ports: + - 8501 + image: quay.io/ai-lab/react-agent:latest diff --git a/recipes/natural_language_processing/agents/app/Containerfile b/recipes/natural_language_processing/agents/app/Containerfile new file mode 100644 index 00000000..350139c5 --- /dev/null +++ b/recipes/natural_language_processing/agents/app/Containerfile @@ -0,0 +1,8 @@ +FROM registry.access.redhat.com/ubi9/python-311:1-77.1726664316 +WORKDIR /agents +COPY requirements.txt . +RUN pip install --upgrade pip +RUN pip install --no-cache-dir --upgrade -r /function-call/requirements.txt +COPY *.py . +EXPOSE 8501 +ENTRYPOINT [ "streamlit", "run", "app.py" ] diff --git a/recipes/natural_language_processing/agents/app/react-agent-app.py b/recipes/natural_language_processing/agents/app/react-agent-app.py new file mode 100644 index 00000000..c06d2034 --- /dev/null +++ b/recipes/natural_language_processing/agents/app/react-agent-app.py @@ -0,0 +1,386 @@ +import os +from typing import Dict, List +import requests +import time +import json +import streamlit as st +from langchain_core.tools import BaseTool +from langchain_openai import ChatOpenAI +from langchain_core.prompts import ChatPromptTemplate +from langchain_core.messages import AIMessage, HumanMessage +from dotenv import load_dotenv +from typing import Any, List, Dict, Union + +# Load env file +load_dotenv() + +# Model service +model_service = os.getenv("MODEL_ENDPOINT", "http://localhost:8001") +model_service = f"{model_service}/v1" + +# Spotify API Configuration +SPOTIFY_BASE_URL = "https://api.spotify.com/v1" + +class SpotifyAPI: + def __init__(self): + self.client_id = os.getenv("SPOTIFY_CLIENT_ID") + self.client_secret = os.getenv("SPOTIFY_CLIENT_SECRET") + + # If not in .env, access it through UI + if not self.client_id or not self.client_secret: + if hasattr(st.session_state, 'spotify_client_id') and hasattr(st.session_state, 'spotify_client_secret'): + self.client_id = st.session_state.spotify_client_id + self.client_secret = st.session_state.spotify_client_secret + + if not self.client_id or not self.client_secret: + raise ValueError("Spotify credentials not found. Please provide them in the sidebar.") + + self.access_token = self._get_access_token() + + def _get_access_token(self): + """Get Spotify access token using client credentials flow""" + auth_url = "https://accounts.spotify.com/api/token" + auth_response = requests.post( + auth_url, + data={ + "grant_type": "client_credentials", + "client_id": self.client_id, + "client_secret": self.client_secret, + } + ) + + if auth_response.status_code != 200: + raise Exception("Failed to get access token") + + return auth_response.json()["access_token"] + + def search_playlists(self, query: str, limit: int = 5) -> Dict: + """Search for playlists using Spotify API""" + enhanced_query = f"{query} playlist top popular" + headers = { + "Authorization": f"Bearer {self.access_token}", + "Content-Type": "application/json" + } + params = { + "q": enhanced_query, + "type": "playlist", + "limit": limit, + "market": "US" + } + + response = requests.get( + f"{SPOTIFY_BASE_URL}/search", + headers=headers, + params=params + ) + + if response.status_code != 200: + raise Exception(f"Search failed: {response.json().get('error', {}).get('message')}") + + return response.json() + + def get_trending_tracks(self, location: str = None, limit: int = 10) -> Dict: + """Get trending tracks for a specific location""" + headers = { + "Authorization": f"Bearer {self.access_token}", + "Content-Type": "application/json" + } + #include location in query + query = f"top charts popular {location}" if location else "top charts" + + params = { + "q": query, + "type": "track", + "limit": limit, + "market": "US", + "offset": 0, + "include_external": "audio" + } + + response = requests.get( + f"{SPOTIFY_BASE_URL}/search", + headers=headers, + params=params + ) + + if response.status_code != 200: + raise Exception(f"Search failed: {response.json().get('error', {}).get('message')}") + + return response.json() + +class SpotifySearchTool(BaseTool): + name: str = "spotify_search" + description: str = """ + Search for playlists on Spotify. + Input should be a search query string. + The tool will return relevant playlists with their details. + """ + spotify: Any = None + + def __init__(self) -> None: + super().__init__() + self.spotify = SpotifyAPI() + + def _run(self, query: str) -> List[Dict]: + try: + results = self.spotify.search_playlists(query) + playlists = [] + for item in results['playlists']['items']: + playlist = { + 'name': item['name'], + 'description': item['description'], + 'tracks_total': item['tracks']['total'], + 'url': item['external_urls']['spotify'], + 'owner': item['owner']['display_name'], + 'followers': item['followers']['total'] if 'followers' in item else 0 + } + playlists.append(playlist) + return playlists + except Exception as e: + return f"Error searching Spotify: {str(e)}" + +class SpotifyTrendingTool(BaseTool): + name: str = "spotify_trending" + description: str = """ + Get trending tracks for a specific location on Spotify. + Input should be a location string (e.g., 'Berkeley', 'Bay Area'). + Returns top trending tracks in that area. + """ + spotify: Any = None + + def __init__(self) -> None: + super().__init__() + self.spotify = SpotifyAPI() + + def _run(self, location: str) -> List[Dict]: + try: + results = self.spotify.get_trending_tracks(location) + tracks = [] + for item in results['tracks']['items']: + track = { + 'name': item['name'], + 'artist': ', '.join([artist['name'] for artist in item['artists']]), + 'album': item['album']['name'], + 'url': item['external_urls']['spotify'], + 'popularity': item['popularity'] + } + tracks.append(track) + return tracks + except Exception as e: + return f"Error getting trending tracks: {str(e)}" + +def format_spotify_response(tool_responses: Dict) -> str: + """Format the Spotify API responses into a readable message""" + response = "" + + # Format trending tracks + trending_tracks = tool_responses.get("trending", []) + if isinstance(trending_tracks, list) and trending_tracks: + response += "📊 Trending Tracks:\n" + for i, track in enumerate(trending_tracks[:5], 1): + response += f"{i}. {track['name']} by {track['artist']}\n" + response += f" - Album: {track['album']}\n" + response += f" - Listen: {track['url']}\n\n" + else: + response += "📊 No trending tracks found for this location.\n\n" + + # Format playlists + playlists = tool_responses.get("playlists", []) + if isinstance(playlists, list) and playlists: + response += "🎵 Related Playlists:\n" + for i, playlist in enumerate(playlists[:3], 1): + response += f"{i}. {playlist['name']}\n" + response += f" - Tracks: {playlist['tracks_total']}\n" + response += f" - Description: {playlist['description']}\n" + response += f" - Listen: {playlist['url']}\n\n" + else: + response += "No related playlists found.\n" + + return response + +# Model service check +@st.cache_resource(show_spinner=False) +def checking_model_service(): + start = time.time() + print("Checking Model Service Availability...") + ready = False + while not ready: + try: + request_cpp = requests.get(f'{model_service}/models') + request_ollama = requests.get(f'{model_service[:-2]}api/tags') + if request_cpp.status_code == 200: + server = "Llamacpp_Python" + ready = True + elif request_ollama.status_code == 200: + server = "Ollama" + ready = True + except: + pass + time.sleep(1) + print(f"{server} Model Service Available") + print(f"Time taken: {time.time()-start} seconds") + return server + +def get_models(): + try: + response = requests.get(f"{model_service[:-2]}api/tags") + return [i["name"].split(":")[0] for i in json.loads(response.content)["models"]] + except: + return None + +# ReAct prompt template +REACT_PROMPT = """You are a helpful assistant that can search for music on Spotify. +You have access to the following tools: + +{tools} + +Use the following format in your internal processing: +Thought: First interpret if the user's input is a casual greeting or an actual search query. +If it seems like a greeting, respond conversationally and suggest some current trending tracks. +If it's a search query, use it directly. + +Action: tool_name (either spotify_search or spotify_trending) +Action Input: input to the tool +Observation: tool's response + +Final Answer: If the input was conversational, start with a greeting before showing the music results. +Then provide results in this format: + +📊 Trending Tracks: +[formatted tracks...] + +🎵 Related Playlists: +[formatted playlists...] +""" + +# Create ReAct Agent function +def create_react_agent(model_name: str): + llm = ChatOpenAI( + base_url=model_service, + api_key="sk-no-key-required", + model=model_name, + streaming=True + ) + + # Create both tools + playlist_tool = SpotifySearchTool() + trending_tool = SpotifyTrendingTool() + + prompt = ChatPromptTemplate.from_messages([ + ("system", REACT_PROMPT), + ("human", "{input}") + ]) + + chain = prompt | llm + + return chain, [playlist_tool, trending_tool] + +#Streamlit +st.title("🎵 Spotify Playlist Explorer") + +if "spotify_credentials_set" not in st.session_state: + st.session_state.spotify_credentials_set = False + +# Spotify Credentials Management in Sidebar +with st.sidebar: + st.markdown("### Spotify Credentials") + + # Check if credentials exist in environment variables + env_credentials_exist = bool(os.getenv("SPOTIFY_CLIENT_ID")) and bool(os.getenv("SPOTIFY_CLIENT_SECRET")) + + if not env_credentials_exist: + st.warning("Spotify credentials not found in environment variables.") + + # Initialize session state for credentials + if "spotify_client_id" not in st.session_state: + st.session_state.spotify_client_id = "" + if "spotify_client_secret" not in st.session_state: + st.session_state.spotify_client_secret = "" + + # Input fields for credentials + client_id = st.text_input( + "Enter Spotify Client ID", + value=st.session_state.spotify_client_id, + type="password" + ) + client_secret = st.text_input( + "Enter Spotify Client Secret", + value=st.session_state.spotify_client_secret, + type="password" + ) + + if st.button("Save Credentials"): + st.session_state.spotify_client_id = client_id + st.session_state.spotify_client_secret = client_secret + st.session_state.spotify_credentials_set = True + st.success("Credentials saved!") + st.rerun() + else: + st.success("Using credentials from environment variables") + st.session_state.spotify_credentials_set = True + +# Check if credentials are available before proceeding +credentials_available = env_credentials_exist or st.session_state.spotify_credentials_set + +if not credentials_available: + st.error("Please provide Spotify credentials in the sidebar to continue.") +else: + + with st.spinner("Checking Model Service Availability..."): + server = checking_model_service() + + model_name = os.getenv("MODEL_NAME", "") + if server == "Ollama": + with st.sidebar: + model_name = st.radio( + label="Select Model", + options=get_models() + ) + + try: + agent, tools = create_react_agent(model_name) + playlist_tool, trending_tool = tools + + if "messages" not in st.session_state: + st.session_state.messages = [] + + for message in st.session_state.messages: + with st.chat_message(message["role"]): + st.markdown(message["content"]) + + if prompt := st.chat_input("What kind of playlists are you looking for?"): + st.session_state.messages.append({"role": "user", "content": prompt}) + + with st.chat_message("user"): + st.markdown(prompt) + + with st.chat_message("assistant"): + try: + tool_responses = { + "playlists": playlist_tool._run(prompt), + "trending": trending_tool._run(prompt) + } + + agent_response = agent.invoke({ + "input": prompt, + "tools": [tool.description for tool in tools], + "query": prompt, + "observation": tool_responses, + "answer": "Based on the search results, here's what I found:" + }) + + with st.expander("See thinking process"): + st.markdown(agent_response.content) + + formatted_response = format_spotify_response(tool_responses) + st.markdown(formatted_response) + + st.session_state.messages.append({ + "role": "assistant", + "content": formatted_response + }) + except Exception as e: + error_message = f"Error processing request: {str(e)}" + st.error(error_message) + except Exception as e: + st.error(f"Error initializing Spotify API: {str(e)}") \ No newline at end of file diff --git a/recipes/natural_language_processing/agents/app/requirements.txt b/recipes/natural_language_processing/agents/app/requirements.txt new file mode 100644 index 00000000..7690cacc --- /dev/null +++ b/recipes/natural_language_processing/agents/app/requirements.txt @@ -0,0 +1,10 @@ +streamlit>=1.24.0 +langchain-core>=0.1.0 +langchain-openai>=0.0.5 +python-dotenv>=0.19.0 + +requests>=2.31.0 + +typing-extensions>=4.5.0 + +streamlit-chat>=0.1.1