mirror of https://github.com/dapr/dapr-agents.git
Add quickstart for a knowledge base agent over Postgres + MCP + Chainlit (#103)
* initial commit Signed-off-by: yaron2 <schneider.yaron@live.com> * add quickstart for a postgres agent with mcp Signed-off-by: yaron2 <schneider.yaron@live.com> * linter Signed-off-by: yaron2 <schneider.yaron@live.com> * linter Signed-off-by: yaron2 <schneider.yaron@live.com> * review feedback Signed-off-by: yaron2 <schneider.yaron@live.com> * changed docker instructions Signed-off-by: yaron2 <schneider.yaron@live.com> * Update README.md Signed-off-by: Yaron Schneider <schneider.yaron@live.com> --------- Signed-off-by: yaron2 <schneider.yaron@live.com> Signed-off-by: Yaron Schneider <schneider.yaron@live.com>
This commit is contained in:
parent
53c1c9ffde
commit
4dce1c0300
|
|
@ -0,0 +1,97 @@
|
||||||
|
# Byte-compiled / optimized / DLL files
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
|
||||||
|
# C extensions
|
||||||
|
*.so
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env/
|
||||||
|
.pipenv/
|
||||||
|
*.egg-info/
|
||||||
|
.eggs/
|
||||||
|
|
||||||
|
# Distribution / packaging
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
*.egg
|
||||||
|
MANIFEST
|
||||||
|
|
||||||
|
# PyInstaller
|
||||||
|
*.manifest
|
||||||
|
*.spec
|
||||||
|
|
||||||
|
# Installer logs
|
||||||
|
pip-log.txt
|
||||||
|
pip-delete-this-directory.txt
|
||||||
|
|
||||||
|
# Unit test / coverage reports
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
.nox/
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
.cache
|
||||||
|
nosetests.xml
|
||||||
|
coverage.xml
|
||||||
|
*.cover
|
||||||
|
*.py,cover
|
||||||
|
|
||||||
|
# Pytest
|
||||||
|
.pytest_cache/
|
||||||
|
|
||||||
|
# Jupyter Notebook
|
||||||
|
.ipynb_checkpoints
|
||||||
|
|
||||||
|
# pyenv
|
||||||
|
.python-version
|
||||||
|
|
||||||
|
# VS Code
|
||||||
|
.vscode/
|
||||||
|
|
||||||
|
# mypy
|
||||||
|
.mypy_cache/
|
||||||
|
.dmypy.json
|
||||||
|
dmypy.json
|
||||||
|
|
||||||
|
# Pyre type checker
|
||||||
|
.pyre/
|
||||||
|
|
||||||
|
# Pylint
|
||||||
|
.pylint.d/
|
||||||
|
|
||||||
|
# IDEs and editors
|
||||||
|
.idea/
|
||||||
|
*.sublime-workspace
|
||||||
|
*.sublime-project
|
||||||
|
*.vscode/
|
||||||
|
|
||||||
|
# MacOS
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
.files/
|
||||||
|
|
||||||
|
# Local environment variables
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
*.pid
|
||||||
|
|
||||||
|
# Chainlit
|
||||||
|
.chainlit/
|
||||||
|
chainlit.md
|
||||||
|
|
@ -0,0 +1,163 @@
|
||||||
|
# A conversational agent over a Postgres database using MCP
|
||||||
|
|
||||||
|
This quickstart demonstrates how to build a fully functional, enterprise-ready agent that allows users to ask their database any question in natural text and get both the results and a highly structured analysis of complex questions. This quickstart also shows the usage of MCP in Dapr Agents to connect to the database and provides a fully functional ChatGPT-like chat interface using Chainlit.
|
||||||
|
|
||||||
|
## Key Benefits
|
||||||
|
|
||||||
|
- **Conversational Knowledge Base**: Users can talk to their database in natural language, ask complex questions and perform advanced analysis over data
|
||||||
|
- **Conversational Memory**: The agent maintains context across interactions in the user's [database of choice](https://docs.dapr.io/reference/components-reference/supported-state-stores/)
|
||||||
|
- **UI Interface**: Use an out-of-the-box, LLM-ready chat interface using [Chainlit](https://github.com/Chainlit/chainlit)
|
||||||
|
- **Boilerplate-Free DB Layer**: MCP allows the Dapr Agent to connect to the database without requiring users to write Postgres-specific code
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Python 3.10 (recommended)
|
||||||
|
- pip package manager
|
||||||
|
- OpenAI API key (for the OpenAI example)
|
||||||
|
- [Dapr CLI installed](https://docs.dapr.io/getting-started/install-dapr-cli/)
|
||||||
|
|
||||||
|
## Environment Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create a virtual environment
|
||||||
|
python3.10 -m venv .venv
|
||||||
|
|
||||||
|
# Activate the virtual environment
|
||||||
|
# On Windows:
|
||||||
|
.venv\Scripts\activate
|
||||||
|
# On macOS/Linux:
|
||||||
|
source .venv/bin/activate
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
# Initialize Dapr
|
||||||
|
dapr init
|
||||||
|
```
|
||||||
|
|
||||||
|
## LLM Configuration
|
||||||
|
|
||||||
|
For this example, we'll be using the OpenAI client that is used by default. To target different LLMs, see [this example](../02_llm_call_dapr/README.md).
|
||||||
|
|
||||||
|
Create a `.env` file in the project root:
|
||||||
|
|
||||||
|
```env
|
||||||
|
OPENAI_API_KEY=your_api_key_here
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace `your_api_key_here` with your actual OpenAI API key.
|
||||||
|
|
||||||
|
## Postgres Configuration
|
||||||
|
|
||||||
|
### Connect to an existing database
|
||||||
|
|
||||||
|
Create an `.env` file in the root directory of this quickstart and insert your database configuration:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DB_HOST=<HOST>
|
||||||
|
DB_PORT=<PORT>
|
||||||
|
DB_NAME=<DATABASE-NAME>
|
||||||
|
DB_USER=<USER>
|
||||||
|
DB_PASSWORD=<PASSWORD>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Create a new sample database
|
||||||
|
|
||||||
|
First, install Postgres on your machine.
|
||||||
|
|
||||||
|
#### Option 1: Using Docker
|
||||||
|
|
||||||
|
Create the following directory and copy the sql files there:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir docker-entrypoint-initdb.d:/docker-entrypoint-initdb.d
|
||||||
|
cp schema.sql users.sql ./docker-entrypoint-initdb.d:/docker-entrypoint-initdb.d
|
||||||
|
```
|
||||||
|
|
||||||
|
Run the database container:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --rm --name sampledb \
|
||||||
|
-e POSTGRES_PASSWORD=mypassword \
|
||||||
|
-e POSTGRES_USER=admin \
|
||||||
|
-e POSTGRES_DB=userdb \
|
||||||
|
-p 5432:5432 \
|
||||||
|
-v $(pwd)/docker-entrypoint-initdb.d:/docker-entrypoint-initdb.d \
|
||||||
|
-d postgres
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Option 2: Using Brew
|
||||||
|
|
||||||
|
Install Postgres:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew install postgresql
|
||||||
|
brew services start postgresql
|
||||||
|
|
||||||
|
psql postgres
|
||||||
|
> CREATE USER admin WITH PASSWORD 'mypassword';
|
||||||
|
> CREATE DATABASE userdb
|
||||||
|
> GRANT ALL PRIVILEGES ON DATABASE userdb TO admin;
|
||||||
|
```
|
||||||
|
|
||||||
|
Next, create the users table and seed data:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
psql -h localhost -U admin -d userdb -f schema.sql
|
||||||
|
psql -h localhost -U admin -d userdb -f users.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Create .env file
|
||||||
|
|
||||||
|
Finally, create an `.env` file in the root directory of this quickstart and insert your database configuration:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DB_HOST=localhost
|
||||||
|
DB_PORT=5432
|
||||||
|
DB_NAME=userdb
|
||||||
|
DB_USER=admin
|
||||||
|
DB_PASSWORD=mypassword
|
||||||
|
```
|
||||||
|
|
||||||
|
## MCP Configuration
|
||||||
|
|
||||||
|
To get the Dapr Agent to connect to our Postgres database, we'll use a Postgres MCP server.
|
||||||
|
Change the settings below based on your Postgres configuration:
|
||||||
|
|
||||||
|
*Note: If you're running Postgres in a Docker container, change `<HOST>` to `localhost`.*
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -p 8000:8000 \
|
||||||
|
-e DATABASE_URI=postgresql://<USERNAME>:<PASSWORD>@<HOST>:5432/userdb \
|
||||||
|
crystaldba/postgres-mcp --access-mode=unrestricted --transport=sse
|
||||||
|
```
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
### Load data to Postgres and create a knowledge base chat interface
|
||||||
|
|
||||||
|
Run the agent:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dapr run --app-id sql --resources-path ./components -- chainlit run app.py -w --port 8001
|
||||||
|
```
|
||||||
|
|
||||||
|
Wait until the browser opens up. Once open, you're ready to talk to your Postgres database!
|
||||||
|
You can find the agent page at http://localhost:8001.
|
||||||
|
|
||||||
|
### Ask Questions
|
||||||
|
|
||||||
|
Now you can start talking to your data. If using the sample database, ask questons like `Show me all churned users from the past month` and `Can you identify the problematic area in our product that led to users churning?`.
|
||||||
|
|
||||||
|
#### Testing the agent's memory
|
||||||
|
|
||||||
|
If you exit the app and restart it, the agent will remember all the previous conversation. When you insall Dapr using `dapr init`, Redis is installed by default and this is where the conversation memory is saved. To change it, edit the `./components/conversationmemory.yaml` file.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
**How It Works:**
|
||||||
|
1. The MCP server is running and connects to our Postgres database
|
||||||
|
2. Dapr starts, loading the conversation history storage configs from the `components` folder. The agent connects to the MCP server.
|
||||||
|
3. Chainlit loads and starts the agent UI in your browser.
|
||||||
|
4. Users can now talk to their database in natural language and have the agent analyze the data.
|
||||||
|
5. The conversation history is automatically managed by Dapr and saved in the state store configured in `./components/conversationmemory.yaml`.
|
||||||
|
|
@ -0,0 +1,83 @@
|
||||||
|
import chainlit as cl
|
||||||
|
from dapr_agents import Agent
|
||||||
|
from dapr_agents.tool.mcp.client import MCPClient
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
from get_schema import get_table_schema_as_dict
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
instructions = [
|
||||||
|
"You are an assistant designed to translate human readable text to postgresql queries. "
|
||||||
|
"Your primary goal is to provide accurate SQL queries based on the user request. "
|
||||||
|
"If something is unclear or you need more context, ask thoughtful clarifying questions."
|
||||||
|
]
|
||||||
|
|
||||||
|
agent = {}
|
||||||
|
|
||||||
|
table_info = {}
|
||||||
|
|
||||||
|
|
||||||
|
@cl.on_chat_start
|
||||||
|
async def start():
|
||||||
|
client = MCPClient()
|
||||||
|
await client.connect_sse(
|
||||||
|
server_name="local", # Unique name you assign to this server
|
||||||
|
url="http://0.0.0.0:8000/sse", # MCP SSE endpoint
|
||||||
|
headers=None, # Optional HTTP headers if needed
|
||||||
|
)
|
||||||
|
|
||||||
|
# See what tools were loaded
|
||||||
|
tools = client.get_all_tools()
|
||||||
|
|
||||||
|
global agent
|
||||||
|
agent = Agent(
|
||||||
|
name="SQL",
|
||||||
|
role="Database Expert",
|
||||||
|
instructions=instructions,
|
||||||
|
tools=tools,
|
||||||
|
)
|
||||||
|
|
||||||
|
global table_info
|
||||||
|
table_info = get_table_schema_as_dict()
|
||||||
|
|
||||||
|
if table_info:
|
||||||
|
await cl.Message(
|
||||||
|
content="Database connection successful. Ask me anything."
|
||||||
|
).send()
|
||||||
|
else:
|
||||||
|
await cl.Message(content="Database connection failed.").send()
|
||||||
|
|
||||||
|
|
||||||
|
@cl.on_message
|
||||||
|
async def main(message: cl.Message):
|
||||||
|
# generate the result set and pass back to the user
|
||||||
|
prompt = create_prompt_for_llm(table_info, message.content)
|
||||||
|
result = await agent.run(prompt)
|
||||||
|
|
||||||
|
await cl.Message(
|
||||||
|
content=result,
|
||||||
|
).send()
|
||||||
|
|
||||||
|
result_set = await agent.run(
|
||||||
|
"Execute the following sql query and always return a table format unless instructed otherwise. If the user asks a question regarding the data, return the result and formalize an answer based on inspecting the data: "
|
||||||
|
+ result
|
||||||
|
)
|
||||||
|
await cl.Message(
|
||||||
|
content=result_set,
|
||||||
|
).send()
|
||||||
|
|
||||||
|
|
||||||
|
def create_prompt_for_llm(schema_data, user_question):
|
||||||
|
prompt = "Here is the schema for the tables in the database:\n\n"
|
||||||
|
|
||||||
|
# Add schema information to the prompt
|
||||||
|
for table, columns in schema_data.items():
|
||||||
|
prompt += f"Table {table}:\n"
|
||||||
|
for col in columns:
|
||||||
|
prompt += f" - {col['column_name']} ({col['data_type']}), Nullable: {col['is_nullable']}, Default: {col['column_default']}\n"
|
||||||
|
|
||||||
|
# Add the user's question for context
|
||||||
|
prompt += f"\nUser's question: {user_question}\n"
|
||||||
|
prompt += "Generate the postgres SQL query to answer the user's question. Return only the query string and nothing else."
|
||||||
|
|
||||||
|
return prompt
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
apiVersion: dapr.io/v1alpha1
|
||||||
|
kind: Component
|
||||||
|
metadata:
|
||||||
|
name: conversationstore
|
||||||
|
spec:
|
||||||
|
type: state.redis
|
||||||
|
version: v1
|
||||||
|
metadata:
|
||||||
|
- name: redisHost
|
||||||
|
value: localhost:6379
|
||||||
|
- name: redisPassword
|
||||||
|
value: ""
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
import os
|
||||||
|
import psycopg
|
||||||
|
|
||||||
|
|
||||||
|
def get_table_schema_as_dict():
|
||||||
|
conn_params = {
|
||||||
|
"host": os.getenv("DB_HOST"),
|
||||||
|
"port": os.getenv("DB_PORT"),
|
||||||
|
"dbname": os.getenv("DB_NAME"),
|
||||||
|
"user": os.getenv("DB_USER"),
|
||||||
|
"password": os.getenv("DB_PASSWORD"),
|
||||||
|
}
|
||||||
|
|
||||||
|
schema_data = {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
with psycopg.connect(**conn_params) as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT table_schema, table_name
|
||||||
|
FROM information_schema.tables
|
||||||
|
WHERE table_type = 'BASE TABLE' AND table_schema NOT IN ('pg_catalog', 'information_schema')
|
||||||
|
ORDER BY table_schema, table_name;
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
tables = cur.fetchall()
|
||||||
|
|
||||||
|
for schema, table in tables:
|
||||||
|
schema_data[f"{schema}.{table}"] = []
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT column_name, data_type, is_nullable, column_default
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_schema = %s AND table_name = %s
|
||||||
|
ORDER BY ordinal_position;
|
||||||
|
""",
|
||||||
|
(schema, table),
|
||||||
|
)
|
||||||
|
columns = cur.fetchall()
|
||||||
|
|
||||||
|
for col in columns:
|
||||||
|
schema_data[f"{schema}.{table}"].append(
|
||||||
|
{
|
||||||
|
"column_name": col[0],
|
||||||
|
"data_type": col[1],
|
||||||
|
"is_nullable": col[2],
|
||||||
|
"column_default": col[3],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return schema_data
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
dapr-agents>=0.5.1
|
||||||
|
python-dotenv
|
||||||
|
chainlit
|
||||||
|
psycopg
|
||||||
|
psycopg[binary]
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
DROP TABLE IF EXISTS users;
|
||||||
|
|
||||||
|
CREATE TABLE users (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
email VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
is_customer BOOLEAN DEFAULT FALSE,
|
||||||
|
churn_reason TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
INSERT INTO users (name, email, is_customer, churn_reason, created_at) VALUES
|
||||||
|
('Alice Johnson', 'alice@example.com', TRUE, NULL, NOW() - INTERVAL '6 months'),
|
||||||
|
('Bob Smith', 'bob@example.com', TRUE, NULL, NOW() - INTERVAL '5 months'),
|
||||||
|
('Carla Ruiz', 'carla@example.com', TRUE, NULL, NOW() - INTERVAL '4 months'),
|
||||||
|
('David Lee', 'david@example.com', TRUE, NULL, NOW() - INTERVAL '3 months'),
|
||||||
|
('Emma Chen', 'emma@example.com', TRUE, NULL, NOW() - INTERVAL '2 months'),
|
||||||
|
('Frank Novak', 'frank@example.com', FALSE, 'Spent 10 minutes trying to find the logout button — it was hidden in a weird place.', NOW() - INTERVAL '6 weeks'),
|
||||||
|
('Grace Patel', 'grace@example.com', FALSE, 'The dashboard felt cluttered and overwhelming right from the start.', NOW() - INTERVAL '5 weeks'),
|
||||||
|
('Hassan Ali', 'hassan@example.com', FALSE, 'Couldn’t figure out how to edit my profile — had to Google it.', NOW() - INTERVAL '4 weeks'),
|
||||||
|
('Isabella Moreau', 'isabella@example.com', FALSE, 'Forms had way too many fields and no clear labels.', NOW() - INTERVAL '3 weeks'),
|
||||||
|
('Jamal Wright', 'jamal@example.com', FALSE, 'Nothing looked clickable — I was stuck on the home screen for a while.', NOW() - INTERVAL '2 weeks');
|
||||||
Loading…
Reference in New Issue