diff --git a/supporting-blog-content/serverless-ai-agent/README.md b/supporting-blog-content/serverless-ai-agent/README.md new file mode 100644 index 00000000..11af69a2 --- /dev/null +++ b/supporting-blog-content/serverless-ai-agent/README.md @@ -0,0 +1,155 @@ +# Elasticsearch Serverless AI Agent + +This little command-line tool lets you manage your [Serverless Elasticsearch projects](https://www.elastic.co/guide/en/serverless/current/intro.html) in plain English. It talks to an AI (in this case, OpenAI) to figure out what you mean and call the right functions using LlamaIndex! + +### What Does It Do? +- **Create a project**: Spin up a new Serverless Elasticsearch project. +- **Delete a project**: Remove an existing project (yep, it cleans up after you). +- **Get project status**: Check on how your project is doing. +- **Get project details**: Fetch all the juicy details about your project. + +### How It Works +When you type in something like: + +_"Create a serverless project named my_project"_ + +…here’s what goes on behind the scenes: + +- **User Input & Context:** Your natural language command is sent to the AI agent. +- **Function Descriptions:** The AI agent already knows about a few functions—like create_ess_project, delete_ess_project, get_ess_project_status, and get_ess_project_details—because we gave it detailed descriptions. These descriptions tell the AI what each function does and what parameters they need. +- **LLM Processing:** Your query plus the function info is sent off to the LLM. This means the AI sees: +- **The User Query**: Your plain-English instruction. +- **Available Functions & Descriptions**: Details on what each tool does so it can choose the right one. +- **Context/Historic Chat Info**: Since it’s a conversation, it remembers what’s been said before. +- **Function Call & Response**: The AI figures out which function to call, passes along the right parameters (like your project name), and then the function is executed. The response is sent back to you in a friendly format. + +In short, we’re sending both your natural language query and a list of detailed tool descriptions to the LLM so it can “understandd” and choose the right action for your request. + +### Setup + +- **Clone the Repoo:** +``` +git clone git@github.com:framsouza/serverless-ai-agent.git +cd serverless-ai-agent +``` + +- **Install the Dependencies**: Make sure you have Python installed, then run: +``` +pip install -r requirements.txt +``` + +- **Configure Your Environment**: Create a .env file in the project root with these variables: +``` +ES_URL=your_elasticsearch_api_url +API_KEY=your_elasticsearch_api_key +REGION=your_region +OPENAI_API_KEY=your_openai_api_key +``` + +- **Projects File**: The tool uses a `projects.json` file to store your project mappings (project names to their details). This file is created automatically if it doesn’t exist. + +### Running the agent + +``` +python main.py +``` + +You’ll see a prompt like this: + +``` +Welcome to the Serverless Project AI Agent Tool! +You can ask things like: + - 'Create a serverless project named my_project' + - 'Delete the serverless project named my_project' + - 'Get the status of the serverless project named my_project' + - 'Get the details of the serverless project named my_project' +``` + +Type in your command, and the AI agent will work its magic! When you're done, type `exit` or `quit` to leave. + +### A few more details + +- **LLM Integration**: The LLM is given both your query and detailed descriptions of each available function. This helps it understand the context and decide, for example, whether to call `create_ess_project` or `delete_ess_project`. +- **Tool Descriptions**: Each function tool (created using FunctionTool.from_defaults) has a friendly description. This description is included in the prompt sent to the LLM so that it “knows” what actions are available and what each action expects. +- **Persistence**: Your projects and their details are saved in projects.json, so you don’t have to re-enter info every time. +- **Verbose Logging**: The agent is set to verbose mode, which is great for debugging and seeing how your instructions get translated into function calls. + +### Example utilization + + +``` +python main.py + +Welcome to the Serverless Project AI Agent Tool! + +You can ask things like: + - 'Create a serverless project named my_project' + - 'Delete the serverless project named my_project' + - 'Get the status of the serverless project named my_project' + - 'Get the details of the serverless project named my_project' + +User: Create a serverless project named my-february-test +Added user message to memory: Create a serverless project named my-february-test + +=== Calling Function === +Calling function: create_ess_project with args: {"project_name":"my-february-test"} +======================== + + +Agent: The serverless Elasticsearch project named "my-february-test" has been created successfully. Here are the details: + +- **Project ID:** <>> +- **Elasticsearch Endpoint:** [https://my-february-test-ccc399.es.eu-west-1.aws.qa.elastic.cloud](https://my-february-test-ccc399.es.eu-west-1.aws.qa.elastic.cloud) +- **Kibana Endpoint:** [https://my-february-test-ccc399.kb.eu-west-1.aws.qa.elastic.cloud](https://my-february-test-ccc399.kb.eu-west-1.aws.qa.elastic.cloud) + +User: get my-february-test status +Added user message to memory: get my-february-test status + +=== Calling Function === +Calling function: get_ess_project_status with args: {"project_name":"my-february-test"} +======================== + +Agent: The status of the "my-february-test" project is currently **initializing**. + +User: get my-february-test info +Added user message to memory: get my-february-test info + +=== Calling Function === +Calling function: get_ess_project_details with args: {"project_name":"my-february-test"} +======================== + +Agent: Here are the details for the "my-february-test" project: + +- **Project Name:** my-february-test +- **Alias:** my-february-test-ccc399 +- **Cloud ID:** "" +- **Project ID:** "" +- **Region ID:** aws-eu-west-1 +- **Created At:** 2025-02-12T15:42:00.530177619Z +- **Created By:** 571213384 +- **Organization ID:** "" +- **Type:** Elasticsearch +- **Optimized For:** General Purpose +- **Search Lake:** + - **Boost Window:** 7 + - **Search Power:** 100 +- **Endpoints:** + - **Elasticsearch:** https://my-february-test-ccc399.es.eu-west-1.aws.qa.elastic.cloud + - **Kibana:** https://my-february-test-ccc399.kb.eu-west-1.aws.qa.elastic.cloud +- **Credentials:** + - **Username:** "" + - **Password:** "" + +Please ensure to keep the credentials secure. + +User: please delete the my-february-test project +Added user message to memory: please delete the my-february-test project + +=== Calling Function === +Calling function: delete_ess_project with args: {"project_name":"my-february-test"} +======================== + +Agent: The "my-february-test" project has been deleted successfully. +``` + +[See original code](https://github.com/framsouza/serverless-ai-agent) \ No newline at end of file diff --git a/supporting-blog-content/serverless-ai-agent/main.py b/supporting-blog-content/serverless-ai-agent/main.py new file mode 100644 index 00000000..f000a13c --- /dev/null +++ b/supporting-blog-content/serverless-ai-agent/main.py @@ -0,0 +1,276 @@ +import os +import re +import json +import requests +from dotenv import load_dotenv +from llama_index.core.tools import FunctionTool +from llama_index.agent.openai import OpenAIAgent +from llama_index.llms.openai import OpenAI + +load_dotenv(".env") +PROJECTS_FILE = "projects.json" + + +def load_projects(): + """Load the project mapping from the JSON file.""" + if os.path.exists(PROJECTS_FILE): + with open(PROJECTS_FILE, "r") as f: + return json.load(f) + return {} + + +def save_projects(project_map): + """Save the project mapping to the JSON file.""" + with open(PROJECTS_FILE, "w") as f: + json.dump(project_map, f) + + +# Store project name -> project info mapping. +PROJECT_MAP = load_projects() + + +def normalize_project_info(project_info): + """ + Ensure that the project info is a dictionary. + If the stored value is a string, convert it to a dictionary with the key "id". + """ + if isinstance(project_info, str): + return {"id": project_info} + return project_info + + +def create_ess_project(project_name: str) -> str: + """ + Creates a Serverless project by calling the ESS API. + The API environment URL, API key, and region are read from the .env file. + The returned project details (ID, credentials, endpoints) are stored + in a global mapping (PROJECT_MAP) which is persisted to a file. + Returns a summary of the created project. + """ + env_url = os.environ.get("ES_URL") + api_key = os.environ.get("API_KEY") + region_id = os.environ.get("REGION", "aws-eu-west-1") + + if not env_url: + return "Error: ES_URL is not set in the environment." + if not api_key: + return "Error: API_KEY is not set in the environment." + + url = f"{env_url}/api/v1/serverless/projects/elasticsearch" + + headers = {"Authorization": f"ApiKey {api_key}", "Content-Type": "application/json"} + payload = {"name": project_name, "region_id": region_id} + + try: + response = requests.post(url, headers=headers, json=payload) + response.raise_for_status() + except requests.exceptions.RequestException as e: + return f"Error during project creation: {e}" + + data = response.json() + project_id = data.get("id") + if not project_id: + return "Error: No project ID returned from the API." + + if not re.fullmatch(r"^[a-z0-9]{32}$", project_id): + return ( + f"Error: Received project ID '{project_id}' does not match expected format." + ) + + credentials = data.get("credentials") + endpoints = data.get("endpoints") + + PROJECT_MAP[project_name] = { + "id": project_id, + "credentials": credentials, + "endpoints": endpoints, + } + save_projects(PROJECT_MAP) + + summary = ( + f"Project '{project_name}' created successfully.\n" + f"Project ID: {project_id}\n" + f"Endpoints: {endpoints}" + ) + return summary + + +def delete_ess_project(project_name: str) -> str: + """ + Deletes a Serverless project by using the project name. + It looks up the project ID automatically from the persisted PROJECT_MAP and deletes the project. + Returns a confirmation message or an error message. + """ + project_info = PROJECT_MAP.get(project_name) + if not project_info: + return f"Error: No project found with name '{project_name}'. Ensure the project was created previously." + project_info = normalize_project_info(project_info) + + project_id = project_info.get("id") + env_url = os.environ.get("ES_URL") + api_key = os.environ.get("API_KEY") + + if not env_url: + return "Error: ES_URL is not set in the environment." + if not api_key: + return "Error: API_KEY is not set in the environment." + + url = f"{env_url}/api/v1/serverless/projects/elasticsearch/{project_id}" + headers = {"Authorization": f"ApiKey {api_key}", "Content-Type": "application/json"} + + try: + response = requests.delete(url, headers=headers) + response.raise_for_status() + except requests.exceptions.RequestException as e: + return f"Error during project deletion: {e}" + + del PROJECT_MAP[project_name] + save_projects(PROJECT_MAP) + return f"Project '{project_name}' (ID: {project_id}) deleted successfully." + + +def get_ess_project_status(project_name: str) -> str: + """ + Retrieves the status of a Serverless project by using its project name. + It looks up the project ID from the persisted PROJECT_MAP and calls the /status endpoint. + Returns the status information as a formatted JSON string. + """ + project_info = PROJECT_MAP.get(project_name) + if not project_info: + return f"Error: No project found with name '{project_name}'." + project_info = normalize_project_info(project_info) + + project_id = project_info.get("id") + env_url = os.environ.get("ES_URL") + api_key = os.environ.get("API_KEY") + + if not env_url: + return "Error: ES_URL is not set in the environment." + if not api_key: + return "Error: API_KEY is not set in the environment." + + url = f"{env_url}/api/v1/serverless/projects/elasticsearch/{project_id}/status" + headers = {"Authorization": f"ApiKey {api_key}"} + + try: + response = requests.get(url, headers=headers) + response.raise_for_status() + except requests.exceptions.RequestException as e: + return f"Error retrieving project status: {e}" + + status_data = response.json() + return json.dumps(status_data, indent=4) + + +def get_ess_project_details(project_name: str) -> str: + """ + Retrieves the full details of a Serverless project by using its project name. + It looks up the project ID from the persisted PROJECT_MAP and calls the GET project endpoint. + If credentials or endpoints are missing in the API response, stored values are used as fallback. + Returns the project details as a formatted JSON string. + """ + project_info = PROJECT_MAP.get(project_name) + if not project_info: + return f"Error: No project found with name '{project_name}'." + project_info = normalize_project_info(project_info) + + project_id = project_info.get("id") + env_url = os.environ.get("ES_URL") + api_key = os.environ.get("API_KEY") + + if not env_url: + return "Error: ES_URL is not set in the environment." + if not api_key: + return "Error: API_KEY is not set in the environment." + + url = f"{env_url}/api/v1/serverless/projects/elasticsearch/{project_id}" + headers = {"Authorization": f"ApiKey {api_key}"} + + try: + response = requests.get(url, headers=headers) + response.raise_for_status() + except requests.exceptions.RequestException as e: + return f"Error retrieving project details: {e}" + + details_data = response.json() + if not details_data.get("credentials"): + details_data["credentials"] = project_info.get("credentials") + if not details_data.get("endpoints"): + details_data["endpoints"] = project_info.get("endpoints") + return json.dumps(details_data, indent=4) + + +create_project_tool = FunctionTool.from_defaults( + create_ess_project, + name="create_ess_project", + description=( + "Creates a Serverless Elasticsearch project. " + "It requires a single parameter: project_name. " + "The API environment URL (ES_URL), API key (API_KEY), and region (REGION) are read from the environment (.env file). " + "The function stores the project details (ID, credentials, endpoints) for later use and persists them to a file." + ), +) + +delete_project_tool = FunctionTool.from_defaults( + delete_ess_project, + name="delete_ess_project", + description=( + "Deletes a Serverless Elasticsearch project using its project name. " + "It automatically looks up the project ID (stored during project creation and persisted in a file) and deletes the project. " + "It returns a confirmation message or an error message." + ), +) + +get_status_tool = FunctionTool.from_defaults( + get_ess_project_status, + name="get_ess_project_status", + description=( + "Retrieves the status of a Serverless Elasticsearch project by using its project name. " + "It looks up the project ID (from a persisted mapping) and calls the /status endpoint. " + "It returns the status information as a JSON string." + ), +) + +get_details_tool = FunctionTool.from_defaults( + get_ess_project_details, + name="get_ess_project_details", + description=( + "Retrieves the full details of a Serverless Elasticsearch project by using its project name. " + "It looks up the project ID (from a persisted mapping) and calls the GET project endpoint. " + "It returns the project details as a JSON string." + ), +) + +openai_api_key = os.environ.get("OPENAI_API_KEY") +if not openai_api_key: + raise ValueError( + "Please set the OPENAI_API_KEY environment variable with your OpenAI API key." + ) + +llm = OpenAI(model="gpt-4o", api_key=openai_api_key) +agent = OpenAIAgent.from_tools( + [create_project_tool, delete_project_tool, get_status_tool, get_details_tool], + llm=llm, + verbose=True, +) + + +def main(): + print("\nWelcome to the Serverless Project AI Agent Tool!\n") + print("You can ask things like:") + print(" - 'Create a serverless project named my_project'") + print(" - 'Delete the serverless project named my_project'") + print(" - 'Get the status of the serverless project named my_project'") + print(" - 'Get the details of the serverless project named my_project'") + + while True: + user_input = input("\nUser: ") + if user_input.strip().lower() in {"exit", "quit"}: + break + + response = agent.chat(user_input) + print("\nAgent:", response) + + +if __name__ == "__main__": + main() diff --git a/supporting-blog-content/serverless-ai-agent/requirements.txt b/supporting-blog-content/serverless-ai-agent/requirements.txt new file mode 100644 index 00000000..1729d5e7 --- /dev/null +++ b/supporting-blog-content/serverless-ai-agent/requirements.txt @@ -0,0 +1,4 @@ +requests +python-dotenv +llama-index +openai