Spaces:
Paused
Paused
Uploaded from local
Browse files- .dockerignore +66 -0
- .env.example +74 -0
- .github/workflows/aws.yaml +128 -0
- .github/workflows/ci.yaml +64 -0
- .github/workflows/task_definition.json +156 -0
- .gitignore +59 -0
- .streamlit/config.toml +13 -0
- Dockerfile +60 -0
- OLD/decision_maker.py +104 -0
- OLD/misc/rag_docker.py +172 -0
- OLD/searchagent_ddg_api.py +261 -0
- database_schema.sql +177 -0
- docker-compose.yml +93 -0
- docker/Dockerfile +64 -0
- docker/Dockerfile.toolbox +52 -0
- docker/entrypoint-hf.sh +55 -0
- docker/entrypoint-toolbox.sh +17 -0
- fastapi_testing.ipynb +1209 -0
- requirements.txt +0 -0
- scripts/setup.sh +85 -0
- scripts/start.sh +58 -0
- src/__init__.py +0 -0
- src/agents/__init__.py +0 -0
- src/agents/crypto_agent_mcp.py +180 -0
- src/agents/finance_tracker_agent_mcp.py +351 -0
- src/agents/rag_agent_mcp.py +449 -0
- src/agents/search_agent_mcp.py +283 -0
- src/agents/stock_agent_mcp.py +196 -0
- src/api/__init__.py +0 -0
- src/api/main.py +450 -0
- src/core/__init__.py +0 -0
- src/core/config.py +121 -0
- src/core/langgraph_supervisor.py +616 -0
- src/core/mcp_client.py +290 -0
- src/utils/__init__.py +0 -0
- src/utils/file_processors.py +187 -0
- tests/__init__.py +0 -0
- tests/test_unit_cases.py +264 -0
- ui/__init__.py +0 -0
- ui/gradio_app.py +542 -0
- ui/streamlit_app.py +473 -0
.dockerignore
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Python
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[cod]
|
| 4 |
+
*$py.class
|
| 5 |
+
*.so
|
| 6 |
+
.Python
|
| 7 |
+
venv/
|
| 8 |
+
env/
|
| 9 |
+
ENV/
|
| 10 |
+
*.egg-info/
|
| 11 |
+
.pytest_cache/
|
| 12 |
+
.coverage
|
| 13 |
+
htmlcov/
|
| 14 |
+
|
| 15 |
+
# Environment and secrets
|
| 16 |
+
.env
|
| 17 |
+
.env.*
|
| 18 |
+
*.pem
|
| 19 |
+
*.key
|
| 20 |
+
credentials.json
|
| 21 |
+
secrets.toml
|
| 22 |
+
|
| 23 |
+
# Git
|
| 24 |
+
.git/
|
| 25 |
+
.gitignore
|
| 26 |
+
.github/
|
| 27 |
+
|
| 28 |
+
# IDEs
|
| 29 |
+
.vscode/
|
| 30 |
+
.idea/
|
| 31 |
+
*.swp
|
| 32 |
+
*.swo
|
| 33 |
+
|
| 34 |
+
# OS
|
| 35 |
+
.DS_Store
|
| 36 |
+
Thumbs.db
|
| 37 |
+
|
| 38 |
+
# Documentation
|
| 39 |
+
README.md
|
| 40 |
+
*.md
|
| 41 |
+
docs/
|
| 42 |
+
|
| 43 |
+
# Docker
|
| 44 |
+
Dockerfile*
|
| 45 |
+
docker-compose*.yml
|
| 46 |
+
.dockerignore
|
| 47 |
+
|
| 48 |
+
# Jupyter
|
| 49 |
+
*.ipynb
|
| 50 |
+
.ipynb_checkpoints/
|
| 51 |
+
|
| 52 |
+
# Old files
|
| 53 |
+
OLD/
|
| 54 |
+
|
| 55 |
+
# Logs
|
| 56 |
+
*.log
|
| 57 |
+
|
| 58 |
+
# Database
|
| 59 |
+
*.db
|
| 60 |
+
*.sqlite
|
| 61 |
+
*.sqlite3
|
| 62 |
+
|
| 63 |
+
# Temporary files
|
| 64 |
+
tmp/
|
| 65 |
+
temp/
|
| 66 |
+
*.tmp
|
.env.example
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# =============================================================================
|
| 2 |
+
# HUGGING FACE SPACES - ENVIRONMENT VARIABLES TEMPLATE
|
| 3 |
+
# =============================================================================
|
| 4 |
+
# Copy these variables to your Hugging Face Space Settings > Variables and Secrets
|
| 5 |
+
# DO NOT commit actual API keys to version control
|
| 6 |
+
# =============================================================================
|
| 7 |
+
|
| 8 |
+
# =============================================================================
|
| 9 |
+
# REQUIRED: Core API Keys (Must be configured)
|
| 10 |
+
# =============================================================================
|
| 11 |
+
|
| 12 |
+
# Google Gemini API Key
|
| 13 |
+
# Get from: https://makersuite.google.com/app/apikey
|
| 14 |
+
GOOGLE_API_KEY=your_google_gemini_api_key_here
|
| 15 |
+
|
| 16 |
+
# ChromaDB Cloud API Key (for RAG document storage)
|
| 17 |
+
# Get from: https://www.trychroma.com/
|
| 18 |
+
CHROMA_API_KEY=your_chroma_api_key_here
|
| 19 |
+
CHROMA_TENANT=your_tenant_id
|
| 20 |
+
CHROMA_DATABASE=your_database_name
|
| 21 |
+
|
| 22 |
+
# Alpha Vantage API Key (for stock market data)
|
| 23 |
+
# Get from: https://www.alphavantage.co/support/#api-key
|
| 24 |
+
ALPHA_VANTAGE_API_KEY=your_alpha_vantage_api_key_here
|
| 25 |
+
|
| 26 |
+
# =============================================================================
|
| 27 |
+
# OPTIONAL: Enhanced Features
|
| 28 |
+
# =============================================================================
|
| 29 |
+
|
| 30 |
+
# CoinGecko Pro API Key (for enhanced crypto data)
|
| 31 |
+
# Get from: https://www.coingecko.com/en/api/pricing
|
| 32 |
+
# Note: Free tier available without API key (rate limited)
|
| 33 |
+
COINGECKO_API_KEY=your_coingecko_api_key_here
|
| 34 |
+
|
| 35 |
+
# =============================================================================
|
| 36 |
+
# OPTIONAL: Google Cloud SQL (for Portfolio Tracking)
|
| 37 |
+
# =============================================================================
|
| 38 |
+
# Only needed if you want persistent portfolio tracking with Cloud SQL
|
| 39 |
+
# Otherwise, portfolio data is ephemeral within the session
|
| 40 |
+
|
| 41 |
+
# Google Cloud Project Configuration
|
| 42 |
+
GCP_PROJECT_ID=your_gcp_project_id
|
| 43 |
+
CLOUD_SQL_REGION=us-central1
|
| 44 |
+
CLOUD_SQL_INSTANCE=your_instance_name
|
| 45 |
+
|
| 46 |
+
# Cloud SQL Database Credentials
|
| 47 |
+
CLOUD_SQL_INSTANCE_CONNECTION_NAME=project:region:instance
|
| 48 |
+
CLOUD_SQL_DB_NAME=finance_tracker
|
| 49 |
+
CLOUD_SQL_DB_USER=your_db_user
|
| 50 |
+
CLOUD_SQL_DB_PASS=your_db_password
|
| 51 |
+
|
| 52 |
+
# MCP Toolbox Server URL (for Cloud SQL access)
|
| 53 |
+
# If running MCP Toolbox as separate service/container
|
| 54 |
+
MCP_TOOLBOX_SERVER_URL=http://localhost:5000
|
| 55 |
+
|
| 56 |
+
# =============================================================================
|
| 57 |
+
# OPTIONAL: ChromaDB Configuration
|
| 58 |
+
# =============================================================================
|
| 59 |
+
|
| 60 |
+
# ChromaDB Cloud Host
|
| 61 |
+
CHROMA_CLOUD_HOST=api.trychroma.com
|
| 62 |
+
|
| 63 |
+
# ChromaDB Collection Name
|
| 64 |
+
DOCUMENTS_COLLECTION=mcp-test
|
| 65 |
+
|
| 66 |
+
# Embedding Function (default, openai, cohere, jina, voyageai)
|
| 67 |
+
CHROMA_EMBEDDING_FUNCTION=default
|
| 68 |
+
|
| 69 |
+
# =============================================================================
|
| 70 |
+
# OPTIONAL: UI Configuration
|
| 71 |
+
# =============================================================================
|
| 72 |
+
|
| 73 |
+
# Maximum file upload size (in MB)
|
| 74 |
+
MAX_FILE_SIZE_MB=50
|
.github/workflows/aws.yaml
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: CI/CD to ECS Fargate
|
| 2 |
+
on:
|
| 3 |
+
workflow_run:
|
| 4 |
+
workflows: ["Run Unit Tests"]
|
| 5 |
+
types:
|
| 6 |
+
- completed
|
| 7 |
+
env:
|
| 8 |
+
AWS_REGION: eu-west-3
|
| 9 |
+
ECR_REPOSITORY: multi-agent-app
|
| 10 |
+
ECR_REPOSITORY_TOOLBOX: mcp-toolbox
|
| 11 |
+
ECS_SERVICE: multi-agent-service
|
| 12 |
+
ECS_CLUSTER: multi-agent-cluster
|
| 13 |
+
ECS_TASK_DEFINITION: .github/workflows/task_definition.json
|
| 14 |
+
CONTAINER_NAME: multi-agent-app
|
| 15 |
+
|
| 16 |
+
permissions:
|
| 17 |
+
id-token: write
|
| 18 |
+
contents: read
|
| 19 |
+
|
| 20 |
+
jobs:
|
| 21 |
+
check-status:
|
| 22 |
+
runs-on: ubuntu-latest
|
| 23 |
+
if: ${{github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.head_branch == 'main'}}
|
| 24 |
+
steps:
|
| 25 |
+
- name: CI passed on main branch
|
| 26 |
+
run: echo "CI passed on main branch, proceeding to build and deploy."
|
| 27 |
+
|
| 28 |
+
# -------------------------------
|
| 29 |
+
# 1. Build & Push Docker Images
|
| 30 |
+
# -------------------------------
|
| 31 |
+
build-and-push:
|
| 32 |
+
name: Build & Push Docker Images
|
| 33 |
+
needs: [check-status]
|
| 34 |
+
runs-on: ubuntu-latest
|
| 35 |
+
outputs:
|
| 36 |
+
image: ${{ steps.build-image.outputs.image }}
|
| 37 |
+
toolbox-image: ${{ steps.build-toolbox.outputs.image }}
|
| 38 |
+
steps:
|
| 39 |
+
- name: Checkout Repo
|
| 40 |
+
uses: actions/checkout@v4
|
| 41 |
+
|
| 42 |
+
- name: Configure AWS Credentials
|
| 43 |
+
uses: aws-actions/configure-aws-credentials@v4
|
| 44 |
+
with:
|
| 45 |
+
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
| 46 |
+
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
| 47 |
+
aws-region: ${{ env.AWS_REGION }}
|
| 48 |
+
|
| 49 |
+
- name: Login to Amazon ECR
|
| 50 |
+
id: login-ecr
|
| 51 |
+
uses: aws-actions/amazon-ecr-login@v2
|
| 52 |
+
|
| 53 |
+
- name: Build and Push Main App Image
|
| 54 |
+
id: build-image
|
| 55 |
+
env:
|
| 56 |
+
ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
|
| 57 |
+
IMAGE_TAG: ${{ github.sha }}
|
| 58 |
+
run: |
|
| 59 |
+
IMAGE_URI=$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
|
| 60 |
+
echo "IMAGE_URI=$IMAGE_URI" >> $GITHUB_ENV
|
| 61 |
+
docker build -f docker/Dockerfile -t $IMAGE_URI .
|
| 62 |
+
docker push $IMAGE_URI
|
| 63 |
+
echo "image=$IMAGE_URI" >> $GITHUB_OUTPUT
|
| 64 |
+
|
| 65 |
+
- name: Build and Push MCP Toolbox Image
|
| 66 |
+
id: build-toolbox
|
| 67 |
+
env:
|
| 68 |
+
ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
|
| 69 |
+
IMAGE_TAG: ${{ github.sha }}
|
| 70 |
+
run: |
|
| 71 |
+
TOOLBOX_IMAGE_URI=$ECR_REGISTRY/$ECR_REPOSITORY_TOOLBOX:$IMAGE_TAG
|
| 72 |
+
docker build -f docker/Dockerfile.toolbox -t $TOOLBOX_IMAGE_URI .
|
| 73 |
+
docker push $TOOLBOX_IMAGE_URI
|
| 74 |
+
|
| 75 |
+
# Also tag and push as 'latest'
|
| 76 |
+
TOOLBOX_LATEST=$ECR_REGISTRY/$ECR_REPOSITORY_TOOLBOX:latest
|
| 77 |
+
docker tag $TOOLBOX_IMAGE_URI $TOOLBOX_LATEST
|
| 78 |
+
docker push $TOOLBOX_LATEST
|
| 79 |
+
|
| 80 |
+
echo "image=$TOOLBOX_IMAGE_URI" >> $GITHUB_OUTPUT
|
| 81 |
+
|
| 82 |
+
# -------------------------------
|
| 83 |
+
# 2. Deploy to ECS
|
| 84 |
+
# -------------------------------
|
| 85 |
+
deploy:
|
| 86 |
+
name: Deploy to ECS Fargate
|
| 87 |
+
needs: build-and-push
|
| 88 |
+
runs-on: ubuntu-latest
|
| 89 |
+
steps:
|
| 90 |
+
- name: Checkout Repo
|
| 91 |
+
uses: actions/checkout@v4
|
| 92 |
+
|
| 93 |
+
- name: Configure AWS Credentials
|
| 94 |
+
uses: aws-actions/configure-aws-credentials@v1
|
| 95 |
+
with:
|
| 96 |
+
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
| 97 |
+
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
| 98 |
+
aws-region: ${{ env.AWS_REGION }}
|
| 99 |
+
|
| 100 |
+
- name: Render Main App Container in Task Definition
|
| 101 |
+
id: render-app
|
| 102 |
+
uses: aws-actions/amazon-ecs-render-task-definition@v1
|
| 103 |
+
with:
|
| 104 |
+
task-definition: ${{ env.ECS_TASK_DEFINITION }}
|
| 105 |
+
container-name: ${{ env.CONTAINER_NAME }}
|
| 106 |
+
image: ${{ needs.build-and-push.outputs.image }}
|
| 107 |
+
|
| 108 |
+
- name: Render MCP Toolbox Container in Task Definition
|
| 109 |
+
id: render-toolbox
|
| 110 |
+
uses: aws-actions/amazon-ecs-render-task-definition@v1
|
| 111 |
+
with:
|
| 112 |
+
task-definition: ${{ steps.render-app.outputs.task-definition }}
|
| 113 |
+
container-name: mcp-toolbox
|
| 114 |
+
image: ${{ needs.build-and-push.outputs.toolbox-image }}
|
| 115 |
+
|
| 116 |
+
- name: Print Final Rendered Task Definition
|
| 117 |
+
run: cat ${{ steps.render-toolbox.outputs.task-definition }}
|
| 118 |
+
|
| 119 |
+
- name: Deploy to ECS
|
| 120 |
+
uses: aws-actions/amazon-ecs-deploy-task-definition@v1
|
| 121 |
+
with:
|
| 122 |
+
task-definition: ${{ steps.render-toolbox.outputs.task-definition }}
|
| 123 |
+
service: ${{ env.ECS_SERVICE }}
|
| 124 |
+
cluster: ${{ env.ECS_CLUSTER }}
|
| 125 |
+
wait-for-service-stability: true
|
| 126 |
+
|
| 127 |
+
- name: Done!
|
| 128 |
+
run: echo "Deployed to ECS Fargate Successfully with both containers!"
|
.github/workflows/ci.yaml
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: Run Unit Tests
|
| 2 |
+
|
| 3 |
+
on: [push, pull_request]
|
| 4 |
+
|
| 5 |
+
jobs:
|
| 6 |
+
test:
|
| 7 |
+
runs-on: ubuntu-latest
|
| 8 |
+
steps:
|
| 9 |
+
- name: Checkout repo
|
| 10 |
+
uses: actions/checkout@v3
|
| 11 |
+
|
| 12 |
+
- name: Set up Python
|
| 13 |
+
uses: actions/setup-python@v4
|
| 14 |
+
with:
|
| 15 |
+
python-version: '3.11.5'
|
| 16 |
+
|
| 17 |
+
- name: Install dependencies
|
| 18 |
+
run: |
|
| 19 |
+
python -m pip install --upgrade pip
|
| 20 |
+
pip install -r requirements.txt
|
| 21 |
+
pip install pytest
|
| 22 |
+
|
| 23 |
+
- name: Setup Google Cloud credentials
|
| 24 |
+
env:
|
| 25 |
+
GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }}
|
| 26 |
+
GOOGLE_APPLICATION_CREDENTIALS: ${{ secrets.GOOGLE_APPLICATION_CREDENTIALS }}
|
| 27 |
+
run: |
|
| 28 |
+
|
| 29 |
+
# If GOOGLE_API_KEY is provided
|
| 30 |
+
if [ ! -z "$GOOGLE_API_KEY" ]; then
|
| 31 |
+
echo "GOOGLE_API_KEY is configured"
|
| 32 |
+
echo "GOOGLE_API_KEY=$GOOGLE_API_KEY" >> $GITHUB_ENV
|
| 33 |
+
fi
|
| 34 |
+
|
| 35 |
+
- name: Setup other environment variables
|
| 36 |
+
env:
|
| 37 |
+
CHROMA_API_KEY: ${{ secrets.CHROMA_API_KEY }}
|
| 38 |
+
CHROMA_TENANT: ${{ secrets.CHROMA_TENANT }}
|
| 39 |
+
CHROMA_DATABASE: ${{ secrets.CHROMA_DATABASE }}
|
| 40 |
+
COINGECKO_API_KEY: ${{ secrets.COINGECKO_API_KEY }}
|
| 41 |
+
ALPHA_VANTAGE_API_KEY: ${{ secrets.ALPHA_VANTAGE_API_KEY }}
|
| 42 |
+
run: |
|
| 43 |
+
# Set ChromaDB credentials if available
|
| 44 |
+
if [ ! -z "$CHROMA_API_KEY" ]; then
|
| 45 |
+
echo "CHROMA_API_KEY=$CHROMA_API_KEY" >> $GITHUB_ENV
|
| 46 |
+
fi
|
| 47 |
+
if [ ! -z "$CHROMA_TENANT" ]; then
|
| 48 |
+
echo "CHROMA_TENANT=$CHROMA_TENANT" >> $GITHUB_ENV
|
| 49 |
+
fi
|
| 50 |
+
if [ ! -z "$CHROMA_DATABASE" ]; then
|
| 51 |
+
echo "CHROMA_DATABASE=$CHROMA_DATABASE" >> $GITHUB_ENV
|
| 52 |
+
fi
|
| 53 |
+
|
| 54 |
+
# Set other API keys if available
|
| 55 |
+
if [ ! -z "$COINGECKO_API_KEY" ]; then
|
| 56 |
+
echo "COINGECKO_API_KEY=$COINGECKO_API_KEY" >> $GITHUB_ENV
|
| 57 |
+
fi
|
| 58 |
+
if [ ! -z "$ALPHA_VANTAGE_API_KEY" ]; then
|
| 59 |
+
echo "ALPHA_VANTAGE_API_KEY=$ALPHA_VANTAGE_API_KEY" >> $GITHUB_ENV
|
| 60 |
+
fi
|
| 61 |
+
|
| 62 |
+
- name: Run unit tests
|
| 63 |
+
run: |
|
| 64 |
+
pytest tests/
|
.github/workflows/task_definition.json
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"family": "multi-agent-app",
|
| 3 |
+
"networkMode": "awsvpc",
|
| 4 |
+
"requiresCompatibilities": ["FARGATE"],
|
| 5 |
+
"cpu": "1024",
|
| 6 |
+
"memory": "2048",
|
| 7 |
+
"executionRoleArn": "arn:aws:iam::730335436231:role/ecsTaskExecutionRole",
|
| 8 |
+
"taskRoleArn": "arn:aws:iam::730335436231:role/ecsTaskRole",
|
| 9 |
+
"containerDefinitions": [
|
| 10 |
+
{
|
| 11 |
+
"name": "multi-agent-app",
|
| 12 |
+
"image": "730335436231.dkr.ecr.eu-west-3.amazonaws.com/multi-agent-app",
|
| 13 |
+
"essential": true,
|
| 14 |
+
"portMappings": [
|
| 15 |
+
{
|
| 16 |
+
"containerPort": 8501,
|
| 17 |
+
"protocol": "tcp",
|
| 18 |
+
"name": "streamlit"
|
| 19 |
+
},
|
| 20 |
+
{
|
| 21 |
+
"containerPort": 8000,
|
| 22 |
+
"protocol": "tcp",
|
| 23 |
+
"name": "fastapi"
|
| 24 |
+
}
|
| 25 |
+
],
|
| 26 |
+
"environment": [
|
| 27 |
+
{
|
| 28 |
+
"name": "ENV",
|
| 29 |
+
"value": "production"
|
| 30 |
+
},
|
| 31 |
+
{
|
| 32 |
+
"name": "API_BASE_URL",
|
| 33 |
+
"value": "http://localhost:8000"
|
| 34 |
+
},
|
| 35 |
+
{
|
| 36 |
+
"name": "MCP_TOOLBOX_SERVER_URL",
|
| 37 |
+
"value": "http://localhost:5000"
|
| 38 |
+
}
|
| 39 |
+
],
|
| 40 |
+
"secrets": [
|
| 41 |
+
{
|
| 42 |
+
"name": "GOOGLE_API_KEY",
|
| 43 |
+
"valueFrom": "arn:aws:secretsmanager:eu-west-3:730335436231:secret:API_KEYS-VM91-fuw8iR:GOOGLE_API_KEY::"
|
| 44 |
+
},
|
| 45 |
+
{
|
| 46 |
+
"name": "ALPHA_VANTAGE_API_KEY",
|
| 47 |
+
"valueFrom": "arn:aws:secretsmanager:eu-west-3:730335436231:secret:API_KEYS-VM91-fuw8iR:ALPHA_VANTAGE_API_KEY::"
|
| 48 |
+
},
|
| 49 |
+
{
|
| 50 |
+
"name": "COINGECKO_API_KEY",
|
| 51 |
+
"valueFrom": "arn:aws:secretsmanager:eu-west-3:730335436231:secret:API_KEYS-VM91-fuw8iR:COINGECKO_API_KEY::"
|
| 52 |
+
},
|
| 53 |
+
{
|
| 54 |
+
"name": "CHROMA_API_KEY",
|
| 55 |
+
"valueFrom": "arn:aws:secretsmanager:eu-west-3:730335436231:secret:API_KEYS-VM91-fuw8iR:CHROMA_API_KEY::"
|
| 56 |
+
},
|
| 57 |
+
{
|
| 58 |
+
"name": "CHROMA_TENANT",
|
| 59 |
+
"valueFrom": "arn:aws:secretsmanager:eu-west-3:730335436231:secret:API_KEYS-VM91-fuw8iR:CHROMA_TENANT::"
|
| 60 |
+
},
|
| 61 |
+
{
|
| 62 |
+
"name": "CHROMA_DATABASE",
|
| 63 |
+
"valueFrom": "arn:aws:secretsmanager:eu-west-3:730335436231:secret:API_KEYS-VM91-fuw8iR:CHROMA_DATABASE::"
|
| 64 |
+
},
|
| 65 |
+
{
|
| 66 |
+
"name": "GCP_PROJECT_ID",
|
| 67 |
+
"valueFrom": "arn:aws:secretsmanager:eu-west-3:730335436231:secret:API_KEYS-VM91-fuw8iR:GCP_PROJECT_ID::"
|
| 68 |
+
},
|
| 69 |
+
{
|
| 70 |
+
"name": "CLOUD_SQL_REGION",
|
| 71 |
+
"valueFrom": "arn:aws:secretsmanager:eu-west-3:730335436231:secret:API_KEYS-VM91-fuw8iR:CLOUD_SQL_REGION::"
|
| 72 |
+
},
|
| 73 |
+
{
|
| 74 |
+
"name": "CLOUD_SQL_INSTANCE",
|
| 75 |
+
"valueFrom": "arn:aws:secretsmanager:eu-west-3:730335436231:secret:API_KEYS-VM91-fuw8iR:CLOUD_SQL_INSTANCE::"
|
| 76 |
+
},
|
| 77 |
+
{
|
| 78 |
+
"name": "CLOUD_SQL_DB_NAME",
|
| 79 |
+
"valueFrom": "arn:aws:secretsmanager:eu-west-3:730335436231:secret:API_KEYS-VM91-fuw8iR:CLOUD_SQL_DB_NAME::"
|
| 80 |
+
},
|
| 81 |
+
{
|
| 82 |
+
"name": "CLOUD_SQL_DB_USER",
|
| 83 |
+
"valueFrom": "arn:aws:secretsmanager:eu-west-3:730335436231:secret:API_KEYS-VM91-fuw8iR:CLOUD_SQL_DB_USER::"
|
| 84 |
+
},
|
| 85 |
+
{
|
| 86 |
+
"name": "CLOUD_SQL_DB_PASS",
|
| 87 |
+
"valueFrom": "arn:aws:secretsmanager:eu-west-3:730335436231:secret:API_KEYS-VM91-fuw8iR:CLOUD_SQL_DB_PASS::"
|
| 88 |
+
}
|
| 89 |
+
],
|
| 90 |
+
"dependsOn": [
|
| 91 |
+
{
|
| 92 |
+
"containerName": "mcp-toolbox",
|
| 93 |
+
"condition": "START"
|
| 94 |
+
}
|
| 95 |
+
],
|
| 96 |
+
"logConfiguration": {
|
| 97 |
+
"logDriver": "awslogs",
|
| 98 |
+
"options": {
|
| 99 |
+
"awslogs-group": "/ecs/multi-agent-app",
|
| 100 |
+
"awslogs-region": "eu-west-3",
|
| 101 |
+
"awslogs-stream-prefix": "ecs"
|
| 102 |
+
}
|
| 103 |
+
}
|
| 104 |
+
},
|
| 105 |
+
{
|
| 106 |
+
"name": "mcp-toolbox",
|
| 107 |
+
"image": "730335436231.dkr.ecr.eu-west-3.amazonaws.com/mcp-toolbox",
|
| 108 |
+
"essential": false,
|
| 109 |
+
"portMappings": [
|
| 110 |
+
{
|
| 111 |
+
"containerPort": 5000,
|
| 112 |
+
"protocol": "tcp",
|
| 113 |
+
"name": "mcp-toolbox"
|
| 114 |
+
}
|
| 115 |
+
],
|
| 116 |
+
"secrets": [
|
| 117 |
+
{
|
| 118 |
+
"name": "GCP_SERVICE_ACCOUNT_JSON",
|
| 119 |
+
"valueFrom": "arn:aws:secretsmanager:eu-west-3:730335436231:secret:API_KEYS-VM91-fuw8iR:GCP_SERVICE_ACCOUNT_JSON::"
|
| 120 |
+
},
|
| 121 |
+
{
|
| 122 |
+
"name": "CLOUD_SQL_POSTGRES_PROJECT",
|
| 123 |
+
"valueFrom": "arn:aws:secretsmanager:eu-west-3:730335436231:secret:API_KEYS-VM91-fuw8iR:GCP_PROJECT_ID::"
|
| 124 |
+
},
|
| 125 |
+
{
|
| 126 |
+
"name": "CLOUD_SQL_POSTGRES_REGION",
|
| 127 |
+
"valueFrom": "arn:aws:secretsmanager:eu-west-3:730335436231:secret:API_KEYS-VM91-fuw8iR:CLOUD_SQL_REGION::"
|
| 128 |
+
},
|
| 129 |
+
{
|
| 130 |
+
"name": "CLOUD_SQL_POSTGRES_INSTANCE",
|
| 131 |
+
"valueFrom": "arn:aws:secretsmanager:eu-west-3:730335436231:secret:API_KEYS-VM91-fuw8iR:CLOUD_SQL_INSTANCE::"
|
| 132 |
+
},
|
| 133 |
+
{
|
| 134 |
+
"name": "CLOUD_SQL_POSTGRES_DATABASE",
|
| 135 |
+
"valueFrom": "arn:aws:secretsmanager:eu-west-3:730335436231:secret:API_KEYS-VM91-fuw8iR:CLOUD_SQL_DB_NAME::"
|
| 136 |
+
},
|
| 137 |
+
{
|
| 138 |
+
"name": "CLOUD_SQL_POSTGRES_USER",
|
| 139 |
+
"valueFrom": "arn:aws:secretsmanager:eu-west-3:730335436231:secret:API_KEYS-VM91-fuw8iR:CLOUD_SQL_DB_USER::"
|
| 140 |
+
},
|
| 141 |
+
{
|
| 142 |
+
"name": "CLOUD_SQL_POSTGRES_PASSWORD",
|
| 143 |
+
"valueFrom": "arn:aws:secretsmanager:eu-west-3:730335436231:secret:API_KEYS-VM91-fuw8iR:CLOUD_SQL_DB_PASS::"
|
| 144 |
+
}
|
| 145 |
+
],
|
| 146 |
+
"logConfiguration": {
|
| 147 |
+
"logDriver": "awslogs",
|
| 148 |
+
"options": {
|
| 149 |
+
"awslogs-group": "/ecs/mcp-toolbox",
|
| 150 |
+
"awslogs-region": "eu-west-3",
|
| 151 |
+
"awslogs-stream-prefix": "ecs"
|
| 152 |
+
}
|
| 153 |
+
}
|
| 154 |
+
}
|
| 155 |
+
]
|
| 156 |
+
}
|
.gitignore
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Environment variables
|
| 2 |
+
.env
|
| 3 |
+
|
| 4 |
+
# Python
|
| 5 |
+
__pycache__/
|
| 6 |
+
*.py[cod]
|
| 7 |
+
*$py.class
|
| 8 |
+
*.so
|
| 9 |
+
.Python
|
| 10 |
+
build/
|
| 11 |
+
develop-eggs/
|
| 12 |
+
dist/
|
| 13 |
+
downloads/
|
| 14 |
+
eggs/
|
| 15 |
+
.eggs/
|
| 16 |
+
lib/
|
| 17 |
+
lib64/
|
| 18 |
+
parts/
|
| 19 |
+
sdist/
|
| 20 |
+
var/
|
| 21 |
+
wheels/
|
| 22 |
+
*.egg-info/
|
| 23 |
+
.installed.cfg
|
| 24 |
+
*.egg
|
| 25 |
+
|
| 26 |
+
# Virtual environments
|
| 27 |
+
venv/
|
| 28 |
+
env/
|
| 29 |
+
ENV/
|
| 30 |
+
env.bak/
|
| 31 |
+
venv.bak/
|
| 32 |
+
|
| 33 |
+
# IDEs
|
| 34 |
+
.vscode/
|
| 35 |
+
.idea/
|
| 36 |
+
*.swp
|
| 37 |
+
*.swo
|
| 38 |
+
*~
|
| 39 |
+
|
| 40 |
+
# OS
|
| 41 |
+
.DS_Store
|
| 42 |
+
Thumbs.db
|
| 43 |
+
|
| 44 |
+
# Gradio
|
| 45 |
+
gradio_queue.db
|
| 46 |
+
|
| 47 |
+
# Streamlit
|
| 48 |
+
.streamlit/secrets.toml
|
| 49 |
+
|
| 50 |
+
# Logs
|
| 51 |
+
*.log
|
| 52 |
+
|
| 53 |
+
# Testing
|
| 54 |
+
.pytest_cache/
|
| 55 |
+
.coverage
|
| 56 |
+
htmlcov/
|
| 57 |
+
|
| 58 |
+
# Jupyter Notebook
|
| 59 |
+
.ipynb_checkpoints
|
.streamlit/config.toml
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[theme]
|
| 2 |
+
base="dark"
|
| 3 |
+
primaryColor="#FF4B4B"
|
| 4 |
+
backgroundColor="#0E1117"
|
| 5 |
+
secondaryBackgroundColor="#262730"
|
| 6 |
+
textColor="#FAFAFA"
|
| 7 |
+
|
| 8 |
+
[server]
|
| 9 |
+
headless = true
|
| 10 |
+
port = 8501
|
| 11 |
+
address = "0.0.0.0"
|
| 12 |
+
enableCORS = false
|
| 13 |
+
enableXsrfProtection = true
|
Dockerfile
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Dockerfile for Hugging Face Spaces - Gradio Multi-Agent Assistant
|
| 2 |
+
# This deploys the complete multi-agent system with Gradio UI
|
| 3 |
+
|
| 4 |
+
FROM python:3.11-slim
|
| 5 |
+
|
| 6 |
+
# Install system dependencies including Node.js for MCP servers
|
| 7 |
+
RUN apt-get update && apt-get install -y \
|
| 8 |
+
ca-certificates \
|
| 9 |
+
curl \
|
| 10 |
+
git \
|
| 11 |
+
build-essential \
|
| 12 |
+
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
| 13 |
+
&& apt-get install -y nodejs \
|
| 14 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 15 |
+
|
| 16 |
+
# Verify installations
|
| 17 |
+
RUN python --version && node --version && npm --version
|
| 18 |
+
|
| 19 |
+
# Set working directory
|
| 20 |
+
WORKDIR /app
|
| 21 |
+
|
| 22 |
+
# Copy requirements first (for layer caching)
|
| 23 |
+
COPY requirements.txt .
|
| 24 |
+
|
| 25 |
+
# Install Python dependencies
|
| 26 |
+
RUN pip install --no-cache-dir --upgrade pip && \
|
| 27 |
+
pip install --no-cache-dir -r requirements.txt
|
| 28 |
+
|
| 29 |
+
# Install npx-based MCP servers globally
|
| 30 |
+
# CoinGecko MCP server for crypto data
|
| 31 |
+
RUN npm install -g @coingecko/coingecko-mcp
|
| 32 |
+
|
| 33 |
+
# Install uvx for Python-based MCP servers
|
| 34 |
+
RUN pip install --no-cache-dir uv
|
| 35 |
+
|
| 36 |
+
# Copy application code
|
| 37 |
+
COPY src/ ./src/
|
| 38 |
+
COPY ui/ ./ui/
|
| 39 |
+
|
| 40 |
+
# Create directory for temporary file uploads
|
| 41 |
+
RUN mkdir -p /tmp/uploads && chmod 777 /tmp/uploads
|
| 42 |
+
|
| 43 |
+
# Copy entrypoint script
|
| 44 |
+
COPY docker/entrypoint-hf.sh /app/entrypoint.sh
|
| 45 |
+
RUN chmod +x /app/entrypoint.sh
|
| 46 |
+
|
| 47 |
+
# Set environment variables for Hugging Face Spaces
|
| 48 |
+
ENV GRADIO_SERVER_NAME="0.0.0.0" \
|
| 49 |
+
GRADIO_SERVER_PORT="7860" \
|
| 50 |
+
PYTHONUNBUFFERED=1
|
| 51 |
+
|
| 52 |
+
# Expose Gradio port (HF Spaces expects 7860)
|
| 53 |
+
EXPOSE 7860
|
| 54 |
+
|
| 55 |
+
# Health check
|
| 56 |
+
HEALTHCHECK --interval=30s --timeout=10s --start-period=90s --retries=3 \
|
| 57 |
+
CMD curl -f http://localhost:7860/ || exit 1
|
| 58 |
+
|
| 59 |
+
# Run Gradio app via entrypoint
|
| 60 |
+
CMD ["/app/entrypoint.sh"]
|
OLD/decision_maker.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Decision Maker to route queries to appropriate agents."""
|
| 2 |
+
from typing import Any, Dict, List, Optional
|
| 3 |
+
|
| 4 |
+
from config import config
|
| 5 |
+
from langchain_google_genai import ChatGoogleGenerativeAI
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class DecisionMaker:
|
| 9 |
+
"""LLM-based decision maker to route queries to appropriate agents."""
|
| 10 |
+
|
| 11 |
+
def __init__(self):
|
| 12 |
+
self.name = "Decision Maker"
|
| 13 |
+
self.model = ChatGoogleGenerativeAI(
|
| 14 |
+
model="gemini-2.5-pro",
|
| 15 |
+
temperature=0.1,
|
| 16 |
+
google_api_key=config.GOOGLE_API_KEY,
|
| 17 |
+
)
|
| 18 |
+
|
| 19 |
+
self.agent_descriptions = {
|
| 20 |
+
"crypto": """
|
| 21 |
+
Crypto Agent: Specializes in cryptocurrency market data, prices, trends,
|
| 22 |
+
market analysis, and blockchain information. Use this for queries about:
|
| 23 |
+
- Cryptocurrency prices and market data
|
| 24 |
+
- Trading volumes and market caps
|
| 25 |
+
- Trending coins and top gainers/losers
|
| 26 |
+
- Blockchain statistics
|
| 27 |
+
- Crypto market analysis
|
| 28 |
+
""",
|
| 29 |
+
"rag": """
|
| 30 |
+
RAG Agent: Specializes in document retrieval and question answering from
|
| 31 |
+
uploaded documents (PDF, TXT, DOCX) stored in ChromaDB Cloud. Use this for queries about:
|
| 32 |
+
- Questions about uploaded documents
|
| 33 |
+
- Information retrieval from your document library
|
| 34 |
+
- Document search and semantic analysis
|
| 35 |
+
- Knowledge base queries
|
| 36 |
+
- Content from stored files
|
| 37 |
+
- Finding specific information in documents
|
| 38 |
+
"""
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
async def decide_agent(self, query: str, history: Optional[List[Dict[str, str]]] = None) -> Dict[str, Any]:
|
| 42 |
+
"""Decide which agent should handle the query."""
|
| 43 |
+
try:
|
| 44 |
+
history_block = ""
|
| 45 |
+
if history:
|
| 46 |
+
recent_history = history[-6:]
|
| 47 |
+
history_lines: List[str] = []
|
| 48 |
+
for turn in recent_history:
|
| 49 |
+
user_text = turn.get("user", "").strip()
|
| 50 |
+
if user_text:
|
| 51 |
+
history_lines.append(f"User: {user_text}")
|
| 52 |
+
assistant_text = turn.get("assistant", "").strip()
|
| 53 |
+
if assistant_text:
|
| 54 |
+
history_lines.append(f"Assistant: {assistant_text}")
|
| 55 |
+
if history_lines:
|
| 56 |
+
history_block = "Recent conversation:\n" + "\n".join(history_lines) + "\n\n"
|
| 57 |
+
|
| 58 |
+
prompt = f"""You are a routing system for a multi-agent AI application.
|
| 59 |
+
|
| 60 |
+
{history_block}Available agents:
|
| 61 |
+
{self.agent_descriptions['crypto']}
|
| 62 |
+
|
| 63 |
+
{self.agent_descriptions['rag']}
|
| 64 |
+
|
| 65 |
+
User query: "{query}"
|
| 66 |
+
|
| 67 |
+
Analyze the query and determine which agent should handle it.
|
| 68 |
+
Respond with ONLY a JSON object in this exact format:
|
| 69 |
+
{{
|
| 70 |
+
"agent": "crypto" or "rag",
|
| 71 |
+
"confidence": 0.0-1.0,
|
| 72 |
+
"reasoning": "brief explanation"
|
| 73 |
+
}}
|
| 74 |
+
|
| 75 |
+
Choose carefully based on the query content."""
|
| 76 |
+
|
| 77 |
+
response = await self.model.ainvoke(prompt)
|
| 78 |
+
result_text = response.content.strip() if isinstance(response.content, str) else str(response.content)
|
| 79 |
+
|
| 80 |
+
# Extract JSON from response
|
| 81 |
+
import json
|
| 82 |
+
# Remove markdown code blocks if present
|
| 83 |
+
if "```json" in result_text:
|
| 84 |
+
result_text = result_text.split("```json")[1].split("```")[0].strip()
|
| 85 |
+
elif "```" in result_text:
|
| 86 |
+
result_text = result_text.split("```")[1].split("```")[0].strip()
|
| 87 |
+
|
| 88 |
+
decision = json.loads(result_text)
|
| 89 |
+
|
| 90 |
+
print(f"\nπ― {self.name} Decision:")
|
| 91 |
+
print(f" Agent: {decision['agent']}")
|
| 92 |
+
print(f" Confidence: {decision['confidence']}")
|
| 93 |
+
print(f" Reasoning: {decision['reasoning']}")
|
| 94 |
+
|
| 95 |
+
return decision
|
| 96 |
+
|
| 97 |
+
except Exception as e:
|
| 98 |
+
print(f"β Error in decision maker: {e}")
|
| 99 |
+
# Default to crypto agent if decision fails
|
| 100 |
+
return {
|
| 101 |
+
"agent": "crypto",
|
| 102 |
+
"confidence": 0.5,
|
| 103 |
+
"reasoning": f"Error in decision maker, defaulting to crypto agent: {str(e)}"
|
| 104 |
+
}
|
OLD/misc/rag_docker.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""RAG Agent using Chroma MCP Server via LangChain MCP adapters."""
|
| 2 |
+
import json
|
| 3 |
+
import os
|
| 4 |
+
import shutil
|
| 5 |
+
import sys
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from typing import Any, Dict, List, Optional
|
| 8 |
+
|
| 9 |
+
from config import config
|
| 10 |
+
from file_processors import process_document
|
| 11 |
+
from langchain_core.tools import BaseTool
|
| 12 |
+
from langchain_google_genai import ChatGoogleGenerativeAI
|
| 13 |
+
from langchain_mcp_adapters.client import MultiServerMCPClient
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class RAGAgentMCP:
|
| 17 |
+
"""Agent specialized in document retrieval and Q&A using Chroma MCP Server."""
|
| 18 |
+
|
| 19 |
+
@staticmethod
|
| 20 |
+
def _to_text(payload: Any) -> str:
|
| 21 |
+
"""Convert tool or model output into a displayable string."""
|
| 22 |
+
if isinstance(payload, str):
|
| 23 |
+
return payload
|
| 24 |
+
try:
|
| 25 |
+
return json.dumps(payload, ensure_ascii=False)
|
| 26 |
+
except TypeError:
|
| 27 |
+
return str(payload)
|
| 28 |
+
|
| 29 |
+
def __init__(self):
|
| 30 |
+
self.name = "RAG Agent (MCP)"
|
| 31 |
+
self.description = "Document storage, retrieval, and question answering expert using Chroma MCP Server"
|
| 32 |
+
self.mcp_client: Optional[MultiServerMCPClient] = None
|
| 33 |
+
self.model: Optional[ChatGoogleGenerativeAI] = None
|
| 34 |
+
self.tools: List[BaseTool] = []
|
| 35 |
+
self.tool_map: Dict[str, BaseTool] = {}
|
| 36 |
+
|
| 37 |
+
async def initialize(self) -> None:
|
| 38 |
+
"""Initialize the agent with Chroma MCP server."""
|
| 39 |
+
print(f"π§ Initializing {self.name}...")
|
| 40 |
+
|
| 41 |
+
try:
|
| 42 |
+
# Connect to Chroma MCP Server for tool access
|
| 43 |
+
print(f" π‘ Connecting to Chroma MCP Server...")
|
| 44 |
+
chroma_connection = self._build_chroma_connection()
|
| 45 |
+
self.mcp_client = MultiServerMCPClient({"chroma": chroma_connection})
|
| 46 |
+
|
| 47 |
+
# Get tools from MCP server
|
| 48 |
+
self.tools = await self.mcp_client.get_tools(server_name="chroma")
|
| 49 |
+
if not self.tools:
|
| 50 |
+
raise RuntimeError("No tools returned by Chroma MCP server")
|
| 51 |
+
|
| 52 |
+
self.tool_map = {tool.name: tool for tool in self.tools}
|
| 53 |
+
|
| 54 |
+
print(f" β
Connected to Chroma MCP Server with {len(self.tools)} tools")
|
| 55 |
+
|
| 56 |
+
# Initialize Gemini model for answer generation
|
| 57 |
+
self.model = ChatGoogleGenerativeAI(
|
| 58 |
+
model="gemini-2.5-flash",
|
| 59 |
+
temperature=0.1,
|
| 60 |
+
google_api_key=config.GOOGLE_API_KEY,
|
| 61 |
+
)
|
| 62 |
+
|
| 63 |
+
print(f" β
{self.name} ready!")
|
| 64 |
+
|
| 65 |
+
except Exception as e:
|
| 66 |
+
import traceback
|
| 67 |
+
print(f" β Error initializing {self.name}: {e}")
|
| 68 |
+
print(f" π Full error details:")
|
| 69 |
+
traceback.print_exc()
|
| 70 |
+
raise
|
| 71 |
+
|
| 72 |
+
async def add_document(
|
| 73 |
+
self,
|
| 74 |
+
file_path: str,
|
| 75 |
+
metadata: Optional[Dict[str, Any]] = None,
|
| 76 |
+
progress_callback: Optional[callable] = None
|
| 77 |
+
) -> Dict[str, Any]:
|
| 78 |
+
"""Add a document to ChromaDB via MCP server."""
|
| 79 |
+
try:
|
| 80 |
+
doc_path = Path(file_path)
|
| 81 |
+
|
| 82 |
+
# Validate file type
|
| 83 |
+
if doc_path.suffix.lower() not in config.ALLOWED_FILE_TYPES:
|
| 84 |
+
return {
|
| 85 |
+
"success": False,
|
| 86 |
+
"error": f"Unsupported file type: {doc_path.suffix}. Allowed: {config.ALLOWED_FILE_TYPES}"
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
print(f"\nπ Processing {doc_path.suffix.upper()}: {doc_path.name}")
|
| 90 |
+
|
| 91 |
+
if progress_callback:
|
| 92 |
+
progress_callback(0.1, "Extracting text from document...")
|
| 93 |
+
|
| 94 |
+
# Process document using file_processors
|
| 95 |
+
doc_info = process_document(doc_path, chunk_size=500, overlap=50)
|
| 96 |
+
|
| 97 |
+
if progress_callback:
|
| 98 |
+
progress_callback(0.4, f"Extracted {doc_info['num_chunks']} chunks...")
|
| 99 |
+
|
| 100 |
+
# Generate document ID
|
| 101 |
+
doc_id = doc_path.stem
|
| 102 |
+
|
| 103 |
+
# Prepare metadata
|
| 104 |
+
doc_metadata = {
|
| 105 |
+
"filename": doc_info["filename"],
|
| 106 |
+
"file_type": doc_info["file_type"],
|
| 107 |
+
"file_size": doc_info["file_size"],
|
| 108 |
+
"num_chunks": doc_info["num_chunks"],
|
| 109 |
+
"source": "user_upload",
|
| 110 |
+
**(metadata or {})
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
# Upload via Chroma MCP Server
|
| 114 |
+
if progress_callback:
|
| 115 |
+
progress_callback(0.6, "Uploading via Chroma MCP Server...")
|
| 116 |
+
|
| 117 |
+
# Prepare data for MCP add_documents tool
|
| 118 |
+
chunks = doc_info["chunks"]
|
| 119 |
+
ids = [f"{doc_id}_chunk_{i}" for i in range(len(chunks))]
|
| 120 |
+
metadatas = [
|
| 121 |
+
{**doc_metadata, "chunk_index": i, "chunk_id": ids[i]}
|
| 122 |
+
for i in range(len(chunks))
|
| 123 |
+
]
|
| 124 |
+
|
| 125 |
+
add_tool = (
|
| 126 |
+
self.tool_map.get("chroma_add_documents")
|
| 127 |
+
or self.tool_map.get("add_documents")
|
| 128 |
+
)
|
| 129 |
+
if not add_tool:
|
| 130 |
+
raise KeyError("add_documents tool not available in Chroma MCP")
|
| 131 |
+
|
| 132 |
+
tool_payload = {
|
| 133 |
+
"collection_name": "gemini-embed", # Use dedicated Gemini collection
|
| 134 |
+
"documents": chunks,
|
| 135 |
+
"ids": ids,
|
| 136 |
+
"metadatas": metadatas,
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
result = await add_tool.ainvoke(tool_payload)
|
| 140 |
+
|
| 141 |
+
if isinstance(result, dict) and result.get("success"):
|
| 142 |
+
print(f" β
Added via MCP: {len(chunks)} chunks from {doc_path.name}")
|
| 143 |
+
else:
|
| 144 |
+
raise ValueError(result)
|
| 145 |
+
|
| 146 |
+
if progress_callback:
|
| 147 |
+
progress_callback(1.0, "Upload complete!")
|
| 148 |
+
|
| 149 |
+
return {
|
| 150 |
+
"success": True,
|
| 151 |
+
"document_id": doc_id,
|
| 152 |
+
"filename": doc_path.name,
|
| 153 |
+
"file_type": doc_info["file_type"],
|
| 154 |
+
"chunks_added": len(chunks),
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
except Exception as e:
|
| 158 |
+
print(f" β Error adding document: {e}")
|
| 159 |
+
import traceback
|
| 160 |
+
traceback.print_exc()
|
| 161 |
+
return {
|
| 162 |
+
"success": False,
|
| 163 |
+
"error": str(e)
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
def _build_chroma_connection(self) -> Dict[str, Any]:
|
| 167 |
+
"""Create connection configuration for the Chroma MCP server."""
|
| 168 |
+
return {
|
| 169 |
+
"transport": "docker",
|
| 170 |
+
"command": "mcp",
|
| 171 |
+
"args": ["gateway", "run", "--servers=chromadb"],
|
| 172 |
+
}
|
OLD/searchagent_ddg_api.py
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""DuckDuckGo Search Agent for web search capabilities."""
|
| 2 |
+
import asyncio
|
| 3 |
+
from typing import Dict, Any, Optional, List
|
| 4 |
+
from langchain_google_genai import ChatGoogleGenerativeAI
|
| 5 |
+
from langchain_core.messages import HumanMessage, SystemMessage
|
| 6 |
+
from ddgs import DDGS
|
| 7 |
+
from config import config
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class SearchAgentMCP:
|
| 11 |
+
"""
|
| 12 |
+
Agent for performing web searches using DuckDuckGo.
|
| 13 |
+
Follows the same pattern as other MCP agents in the system.
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
def __init__(self):
|
| 17 |
+
"""Initialize the DuckDuckGo search agent."""
|
| 18 |
+
self.llm = None
|
| 19 |
+
self.ddgs = DDGS()
|
| 20 |
+
self.initialized = False
|
| 21 |
+
|
| 22 |
+
async def initialize(self):
|
| 23 |
+
"""Initialize the agent with LLM."""
|
| 24 |
+
if not self.initialized:
|
| 25 |
+
self.llm = ChatGoogleGenerativeAI(
|
| 26 |
+
model="gemini-2.5-flash",
|
| 27 |
+
temperature=0.3,
|
| 28 |
+
google_api_key=config.GOOGLE_API_KEY,
|
| 29 |
+
)
|
| 30 |
+
self.initialized = True
|
| 31 |
+
print("β
Search Agent initialized")
|
| 32 |
+
|
| 33 |
+
async def search_web(
|
| 34 |
+
self,
|
| 35 |
+
query: str,
|
| 36 |
+
max_results: int = 4,
|
| 37 |
+
region: str = "wt-wt"
|
| 38 |
+
) -> Dict[str, Any]:
|
| 39 |
+
"""
|
| 40 |
+
Perform a web search using DuckDuckGo.
|
| 41 |
+
|
| 42 |
+
Args:
|
| 43 |
+
query: Search query
|
| 44 |
+
max_results: Maximum number of results to return (default: 5)
|
| 45 |
+
region: Region for search (default: wt-wt for worldwide)
|
| 46 |
+
|
| 47 |
+
Returns:
|
| 48 |
+
Dictionary with search results
|
| 49 |
+
"""
|
| 50 |
+
try:
|
| 51 |
+
# Perform search
|
| 52 |
+
results = []
|
| 53 |
+
# Pass query as positional argument (API changed in newer versions)
|
| 54 |
+
search_results = list(self.ddgs.text(
|
| 55 |
+
query,
|
| 56 |
+
region=region,
|
| 57 |
+
safesearch='moderate',
|
| 58 |
+
max_results=max_results
|
| 59 |
+
))
|
| 60 |
+
|
| 61 |
+
for result in search_results:
|
| 62 |
+
results.append({
|
| 63 |
+
"title": result.get("title", ""),
|
| 64 |
+
"link": result.get("href", ""),
|
| 65 |
+
"snippet": result.get("body", "")
|
| 66 |
+
})
|
| 67 |
+
|
| 68 |
+
return {
|
| 69 |
+
"success": True,
|
| 70 |
+
"query": query,
|
| 71 |
+
"results": results,
|
| 72 |
+
"count": len(results)
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
except Exception as e:
|
| 76 |
+
return {
|
| 77 |
+
"success": False,
|
| 78 |
+
"error": str(e),
|
| 79 |
+
"query": query,
|
| 80 |
+
"results": []
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
async def search_news(
|
| 84 |
+
self,
|
| 85 |
+
query: str,
|
| 86 |
+
max_results: int = 4
|
| 87 |
+
) -> Dict[str, Any]:
|
| 88 |
+
"""
|
| 89 |
+
Search for news articles using DuckDuckGo.
|
| 90 |
+
|
| 91 |
+
Args:
|
| 92 |
+
query: Search query
|
| 93 |
+
max_results: Maximum number of results
|
| 94 |
+
|
| 95 |
+
Returns:
|
| 96 |
+
Dictionary with news results
|
| 97 |
+
"""
|
| 98 |
+
try:
|
| 99 |
+
results = []
|
| 100 |
+
# Pass query as positional argument (API changed in newer versions)
|
| 101 |
+
news_results = list(self.ddgs.news(
|
| 102 |
+
query,
|
| 103 |
+
safesearch='moderate',
|
| 104 |
+
max_results=max_results
|
| 105 |
+
))
|
| 106 |
+
|
| 107 |
+
for result in news_results:
|
| 108 |
+
results.append({
|
| 109 |
+
"title": result.get("title", ""),
|
| 110 |
+
"link": result.get("url", ""),
|
| 111 |
+
"snippet": result.get("body", ""),
|
| 112 |
+
"source": result.get("source", ""),
|
| 113 |
+
"date": result.get("date", "")
|
| 114 |
+
})
|
| 115 |
+
|
| 116 |
+
return {
|
| 117 |
+
"success": True,
|
| 118 |
+
"query": query,
|
| 119 |
+
"results": results,
|
| 120 |
+
"count": len(results)
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
except Exception as e:
|
| 124 |
+
return {
|
| 125 |
+
"success": False,
|
| 126 |
+
"error": str(e),
|
| 127 |
+
"query": query,
|
| 128 |
+
"results": []
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
def _format_search_results(self, search_data: Dict[str, Any]) -> str:
|
| 132 |
+
"""
|
| 133 |
+
Format search results into a readable string.
|
| 134 |
+
|
| 135 |
+
Args:
|
| 136 |
+
search_data: Raw search results
|
| 137 |
+
|
| 138 |
+
Returns:
|
| 139 |
+
Formatted string of results
|
| 140 |
+
"""
|
| 141 |
+
if not search_data.get("success"):
|
| 142 |
+
return f"Search failed: {search_data.get('error', 'Unknown error')}"
|
| 143 |
+
|
| 144 |
+
results = search_data.get("results", [])
|
| 145 |
+
if not results:
|
| 146 |
+
return f"No results found for query: {search_data.get('query')}"
|
| 147 |
+
|
| 148 |
+
formatted = f"Found {len(results)} results for '{search_data.get('query')}':\n\n"
|
| 149 |
+
|
| 150 |
+
for idx, result in enumerate(results, 1):
|
| 151 |
+
formatted += f"{idx}. {result.get('title', 'No title')}\n"
|
| 152 |
+
formatted += f" URL: {result.get('link', 'N/A')}\n"
|
| 153 |
+
formatted += f" {result.get('snippet', 'No description')}\n"
|
| 154 |
+
|
| 155 |
+
# Add source and date for news results
|
| 156 |
+
if result.get('source'):
|
| 157 |
+
formatted += f" Source: {result['source']}"
|
| 158 |
+
if result.get('date'):
|
| 159 |
+
formatted += f" | Date: {result['date']}"
|
| 160 |
+
formatted += "\n"
|
| 161 |
+
|
| 162 |
+
formatted += "\n"
|
| 163 |
+
|
| 164 |
+
return formatted
|
| 165 |
+
|
| 166 |
+
async def process(
|
| 167 |
+
self,
|
| 168 |
+
query: str,
|
| 169 |
+
history: Optional[List[Dict[str, str]]] = None
|
| 170 |
+
) -> Dict[str, Any]:
|
| 171 |
+
"""
|
| 172 |
+
Process a search query with LLM interpretation.
|
| 173 |
+
|
| 174 |
+
Args:
|
| 175 |
+
query: User's search query
|
| 176 |
+
history: Optional conversation history
|
| 177 |
+
|
| 178 |
+
Returns:
|
| 179 |
+
Dictionary with agent response
|
| 180 |
+
"""
|
| 181 |
+
try:
|
| 182 |
+
if not self.initialized:
|
| 183 |
+
await self.initialize()
|
| 184 |
+
|
| 185 |
+
# Determine search type and execute
|
| 186 |
+
search_type = await self._determine_search_type(query)
|
| 187 |
+
|
| 188 |
+
if search_type == "news":
|
| 189 |
+
search_results = await self.search_news(query, max_results=5)
|
| 190 |
+
else:
|
| 191 |
+
search_results = await self.search_web(query, max_results=5)
|
| 192 |
+
|
| 193 |
+
# Format results
|
| 194 |
+
formatted_results = self._format_search_results(search_results)
|
| 195 |
+
|
| 196 |
+
# Generate LLM response with context
|
| 197 |
+
system_prompt = """You are a web search assistant. Your role is to:
|
| 198 |
+
1. Interpret web search results
|
| 199 |
+
2. Synthesize information from multiple sources
|
| 200 |
+
3. Provide clear, accurate answers with source attribution
|
| 201 |
+
4. Highlight the most relevant findings
|
| 202 |
+
5. Note any limitations or conflicting information
|
| 203 |
+
|
| 204 |
+
Always cite sources by referring to their result numbers (e.g., "According to result #1...")."""
|
| 205 |
+
|
| 206 |
+
user_prompt = f"""User Query: {query}
|
| 207 |
+
|
| 208 |
+
Search Results:
|
| 209 |
+
{formatted_results}
|
| 210 |
+
|
| 211 |
+
Based on these search results, provide a comprehensive answer to the user's query.
|
| 212 |
+
Include relevant details and cite your sources."""
|
| 213 |
+
|
| 214 |
+
response = await self.llm.ainvoke([
|
| 215 |
+
SystemMessage(content=system_prompt),
|
| 216 |
+
HumanMessage(content=user_prompt)
|
| 217 |
+
])
|
| 218 |
+
|
| 219 |
+
return {
|
| 220 |
+
"success": True,
|
| 221 |
+
"response": response.content,
|
| 222 |
+
"raw_results": search_results,
|
| 223 |
+
"search_type": search_type,
|
| 224 |
+
"metadata": {
|
| 225 |
+
"results_count": search_results.get("count", 0),
|
| 226 |
+
"query": query
|
| 227 |
+
}
|
| 228 |
+
}
|
| 229 |
+
|
| 230 |
+
except Exception as e:
|
| 231 |
+
error_msg = f"Error processing search query: {str(e)}"
|
| 232 |
+
return {
|
| 233 |
+
"success": False,
|
| 234 |
+
"error": error_msg,
|
| 235 |
+
"response": f"I encountered an error while searching: {str(e)}"
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
async def _determine_search_type(self, query: str) -> str:
|
| 239 |
+
"""
|
| 240 |
+
Determine if query is asking for news or general web search.
|
| 241 |
+
|
| 242 |
+
Args:
|
| 243 |
+
query: User query
|
| 244 |
+
|
| 245 |
+
Returns:
|
| 246 |
+
"news" or "web"
|
| 247 |
+
"""
|
| 248 |
+
query_lower = query.lower()
|
| 249 |
+
news_keywords = ["news", "latest", "recent", "today", "breaking", "update"]
|
| 250 |
+
|
| 251 |
+
if any(keyword in query_lower for keyword in news_keywords):
|
| 252 |
+
return "news"
|
| 253 |
+
|
| 254 |
+
return "web"
|
| 255 |
+
|
| 256 |
+
async def cleanup(self):
|
| 257 |
+
"""Cleanup resources."""
|
| 258 |
+
if self.initialized:
|
| 259 |
+
# DuckDuckGo search doesn't need explicit cleanup
|
| 260 |
+
self.initialized = False
|
| 261 |
+
print("π§Ή Search Agent cleanup complete")
|
database_schema.sql
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
| 2 |
+
|
| 3 |
+
-- Table: stock_transactions
|
| 4 |
+
-- Stores all buy/sell transactions for the portfolio
|
| 5 |
+
CREATE TABLE IF NOT EXISTS stock_transactions (
|
| 6 |
+
id SERIAL PRIMARY KEY,
|
| 7 |
+
transaction_id UUID DEFAULT uuid_generate_v4() UNIQUE NOT NULL,
|
| 8 |
+
symbol VARCHAR(10) NOT NULL,
|
| 9 |
+
transaction_type VARCHAR(10) NOT NULL CHECK (transaction_type IN ('BUY', 'SELL')),
|
| 10 |
+
quantity DECIMAL(15, 4) NOT NULL CHECK (quantity > 0),
|
| 11 |
+
price DECIMAL(15, 4) NOT NULL CHECK (price >= 0),
|
| 12 |
+
transaction_date DATE NOT NULL,
|
| 13 |
+
notes TEXT,
|
| 14 |
+
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
| 15 |
+
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
| 16 |
+
);
|
| 17 |
+
|
| 18 |
+
-- Table: portfolio_positions
|
| 19 |
+
-- Stores current aggregated positions (calculated from transactions)
|
| 20 |
+
CREATE TABLE IF NOT EXISTS portfolio_positions (
|
| 21 |
+
symbol VARCHAR(10) PRIMARY KEY,
|
| 22 |
+
total_quantity DECIMAL(15, 4) NOT NULL CHECK (total_quantity >= 0),
|
| 23 |
+
avg_cost_basis DECIMAL(15, 4) NOT NULL CHECK (avg_cost_basis >= 0),
|
| 24 |
+
first_purchase_date DATE,
|
| 25 |
+
last_transaction_date DATE,
|
| 26 |
+
total_invested DECIMAL(15, 2) NOT NULL DEFAULT 0,
|
| 27 |
+
realized_gains DECIMAL(15, 2) DEFAULT 0,
|
| 28 |
+
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
| 29 |
+
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
| 30 |
+
);
|
| 31 |
+
|
| 32 |
+
-- Table: portfolio_snapshots
|
| 33 |
+
-- Stores historical portfolio value snapshots for performance tracking
|
| 34 |
+
CREATE TABLE IF NOT EXISTS portfolio_snapshots (
|
| 35 |
+
id SERIAL PRIMARY KEY,
|
| 36 |
+
snapshot_date DATE NOT NULL,
|
| 37 |
+
total_value DECIMAL(15, 2) NOT NULL,
|
| 38 |
+
total_cost_basis DECIMAL(15, 2) NOT NULL,
|
| 39 |
+
total_gain_loss DECIMAL(15, 2) NOT NULL,
|
| 40 |
+
total_gain_loss_pct DECIMAL(10, 4) NOT NULL,
|
| 41 |
+
num_positions INTEGER NOT NULL,
|
| 42 |
+
snapshot_data JSONB, -- Store detailed position data
|
| 43 |
+
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
| 44 |
+
);
|
| 45 |
+
|
| 46 |
+
-- Table: stock_metadata
|
| 47 |
+
-- Cache stock information to reduce API calls
|
| 48 |
+
CREATE TABLE IF NOT EXISTS stock_metadata (
|
| 49 |
+
symbol VARCHAR(10) PRIMARY KEY,
|
| 50 |
+
company_name VARCHAR(255),
|
| 51 |
+
sector VARCHAR(100),
|
| 52 |
+
industry VARCHAR(100),
|
| 53 |
+
market_cap BIGINT,
|
| 54 |
+
last_updated TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
| 55 |
+
);
|
| 56 |
+
|
| 57 |
+
-- Indexes for performance optimization
|
| 58 |
+
CREATE INDEX IF NOT EXISTS idx_transactions_symbol ON stock_transactions(symbol);
|
| 59 |
+
CREATE INDEX IF NOT EXISTS idx_transactions_date ON stock_transactions(transaction_date DESC);
|
| 60 |
+
CREATE INDEX IF NOT EXISTS idx_transactions_type ON stock_transactions(transaction_type);
|
| 61 |
+
CREATE INDEX IF NOT EXISTS idx_snapshots_date ON portfolio_snapshots(snapshot_date DESC);
|
| 62 |
+
CREATE INDEX IF NOT EXISTS idx_stock_metadata_sector ON stock_metadata(sector);
|
| 63 |
+
|
| 64 |
+
-- Function to update the updated_at timestamp
|
| 65 |
+
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
| 66 |
+
RETURNS TRIGGER AS $$
|
| 67 |
+
BEGIN
|
| 68 |
+
NEW.updated_at = CURRENT_TIMESTAMP;
|
| 69 |
+
RETURN NEW;
|
| 70 |
+
END;
|
| 71 |
+
$$ LANGUAGE plpgsql;
|
| 72 |
+
|
| 73 |
+
-- Triggers to automatically update updated_at
|
| 74 |
+
CREATE TRIGGER update_stock_transactions_updated_at
|
| 75 |
+
BEFORE UPDATE ON stock_transactions
|
| 76 |
+
FOR EACH ROW
|
| 77 |
+
EXECUTE FUNCTION update_updated_at_column();
|
| 78 |
+
|
| 79 |
+
CREATE TRIGGER update_portfolio_positions_updated_at
|
| 80 |
+
BEFORE UPDATE ON portfolio_positions
|
| 81 |
+
FOR EACH ROW
|
| 82 |
+
EXECUTE FUNCTION update_updated_at_column();
|
| 83 |
+
|
| 84 |
+
CREATE TRIGGER update_stock_metadata_updated_at
|
| 85 |
+
BEFORE UPDATE ON stock_metadata
|
| 86 |
+
FOR EACH ROW
|
| 87 |
+
EXECUTE FUNCTION update_updated_at_column();
|
| 88 |
+
|
| 89 |
+
-- Function to recalculate portfolio positions
|
| 90 |
+
-- This ensures positions are always in sync with transactions
|
| 91 |
+
CREATE OR REPLACE FUNCTION recalculate_position(p_symbol VARCHAR)
|
| 92 |
+
RETURNS VOID AS $$
|
| 93 |
+
DECLARE
|
| 94 |
+
v_total_quantity DECIMAL(15, 4);
|
| 95 |
+
v_avg_cost_basis DECIMAL(15, 4);
|
| 96 |
+
v_total_invested DECIMAL(15, 2);
|
| 97 |
+
v_realized_gains DECIMAL(15, 2);
|
| 98 |
+
v_first_purchase DATE;
|
| 99 |
+
v_last_transaction DATE;
|
| 100 |
+
BEGIN
|
| 101 |
+
-- Calculate position from transactions
|
| 102 |
+
WITH position_calc AS (
|
| 103 |
+
SELECT
|
| 104 |
+
SUM(CASE WHEN transaction_type = 'BUY' THEN quantity ELSE -quantity END) as net_quantity,
|
| 105 |
+
SUM(CASE WHEN transaction_type = 'BUY' THEN quantity * price ELSE 0 END) as total_cost,
|
| 106 |
+
SUM(CASE WHEN transaction_type = 'BUY' THEN quantity ELSE 0 END) as total_bought,
|
| 107 |
+
MIN(CASE WHEN transaction_type = 'BUY' THEN transaction_date END) as first_buy,
|
| 108 |
+
MAX(transaction_date) as last_trans,
|
| 109 |
+
SUM(CASE WHEN transaction_type = 'SELL' THEN quantity * price ELSE 0 END) -
|
| 110 |
+
SUM(CASE WHEN transaction_type = 'SELL' THEN quantity *
|
| 111 |
+
(SELECT AVG(price) FROM stock_transactions WHERE symbol = p_symbol AND transaction_type = 'BUY')
|
| 112 |
+
ELSE 0 END) as realized_gain
|
| 113 |
+
FROM stock_transactions
|
| 114 |
+
WHERE symbol = p_symbol
|
| 115 |
+
)
|
| 116 |
+
SELECT
|
| 117 |
+
net_quantity,
|
| 118 |
+
CASE WHEN total_bought > 0 THEN total_cost / total_bought ELSE 0 END,
|
| 119 |
+
total_cost,
|
| 120 |
+
COALESCE(realized_gain, 0),
|
| 121 |
+
first_buy,
|
| 122 |
+
last_trans
|
| 123 |
+
INTO v_total_quantity, v_avg_cost_basis, v_total_invested, v_realized_gains, v_first_purchase, v_last_transaction
|
| 124 |
+
FROM position_calc;
|
| 125 |
+
|
| 126 |
+
-- Update or delete position
|
| 127 |
+
IF v_total_quantity > 0 THEN
|
| 128 |
+
INSERT INTO portfolio_positions (
|
| 129 |
+
symbol, total_quantity, avg_cost_basis, total_invested,
|
| 130 |
+
realized_gains, first_purchase_date, last_transaction_date
|
| 131 |
+
)
|
| 132 |
+
VALUES (
|
| 133 |
+
p_symbol, v_total_quantity, v_avg_cost_basis, v_total_invested,
|
| 134 |
+
v_realized_gains, v_first_purchase, v_last_transaction
|
| 135 |
+
)
|
| 136 |
+
ON CONFLICT (symbol) DO UPDATE SET
|
| 137 |
+
total_quantity = EXCLUDED.total_quantity,
|
| 138 |
+
avg_cost_basis = EXCLUDED.avg_cost_basis,
|
| 139 |
+
total_invested = EXCLUDED.total_invested,
|
| 140 |
+
realized_gains = EXCLUDED.realized_gains,
|
| 141 |
+
first_purchase_date = EXCLUDED.first_purchase_date,
|
| 142 |
+
last_transaction_date = EXCLUDED.last_transaction_date;
|
| 143 |
+
ELSE
|
| 144 |
+
-- If position is fully closed, delete it
|
| 145 |
+
DELETE FROM portfolio_positions WHERE symbol = p_symbol;
|
| 146 |
+
END IF;
|
| 147 |
+
END;
|
| 148 |
+
$$ LANGUAGE plpgsql;
|
| 149 |
+
|
| 150 |
+
-- Trigger to automatically recalculate positions when transactions change
|
| 151 |
+
CREATE OR REPLACE FUNCTION trigger_recalculate_position()
|
| 152 |
+
RETURNS TRIGGER AS $$
|
| 153 |
+
BEGIN
|
| 154 |
+
IF TG_OP = 'DELETE' THEN
|
| 155 |
+
PERFORM recalculate_position(OLD.symbol);
|
| 156 |
+
RETURN OLD;
|
| 157 |
+
ELSE
|
| 158 |
+
PERFORM recalculate_position(NEW.symbol);
|
| 159 |
+
RETURN NEW;
|
| 160 |
+
END IF;
|
| 161 |
+
END;
|
| 162 |
+
$$ LANGUAGE plpgsql;
|
| 163 |
+
|
| 164 |
+
CREATE TRIGGER auto_recalculate_position
|
| 165 |
+
AFTER INSERT OR UPDATE OR DELETE ON stock_transactions
|
| 166 |
+
FOR EACH ROW
|
| 167 |
+
EXECUTE FUNCTION trigger_recalculate_position();
|
| 168 |
+
|
| 169 |
+
-- View: current_portfolio_summary
|
| 170 |
+
-- Provides a quick overview of the portfolio
|
| 171 |
+
CREATE OR REPLACE VIEW current_portfolio_summary AS
|
| 172 |
+
SELECT
|
| 173 |
+
COUNT(*) as total_positions,
|
| 174 |
+
SUM(total_quantity) as total_shares,
|
| 175 |
+
SUM(total_invested) as total_invested,
|
| 176 |
+
SUM(realized_gains) as total_realized_gains
|
| 177 |
+
FROM portfolio_positions;
|
docker-compose.yml
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version: '3.8'
|
| 2 |
+
|
| 3 |
+
services:
|
| 4 |
+
# Main Multi-Agent Application (Streamlit UI + FastAPI Backend)
|
| 5 |
+
app:
|
| 6 |
+
build:
|
| 7 |
+
context: .
|
| 8 |
+
dockerfile: Dockerfile
|
| 9 |
+
container_name: multi-agent-app
|
| 10 |
+
ports:
|
| 11 |
+
- "8501:8501" # Streamlit UI
|
| 12 |
+
- "8000:8000" # FastAPI Backend
|
| 13 |
+
environment:
|
| 14 |
+
# API Keys
|
| 15 |
+
GOOGLE_API_KEY: ${GOOGLE_API_KEY}
|
| 16 |
+
COINGECKO_API_KEY: ${COINGECKO_API_KEY}
|
| 17 |
+
ALPHA_VANTAGE_API_KEY: ${ALPHA_VANTAGE_API_KEY}
|
| 18 |
+
|
| 19 |
+
# ChromaDB Cloud Configuration
|
| 20 |
+
CHROMA_API_KEY: ${CHROMA_API_KEY}
|
| 21 |
+
CHROMA_TENANT: ${CHROMA_TENANT}
|
| 22 |
+
CHROMA_DATABASE: ${CHROMA_DATABASE}
|
| 23 |
+
CHROMA_CLOUD_HOST: ${CHROMA_CLOUD_HOST:-api.trychroma.com}
|
| 24 |
+
CHROMA_EMBEDDING_FUNCTION: ${CHROMA_EMBEDDING_FUNCTION:-default}
|
| 25 |
+
DOCUMENTS_COLLECTION: ${DOCUMENTS_COLLECTION:-mcp-test}
|
| 26 |
+
|
| 27 |
+
# Google Cloud SQL Configuration
|
| 28 |
+
GCP_PROJECT_ID: ${GCP_PROJECT_ID}
|
| 29 |
+
CLOUD_SQL_REGION: ${CLOUD_SQL_REGION}
|
| 30 |
+
CLOUD_SQL_INSTANCE: ${CLOUD_SQL_INSTANCE}
|
| 31 |
+
CLOUD_SQL_DB_NAME: ${CLOUD_SQL_DB_NAME:-finance_tracker}
|
| 32 |
+
CLOUD_SQL_DB_USER: ${CLOUD_SQL_DB_USER}
|
| 33 |
+
CLOUD_SQL_DB_PASS: ${CLOUD_SQL_DB_PASS}
|
| 34 |
+
|
| 35 |
+
# MCP Toolbox connection (internal network)
|
| 36 |
+
MCP_TOOLBOX_SERVER_URL: http://mcp-toolbox:5000
|
| 37 |
+
|
| 38 |
+
# UI Configuration
|
| 39 |
+
MAX_FILE_SIZE_MB: ${MAX_FILE_SIZE_MB:-50}
|
| 40 |
+
|
| 41 |
+
depends_on:
|
| 42 |
+
- mcp-toolbox
|
| 43 |
+
|
| 44 |
+
networks:
|
| 45 |
+
- mcp-network
|
| 46 |
+
|
| 47 |
+
restart: unless-stopped
|
| 48 |
+
|
| 49 |
+
healthcheck:
|
| 50 |
+
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
| 51 |
+
interval: 30s
|
| 52 |
+
timeout: 10s
|
| 53 |
+
retries: 3
|
| 54 |
+
start_period: 90s
|
| 55 |
+
|
| 56 |
+
# MCP Toolbox for Cloud SQL Access
|
| 57 |
+
mcp-toolbox:
|
| 58 |
+
build:
|
| 59 |
+
context: .
|
| 60 |
+
dockerfile: Dockerfile.toolbox
|
| 61 |
+
container_name: mcp-toolbox-server
|
| 62 |
+
ports:
|
| 63 |
+
- "5000:5000"
|
| 64 |
+
environment:
|
| 65 |
+
# Cloud SQL Connection Details
|
| 66 |
+
CLOUD_SQL_POSTGRES_PROJECT: ${GCP_PROJECT_ID}
|
| 67 |
+
CLOUD_SQL_POSTGRES_REGION: ${CLOUD_SQL_REGION}
|
| 68 |
+
CLOUD_SQL_POSTGRES_INSTANCE: ${CLOUD_SQL_INSTANCE}
|
| 69 |
+
CLOUD_SQL_POSTGRES_DATABASE: ${CLOUD_SQL_DB_NAME}
|
| 70 |
+
CLOUD_SQL_POSTGRES_USER: ${CLOUD_SQL_DB_USER}
|
| 71 |
+
CLOUD_SQL_POSTGRES_PASSWORD: ${CLOUD_SQL_DB_PASS}
|
| 72 |
+
|
| 73 |
+
# Google Cloud credentials path (used by Cloud SQL connector)
|
| 74 |
+
GOOGLE_APPLICATION_CREDENTIALS: /app/credentials/credentials.json
|
| 75 |
+
|
| 76 |
+
volumes:
|
| 77 |
+
# Mount Application Default Credentials to the location MCP Toolbox expects
|
| 78 |
+
# Linux/Mac: ${HOME}/.config/gcloud/application_default_credentials.json
|
| 79 |
+
# Windows: ${APPDATA}/gcloud/application_default_credentials.json
|
| 80 |
+
- ${APPDATA}/gcloud/application_default_credentials.json:/app/credentials/credentials.json:ro
|
| 81 |
+
|
| 82 |
+
networks:
|
| 83 |
+
- mcp-network
|
| 84 |
+
|
| 85 |
+
restart: unless-stopped
|
| 86 |
+
|
| 87 |
+
# Note: MCP Toolbox doesn't expose a health endpoint
|
| 88 |
+
# Check if container is running with: docker-compose ps
|
| 89 |
+
|
| 90 |
+
networks:
|
| 91 |
+
mcp-network:
|
| 92 |
+
name: mcp-toolbox-network
|
| 93 |
+
driver: bridge
|
docker/Dockerfile
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Multi-stage Dockerfile for Multi-Agent Assistant
|
| 2 |
+
# Supports both Python (uvx) and Node.js (npx) MCP servers
|
| 3 |
+
|
| 4 |
+
FROM python:3.11-slim as base
|
| 5 |
+
|
| 6 |
+
# Install system dependencies including Node.js
|
| 7 |
+
RUN apt-get update && apt-get install -y \
|
| 8 |
+
ca-certificates \
|
| 9 |
+
curl \
|
| 10 |
+
git \
|
| 11 |
+
build-essential \
|
| 12 |
+
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
| 13 |
+
&& apt-get install -y nodejs \
|
| 14 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 15 |
+
|
| 16 |
+
# Verify installations
|
| 17 |
+
RUN python --version && node --version && npm --version
|
| 18 |
+
|
| 19 |
+
# Set working directory
|
| 20 |
+
WORKDIR /app
|
| 21 |
+
|
| 22 |
+
# Copy requirements first (for layer caching)
|
| 23 |
+
COPY requirements.txt .
|
| 24 |
+
|
| 25 |
+
# Install Python dependencies
|
| 26 |
+
RUN pip install --no-cache-dir --upgrade pip && \
|
| 27 |
+
pip install --no-cache-dir -r requirements.txt
|
| 28 |
+
|
| 29 |
+
# Install npx-based MCP servers globally (for CoinGecko)
|
| 30 |
+
RUN npm install -g @coingecko/coingecko-mcp
|
| 31 |
+
|
| 32 |
+
# Copy application code with new structure
|
| 33 |
+
COPY src/ ./src/
|
| 34 |
+
COPY ui/ ./ui/
|
| 35 |
+
COPY tests/ ./tests/
|
| 36 |
+
|
| 37 |
+
# Copy startup script
|
| 38 |
+
COPY scripts/start.sh ./start.sh
|
| 39 |
+
|
| 40 |
+
# Create directory for temporary file uploads
|
| 41 |
+
RUN mkdir -p /tmp/uploads && chmod 777 /tmp/uploads
|
| 42 |
+
|
| 43 |
+
# Make startup script executable
|
| 44 |
+
RUN chmod +x /app/start.sh
|
| 45 |
+
|
| 46 |
+
# Copy Streamlit config (includes dark theme)
|
| 47 |
+
RUN mkdir -p /root/.streamlit
|
| 48 |
+
COPY .streamlit/config.toml /root/.streamlit/config.toml
|
| 49 |
+
|
| 50 |
+
# Additional Streamlit config for production
|
| 51 |
+
RUN echo '\n[browser]\ngatherUsageStats = false\n' >> /root/.streamlit/config.toml
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
# Expose ports
|
| 55 |
+
# 8501 - Streamlit UI
|
| 56 |
+
# 8000 - FastAPI Backend
|
| 57 |
+
EXPOSE 8501 8000
|
| 58 |
+
|
| 59 |
+
# Health check - check both FastAPI and Streamlit
|
| 60 |
+
HEALTHCHECK --interval=30s --timeout=10s --start-period=90s --retries=3 \
|
| 61 |
+
CMD curl -f http://localhost:8000/health && curl -f http://localhost:8501/_stcore/health || exit 1
|
| 62 |
+
|
| 63 |
+
# Use startup script to run both FastAPI and Streamlit
|
| 64 |
+
CMD ["/app/start.sh"]
|
docker/Dockerfile.toolbox
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Dockerfile for MCP Toolbox Server
|
| 2 |
+
# Runs MCP Toolbox as an HTTP server for Cloud SQL PostgreSQL access
|
| 3 |
+
|
| 4 |
+
FROM debian:bookworm-slim
|
| 5 |
+
|
| 6 |
+
# Install dependencies
|
| 7 |
+
RUN apt-get update && apt-get install -y \
|
| 8 |
+
ca-certificates \
|
| 9 |
+
curl \
|
| 10 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 11 |
+
|
| 12 |
+
# Set MCP Toolbox version
|
| 13 |
+
ARG TOOLBOX_VERSION=v0.18.0
|
| 14 |
+
|
| 15 |
+
# Detect architecture and download appropriate binary
|
| 16 |
+
RUN ARCH=$(dpkg --print-architecture) && \
|
| 17 |
+
if [ "$ARCH" = "amd64" ]; then \
|
| 18 |
+
TOOLBOX_ARCH="amd64"; \
|
| 19 |
+
elif [ "$ARCH" = "arm64" ]; then \
|
| 20 |
+
TOOLBOX_ARCH="arm64"; \
|
| 21 |
+
else \
|
| 22 |
+
echo "Unsupported architecture: $ARCH" && exit 1; \
|
| 23 |
+
fi && \
|
| 24 |
+
curl -L "https://storage.googleapis.com/genai-toolbox/${TOOLBOX_VERSION}/linux/${TOOLBOX_ARCH}/toolbox" \
|
| 25 |
+
-o /usr/local/bin/toolbox && \
|
| 26 |
+
chmod +x /usr/local/bin/toolbox
|
| 27 |
+
|
| 28 |
+
# Verify installation
|
| 29 |
+
RUN toolbox --version
|
| 30 |
+
|
| 31 |
+
# Create credentials directory
|
| 32 |
+
RUN mkdir -p /app/credentials
|
| 33 |
+
|
| 34 |
+
# Copy entrypoint script
|
| 35 |
+
COPY docker/entrypoint-toolbox.sh /entrypoint.sh
|
| 36 |
+
RUN chmod +x /entrypoint.sh
|
| 37 |
+
|
| 38 |
+
# Expose port for HTTP server
|
| 39 |
+
EXPOSE 5000
|
| 40 |
+
|
| 41 |
+
# Environment variables for Cloud SQL connection
|
| 42 |
+
# These will be provided via docker-compose or docker run
|
| 43 |
+
ENV CLOUD_SQL_POSTGRES_PROJECT="" \
|
| 44 |
+
CLOUD_SQL_POSTGRES_REGION="" \
|
| 45 |
+
CLOUD_SQL_POSTGRES_INSTANCE="" \
|
| 46 |
+
CLOUD_SQL_POSTGRES_DATABASE="" \
|
| 47 |
+
CLOUD_SQL_POSTGRES_USER="" \
|
| 48 |
+
CLOUD_SQL_POSTGRES_PASSWORD="" \
|
| 49 |
+
GCP_SERVICE_ACCOUNT_JSON=""
|
| 50 |
+
|
| 51 |
+
# Run MCP Toolbox via entrypoint script (handles GCP credentials)
|
| 52 |
+
ENTRYPOINT ["/entrypoint.sh"]
|
docker/entrypoint-hf.sh
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/bin/bash
|
| 2 |
+
# Entrypoint script for Hugging Face Spaces deployment
|
| 3 |
+
# Launches the Gradio multi-agent assistant
|
| 4 |
+
|
| 5 |
+
set -e
|
| 6 |
+
|
| 7 |
+
echo "=================================================="
|
| 8 |
+
echo "π Starting PortfolioMind Multi-Agent Assistant"
|
| 9 |
+
echo "=================================================="
|
| 10 |
+
|
| 11 |
+
# Validate required environment variables
|
| 12 |
+
echo "π Validating configuration..."
|
| 13 |
+
|
| 14 |
+
REQUIRED_VARS=(
|
| 15 |
+
"GOOGLE_API_KEY"
|
| 16 |
+
"CHROMA_API_KEY"
|
| 17 |
+
"CHROMA_TENANT"
|
| 18 |
+
"CHROMA_DATABASE"
|
| 19 |
+
"ALPHA_VANTAGE_API_KEY"
|
| 20 |
+
)
|
| 21 |
+
|
| 22 |
+
MISSING_VARS=()
|
| 23 |
+
for var in "${REQUIRED_VARS[@]}"; do
|
| 24 |
+
if [ -z "${!var}" ]; then
|
| 25 |
+
MISSING_VARS+=("$var")
|
| 26 |
+
fi
|
| 27 |
+
done
|
| 28 |
+
|
| 29 |
+
if [ ${#MISSING_VARS[@]} -gt 0 ]; then
|
| 30 |
+
echo "β Missing required environment variables:"
|
| 31 |
+
for var in "${MISSING_VARS[@]}"; do
|
| 32 |
+
echo " - $var"
|
| 33 |
+
done
|
| 34 |
+
echo ""
|
| 35 |
+
echo "Please configure these in Hugging Face Space Settings > Variables and Secrets"
|
| 36 |
+
exit 1
|
| 37 |
+
fi
|
| 38 |
+
|
| 39 |
+
echo "β
All required environment variables are set"
|
| 40 |
+
|
| 41 |
+
# Optional variables (log if present)
|
| 42 |
+
echo ""
|
| 43 |
+
echo "π Optional services status:"
|
| 44 |
+
[ -n "$COINGECKO_API_KEY" ] && echo " β CoinGecko API configured" || echo " β CoinGecko API not configured (using free tier)"
|
| 45 |
+
[ -n "$GCP_PROJECT_ID" ] && echo " β Google Cloud SQL configured" || echo " β Google Cloud SQL not configured"
|
| 46 |
+
[ -n "$MCP_TOOLBOX_SERVER_URL" ] && echo " β MCP Toolbox URL: $MCP_TOOLBOX_SERVER_URL" || echo " β MCP Toolbox not configured"
|
| 47 |
+
|
| 48 |
+
echo ""
|
| 49 |
+
echo "π― Starting Gradio application on port 7860..."
|
| 50 |
+
echo "=================================================="
|
| 51 |
+
echo ""
|
| 52 |
+
|
| 53 |
+
# Launch Gradio app
|
| 54 |
+
cd /app
|
| 55 |
+
exec python -u ui/gradio_app.py
|
docker/entrypoint-toolbox.sh
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/bin/bash
|
| 2 |
+
set -e
|
| 3 |
+
|
| 4 |
+
# Write GCP credentials from environment variable to file
|
| 5 |
+
if [ -n "$GCP_SERVICE_ACCOUNT_JSON" ]; then
|
| 6 |
+
echo "Configuring GCP credentials from environment variable..."
|
| 7 |
+
mkdir -p /app/credentials
|
| 8 |
+
echo "$GCP_SERVICE_ACCOUNT_JSON" > /app/credentials/credentials.json
|
| 9 |
+
export GOOGLE_APPLICATION_CREDENTIALS=/app/credentials/credentials.json
|
| 10 |
+
echo "GCP credentials configured successfully"
|
| 11 |
+
else
|
| 12 |
+
echo "WARNING: GCP_SERVICE_ACCOUNT_JSON not set. Cloud SQL authentication may fail."
|
| 13 |
+
fi
|
| 14 |
+
|
| 15 |
+
# Start MCP Toolbox
|
| 16 |
+
echo "Starting MCP Toolbox server on port 5000..."
|
| 17 |
+
exec toolbox --prebuilt cloud-sql-postgres --port 5000 --address 0.0.0.0
|
fastapi_testing.ipynb
ADDED
|
@@ -0,0 +1,1209 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"cells": [
|
| 3 |
+
{
|
| 4 |
+
"cell_type": "markdown",
|
| 5 |
+
"metadata": {},
|
| 6 |
+
"source": [
|
| 7 |
+
"# FastAPI Multi-Agent System Testing\n",
|
| 8 |
+
"\n",
|
| 9 |
+
"This notebook demonstrates how to test and interact with the FastAPI endpoints.\n",
|
| 10 |
+
"\n",
|
| 11 |
+
"## Prerequisites\n",
|
| 12 |
+
"\n",
|
| 13 |
+
"Make sure the FastAPI server is running:\n",
|
| 14 |
+
"```bash\n",
|
| 15 |
+
"python api.py\n",
|
| 16 |
+
"# OR\n",
|
| 17 |
+
"uvicorn api:app --host 0.0.0.0 --port 8000 --reload\n",
|
| 18 |
+
"```"
|
| 19 |
+
]
|
| 20 |
+
},
|
| 21 |
+
{
|
| 22 |
+
"cell_type": "markdown",
|
| 23 |
+
"metadata": {},
|
| 24 |
+
"source": [
|
| 25 |
+
"## Setup and Imports"
|
| 26 |
+
]
|
| 27 |
+
},
|
| 28 |
+
{
|
| 29 |
+
"cell_type": "code",
|
| 30 |
+
"execution_count": 2,
|
| 31 |
+
"metadata": {},
|
| 32 |
+
"outputs": [
|
| 33 |
+
{
|
| 34 |
+
"name": "stdout",
|
| 35 |
+
"output_type": "stream",
|
| 36 |
+
"text": [
|
| 37 |
+
"β
Imports successful\n",
|
| 38 |
+
"π API Base URL: http://localhost:8000\n"
|
| 39 |
+
]
|
| 40 |
+
}
|
| 41 |
+
],
|
| 42 |
+
"source": [
|
| 43 |
+
"import requests\n",
|
| 44 |
+
"import json\n",
|
| 45 |
+
"import asyncio\n",
|
| 46 |
+
"import websockets\n",
|
| 47 |
+
"from pathlib import Path\n",
|
| 48 |
+
"import time\n",
|
| 49 |
+
"from IPython.display import display, Markdown, HTML\n",
|
| 50 |
+
"\n",
|
| 51 |
+
"# API Base URL\n",
|
| 52 |
+
"BASE_URL = \"http://localhost:8000\"\n",
|
| 53 |
+
"\n",
|
| 54 |
+
"print(\"β
Imports successful\")\n",
|
| 55 |
+
"print(f\"π API Base URL: {BASE_URL}\")"
|
| 56 |
+
]
|
| 57 |
+
},
|
| 58 |
+
{
|
| 59 |
+
"cell_type": "markdown",
|
| 60 |
+
"metadata": {},
|
| 61 |
+
"source": [
|
| 62 |
+
"## 1. Health Check\n",
|
| 63 |
+
"\n",
|
| 64 |
+
"Test if the API is running and all agents are initialized."
|
| 65 |
+
]
|
| 66 |
+
},
|
| 67 |
+
{
|
| 68 |
+
"cell_type": "code",
|
| 69 |
+
"execution_count": 3,
|
| 70 |
+
"metadata": {},
|
| 71 |
+
"outputs": [
|
| 72 |
+
{
|
| 73 |
+
"name": "stdout",
|
| 74 |
+
"output_type": "stream",
|
| 75 |
+
"text": [
|
| 76 |
+
"π₯ Health Check Response:\n",
|
| 77 |
+
" Status: healthy\n",
|
| 78 |
+
" Initialized: True\n",
|
| 79 |
+
"\n",
|
| 80 |
+
" Agent Status:\n",
|
| 81 |
+
" β
crypto: True\n",
|
| 82 |
+
" β
rag: True\n",
|
| 83 |
+
" β
stock: True\n",
|
| 84 |
+
" β
search: True\n",
|
| 85 |
+
" β
finance_tracker: True\n"
|
| 86 |
+
]
|
| 87 |
+
}
|
| 88 |
+
],
|
| 89 |
+
"source": [
|
| 90 |
+
"def test_health_check():\n",
|
| 91 |
+
" \"\"\"Test the health check endpoint.\"\"\"\n",
|
| 92 |
+
" try:\n",
|
| 93 |
+
" response = requests.get(f\"{BASE_URL}/health\")\n",
|
| 94 |
+
" response.raise_for_status()\n",
|
| 95 |
+
" data = response.json()\n",
|
| 96 |
+
" \n",
|
| 97 |
+
" print(\"π₯ Health Check Response:\")\n",
|
| 98 |
+
" print(f\" Status: {data['status']}\")\n",
|
| 99 |
+
" print(f\" Initialized: {data['initialized']}\")\n",
|
| 100 |
+
" print(\"\\n Agent Status:\")\n",
|
| 101 |
+
" for agent, status in data['agents'].items():\n",
|
| 102 |
+
" emoji = \"β
\" if status else \"β\"\n",
|
| 103 |
+
" print(f\" {emoji} {agent}: {status}\")\n",
|
| 104 |
+
" \n",
|
| 105 |
+
" return data\n",
|
| 106 |
+
" except Exception as e:\n",
|
| 107 |
+
" print(f\"β Error: {e}\")\n",
|
| 108 |
+
" return None\n",
|
| 109 |
+
"\n",
|
| 110 |
+
"health_data = test_health_check()"
|
| 111 |
+
]
|
| 112 |
+
},
|
| 113 |
+
{
|
| 114 |
+
"cell_type": "markdown",
|
| 115 |
+
"metadata": {},
|
| 116 |
+
"source": [
|
| 117 |
+
"## 2. Root Endpoint\n",
|
| 118 |
+
"\n",
|
| 119 |
+
"Get API information and available endpoints."
|
| 120 |
+
]
|
| 121 |
+
},
|
| 122 |
+
{
|
| 123 |
+
"cell_type": "code",
|
| 124 |
+
"execution_count": 4,
|
| 125 |
+
"metadata": {},
|
| 126 |
+
"outputs": [
|
| 127 |
+
{
|
| 128 |
+
"name": "stdout",
|
| 129 |
+
"output_type": "stream",
|
| 130 |
+
"text": [
|
| 131 |
+
"π API Information:\n",
|
| 132 |
+
"{\n",
|
| 133 |
+
" \"message\": \"Multi-Agent Assistant API\",\n",
|
| 134 |
+
" \"version\": \"1.0.0\",\n",
|
| 135 |
+
" \"docs\": \"/docs\",\n",
|
| 136 |
+
" \"health\": \"/health\"\n",
|
| 137 |
+
"}\n"
|
| 138 |
+
]
|
| 139 |
+
}
|
| 140 |
+
],
|
| 141 |
+
"source": [
|
| 142 |
+
"def test_root_endpoint():\n",
|
| 143 |
+
" \"\"\"Test the root endpoint.\"\"\"\n",
|
| 144 |
+
" try:\n",
|
| 145 |
+
" response = requests.get(f\"{BASE_URL}/\")\n",
|
| 146 |
+
" response.raise_for_status()\n",
|
| 147 |
+
" data = response.json()\n",
|
| 148 |
+
" \n",
|
| 149 |
+
" print(\"π API Information:\")\n",
|
| 150 |
+
" print(json.dumps(data, indent=2))\n",
|
| 151 |
+
" \n",
|
| 152 |
+
" return data\n",
|
| 153 |
+
" except Exception as e:\n",
|
| 154 |
+
" print(f\"β Error: {e}\")\n",
|
| 155 |
+
" return None\n",
|
| 156 |
+
"\n",
|
| 157 |
+
"api_info = test_root_endpoint()"
|
| 158 |
+
]
|
| 159 |
+
},
|
| 160 |
+
{
|
| 161 |
+
"cell_type": "markdown",
|
| 162 |
+
"metadata": {},
|
| 163 |
+
"source": [
|
| 164 |
+
"## 3. Streaming Chat with Server-Sent Events (SSE)\n",
|
| 165 |
+
"\n",
|
| 166 |
+
"Test the streaming chat endpoint that returns real-time updates."
|
| 167 |
+
]
|
| 168 |
+
},
|
| 169 |
+
{
|
| 170 |
+
"cell_type": "code",
|
| 171 |
+
"execution_count": 5,
|
| 172 |
+
"metadata": {},
|
| 173 |
+
"outputs": [
|
| 174 |
+
{
|
| 175 |
+
"name": "stdout",
|
| 176 |
+
"output_type": "stream",
|
| 177 |
+
"text": [
|
| 178 |
+
"π¬ Query: What is the current price of Bitcoin?\n",
|
| 179 |
+
"\n",
|
| 180 |
+
"π‘ Streaming Response:\n",
|
| 181 |
+
"================================================================================\n",
|
| 182 |
+
"\n",
|
| 183 |
+
"π Step 1: Reasoning\n",
|
| 184 |
+
" Thought: The user is asking for the current price of Bitcoin. The `CALL_CRYPTO` agent is designed to get cryptocurrency market data and prices. This is the most direct and appropriate tool to answer the query.\n",
|
| 185 |
+
" Action: CRYPTO\n",
|
| 186 |
+
" Justification: This action will directly retrieve the current price of Bitcoin, which is precisely what the user is asking for.\n",
|
| 187 |
+
"\n",
|
| 188 |
+
"π§ Calling Crypto Agent...\n",
|
| 189 |
+
"\n",
|
| 190 |
+
"π Crypto Agent Results:\n",
|
| 191 |
+
" The current price of Bitcoin is $95,748.\n",
|
| 192 |
+
"\n",
|
| 193 |
+
"π Step 2: Reasoning\n",
|
| 194 |
+
" Thought: I have already called the `CALL_CRYPTO` agent, which provided the current price of Bitcoin as $95,748. This directly answers the user's query. I have sufficient information to provide a final answer.\n",
|
| 195 |
+
" Action: FINISH\n",
|
| 196 |
+
" Justification: The `CALL_CRYPTO` agent has successfully retrieved the current price of Bitcoin, which was the user's original query. No further information is needed.\n",
|
| 197 |
+
"\n",
|
| 198 |
+
"β¨ Final Answer:\n",
|
| 199 |
+
"--------------------------------------------------------------------------------\n",
|
| 200 |
+
"The current price of Bitcoin is $95,748.\n",
|
| 201 |
+
"--------------------------------------------------------------------------------\n",
|
| 202 |
+
"\n",
|
| 203 |
+
"================================================================================\n"
|
| 204 |
+
]
|
| 205 |
+
}
|
| 206 |
+
],
|
| 207 |
+
"source": [
|
| 208 |
+
"def test_streaming_chat(message, history=None):\n",
|
| 209 |
+
" \"\"\"Test streaming chat with SSE.\"\"\"\n",
|
| 210 |
+
" if history is None:\n",
|
| 211 |
+
" history = []\n",
|
| 212 |
+
" \n",
|
| 213 |
+
" payload = {\n",
|
| 214 |
+
" \"message\": message,\n",
|
| 215 |
+
" \"history\": history\n",
|
| 216 |
+
" }\n",
|
| 217 |
+
" \n",
|
| 218 |
+
" print(f\"π¬ Query: {message}\")\n",
|
| 219 |
+
" print(\"\\nπ‘ Streaming Response:\")\n",
|
| 220 |
+
" print(\"=\" * 80)\n",
|
| 221 |
+
" \n",
|
| 222 |
+
" try:\n",
|
| 223 |
+
" response = requests.post(\n",
|
| 224 |
+
" f\"{BASE_URL}/api/v1/chat/stream\",\n",
|
| 225 |
+
" json=payload,\n",
|
| 226 |
+
" headers={\"Accept\": \"text/event-stream\"},\n",
|
| 227 |
+
" stream=True\n",
|
| 228 |
+
" )\n",
|
| 229 |
+
" response.raise_for_status()\n",
|
| 230 |
+
" \n",
|
| 231 |
+
" final_answer = \"\"\n",
|
| 232 |
+
" \n",
|
| 233 |
+
" for line in response.iter_lines():\n",
|
| 234 |
+
" if line:\n",
|
| 235 |
+
" line_str = line.decode('utf-8')\n",
|
| 236 |
+
" if line_str.startswith('data: '):\n",
|
| 237 |
+
" data_str = line_str[6:] # Remove 'data: ' prefix\n",
|
| 238 |
+
" try:\n",
|
| 239 |
+
" event = json.loads(data_str)\n",
|
| 240 |
+
" event_type = event.get('type', 'unknown')\n",
|
| 241 |
+
" \n",
|
| 242 |
+
" if event_type == 'thinking':\n",
|
| 243 |
+
" print(f\"\\nπ Step {event.get('step', '?')}: Reasoning\")\n",
|
| 244 |
+
" print(f\" Thought: {event.get('thought', 'N/A')}\")\n",
|
| 245 |
+
" print(f\" Action: {event.get('action', 'N/A').upper()}\")\n",
|
| 246 |
+
" print(f\" Justification: {event.get('justification', 'N/A')}\")\n",
|
| 247 |
+
" \n",
|
| 248 |
+
" elif event_type == 'action':\n",
|
| 249 |
+
" agent = event.get('agent', 'unknown')\n",
|
| 250 |
+
" print(f\"\\nπ§ Calling {agent.title()} Agent...\")\n",
|
| 251 |
+
" \n",
|
| 252 |
+
" elif event_type == 'observation':\n",
|
| 253 |
+
" agent = event.get('agent', 'unknown')\n",
|
| 254 |
+
" summary = event.get('summary', '')\n",
|
| 255 |
+
" print(f\"\\nπ {agent.title()} Agent Results:\")\n",
|
| 256 |
+
" print(f\" {summary[:200]}...\" if len(summary) > 200 else f\" {summary}\")\n",
|
| 257 |
+
" \n",
|
| 258 |
+
" elif event_type == 'final_start':\n",
|
| 259 |
+
" print(\"\\n⨠Final Answer:\")\n",
|
| 260 |
+
" print(\"-\" * 80)\n",
|
| 261 |
+
" \n",
|
| 262 |
+
" elif event_type == 'final_token':\n",
|
| 263 |
+
" final_answer = event.get('accumulated', '')\n",
|
| 264 |
+
" \n",
|
| 265 |
+
" elif event_type == 'final_complete':\n",
|
| 266 |
+
" print(final_answer)\n",
|
| 267 |
+
" print(\"-\" * 80)\n",
|
| 268 |
+
" \n",
|
| 269 |
+
" elif event_type == 'error':\n",
|
| 270 |
+
" print(f\"\\nβ Error: {event.get('error', 'Unknown error')}\")\n",
|
| 271 |
+
" \n",
|
| 272 |
+
" except json.JSONDecodeError:\n",
|
| 273 |
+
" continue\n",
|
| 274 |
+
" \n",
|
| 275 |
+
" print(\"\\n\" + \"=\" * 80)\n",
|
| 276 |
+
" return final_answer\n",
|
| 277 |
+
" \n",
|
| 278 |
+
" except Exception as e:\n",
|
| 279 |
+
" print(f\"\\nβ Error: {e}\")\n",
|
| 280 |
+
" return None\n",
|
| 281 |
+
"\n",
|
| 282 |
+
"# Test with a simple crypto query\n",
|
| 283 |
+
"result = test_streaming_chat(\"What is the current price of Bitcoin?\")"
|
| 284 |
+
]
|
| 285 |
+
},
|
| 286 |
+
{
|
| 287 |
+
"cell_type": "markdown",
|
| 288 |
+
"metadata": {},
|
| 289 |
+
"source": [
|
| 290 |
+
"### Test with Different Queries"
|
| 291 |
+
]
|
| 292 |
+
},
|
| 293 |
+
{
|
| 294 |
+
"cell_type": "code",
|
| 295 |
+
"execution_count": 6,
|
| 296 |
+
"metadata": {},
|
| 297 |
+
"outputs": [
|
| 298 |
+
{
|
| 299 |
+
"name": "stdout",
|
| 300 |
+
"output_type": "stream",
|
| 301 |
+
"text": [
|
| 302 |
+
"π¬ Query: What are the top 5 cryptocurrencies by market cap?\n",
|
| 303 |
+
"\n",
|
| 304 |
+
"π‘ Streaming Response:\n",
|
| 305 |
+
"================================================================================\n",
|
| 306 |
+
"\n",
|
| 307 |
+
"π Step 1: Reasoning\n",
|
| 308 |
+
" Thought: The user is asking for a list of the top 5 cryptocurrencies based on their market capitalization. This is a specific query about cryptocurrency market data. I have an action called `CALL_CRYPTO` which is designed to get cryptocurrency market data, prices, and trends. This is the most direct and specialized tool for the job.\n",
|
| 309 |
+
" Action: CRYPTO\n",
|
| 310 |
+
" Justification: The `CALL_CRYPTO` action is the most appropriate tool to get real-time market capitalization data for cryptocurrencies, which is exactly what is needed to answer the user's query.\n",
|
| 311 |
+
"\n",
|
| 312 |
+
"π§ Calling Crypto Agent...\n",
|
| 313 |
+
"\n",
|
| 314 |
+
"π Crypto Agent Results:\n",
|
| 315 |
+
" The top 5 cryptocurrencies by market cap are:\n",
|
| 316 |
+
"\n",
|
| 317 |
+
"1. Bitcoin (BTC) - Market Cap: $2,025,171,098,463 - Price: $101,536\n",
|
| 318 |
+
"2. Ethereum (ETH) - Market Cap: $411,695,018,306 - Price: $3,410.68\n",
|
| 319 |
+
"3. Tether (USDT) ...\n",
|
| 320 |
+
"\n",
|
| 321 |
+
"π Step 2: Reasoning\n",
|
| 322 |
+
" Thought: The CRYPTO agent has provided a list of the top 5 cryptocurrencies by market cap, which directly answers the user's query. I have all the necessary information to provide the final answer.\n",
|
| 323 |
+
" Action: FINISH\n",
|
| 324 |
+
" Justification: The information gathered from the CRYPTO agent is sufficient and complete to answer the user's question. No further actions are required.\n",
|
| 325 |
+
"\n",
|
| 326 |
+
"β¨ Final Answer:\n",
|
| 327 |
+
"--------------------------------------------------------------------------------\n",
|
| 328 |
+
"Based on current market data, here are the top 5 cryptocurrencies ranked by market capitalization:\n",
|
| 329 |
+
"\n",
|
| 330 |
+
"**1. Bitcoin (BTC)**\n",
|
| 331 |
+
"* **Market Cap:** $2,025,171,098,463\n",
|
| 332 |
+
"* **Price:** $101,536.00\n",
|
| 333 |
+
"\n",
|
| 334 |
+
"**2. Ethereum (ETH)**\n",
|
| 335 |
+
"* **Market Cap:** $411,695,018,306\n",
|
| 336 |
+
"* **Price:** $3,410.68\n",
|
| 337 |
+
"\n",
|
| 338 |
+
"**3. Tether (USDT)**\n",
|
| 339 |
+
"* **Market Cap:** $184,005,021,987\n",
|
| 340 |
+
"* **Price:** $0.999813\n",
|
| 341 |
+
"\n",
|
| 342 |
+
"**4. XRP (XRP)**\n",
|
| 343 |
+
"* **Market Cap:** $143,300,134,340* **Price:** $2.38\n",
|
| 344 |
+
"\n",
|
| 345 |
+
"**5. BNB (BNB)**\n",
|
| 346 |
+
"* **Market Cap:** $131,225,068,810\n",
|
| 347 |
+
"* **Price:** $952.81\n",
|
| 348 |
+
"\n",
|
| 349 |
+
"**Key Insight:**\n",
|
| 350 |
+
"\n",
|
| 351 |
+
"Market capitalization is a key metric used to determine the relative size and value of a cryptocurrency. It is calculated by multiplying the current market price of a single coin by the total number of coins in circulation. As shown above, Bitcoin holds a dominant position with a market cap significantly larger than the others combined.\n",
|
| 352 |
+
"\n",
|
| 353 |
+
"*It is important to note that the cryptocurrency market is highly volatile, and these rankings, market caps, and prices are subject to change rapidly.*\n",
|
| 354 |
+
"--------------------------------------------------------------------------------\n",
|
| 355 |
+
"\n",
|
| 356 |
+
"================================================================================\n"
|
| 357 |
+
]
|
| 358 |
+
},
|
| 359 |
+
{
|
| 360 |
+
"data": {
|
| 361 |
+
"text/plain": [
|
| 362 |
+
"'Based on current market data, here are the top 5 cryptocurrencies ranked by market capitalization:\\n\\n**1. Bitcoin (BTC)**\\n* **Market Cap:** $2,025,171,098,463\\n* **Price:** $101,536.00\\n\\n**2. Ethereum (ETH)**\\n* **Market Cap:** $411,695,018,306\\n* **Price:** $3,410.68\\n\\n**3. Tether (USDT)**\\n* **Market Cap:** $184,005,021,987\\n* **Price:** $0.999813\\n\\n**4. XRP (XRP)**\\n* **Market Cap:** $143,300,134,340* **Price:** $2.38\\n\\n**5. BNB (BNB)**\\n* **Market Cap:** $131,225,068,810\\n* **Price:** $952.81\\n\\n**Key Insight:**\\n\\nMarket capitalization is a key metric used to determine the relative size and value of a cryptocurrency. It is calculated by multiplying the current market price of a single coin by the total number of coins in circulation. As shown above, Bitcoin holds a dominant position with a market cap significantly larger than the others combined.\\n\\n*It is important to note that the cryptocurrency market is highly volatile, and these rankings, market caps, and prices are subject to change rapidly.*'"
|
| 363 |
+
]
|
| 364 |
+
},
|
| 365 |
+
"execution_count": 6,
|
| 366 |
+
"metadata": {},
|
| 367 |
+
"output_type": "execute_result"
|
| 368 |
+
}
|
| 369 |
+
],
|
| 370 |
+
"source": [
|
| 371 |
+
"# Test crypto query\n",
|
| 372 |
+
"test_streaming_chat(\"What are the top 5 cryptocurrencies by market cap?\")"
|
| 373 |
+
]
|
| 374 |
+
},
|
| 375 |
+
{
|
| 376 |
+
"cell_type": "code",
|
| 377 |
+
"execution_count": 7,
|
| 378 |
+
"metadata": {},
|
| 379 |
+
"outputs": [
|
| 380 |
+
{
|
| 381 |
+
"name": "stdout",
|
| 382 |
+
"output_type": "stream",
|
| 383 |
+
"text": [
|
| 384 |
+
"π¬ Query: What is the current stock price of Apple (AAPL)?\n",
|
| 385 |
+
"\n",
|
| 386 |
+
"π‘ Streaming Response:\n",
|
| 387 |
+
"================================================================================\n",
|
| 388 |
+
"\n",
|
| 389 |
+
"π Step 1: Reasoning\n",
|
| 390 |
+
" Thought: The user is asking for the current stock price of Apple (AAPL). The `CALL_STOCK` action is the most appropriate tool for this query as it is designed to get stock market data. I will use this action to retrieve the price for the ticker \"AAPL\".\n",
|
| 391 |
+
" Action: STOCK\n",
|
| 392 |
+
" Justification: This action is specifically designed to retrieve stock market data, which is exactly what is needed to answer the user's query about Apple's stock price.\n",
|
| 393 |
+
"\n",
|
| 394 |
+
"π§ Calling Stock Agent...\n",
|
| 395 |
+
"\n",
|
| 396 |
+
"π Stock Agent Results:\n",
|
| 397 |
+
" The current stock price of Apple (AAPL) is $273.47.\n",
|
| 398 |
+
"\n",
|
| 399 |
+
"π Step 2: Reasoning\n",
|
| 400 |
+
" Thought: I have successfully retrieved the current stock price of Apple (AAPL) from the STOCK agent. This information directly answers the user's query. Therefore, I have sufficient information and can provide the final answer.\n",
|
| 401 |
+
" Action: FINISH\n",
|
| 402 |
+
" Justification: The STOCK agent has provided the exact information requested by the user. No further actions are necessary.\n",
|
| 403 |
+
"\n",
|
| 404 |
+
"β¨ Final Answer:\n",
|
| 405 |
+
"--------------------------------------------------------------------------------\n",
|
| 406 |
+
"Based on the real-time data retrieved, the current stock price of Apple Inc. (AAPL) is **$273.47**.\n",
|
| 407 |
+
"\n",
|
| 408 |
+
"Please be aware that this price reflects the market value at the time the query was made and is subject to constant fluctuation based on market activity.\n",
|
| 409 |
+
"--------------------------------------------------------------------------------\n",
|
| 410 |
+
"\n",
|
| 411 |
+
"================================================================================\n"
|
| 412 |
+
]
|
| 413 |
+
},
|
| 414 |
+
{
|
| 415 |
+
"data": {
|
| 416 |
+
"text/plain": [
|
| 417 |
+
"'Based on the real-time data retrieved, the current stock price of Apple Inc. (AAPL) is **$273.47**.\\n\\nPlease be aware that this price reflects the market value at the time the query was made and is subject to constant fluctuation based on market activity.'"
|
| 418 |
+
]
|
| 419 |
+
},
|
| 420 |
+
"execution_count": 7,
|
| 421 |
+
"metadata": {},
|
| 422 |
+
"output_type": "execute_result"
|
| 423 |
+
}
|
| 424 |
+
],
|
| 425 |
+
"source": [
|
| 426 |
+
"# Test stock query\n",
|
| 427 |
+
"test_streaming_chat(\"What is the current stock price of Apple (AAPL)?\")"
|
| 428 |
+
]
|
| 429 |
+
},
|
| 430 |
+
{
|
| 431 |
+
"cell_type": "code",
|
| 432 |
+
"execution_count": 7,
|
| 433 |
+
"metadata": {},
|
| 434 |
+
"outputs": [
|
| 435 |
+
{
|
| 436 |
+
"name": "stdout",
|
| 437 |
+
"output_type": "stream",
|
| 438 |
+
"text": [
|
| 439 |
+
"π¬ Query: Search for the latest AI developments in 2025\n",
|
| 440 |
+
"\n",
|
| 441 |
+
"π‘ Streaming Response:\n",
|
| 442 |
+
"================================================================================\n",
|
| 443 |
+
"\n",
|
| 444 |
+
"π Step 1: Reasoning\n",
|
| 445 |
+
" Thought: The user is asking about future AI developments in 2025. This is a general knowledge and predictive query. I have no information gathered yet. The best tool to find information about current events, news, and future trends is the web search tool. The other tools (CRYPTO, STOCK, FINANCE_TRACKER, RAG) are not relevant to this topic. Therefore, I should use CALL_SEARCH to find the requested information.\n",
|
| 446 |
+
" Action: SEARCH\n",
|
| 447 |
+
" Justification: This action is the most appropriate for finding information on future technological trends and developments like those anticipated for AI in 2025.\n",
|
| 448 |
+
"\n",
|
| 449 |
+
"π§ Calling Search Agent...\n",
|
| 450 |
+
"\n",
|
| 451 |
+
"π Search Agent Results:\n",
|
| 452 |
+
" I couldn't find any results for \"latest AI developments in 2025.\" Since 2025 is in the future, there are no \"developments\" yet. I can search for predictions or trends for AI in 2025 if you'd like.\n",
|
| 453 |
+
"\n",
|
| 454 |
+
"π Step 2: Reasoning\n",
|
| 455 |
+
" Thought: The initial search for \"latest AI developments in 2025\" failed because 2025 is in the future. The SEARCH agent correctly pointed this out and suggested a better query: searching for \"predictions or trends for AI in 2025\". To answer the user's underlying question, I need to follow this suggestion and perform a new search with the revised query. This will gather the predictive information the user is likely looking for.\n",
|
| 456 |
+
" Action: SEARCH\n",
|
| 457 |
+
" Justification: The first search failed because the query was flawed. I will now use the refined query \"AI predictions and trends for 2025\" as suggested by the search agent to gather relevant information about what experts expect to see in the field of AI in the near future.\n",
|
| 458 |
+
"\n",
|
| 459 |
+
"π§ Calling Search Agent...\n",
|
| 460 |
+
"\n",
|
| 461 |
+
"π Search Agent Results:\n",
|
| 462 |
+
" I couldn't find any results for \"AI predictions and trends for 2025.\" This might be due to a temporary issue with the search engine or the way the query was phrased. I will try a more general search f...\n",
|
| 463 |
+
"\n",
|
| 464 |
+
"π Step 3: Reasoning\n",
|
| 465 |
+
" Thought: I have attempted two searches so far, and both have failed to return results. The first search was too specific about \"developments\" in the future. The second, more reasonable search for \"predictions and trends for AI in 2025\" also failed, which the agent noted might be a temporary issue. The agent suggested a broader search for \"future of AI\". This is a logical next step to gather some relevant information, as it's a broader topic that is more likely to yield results which can then be analyzed for trends relevant to 2025. I still have no information to answer the user's query, so another search is necessary.\n",
|
| 466 |
+
" Action: SEARCH\n",
|
| 467 |
+
" Justification: My previous two search attempts failed. The search agent suggested a broader query, \"future of AI,\" which is a logical next step to gather relevant information to answer the user's question about AI in 2025. I need to execute this search to obtain the necessary information.\n",
|
| 468 |
+
"\n",
|
| 469 |
+
"π§ Calling Search Agent...\n",
|
| 470 |
+
"\n",
|
| 471 |
+
"π Search Agent Results:\n",
|
| 472 |
+
" I am sorry, but I was unable to find any search results for \"latest AI developments in 2025\" or related queries like \"AI predictions and trends for 2025\" and \"future of AI.\" It seems there might be an...\n",
|
| 473 |
+
"\n",
|
| 474 |
+
"π Step 4: Reasoning\n",
|
| 475 |
+
" Thought: I have attempted to answer the user's query by using the `CALL_SEARCH` agent three times with progressively broader queries (\"latest AI developments in 2025\", \"AI predictions and trends for 2025\", and \"future of AI\"). Each attempt has failed, and the agent has explicitly stated that there might be an issue with the search tool itself. Since the only available tool capable of answering this query is not functioning, I cannot gather the necessary information. Further attempts to use the search tool are unlikely to succeed. Therefore, I must conclude the process and inform the user about the inability to retrieve the information due to a technical issue.\n",
|
| 476 |
+
" Action: FINISH\n",
|
| 477 |
+
" Justification: The `CALL_SEARCH` agent, which is the only relevant tool for this query, has failed multiple times and indicated a potential technical problem. I have exhausted all reasonable attempts to gather the information and cannot proceed further. The best course of action is to finish and communicate the issue to the user.\n",
|
| 478 |
+
"\n",
|
| 479 |
+
"β¨ Final Answer:\n",
|
| 480 |
+
"--------------------------------------------------------------------------------\n",
|
| 481 |
+
"I was unable to find information on the latest AI developments for 2025.\n",
|
| 482 |
+
"\n",
|
| 483 |
+
"My attempt to answer your query involved a multi-step search process. Initially, I searched for \"latest AI developments in 2025,\" but since 2025 is in the future, no \"developments\" have occurred yet. I then broadened my search to look for \"AI predictions and trends for 2025\" and subsequently the \"future of AI.\"\n",
|
| 484 |
+
"\n",
|
| 485 |
+
"Unfortunately, all of these searches failed to return any results. This indicates a potential temporary issue with the search tool or that information specifically categorized for 2025 is not widely available at this moment.\n",
|
| 486 |
+
"\n",
|
| 487 |
+
"I recommend trying your query again later, as the issue with the search tool may be resolved.\n",
|
| 488 |
+
"--------------------------------------------------------------------------------\n",
|
| 489 |
+
"\n",
|
| 490 |
+
"================================================================================\n"
|
| 491 |
+
]
|
| 492 |
+
},
|
| 493 |
+
{
|
| 494 |
+
"data": {
|
| 495 |
+
"text/plain": [
|
| 496 |
+
"'I was unable to find information on the latest AI developments for 2025.\\n\\nMy attempt to answer your query involved a multi-step search process. Initially, I searched for \"latest AI developments in 2025,\" but since 2025 is in the future, no \"developments\" have occurred yet. I then broadened my search to look for \"AI predictions and trends for 2025\" and subsequently the \"future of AI.\"\\n\\nUnfortunately, all of these searches failed to return any results. This indicates a potential temporary issue with the search tool or that information specifically categorized for 2025 is not widely available at this moment.\\n\\nI recommend trying your query again later, as the issue with the search tool may be resolved.'"
|
| 497 |
+
]
|
| 498 |
+
},
|
| 499 |
+
"execution_count": 7,
|
| 500 |
+
"metadata": {},
|
| 501 |
+
"output_type": "execute_result"
|
| 502 |
+
}
|
| 503 |
+
],
|
| 504 |
+
"source": [
|
| 505 |
+
"# Test web search query\n",
|
| 506 |
+
"test_streaming_chat(\"Search for the latest AI developments in 2025\")"
|
| 507 |
+
]
|
| 508 |
+
},
|
| 509 |
+
{
|
| 510 |
+
"cell_type": "code",
|
| 511 |
+
"execution_count": null,
|
| 512 |
+
"metadata": {},
|
| 513 |
+
"outputs": [],
|
| 514 |
+
"source": [
|
| 515 |
+
"# Test finance tracker - adding a transaction\n",
|
| 516 |
+
"test_streaming_chat(\"Add 10 shares of AAPL that I bought at $150\")"
|
| 517 |
+
]
|
| 518 |
+
},
|
| 519 |
+
{
|
| 520 |
+
"cell_type": "code",
|
| 521 |
+
"execution_count": 8,
|
| 522 |
+
"metadata": {},
|
| 523 |
+
"outputs": [
|
| 524 |
+
{
|
| 525 |
+
"name": "stdout",
|
| 526 |
+
"output_type": "stream",
|
| 527 |
+
"text": [
|
| 528 |
+
"π¬ Query: What's Bitcoin's price and what's the latest news about cryptocurrency?\n",
|
| 529 |
+
"\n",
|
| 530 |
+
"π‘ Streaming Response:\n",
|
| 531 |
+
"================================================================================\n",
|
| 532 |
+
"\n",
|
| 533 |
+
"π Step 1: Reasoning\n",
|
| 534 |
+
" Thought: The user is asking for two pieces of information: the current price of Bitcoin and the latest news about cryptocurrency. I need to address both parts of the query. The `CALL_CRYPTO` action is the most direct way to get the price of Bitcoin. I will start with that.\n",
|
| 535 |
+
" Action: CRYPTO\n",
|
| 536 |
+
" Justification: This action is specifically designed to retrieve cryptocurrency market data and prices, which directly answers the first part of the user's query about Bitcoin's price.\n",
|
| 537 |
+
"\n",
|
| 538 |
+
"π§ Calling Crypto Agent...\n",
|
| 539 |
+
"\n",
|
| 540 |
+
"π Crypto Agent Results:\n",
|
| 541 |
+
" I can only fetch the price of Bitcoin at the moment. Bitcoin's current price is $101,568. I cannot get the latest news about cryptocurrency.\n",
|
| 542 |
+
"\n",
|
| 543 |
+
"π Step 2: Reasoning\n",
|
| 544 |
+
" Thought: I have successfully obtained the price of Bitcoin from the CRYPTO agent. However, the user's query also asks for the latest news about cryptocurrency, which the CRYPTO agent was unable to provide. To fulfill the second part of the request, I need to use an agent that can find current news. The SEARCH agent is the most suitable tool for this purpose.\n",
|
| 545 |
+
" Action: SEARCH\n",
|
| 546 |
+
" Justification: This action will allow me to find the latest news about cryptocurrency, which is the remaining piece of information needed to fully answer the user's query.\n",
|
| 547 |
+
"\n",
|
| 548 |
+
"π§ Calling Search Agent...\n",
|
| 549 |
+
"\n",
|
| 550 |
+
"π Search Agent Results:\n",
|
| 551 |
+
" Bitcoin's current price is $101,568.\n",
|
| 552 |
+
"\n",
|
| 553 |
+
"For the latest cryptocurrency news, you can check out these sources:\n",
|
| 554 |
+
"\n",
|
| 555 |
+
"* **Crypto News:** https://cryptonews.com/\n",
|
| 556 |
+
"* **Forbes Digital Assets:** https://www.forb...\n",
|
| 557 |
+
"\n",
|
| 558 |
+
"π Step 3: Reasoning\n",
|
| 559 |
+
" Thought: I have successfully gathered the necessary information to answer the user's query. The CRYPTO and SEARCH agents both provided Bitcoin's current price, which is consistent. The SEARCH agent also provided a list of reliable sources for the latest cryptocurrency news. I do not need to call any more agents as I have a complete answer. I can now synthesize this information and provide the final response.\n",
|
| 560 |
+
" Action: FINISH\n",
|
| 561 |
+
" Justification: I have the price of Bitcoin and a list of sources for the latest cryptocurrency news. This is sufficient to fully answer the user's query.\n",
|
| 562 |
+
"\n",
|
| 563 |
+
"β¨ Final Answer:\n",
|
| 564 |
+
"--------------------------------------------------------------------------------\n",
|
| 565 |
+
"The current price of Bitcoin is **$101,568**.\n",
|
| 566 |
+
"\n",
|
| 567 |
+
"For the latest news and in-depth coverage of the cryptocurrency market, you can refer to these reputable sources:\n",
|
| 568 |
+
"\n",
|
| 569 |
+
"* **CoinDesk:** [https://www.coindesk.com/](https://www.coindesk.com/)\n",
|
| 570 |
+
"* **Cointelegraph:** [https://cointelegraph.com/](https://cointelegraph.com/)\n",
|
| 571 |
+
"* **Crypto News:** [https://cryptonews.com/](https://cryptonews.com/)\n",
|
| 572 |
+
"* **Forbes Digital Assets:** [https://www.forbes.com/digital-assets/news/](https://www.forbes.com/digital-assets/news/)\n",
|
| 573 |
+
"* **Reuters Cryptocurrency:** [https://www.reuters.com/markets/cryptocurrency/](https://www.reuters.com/markets/cryptocurrency/)\n",
|
| 574 |
+
"--------------------------------------------------------------------------------\n",
|
| 575 |
+
"\n",
|
| 576 |
+
"================================================================================\n"
|
| 577 |
+
]
|
| 578 |
+
},
|
| 579 |
+
{
|
| 580 |
+
"data": {
|
| 581 |
+
"text/plain": [
|
| 582 |
+
"'The current price of Bitcoin is **$101,568**.\\n\\nFor the latest news and in-depth coverage of the cryptocurrency market, you can refer to these reputable sources:\\n\\n* **CoinDesk:** [https://www.coindesk.com/](https://www.coindesk.com/)\\n* **Cointelegraph:** [https://cointelegraph.com/](https://cointelegraph.com/)\\n* **Crypto News:** [https://cryptonews.com/](https://cryptonews.com/)\\n* **Forbes Digital Assets:** [https://www.forbes.com/digital-assets/news/](https://www.forbes.com/digital-assets/news/)\\n* **Reuters Cryptocurrency:** [https://www.reuters.com/markets/cryptocurrency/](https://www.reuters.com/markets/cryptocurrency/)'"
|
| 583 |
+
]
|
| 584 |
+
},
|
| 585 |
+
"execution_count": 8,
|
| 586 |
+
"metadata": {},
|
| 587 |
+
"output_type": "execute_result"
|
| 588 |
+
}
|
| 589 |
+
],
|
| 590 |
+
"source": [
|
| 591 |
+
"# Test multi-agent query (requires multiple agents)\n",
|
| 592 |
+
"test_streaming_chat(\"What's Bitcoin's price and what's the latest news about cryptocurrency?\")"
|
| 593 |
+
]
|
| 594 |
+
},
|
| 595 |
+
{
|
| 596 |
+
"cell_type": "markdown",
|
| 597 |
+
"metadata": {},
|
| 598 |
+
"source": [
|
| 599 |
+
"## 4. WebSocket Chat\n",
|
| 600 |
+
"\n",
|
| 601 |
+
"Test the WebSocket endpoint for real-time bidirectional communication."
|
| 602 |
+
]
|
| 603 |
+
},
|
| 604 |
+
{
|
| 605 |
+
"cell_type": "code",
|
| 606 |
+
"execution_count": 9,
|
| 607 |
+
"metadata": {},
|
| 608 |
+
"outputs": [
|
| 609 |
+
{
|
| 610 |
+
"name": "stdout",
|
| 611 |
+
"output_type": "stream",
|
| 612 |
+
"text": [
|
| 613 |
+
"π¬ WebSocket Query: What is Ethereum's current price?\n",
|
| 614 |
+
"\n",
|
| 615 |
+
"π‘ WebSocket Response:\n",
|
| 616 |
+
"================================================================================\n",
|
| 617 |
+
"\n",
|
| 618 |
+
"π Step 1: Reasoning\n",
|
| 619 |
+
" Thought: The user is asking for the current price of Ethereum. Ethereum is a cryptocurrency. The `CALL_CRYPTO` action is specifically designed to get cryptocurrency market data and prices. Therefore, this is the most direct and appropriate action to take.\n",
|
| 620 |
+
" Action: CRYPTO\n",
|
| 621 |
+
"\n",
|
| 622 |
+
"π§ Calling Crypto Agent...\n",
|
| 623 |
+
"\n",
|
| 624 |
+
"π Crypto Agent Results\n",
|
| 625 |
+
"\n",
|
| 626 |
+
"π Step 2: Reasoning\n",
|
| 627 |
+
" Thought: The user asked for the current price of Ethereum. The CRYPTO agent has already provided this information: \"The current price of Ethereum is $3423.75.\" I have a direct and complete answer to the user's query. Therefore, I can now finish.\n",
|
| 628 |
+
" Action: FINISH\n",
|
| 629 |
+
"\n",
|
| 630 |
+
"β¨ Final Answer:\n",
|
| 631 |
+
"--------------------------------------------------------------------------------\n",
|
| 632 |
+
"Based on the latest market data, the current price of Ethereum (ETH) is **$3,423.75**.\n",
|
| 633 |
+
"\n",
|
| 634 |
+
"It is important to note that cryptocurrency prices are highly volatile and can change rapidly. This price reflects a snapshot at the time the data was retrieved.\n",
|
| 635 |
+
"--------------------------------------------------------------------------------\n",
|
| 636 |
+
"\n",
|
| 637 |
+
"================================================================================\n"
|
| 638 |
+
]
|
| 639 |
+
}
|
| 640 |
+
],
|
| 641 |
+
"source": [
|
| 642 |
+
"async def test_websocket_chat(message, history=None):\n",
|
| 643 |
+
" \"\"\"Test WebSocket chat endpoint.\"\"\"\n",
|
| 644 |
+
" if history is None:\n",
|
| 645 |
+
" history = []\n",
|
| 646 |
+
" \n",
|
| 647 |
+
" ws_url = BASE_URL.replace('http://', 'ws://') + '/ws/v1/chat'\n",
|
| 648 |
+
" \n",
|
| 649 |
+
" print(f\"π¬ WebSocket Query: {message}\")\n",
|
| 650 |
+
" print(\"\\nπ‘ WebSocket Response:\")\n",
|
| 651 |
+
" print(\"=\" * 80)\n",
|
| 652 |
+
" \n",
|
| 653 |
+
" try:\n",
|
| 654 |
+
" async with websockets.connect(ws_url) as websocket:\n",
|
| 655 |
+
" # Send message\n",
|
| 656 |
+
" await websocket.send(json.dumps({\n",
|
| 657 |
+
" \"message\": message,\n",
|
| 658 |
+
" \"history\": history\n",
|
| 659 |
+
" }))\n",
|
| 660 |
+
" \n",
|
| 661 |
+
" final_answer = \"\"\n",
|
| 662 |
+
" \n",
|
| 663 |
+
" # Receive streaming updates\n",
|
| 664 |
+
" while True:\n",
|
| 665 |
+
" try:\n",
|
| 666 |
+
" response = await asyncio.wait_for(websocket.recv(), timeout=60.0)\n",
|
| 667 |
+
" event = json.loads(response)\n",
|
| 668 |
+
" event_type = event.get('type', 'unknown')\n",
|
| 669 |
+
" \n",
|
| 670 |
+
" if event_type == 'thinking':\n",
|
| 671 |
+
" print(f\"\\nπ Step {event.get('step', '?')}: Reasoning\")\n",
|
| 672 |
+
" print(f\" Thought: {event.get('thought', 'N/A')}\")\n",
|
| 673 |
+
" print(f\" Action: {event.get('action', 'N/A').upper()}\")\n",
|
| 674 |
+
" \n",
|
| 675 |
+
" elif event_type == 'action':\n",
|
| 676 |
+
" agent = event.get('agent', 'unknown')\n",
|
| 677 |
+
" print(f\"\\nπ§ Calling {agent.title()} Agent...\")\n",
|
| 678 |
+
" \n",
|
| 679 |
+
" elif event_type == 'observation':\n",
|
| 680 |
+
" agent = event.get('agent', 'unknown')\n",
|
| 681 |
+
" summary = event.get('summary', '')\n",
|
| 682 |
+
" print(f\"\\nπ {agent.title()} Agent Results\")\n",
|
| 683 |
+
" \n",
|
| 684 |
+
" elif event_type == 'final_start':\n",
|
| 685 |
+
" print(\"\\n⨠Final Answer:\")\n",
|
| 686 |
+
" print(\"-\" * 80)\n",
|
| 687 |
+
" \n",
|
| 688 |
+
" elif event_type == 'final_token':\n",
|
| 689 |
+
" final_answer = event.get('accumulated', '')\n",
|
| 690 |
+
" \n",
|
| 691 |
+
" elif event_type == 'final_complete':\n",
|
| 692 |
+
" print(final_answer)\n",
|
| 693 |
+
" print(\"-\" * 80)\n",
|
| 694 |
+
" break\n",
|
| 695 |
+
" \n",
|
| 696 |
+
" elif event_type == 'error':\n",
|
| 697 |
+
" print(f\"\\nβ Error: {event.get('error', 'Unknown error')}\")\n",
|
| 698 |
+
" break\n",
|
| 699 |
+
" \n",
|
| 700 |
+
" except asyncio.TimeoutError:\n",
|
| 701 |
+
" print(\"\\nβ±οΈ Timeout waiting for response\")\n",
|
| 702 |
+
" break\n",
|
| 703 |
+
" \n",
|
| 704 |
+
" print(\"\\n\" + \"=\" * 80)\n",
|
| 705 |
+
" return final_answer\n",
|
| 706 |
+
" \n",
|
| 707 |
+
" except Exception as e:\n",
|
| 708 |
+
" print(f\"\\nβ Error: {e}\")\n",
|
| 709 |
+
" return None\n",
|
| 710 |
+
"\n",
|
| 711 |
+
"# Test WebSocket\n",
|
| 712 |
+
"ws_result = await test_websocket_chat(\"What is Ethereum's current price?\")"
|
| 713 |
+
]
|
| 714 |
+
},
|
| 715 |
+
{
|
| 716 |
+
"cell_type": "markdown",
|
| 717 |
+
"metadata": {},
|
| 718 |
+
"source": [
|
| 719 |
+
"## 5. Document Upload\n",
|
| 720 |
+
"\n",
|
| 721 |
+
"Test uploading a document to the RAG agent."
|
| 722 |
+
]
|
| 723 |
+
},
|
| 724 |
+
{
|
| 725 |
+
"cell_type": "code",
|
| 726 |
+
"execution_count": null,
|
| 727 |
+
"metadata": {},
|
| 728 |
+
"outputs": [],
|
| 729 |
+
"source": [
|
| 730 |
+
"def test_document_upload(file_path):\n",
|
| 731 |
+
" \"\"\"Test document upload endpoint.\"\"\"\n",
|
| 732 |
+
" try:\n",
|
| 733 |
+
" if not Path(file_path).exists():\n",
|
| 734 |
+
" print(f\"β File not found: {file_path}\")\n",
|
| 735 |
+
" return None\n",
|
| 736 |
+
" \n",
|
| 737 |
+
" print(f\"π€ Uploading: {file_path}\")\n",
|
| 738 |
+
" \n",
|
| 739 |
+
" with open(file_path, 'rb') as f:\n",
|
| 740 |
+
" files = {'file': (Path(file_path).name, f)}\n",
|
| 741 |
+
" response = requests.post(\n",
|
| 742 |
+
" f\"{BASE_URL}/api/v1/documents/upload\",\n",
|
| 743 |
+
" files=files\n",
|
| 744 |
+
" )\n",
|
| 745 |
+
" \n",
|
| 746 |
+
" response.raise_for_status()\n",
|
| 747 |
+
" data = response.json()\n",
|
| 748 |
+
" \n",
|
| 749 |
+
" print(\"\\nβ
Upload Response:\")\n",
|
| 750 |
+
" print(f\" Success: {data['success']}\")\n",
|
| 751 |
+
" print(f\" Message: {data['message']}\")\n",
|
| 752 |
+
" \n",
|
| 753 |
+
" if data.get('details'):\n",
|
| 754 |
+
" print(\"\\n Details:\")\n",
|
| 755 |
+
" for key, value in data['details'].items():\n",
|
| 756 |
+
" print(f\" {key}: {value}\")\n",
|
| 757 |
+
" \n",
|
| 758 |
+
" return data\n",
|
| 759 |
+
" \n",
|
| 760 |
+
" except requests.exceptions.HTTPError as e:\n",
|
| 761 |
+
" print(f\"\\nβ HTTP Error: {e}\")\n",
|
| 762 |
+
" print(f\" Response: {e.response.text}\")\n",
|
| 763 |
+
" return None\n",
|
| 764 |
+
" except Exception as e:\n",
|
| 765 |
+
" print(f\"\\nβ Error: {e}\")\n",
|
| 766 |
+
" return None\n",
|
| 767 |
+
"\n",
|
| 768 |
+
"# Example: Upload a test file (update path to your actual file)\n",
|
| 769 |
+
"test_document_upload(\"PATH_TO_YOUR_DOCUEMNT\")\n",
|
| 770 |
+
"\n",
|
| 771 |
+
"print(\"β οΈ To test document upload, uncomment the line above and provide a valid file path\")"
|
| 772 |
+
]
|
| 773 |
+
},
|
| 774 |
+
{
|
| 775 |
+
"cell_type": "code",
|
| 776 |
+
"execution_count": 8,
|
| 777 |
+
"metadata": {},
|
| 778 |
+
"outputs": [
|
| 779 |
+
{
|
| 780 |
+
"name": "stdout",
|
| 781 |
+
"output_type": "stream",
|
| 782 |
+
"text": [
|
| 783 |
+
"β
Created test file: test_document.txt\n",
|
| 784 |
+
"π€ Uploading: test_document.txt\n",
|
| 785 |
+
"\n",
|
| 786 |
+
"β
Upload Response:\n",
|
| 787 |
+
" Success: True\n",
|
| 788 |
+
" Message: Document uploaded successfully\n",
|
| 789 |
+
"\n",
|
| 790 |
+
" Details:\n",
|
| 791 |
+
" filename: tmphdduvt25.txt\n",
|
| 792 |
+
" file_type: .txt\n",
|
| 793 |
+
" chunks_added: 1\n",
|
| 794 |
+
" total_documents: 229\n"
|
| 795 |
+
]
|
| 796 |
+
}
|
| 797 |
+
],
|
| 798 |
+
"source": [
|
| 799 |
+
"# Create a test text file and upload it\n",
|
| 800 |
+
"test_file_path = \"test_document.txt\"\n",
|
| 801 |
+
"\n",
|
| 802 |
+
"with open(test_file_path, 'w') as f:\n",
|
| 803 |
+
" f.write(\"\"\"\n",
|
| 804 |
+
" Test Document for RAG Agent\n",
|
| 805 |
+
" \n",
|
| 806 |
+
" This is a sample document for testing the RAG agent's document upload functionality.\n",
|
| 807 |
+
" \n",
|
| 808 |
+
" Key Information:\n",
|
| 809 |
+
" - The multi-agent system uses Google Gemini 2.5 Pro\n",
|
| 810 |
+
" - It implements a ReAct (Reasoning + Acting) pattern\n",
|
| 811 |
+
" - Available agents: Crypto, Stock, RAG, Search, Finance Tracker\n",
|
| 812 |
+
" - Each agent uses specialized MCP servers for their domain\n",
|
| 813 |
+
" \n",
|
| 814 |
+
" The system can handle co plex queries that require multiple agents working together.\n",
|
| 815 |
+
" \"\"\")\n",
|
| 816 |
+
"\n",
|
| 817 |
+
"print(f\"β
Created test file: {test_file_path}\")\n",
|
| 818 |
+
"\n",
|
| 819 |
+
"# Upload the test file\n",
|
| 820 |
+
"upload_result = test_document_upload(test_file_path)"
|
| 821 |
+
]
|
| 822 |
+
},
|
| 823 |
+
{
|
| 824 |
+
"cell_type": "code",
|
| 825 |
+
"execution_count": 9,
|
| 826 |
+
"metadata": {},
|
| 827 |
+
"outputs": [
|
| 828 |
+
{
|
| 829 |
+
"name": "stdout",
|
| 830 |
+
"output_type": "stream",
|
| 831 |
+
"text": [
|
| 832 |
+
"π¬ Query: What applications of AI agents are described in my collection?\n",
|
| 833 |
+
"\n",
|
| 834 |
+
"π‘ Streaming Response:\n",
|
| 835 |
+
"================================================================================\n",
|
| 836 |
+
"\n",
|
| 837 |
+
"π Step 1: Reasoning\n",
|
| 838 |
+
" Thought: The user is asking about applications of AI agents described in \"my collection\". This indicates that the information should be retrieved from the user's uploaded documents. The `CALL_RAG` tool is designed for this purpose.\n",
|
| 839 |
+
" Action: RAG\n",
|
| 840 |
+
" Justification: The query specifically asks for information from \"my collection,\" which implies searching the user's uploaded documents. `CALL_RAG` is the appropriate tool to retrieve information from these documents.\n",
|
| 841 |
+
"\n",
|
| 842 |
+
"π§ Calling Rag Agent...\n",
|
| 843 |
+
"\n",
|
| 844 |
+
"π Rag Agent Results:\n",
|
| 845 |
+
" Based on the provided document excerpts, there is no information available regarding the current status of any project. The documents discuss applications of AI agents, challenges in their integration...\n",
|
| 846 |
+
"\n",
|
| 847 |
+
"π Step 2: Reasoning\n",
|
| 848 |
+
" Thought: The user asked for \"What applications of AI agents are described in my collection?\". I previously called `CALL_RAG`, and the observation stated: \"The documents discuss applications of AI agents, challenges in their integration, and various machine learning models and their use cases.\" This confirms that the information about AI agent applications *is* present in the documents. However, the previous `RAG` call did not actually list these applications. I need to call `CALL_RAG` again, but this time with a more specific prompt to extract and list the applications of AI agents mentioned in the documents.\n",
|
| 849 |
+
" Action: RAG\n",
|
| 850 |
+
" Justification: The previous `CALL_RAG` observation confirmed that the documents contain information about \"applications of AI agents\". I need to call `CALL_RAG` again to specifically extract and list these applications, as the previous call did not provide them.\n",
|
| 851 |
+
"\n",
|
| 852 |
+
"π§ Calling Rag Agent...\n",
|
| 853 |
+
"\n",
|
| 854 |
+
"π Rag Agent Results:\n",
|
| 855 |
+
" Based on the provided document excerpts, there is no information available regarding the benefits of the Mediterranean diet for brain health. The documents discuss applications of AI agents, challenge...\n",
|
| 856 |
+
"\n",
|
| 857 |
+
"π Step 3: Reasoning\n",
|
| 858 |
+
" Thought: The user is asking for a list of AI agent applications from their collection. I have called `CALL_RAG` twice. The first call indicated that the documents discuss \"applications of AI agents\". The second call was problematic, mentioning \"Mediterranean diet\" while still stating that \"The documents discuss applications of AI agents\". This indicates that the RAG tool is acknowledging the presence of the information but failing to extract and list the specific applications. I need to call `CALL_RAG` again, but this time, I must ensure the prompt is very explicit about *listing* the applications to overcome the previous failures.\n",
|
| 859 |
+
" Action: RAG\n",
|
| 860 |
+
" Justification: The `CALL_RAG` tool is the only way to access information from the user's uploaded documents (\"my collection\"). Despite previous attempts, the tool has not yet provided the actual list of AI agent applications, even though it confirms the information is present. I need to try again with a more precise prompt to extract and list these applications.\n",
|
| 861 |
+
"\n",
|
| 862 |
+
"π§ Calling Rag Agent...\n",
|
| 863 |
+
"\n",
|
| 864 |
+
"π Rag Agent Results:\n",
|
| 865 |
+
" Based on the provided document excerpts, the following applications of AI agents are described:\n",
|
| 866 |
+
"\n",
|
| 867 |
+
"For **Marketing Professionals**:\n",
|
| 868 |
+
"* Content generation agents\n",
|
| 869 |
+
"* Market research agents\n",
|
| 870 |
+
"* Performan...\n",
|
| 871 |
+
"\n",
|
| 872 |
+
"π Step 4: Reasoning\n",
|
| 873 |
+
" Thought: I have successfully retrieved the applications of AI agents described in the user's collection using `CALL_RAG`. The information gathered directly answers the user's query.\n",
|
| 874 |
+
" Action: FINISH\n",
|
| 875 |
+
" Justification: I have sufficient information to answer the user's query about the applications of AI agents described in their collection.\n",
|
| 876 |
+
"\n",
|
| 877 |
+
"β¨ Final Answer:\n",
|
| 878 |
+
"--------------------------------------------------------------------------------\n",
|
| 879 |
+
"Based on the documents in your collection, AI agents are described with specific applications for both marketing and sales professionals:\n",
|
| 880 |
+
"\n",
|
| 881 |
+
"**For Marketing Professionals:**\n",
|
| 882 |
+
"* **Content generation agents:** To assist in creating various forms of marketing content.\n",
|
| 883 |
+
"* **Market research agents:** For conducting research to understand market trends and opportunities.\n",
|
| 884 |
+
"* **Performance analytics agents:** To analyze the effectiveness of marketing campaigns and strategies.\n",
|
| 885 |
+
"* **Audience insight agents:** For gathering and interpreting data about target audiences.\n",
|
| 886 |
+
"\n",
|
| 887 |
+
"**For Sales Professionals:**\n",
|
| 888 |
+
"* **Prospect research agents:** To identify and gather information on potential clients.\n",
|
| 889 |
+
"* **Outreach customization agents:** For tailoring communication to individual prospects.\n",
|
| 890 |
+
"* **Meeting preparation agents:** To assist in preparing for sales meetings.\n",
|
| 891 |
+
"* **Follow-up management agents:** For organizing and executing post-meeting follow-up tasks.\n",
|
| 892 |
+
"--------------------------------------------------------------------------------\n",
|
| 893 |
+
"\n",
|
| 894 |
+
"================================================================================\n"
|
| 895 |
+
]
|
| 896 |
+
}
|
| 897 |
+
],
|
| 898 |
+
"source": [
|
| 899 |
+
"# Now query the uploaded document\n",
|
| 900 |
+
"if upload_result and upload_result.get('success'):\n",
|
| 901 |
+
" test_streaming_chat(\"What applications of AI agents are described in my collection?\")"
|
| 902 |
+
]
|
| 903 |
+
},
|
| 904 |
+
{
|
| 905 |
+
"cell_type": "markdown",
|
| 906 |
+
"metadata": {},
|
| 907 |
+
"source": [
|
| 908 |
+
"## 6. Conversation History\n",
|
| 909 |
+
"\n",
|
| 910 |
+
"Test chat with conversation history."
|
| 911 |
+
]
|
| 912 |
+
},
|
| 913 |
+
{
|
| 914 |
+
"cell_type": "code",
|
| 915 |
+
"execution_count": 6,
|
| 916 |
+
"metadata": {},
|
| 917 |
+
"outputs": [
|
| 918 |
+
{
|
| 919 |
+
"name": "stdout",
|
| 920 |
+
"output_type": "stream",
|
| 921 |
+
"text": [
|
| 922 |
+
"\n",
|
| 923 |
+
"====================================================================================================\n",
|
| 924 |
+
"MESSAGE 1\n",
|
| 925 |
+
"====================================================================================================\n",
|
| 926 |
+
"π¬ Query: What is Bitcoin?\n",
|
| 927 |
+
"\n",
|
| 928 |
+
"π‘ Streaming Response:\n",
|
| 929 |
+
"================================================================================\n",
|
| 930 |
+
"\n",
|
| 931 |
+
"π Step 1: Reasoning\n",
|
| 932 |
+
" Thought: The user is asking for a definition of \"Bitcoin\". This is a general knowledge question. I do not have any information yet. The best tool for answering general knowledge questions is `CALL_SEARCH`. The other tools are more specialized: `CALL_CRYPTO` is for market data, not definitions; `CALL_STOCK` and `CALL_FINANCE_TRACKER` are for traditional finance; and `CALL_RAG` requires pre-existing documents. Therefore, I should use `CALL_SEARCH` to find out what Bitcoin is.\n",
|
| 933 |
+
" Action: SEARCH\n",
|
| 934 |
+
" Justification: The user's query \"What is Bitcoin?\" is a general knowledge question. The `CALL_SEARCH` action is the most appropriate tool to find a definition and explanation from the web.\n",
|
| 935 |
+
"\n",
|
| 936 |
+
"π§ Calling Search Agent...\n",
|
| 937 |
+
"\n",
|
| 938 |
+
"π Search Agent Results:\n",
|
| 939 |
+
" Bitcoin is a decentralized digital currency that allows for direct transactions between users without the need for an intermediary like a bank. It was invented in 2008 by an unknown entity or group us...\n",
|
| 940 |
+
"\n",
|
| 941 |
+
"π Step 2: Reasoning\n",
|
| 942 |
+
" Thought: I have successfully used the `CALL_SEARCH` agent to get a clear and comprehensive definition of Bitcoin. The information gathered explains what it is, who created it, and the technology behind it. This is sufficient to answer the user's query. I do not need to call any other agents. I can now provide the final answer.\n",
|
| 943 |
+
" Action: FINISH\n",
|
| 944 |
+
" Justification: I have a complete answer to the user's question \"What is Bitcoin?\" from the previous `CALL_SEARCH` action. No further information is required.\n",
|
| 945 |
+
"\n",
|
| 946 |
+
"β¨ Final Answer:\n",
|
| 947 |
+
"--------------------------------------------------------------------------------\n",
|
| 948 |
+
"Based on the information gathered, here is a comprehensive answer to your query.\n",
|
| 949 |
+
"\n",
|
| 950 |
+
"### What is Bitcoin?\n",
|
| 951 |
+
"\n",
|
| 952 |
+
"Bitcoin is a decentralized digital currency that enables direct, peer-to-peer transactions between users without the need for an intermediary like a bank or financial institution. It was the first cryptocurrency to be created and remains the most well-known.\n",
|
| 953 |
+
"\n",
|
| 954 |
+
"#### Key Features:* **Decentralized:** Unlike traditional currencies issued by governments (like the U.S. Dollar), Bitcoin is not controlled by any single entity. This means it is resistant to censorship and manipulation by a central authority.\n",
|
| 955 |
+
"* **Digital Nature:** Bitcoin exists only in the digital realm. It is not a physical coin or bill but a balance associated with a digital address.\n",
|
| 956 |
+
"* **Peer-to-Peer Technology:** Transactions are sent directly from one user to another across a global network of computers.\n",
|
| 957 |
+
"* **Blockchain Technology:** All transactions are recorded on a public, distributed ledger called the blockchain. This technology ensures transparency and security, as every transaction is permanent and visible to everyone on the network.\n",
|
| 958 |
+
"* **Cryptography:** Advanced cryptographic techniques are used to secure the network, verify transactions, and control the creation of new bitcoins.\n",
|
| 959 |
+
"\n",
|
| 960 |
+
"#### Origins:\n",
|
| 961 |
+
"\n",
|
| 962 |
+
"Bitcoin was invented in 2008 by an unknown person or group of people using the pseudonym **Satoshi Nakamoto**. The network went live in 2009, marking the beginning of its use as a currency.\n",
|
| 963 |
+
"\n",
|
| 964 |
+
"***\n",
|
| 965 |
+
"*Sources: Forbes Advisor, Investopedia, Bitcoin.org, Analytics Insight, Wikipedia, Britannica Money, Crypto.com, Cointelegraph*\n",
|
| 966 |
+
"--------------------------------------------------------------------------------\n",
|
| 967 |
+
"\n",
|
| 968 |
+
"================================================================================\n",
|
| 969 |
+
"\n",
|
| 970 |
+
"====================================================================================================\n",
|
| 971 |
+
"MESSAGE 2 (with context from previous message)\n",
|
| 972 |
+
"====================================================================================================\n",
|
| 973 |
+
"π¬ Query: What is its current price?\n",
|
| 974 |
+
"\n",
|
| 975 |
+
"π‘ Streaming Response:\n",
|
| 976 |
+
"================================================================================\n",
|
| 977 |
+
"\n",
|
| 978 |
+
"π Step 1: Reasoning\n",
|
| 979 |
+
" Thought: The user is asking for the current price of \"it\". Based on the previous turn in the conversation history, \"it\" refers to Bitcoin. To answer this question, I need to get the current market price of Bitcoin. The `CALL_CRYPTO` tool is specifically designed to retrieve cryptocurrency prices.\n",
|
| 980 |
+
" Action: CRYPTO\n",
|
| 981 |
+
" Justification: This action will provide the real-time price of Bitcoin, which is the specific information needed to answer the user's query.\n",
|
| 982 |
+
"\n",
|
| 983 |
+
"π§ Calling Crypto Agent...\n",
|
| 984 |
+
"\n",
|
| 985 |
+
"π Crypto Agent Results:\n",
|
| 986 |
+
" The current price of Bitcoin is $98,857. Over the last 24 hours, its price has changed by -2.74%, and its 24-hour trading volume is $98,820,417,484.34. The market cap for Bitcoin is $1,970,383,895,456...\n",
|
| 987 |
+
"\n",
|
| 988 |
+
"π Step 2: Reasoning\n",
|
| 989 |
+
" Thought: I have successfully retrieved the current price of Bitcoin using the `CALL_CRYPTO` agent. The information gathered directly answers the user's query. Therefore, I have sufficient information and should now provide the final answer.\n",
|
| 990 |
+
" Action: FINISH\n",
|
| 991 |
+
" Justification: The `CALL_CRYPTO` agent provided the current price of Bitcoin, which is exactly what the user asked for. No further information is needed.\n",
|
| 992 |
+
"\n",
|
| 993 |
+
"β¨ Final Answer:\n",
|
| 994 |
+
"--------------------------------------------------------------------------------\n",
|
| 995 |
+
"Based on real-time market data, the current price of Bitcoin is **$98,857**.\n",
|
| 996 |
+
"\n",
|
| 997 |
+
"Here is some additional market information:\n",
|
| 998 |
+
"\n",
|
| 999 |
+
"* **24-Hour Price Change:** -2.74%\n",
|
| 1000 |
+
"* **Market Cap:** $1,970,383,895,456.65* **24-Hour Trading Volume:** $98,820,417,484.34\n",
|
| 1001 |
+
"\n",
|
| 1002 |
+
"***\n",
|
| 1003 |
+
"*Please note: Cryptocurrency prices are highly volatile and can change rapidly. This information reflects the market at the time this query was made.*\n",
|
| 1004 |
+
"--------------------------------------------------------------------------------\n",
|
| 1005 |
+
"\n",
|
| 1006 |
+
"================================================================================\n"
|
| 1007 |
+
]
|
| 1008 |
+
}
|
| 1009 |
+
],
|
| 1010 |
+
"source": [
|
| 1011 |
+
"# Start a conversation\n",
|
| 1012 |
+
"history = []\n",
|
| 1013 |
+
"\n",
|
| 1014 |
+
"# First message\n",
|
| 1015 |
+
"print(\"\\n\" + \"=\"*100)\n",
|
| 1016 |
+
"print(\"MESSAGE 1\")\n",
|
| 1017 |
+
"print(\"=\"*100)\n",
|
| 1018 |
+
"result1 = test_streaming_chat(\"What is Bitcoin?\", history=history)\n",
|
| 1019 |
+
"history.append({\"role\": \"user\", \"content\": \"What is Bitcoin?\"})\n",
|
| 1020 |
+
"history.append({\"role\": \"assistant\", \"content\": result1})\n",
|
| 1021 |
+
"\n",
|
| 1022 |
+
"time.sleep(2)\n",
|
| 1023 |
+
"\n",
|
| 1024 |
+
"# Follow-up question (with context)\n",
|
| 1025 |
+
"print(\"\\n\" + \"=\"*100)\n",
|
| 1026 |
+
"print(\"MESSAGE 2 (with context from previous message)\")\n",
|
| 1027 |
+
"print(\"=\"*100)\n",
|
| 1028 |
+
"result2 = test_streaming_chat(\"What is its current price?\", history=history)\n",
|
| 1029 |
+
"history.append({\"role\": \"user\", \"content\": \"What is its current price?\"})\n",
|
| 1030 |
+
"history.append({\"role\": \"assistant\", \"content\": result2})"
|
| 1031 |
+
]
|
| 1032 |
+
},
|
| 1033 |
+
{
|
| 1034 |
+
"cell_type": "markdown",
|
| 1035 |
+
"metadata": {},
|
| 1036 |
+
"source": [
|
| 1037 |
+
"## 7. Performance Testing\n",
|
| 1038 |
+
"\n",
|
| 1039 |
+
"Test response times and measure performance."
|
| 1040 |
+
]
|
| 1041 |
+
},
|
| 1042 |
+
{
|
| 1043 |
+
"cell_type": "code",
|
| 1044 |
+
"execution_count": 26,
|
| 1045 |
+
"metadata": {},
|
| 1046 |
+
"outputs": [
|
| 1047 |
+
{
|
| 1048 |
+
"name": "stdout",
|
| 1049 |
+
"output_type": "stream",
|
| 1050 |
+
"text": [
|
| 1051 |
+
"\n",
|
| 1052 |
+
"β±οΈ Performance Metrics for: 'What is Bitcoin price?'\n",
|
| 1053 |
+
" Time to first token: 14.65s\n",
|
| 1054 |
+
" Total response time: 36.27s\n"
|
| 1055 |
+
]
|
| 1056 |
+
},
|
| 1057 |
+
{
|
| 1058 |
+
"data": {
|
| 1059 |
+
"text/plain": [
|
| 1060 |
+
"{'first_token': 14.646224975585938, 'total': 36.274803161621094}"
|
| 1061 |
+
]
|
| 1062 |
+
},
|
| 1063 |
+
"execution_count": 26,
|
| 1064 |
+
"metadata": {},
|
| 1065 |
+
"output_type": "execute_result"
|
| 1066 |
+
}
|
| 1067 |
+
],
|
| 1068 |
+
"source": [
|
| 1069 |
+
"import time\n",
|
| 1070 |
+
"\n",
|
| 1071 |
+
"def measure_response_time(query):\n",
|
| 1072 |
+
" \"\"\"Measure response time for a query.\"\"\"\n",
|
| 1073 |
+
" payload = {\"message\": query, \"history\": []}\n",
|
| 1074 |
+
" \n",
|
| 1075 |
+
" start_time = time.time()\n",
|
| 1076 |
+
" first_token_time = None\n",
|
| 1077 |
+
" \n",
|
| 1078 |
+
" try:\n",
|
| 1079 |
+
" response = requests.post(\n",
|
| 1080 |
+
" f\"{BASE_URL}/api/v1/chat/stream\",\n",
|
| 1081 |
+
" json=payload,\n",
|
| 1082 |
+
" headers={\"Accept\": \"text/event-stream\"},\n",
|
| 1083 |
+
" stream=True\n",
|
| 1084 |
+
" )\n",
|
| 1085 |
+
" \n",
|
| 1086 |
+
" for i, line in enumerate(response.iter_lines()):\n",
|
| 1087 |
+
" if i == 0 and first_token_time is None:\n",
|
| 1088 |
+
" first_token_time = time.time() - start_time\n",
|
| 1089 |
+
" if line and b'final_complete' in line:\n",
|
| 1090 |
+
" break\n",
|
| 1091 |
+
" \n",
|
| 1092 |
+
" total_time = time.time() - start_time\n",
|
| 1093 |
+
" \n",
|
| 1094 |
+
" print(f\"\\nβ±οΈ Performance Metrics for: '{query}'\")\n",
|
| 1095 |
+
" print(f\" Time to first token: {first_token_time:.2f}s\")\n",
|
| 1096 |
+
" print(f\" Total response time: {total_time:.2f}s\")\n",
|
| 1097 |
+
" \n",
|
| 1098 |
+
" return {\"first_token\": first_token_time, \"total\": total_time}\n",
|
| 1099 |
+
" \n",
|
| 1100 |
+
" except Exception as e:\n",
|
| 1101 |
+
" print(f\"β Error: {e}\")\n",
|
| 1102 |
+
" return None\n",
|
| 1103 |
+
"\n",
|
| 1104 |
+
"# Test with simple query\n",
|
| 1105 |
+
"measure_response_time(\"What is Bitcoin price?\")"
|
| 1106 |
+
]
|
| 1107 |
+
},
|
| 1108 |
+
{
|
| 1109 |
+
"cell_type": "markdown",
|
| 1110 |
+
"metadata": {},
|
| 1111 |
+
"source": [
|
| 1112 |
+
"## 8. Error Handling Tests\n",
|
| 1113 |
+
"\n",
|
| 1114 |
+
"Test how the API handles various error conditions."
|
| 1115 |
+
]
|
| 1116 |
+
},
|
| 1117 |
+
{
|
| 1118 |
+
"cell_type": "code",
|
| 1119 |
+
"execution_count": null,
|
| 1120 |
+
"metadata": {},
|
| 1121 |
+
"outputs": [],
|
| 1122 |
+
"source": [
|
| 1123 |
+
"# Test empty message\n",
|
| 1124 |
+
"print(\"Testing empty message:\")\n",
|
| 1125 |
+
"test_streaming_chat(\"\")"
|
| 1126 |
+
]
|
| 1127 |
+
},
|
| 1128 |
+
{
|
| 1129 |
+
"cell_type": "code",
|
| 1130 |
+
"execution_count": null,
|
| 1131 |
+
"metadata": {},
|
| 1132 |
+
"outputs": [],
|
| 1133 |
+
"source": [
|
| 1134 |
+
"# Test invalid file upload\n",
|
| 1135 |
+
"print(\"Testing invalid file type:\")\n",
|
| 1136 |
+
"\n",
|
| 1137 |
+
"invalid_file_path = \"/tmp/test.exe\"\n",
|
| 1138 |
+
"with open(invalid_file_path, 'w') as f:\n",
|
| 1139 |
+
" f.write(\"invalid content\")\n",
|
| 1140 |
+
"\n",
|
| 1141 |
+
"test_document_upload(invalid_file_path)"
|
| 1142 |
+
]
|
| 1143 |
+
},
|
| 1144 |
+
{
|
| 1145 |
+
"cell_type": "markdown",
|
| 1146 |
+
"metadata": {},
|
| 1147 |
+
"source": [
|
| 1148 |
+
"## 9. API Documentation\n",
|
| 1149 |
+
"\n",
|
| 1150 |
+
"The FastAPI server provides automatic interactive documentation.\n",
|
| 1151 |
+
"\n",
|
| 1152 |
+
"**Access the documentation at:**\n",
|
| 1153 |
+
"- Swagger UI: http://localhost:8000/docs\n",
|
| 1154 |
+
"- ReDoc: http://localhost:8000/redoc\n",
|
| 1155 |
+
"\n",
|
| 1156 |
+
"You can test all endpoints directly from the browser using the Swagger UI!"
|
| 1157 |
+
]
|
| 1158 |
+
},
|
| 1159 |
+
{
|
| 1160 |
+
"cell_type": "code",
|
| 1161 |
+
"execution_count": 25,
|
| 1162 |
+
"metadata": {},
|
| 1163 |
+
"outputs": [
|
| 1164 |
+
{
|
| 1165 |
+
"name": "stdout",
|
| 1166 |
+
"output_type": "stream",
|
| 1167 |
+
"text": [
|
| 1168 |
+
"π FastAPI Interactive Documentation\n",
|
| 1169 |
+
"\n",
|
| 1170 |
+
"π Swagger UI: http://localhost:8000/docs\n",
|
| 1171 |
+
"π ReDoc: http://localhost:8000/redoc\n"
|
| 1172 |
+
]
|
| 1173 |
+
}
|
| 1174 |
+
],
|
| 1175 |
+
"source": [
|
| 1176 |
+
"from IPython.display import IFrame\n",
|
| 1177 |
+
"\n",
|
| 1178 |
+
"# Display Swagger UI in notebook (if server is running)\n",
|
| 1179 |
+
"print(\"π FastAPI Interactive Documentation\")\n",
|
| 1180 |
+
"print(f\"\\nπ Swagger UI: {BASE_URL}/docs\")\n",
|
| 1181 |
+
"print(f\"π ReDoc: {BASE_URL}/redoc\")\n",
|
| 1182 |
+
"\n",
|
| 1183 |
+
"# Uncomment to embed in notebook\n",
|
| 1184 |
+
"# IFrame(f\"{BASE_URL}/docs\", width=1000, height=600)"
|
| 1185 |
+
]
|
| 1186 |
+
}
|
| 1187 |
+
],
|
| 1188 |
+
"metadata": {
|
| 1189 |
+
"kernelspec": {
|
| 1190 |
+
"display_name": "venv (3.11.5)",
|
| 1191 |
+
"language": "python",
|
| 1192 |
+
"name": "python3"
|
| 1193 |
+
},
|
| 1194 |
+
"language_info": {
|
| 1195 |
+
"codemirror_mode": {
|
| 1196 |
+
"name": "ipython",
|
| 1197 |
+
"version": 3
|
| 1198 |
+
},
|
| 1199 |
+
"file_extension": ".py",
|
| 1200 |
+
"mimetype": "text/x-python",
|
| 1201 |
+
"name": "python",
|
| 1202 |
+
"nbconvert_exporter": "python",
|
| 1203 |
+
"pygments_lexer": "ipython3",
|
| 1204 |
+
"version": "3.11.5"
|
| 1205 |
+
}
|
| 1206 |
+
},
|
| 1207 |
+
"nbformat": 4,
|
| 1208 |
+
"nbformat_minor": 4
|
| 1209 |
+
}
|
requirements.txt
ADDED
|
Binary file (3.15 kB). View file
|
|
|
scripts/setup.sh
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/bin/bash
|
| 2 |
+
|
| 3 |
+
echo "======================================"
|
| 4 |
+
echo "Multi-Agent Crypto Assistant Setup"
|
| 5 |
+
echo "======================================"
|
| 6 |
+
echo ""
|
| 7 |
+
|
| 8 |
+
# Check Python version
|
| 9 |
+
python_version=$(python3 --version 2>&1 | grep -oP '\d+\.\d+')
|
| 10 |
+
required_version="3.9"
|
| 11 |
+
|
| 12 |
+
if [ "$(printf '%s\n' "$required_version" "$python_version" | sort -V | head -n1)" != "$required_version" ]; then
|
| 13 |
+
echo "β Error: Python 3.9 or higher is required"
|
| 14 |
+
echo "Current version: $(python3 --version)"
|
| 15 |
+
exit 1
|
| 16 |
+
fi
|
| 17 |
+
|
| 18 |
+
echo "β
Python version check passed: $(python3 --version)"
|
| 19 |
+
echo ""
|
| 20 |
+
|
| 21 |
+
# Create virtual environment
|
| 22 |
+
echo "π¦ Creating virtual environment..."
|
| 23 |
+
python3 -m venv venv
|
| 24 |
+
|
| 25 |
+
if [ $? -ne 0 ]; then
|
| 26 |
+
echo "β Failed to create virtual environment"
|
| 27 |
+
exit 1
|
| 28 |
+
fi
|
| 29 |
+
|
| 30 |
+
echo "β
Virtual environment created"
|
| 31 |
+
echo ""
|
| 32 |
+
|
| 33 |
+
# Activate virtual environment
|
| 34 |
+
echo "π Activating virtual environment..."
|
| 35 |
+
source venv/bin/activate
|
| 36 |
+
|
| 37 |
+
if [ $? -ne 0 ]; then
|
| 38 |
+
echo "β Failed to activate virtual environment"
|
| 39 |
+
exit 1
|
| 40 |
+
fi
|
| 41 |
+
|
| 42 |
+
echo "β
Virtual environment activated"
|
| 43 |
+
echo ""
|
| 44 |
+
|
| 45 |
+
# Install dependencies
|
| 46 |
+
echo "π₯ Installing dependencies..."
|
| 47 |
+
pip install --upgrade pip
|
| 48 |
+
pip install -r requirements.txt
|
| 49 |
+
|
| 50 |
+
if [ $? -ne 0 ]; then
|
| 51 |
+
echo "β Failed to install dependencies"
|
| 52 |
+
exit 1
|
| 53 |
+
fi
|
| 54 |
+
|
| 55 |
+
echo "β
Dependencies installed"
|
| 56 |
+
echo ""
|
| 57 |
+
|
| 58 |
+
# Setup .env file
|
| 59 |
+
if [ ! -f .env ]; then
|
| 60 |
+
echo "π Setting up .env file..."
|
| 61 |
+
cp .env.example .env
|
| 62 |
+
echo "β
.env file created from template"
|
| 63 |
+
echo ""
|
| 64 |
+
echo "β οΈ IMPORTANT: Edit .env and add your API keys:"
|
| 65 |
+
echo " - GEMINI_API_KEY"
|
| 66 |
+
echo " - COINGECKO_API_KEY"
|
| 67 |
+
echo ""
|
| 68 |
+
else
|
| 69 |
+
echo "β
.env file already exists"
|
| 70 |
+
echo ""
|
| 71 |
+
fi
|
| 72 |
+
|
| 73 |
+
echo "======================================"
|
| 74 |
+
echo "β¨ Setup Complete!"
|
| 75 |
+
echo "======================================"
|
| 76 |
+
echo ""
|
| 77 |
+
echo "Next steps:"
|
| 78 |
+
echo "1. Edit .env file and add your API keys"
|
| 79 |
+
echo "2. Activate the virtual environment:"
|
| 80 |
+
echo " source venv/bin/activate"
|
| 81 |
+
echo "3. Run the application:"
|
| 82 |
+
echo " python app.py"
|
| 83 |
+
echo ""
|
| 84 |
+
echo "The app will be available at: http://localhost:7860"
|
| 85 |
+
echo ""
|
scripts/start.sh
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/bin/bash
|
| 2 |
+
# Startup script to run both FastAPI backend and Streamlit frontend
|
| 3 |
+
|
| 4 |
+
set -e
|
| 5 |
+
|
| 6 |
+
echo "π Starting Multi-Agent Assistant..."
|
| 7 |
+
|
| 8 |
+
# Function to handle shutdown
|
| 9 |
+
shutdown() {
|
| 10 |
+
echo "π Shutting down services..."
|
| 11 |
+
kill $FASTAPI_PID $STREAMLIT_PID 2>/dev/null || true
|
| 12 |
+
wait
|
| 13 |
+
exit 0
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
trap shutdown SIGTERM SIGINT
|
| 17 |
+
|
| 18 |
+
# Start FastAPI backend in background
|
| 19 |
+
echo "π‘ Starting FastAPI backend on port 8000..."
|
| 20 |
+
uvicorn src.api.main:app --host 0.0.0.0 --port 8000 &
|
| 21 |
+
FASTAPI_PID=$!
|
| 22 |
+
|
| 23 |
+
# Wait for FastAPI to be ready
|
| 24 |
+
echo "β³ Waiting for FastAPI to be ready..."
|
| 25 |
+
for i in {1..30}; do
|
| 26 |
+
if curl -s http://localhost:8000/health > /dev/null 2>&1; then
|
| 27 |
+
echo "β
FastAPI is ready!"
|
| 28 |
+
break
|
| 29 |
+
fi
|
| 30 |
+
if [ $i -eq 30 ]; then
|
| 31 |
+
echo "β FastAPI failed to start"
|
| 32 |
+
kill $FASTAPI_PID 2>/dev/null || true
|
| 33 |
+
exit 1
|
| 34 |
+
fi
|
| 35 |
+
sleep 2
|
| 36 |
+
done
|
| 37 |
+
|
| 38 |
+
# Start Streamlit frontend in foreground
|
| 39 |
+
echo "π¨ Starting Streamlit UI on port 8501..."
|
| 40 |
+
streamlit run ui/streamlit_app.py \
|
| 41 |
+
--server.port 8501 \
|
| 42 |
+
--server.address 0.0.0.0 \
|
| 43 |
+
--server.headless true \
|
| 44 |
+
--browser.gatherUsageStats false \
|
| 45 |
+
--server.enableCORS false \
|
| 46 |
+
--server.enableXsrfProtection true &
|
| 47 |
+
|
| 48 |
+
STREAMLIT_PID=$!
|
| 49 |
+
|
| 50 |
+
echo "β
All services started!"
|
| 51 |
+
echo "π‘ FastAPI: http://localhost:8000"
|
| 52 |
+
echo "π¨ Streamlit: http://localhost:8501"
|
| 53 |
+
|
| 54 |
+
# Wait for either process to exit
|
| 55 |
+
wait -n
|
| 56 |
+
|
| 57 |
+
# If we get here, one process exited - shut down everything
|
| 58 |
+
shutdown
|
src/__init__.py
ADDED
|
File without changes
|
src/agents/__init__.py
ADDED
|
File without changes
|
src/agents/crypto_agent_mcp.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Crypto Agent using CoinGecko MCP Server via LangChain MCP adapters."""
|
| 2 |
+
import json
|
| 3 |
+
import shutil
|
| 4 |
+
from typing import Any, Dict, List, Optional
|
| 5 |
+
|
| 6 |
+
from src.core.config import config
|
| 7 |
+
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
|
| 8 |
+
from langchain_core.tools import BaseTool
|
| 9 |
+
from langchain_google_genai import ChatGoogleGenerativeAI
|
| 10 |
+
from langchain_mcp_adapters.client import MultiServerMCPClient
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def _resolve_executable(candidates: List[str]) -> str:
|
| 14 |
+
"""Return first executable path found in PATH."""
|
| 15 |
+
for name in candidates:
|
| 16 |
+
resolved = shutil.which(name)
|
| 17 |
+
if resolved:
|
| 18 |
+
return resolved
|
| 19 |
+
raise FileNotFoundError(f"Unable to locate any of: {', '.join(candidates)}")
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def _to_text(payload: Any) -> str:
|
| 23 |
+
"""Convert model or tool output into a printable string."""
|
| 24 |
+
if isinstance(payload, str):
|
| 25 |
+
return payload
|
| 26 |
+
try:
|
| 27 |
+
return json.dumps(payload, ensure_ascii=False)
|
| 28 |
+
except TypeError:
|
| 29 |
+
return str(payload)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
class CryptoAgentMCP:
|
| 33 |
+
"""Agent specialized in cryptocurrency data using CoinGecko MCP Server."""
|
| 34 |
+
|
| 35 |
+
def __init__(self, use_public_endpoint: bool = False):
|
| 36 |
+
self.name = "Crypto Agent (MCP)"
|
| 37 |
+
self.description = "Cryptocurrency market data and analysis expert using CoinGecko MCP Server"
|
| 38 |
+
self.use_public_endpoint = use_public_endpoint
|
| 39 |
+
self.mcp_client: Optional[MultiServerMCPClient] = None
|
| 40 |
+
self.model: Optional[ChatGoogleGenerativeAI] = None
|
| 41 |
+
self.model_with_tools = None
|
| 42 |
+
self.tools: List[BaseTool] = []
|
| 43 |
+
self.tool_map: Dict[str, BaseTool] = {}
|
| 44 |
+
|
| 45 |
+
async def initialize(self) -> None:
|
| 46 |
+
"""Initialize the agent with CoinGecko MCP Server."""
|
| 47 |
+
print(f"π§ Initializing {self.name}...")
|
| 48 |
+
|
| 49 |
+
try:
|
| 50 |
+
# Connect to CoinGecko MCP Server
|
| 51 |
+
print(f" π‘ Connecting to CoinGecko MCP Server...")
|
| 52 |
+
|
| 53 |
+
connection_name = "coingecko"
|
| 54 |
+
connections: Dict[str, Dict[str, Any]] = {}
|
| 55 |
+
|
| 56 |
+
api_key = (config.COINGECKO_API_KEY or "").strip()
|
| 57 |
+
if api_key.lower().startswith("demo"):
|
| 58 |
+
print(" Demo API key detected. Using public endpoint with limited access...")
|
| 59 |
+
self.use_public_endpoint = True
|
| 60 |
+
|
| 61 |
+
if self.use_public_endpoint or not api_key:
|
| 62 |
+
print(" Using public SSE endpoint...")
|
| 63 |
+
connections[connection_name] = {
|
| 64 |
+
"transport": "sse",
|
| 65 |
+
"url": "https://mcp.api.coingecko.com/sse",
|
| 66 |
+
}
|
| 67 |
+
else:
|
| 68 |
+
print(" Using Pro endpoint with API key...")
|
| 69 |
+
npx_executable = _resolve_executable(["npx.cmd", "npx.exe", "npx"])
|
| 70 |
+
env = {
|
| 71 |
+
"COINGECKO_PRO_API_KEY": api_key,
|
| 72 |
+
"COINGECKO_ENVIRONMENT": "pro",
|
| 73 |
+
}
|
| 74 |
+
connections[connection_name] = {
|
| 75 |
+
"transport": "stdio",
|
| 76 |
+
"command": npx_executable,
|
| 77 |
+
"args": ["-y", "@coingecko/coingecko-mcp"],
|
| 78 |
+
"env": env,
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
self.mcp_client = MultiServerMCPClient(connections)
|
| 82 |
+
|
| 83 |
+
# Load MCP tools as LangChain tools
|
| 84 |
+
self.tools = await self.mcp_client.get_tools(server_name=connection_name)
|
| 85 |
+
if not self.tools:
|
| 86 |
+
raise RuntimeError("No tools available from CoinGecko MCP Server")
|
| 87 |
+
|
| 88 |
+
self.tool_map = {tool.name: tool for tool in self.tools}
|
| 89 |
+
|
| 90 |
+
# Initialize Gemini chat model bound to tools
|
| 91 |
+
self.model = ChatGoogleGenerativeAI(
|
| 92 |
+
model="gemini-2.5-flash",
|
| 93 |
+
temperature=0.1,
|
| 94 |
+
google_api_key=config.GOOGLE_API_KEY,
|
| 95 |
+
)
|
| 96 |
+
self.model_with_tools = self.model.bind_tools(self.tools)
|
| 97 |
+
|
| 98 |
+
print(f" β
Connected to CoinGecko MCP Server with {len(self.tools)} tools")
|
| 99 |
+
|
| 100 |
+
print(f" β
{self.name} ready!")
|
| 101 |
+
|
| 102 |
+
except Exception as e:
|
| 103 |
+
import traceback
|
| 104 |
+
print(f" β Error initializing {self.name}: {e}")
|
| 105 |
+
print(f" π Full error details:")
|
| 106 |
+
traceback.print_exc()
|
| 107 |
+
raise
|
| 108 |
+
|
| 109 |
+
async def process(self, query: str, history: Optional[List[Dict[str, str]]] = None) -> Dict[str, Any]:
|
| 110 |
+
"""Process a query using CoinGecko MCP Server tools."""
|
| 111 |
+
try:
|
| 112 |
+
print(f"\nπ° {self.name} processing: '{query}'")
|
| 113 |
+
messages: List[Any] = []
|
| 114 |
+
if history:
|
| 115 |
+
trimmed_history = history[-10:]
|
| 116 |
+
for turn in trimmed_history:
|
| 117 |
+
user_text = turn.get("user")
|
| 118 |
+
if user_text:
|
| 119 |
+
messages.append(HumanMessage(content=user_text))
|
| 120 |
+
assistant_text = turn.get("assistant")
|
| 121 |
+
if assistant_text:
|
| 122 |
+
messages.append(AIMessage(content=assistant_text))
|
| 123 |
+
messages.append(HumanMessage(content=query))
|
| 124 |
+
final_response = ""
|
| 125 |
+
tool_calls_info = []
|
| 126 |
+
|
| 127 |
+
while True:
|
| 128 |
+
if not self.model_with_tools:
|
| 129 |
+
raise RuntimeError("Model not initialized with tools")
|
| 130 |
+
|
| 131 |
+
ai_message = await self.model_with_tools.ainvoke(messages)
|
| 132 |
+
messages.append(ai_message)
|
| 133 |
+
|
| 134 |
+
tool_calls = getattr(ai_message, "tool_calls", [])
|
| 135 |
+
if not tool_calls:
|
| 136 |
+
final_response = _to_text(ai_message.content)
|
| 137 |
+
break
|
| 138 |
+
|
| 139 |
+
for call in tool_calls:
|
| 140 |
+
tool_name = call.get("name")
|
| 141 |
+
tool_args = call.get("args", {})
|
| 142 |
+
tool_call_id = call.get("id")
|
| 143 |
+
print(f" π§ MCP Tool call: {tool_name}({tool_args})")
|
| 144 |
+
tool_calls_info.append(f"π§ MCP Tool call: {tool_name}({tool_args})")
|
| 145 |
+
|
| 146 |
+
tool = self.tool_map.get(tool_name)
|
| 147 |
+
if not tool:
|
| 148 |
+
tool_result = {"error": f"Tool '{tool_name}' not found"}
|
| 149 |
+
else:
|
| 150 |
+
tool_result = await tool.ainvoke(tool_args)
|
| 151 |
+
|
| 152 |
+
messages.append(
|
| 153 |
+
ToolMessage(
|
| 154 |
+
content=_to_text(tool_result),
|
| 155 |
+
tool_call_id=tool_call_id or "",
|
| 156 |
+
)
|
| 157 |
+
)
|
| 158 |
+
|
| 159 |
+
return {
|
| 160 |
+
"success": True,
|
| 161 |
+
"agent": self.name,
|
| 162 |
+
"response": final_response,
|
| 163 |
+
"query": query,
|
| 164 |
+
"tool_calls": tool_calls_info
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
except Exception as e:
|
| 168 |
+
print(f" β Error in {self.name}: {e}")
|
| 169 |
+
import traceback
|
| 170 |
+
traceback.print_exc()
|
| 171 |
+
return {
|
| 172 |
+
"success": False,
|
| 173 |
+
"agent": self.name,
|
| 174 |
+
"error": str(e),
|
| 175 |
+
"query": query
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
async def cleanup(self) -> None:
|
| 179 |
+
"""Cleanup resources."""
|
| 180 |
+
print(f"π§Ή {self.name} cleaned up")
|
src/agents/finance_tracker_agent_mcp.py
ADDED
|
@@ -0,0 +1,351 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Finance Tracker Agent using MCP Toolbox Docker server via HTTP."""
|
| 2 |
+
import json
|
| 3 |
+
from typing import Any, Dict, List, Optional, Literal
|
| 4 |
+
from pydantic import BaseModel, Field
|
| 5 |
+
|
| 6 |
+
from src.core.config import config
|
| 7 |
+
from langchain_google_genai import ChatGoogleGenerativeAI
|
| 8 |
+
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
|
| 9 |
+
from toolbox_langchain import ToolboxClient
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def _to_text(payload: Any) -> str:
|
| 13 |
+
"""Convert model or tool output into a printable string."""
|
| 14 |
+
if isinstance(payload, str):
|
| 15 |
+
return payload
|
| 16 |
+
try:
|
| 17 |
+
return json.dumps(payload, ensure_ascii=False)
|
| 18 |
+
except TypeError:
|
| 19 |
+
return str(payload)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
# Pydantic schemas for structured output
|
| 23 |
+
class PortfolioHolding(BaseModel):
|
| 24 |
+
"""A single stock holding in the portfolio."""
|
| 25 |
+
symbol: str = Field(description="Stock ticker symbol (e.g., AAPL, MSFT)")
|
| 26 |
+
quantity: float = Field(description="Number of shares held")
|
| 27 |
+
avg_cost_basis: float = Field(description="Average cost per share")
|
| 28 |
+
total_invested: float = Field(description="Total amount invested (quantity * avg_cost_basis)")
|
| 29 |
+
realized_gains: Optional[float] = Field(default=None, description="Realized gains/losses from sales")
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
class TransactionRecord(BaseModel):
|
| 33 |
+
"""A stock transaction record."""
|
| 34 |
+
symbol: str = Field(description="Stock ticker symbol")
|
| 35 |
+
transaction_type: Literal["BUY", "SELL"] = Field(description="Type of transaction")
|
| 36 |
+
quantity: float = Field(description="Number of shares")
|
| 37 |
+
price: float = Field(description="Price per share")
|
| 38 |
+
transaction_date: str = Field(description="Date of transaction")
|
| 39 |
+
notes: Optional[str] = Field(default=None, description="Additional notes")
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
class PortfolioResponse(BaseModel):
|
| 43 |
+
"""Structured response for portfolio queries."""
|
| 44 |
+
response_type: Literal["portfolio_view", "transaction_added", "transaction_history", "general_response"] = Field(
|
| 45 |
+
description="Type of response being provided"
|
| 46 |
+
)
|
| 47 |
+
summary: str = Field(description="Brief summary of the response")
|
| 48 |
+
holdings: Optional[List[PortfolioHolding]] = Field(
|
| 49 |
+
default=None,
|
| 50 |
+
description="Current portfolio holdings (for portfolio_view)"
|
| 51 |
+
)
|
| 52 |
+
transaction: Optional[TransactionRecord] = Field(
|
| 53 |
+
default=None,
|
| 54 |
+
description="Transaction details (for transaction_added)"
|
| 55 |
+
)
|
| 56 |
+
transactions: Optional[List[TransactionRecord]] = Field(
|
| 57 |
+
default=None,
|
| 58 |
+
description="List of transactions (for transaction_history)"
|
| 59 |
+
)
|
| 60 |
+
total_portfolio_value: Optional[float] = Field(
|
| 61 |
+
default=None,
|
| 62 |
+
description="Total portfolio value"
|
| 63 |
+
)
|
| 64 |
+
insights: Optional[List[str]] = Field(
|
| 65 |
+
default=None,
|
| 66 |
+
description="Key insights or recommendations"
|
| 67 |
+
)
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def format_portfolio_response(response: PortfolioResponse) -> str:
|
| 71 |
+
"""Format structured portfolio response into readable text."""
|
| 72 |
+
output = []
|
| 73 |
+
|
| 74 |
+
# Add summary
|
| 75 |
+
output.append(response.summary)
|
| 76 |
+
output.append("")
|
| 77 |
+
|
| 78 |
+
# Format holdings if present
|
| 79 |
+
if response.holdings:
|
| 80 |
+
output.append("## Current Portfolio Holdings")
|
| 81 |
+
output.append("")
|
| 82 |
+
# Create table header
|
| 83 |
+
output.append("| Symbol | Shares | Avg Cost | Total Invested | Realized Gains |")
|
| 84 |
+
output.append("|--------|--------|----------|----------------|----------------|")
|
| 85 |
+
# Add each holding as a table row
|
| 86 |
+
for holding in response.holdings:
|
| 87 |
+
realized_gains = f"${holding.realized_gains:.2f}" if holding.realized_gains is not None else "$0.00"
|
| 88 |
+
output.append(
|
| 89 |
+
f"| {holding.symbol} | {holding.quantity} | "
|
| 90 |
+
f"${holding.avg_cost_basis:.2f} | ${holding.total_invested:.2f} | {realized_gains} |"
|
| 91 |
+
)
|
| 92 |
+
output.append("")
|
| 93 |
+
|
| 94 |
+
# Format single transaction if present
|
| 95 |
+
if response.transaction:
|
| 96 |
+
output.append("## Transaction Details")
|
| 97 |
+
output.append("")
|
| 98 |
+
t = response.transaction
|
| 99 |
+
output.append(f"- **Type**: {t.transaction_type}")
|
| 100 |
+
output.append(f"- **Symbol**: {t.symbol}")
|
| 101 |
+
output.append(f"- **Quantity**: {t.quantity} shares")
|
| 102 |
+
output.append(f"- **Price**: ${t.price:.2f} per share")
|
| 103 |
+
output.append(f"- **Date**: {t.transaction_date}")
|
| 104 |
+
output.append(f"- **Total Amount**: ${t.quantity * t.price:.2f}")
|
| 105 |
+
if t.notes:
|
| 106 |
+
output.append(f"- **Notes**: {t.notes}")
|
| 107 |
+
output.append("")
|
| 108 |
+
|
| 109 |
+
# Format transaction history if present
|
| 110 |
+
if response.transactions:
|
| 111 |
+
output.append("## Transaction History")
|
| 112 |
+
output.append("")
|
| 113 |
+
for t in response.transactions:
|
| 114 |
+
output.append(f"**{t.transaction_date}** - {t.transaction_type} {t.quantity} shares of {t.symbol} @ ${t.price:.2f}")
|
| 115 |
+
if t.notes:
|
| 116 |
+
output.append(f" Notes: {t.notes}")
|
| 117 |
+
output.append("")
|
| 118 |
+
|
| 119 |
+
# Add total portfolio value if present
|
| 120 |
+
if response.total_portfolio_value is not None:
|
| 121 |
+
output.append(f"**Total Portfolio Value**: ${response.total_portfolio_value:.2f}")
|
| 122 |
+
output.append("")
|
| 123 |
+
|
| 124 |
+
# Add insights if present
|
| 125 |
+
if response.insights:
|
| 126 |
+
output.append("## Key Insights")
|
| 127 |
+
output.append("")
|
| 128 |
+
for insight in response.insights:
|
| 129 |
+
output.append(f"- {insight}")
|
| 130 |
+
output.append("")
|
| 131 |
+
|
| 132 |
+
return "\n".join(output).strip()
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
class FinanceTrackerMCP:
|
| 136 |
+
"""
|
| 137 |
+
Agent for managing personal investment portfolio using MCP Toolbox HTTP server.
|
| 138 |
+
|
| 139 |
+
Features:
|
| 140 |
+
- Portfolio Management: Track stock purchases/sales via Cloud SQL
|
| 141 |
+
- Position Analysis: View holdings, cost basis, realized gains
|
| 142 |
+
- Performance Metrics: Calculate gains/losses with current prices
|
| 143 |
+
- Database Operations: Full CRUD via MCP Toolbox Docker container
|
| 144 |
+
- Secure Access: Uses Google Cloud SQL with MCP Toolbox connector
|
| 145 |
+
"""
|
| 146 |
+
|
| 147 |
+
def __init__(self):
|
| 148 |
+
self.name = "Finance Tracker (MCP)"
|
| 149 |
+
self.description = "Personal portfolio tracking and analysis using Cloud SQL via MCP Toolbox"
|
| 150 |
+
self.toolbox_client: Optional[ToolboxClient] = None
|
| 151 |
+
self.model: Optional[ChatGoogleGenerativeAI] = None
|
| 152 |
+
self.model_with_tools = None
|
| 153 |
+
self.model_structured = None
|
| 154 |
+
self.tools: List[Any] = []
|
| 155 |
+
self.tool_map: Dict[str, Any] = {}
|
| 156 |
+
self.initialized = False
|
| 157 |
+
|
| 158 |
+
async def initialize(self) -> None:
|
| 159 |
+
"""Initialize the agent with MCP Toolbox HTTP server."""
|
| 160 |
+
print(f"πΌ Initializing {self.name}...")
|
| 161 |
+
|
| 162 |
+
try:
|
| 163 |
+
# Connect to MCP Toolbox HTTP server (Docker container)
|
| 164 |
+
print(f" π‘ Connecting to MCP Toolbox HTTP server...")
|
| 165 |
+
print(f" Server URL: {config.MCP_TOOLBOX_SERVER_URL}")
|
| 166 |
+
|
| 167 |
+
# Create toolbox client and enter async context manager
|
| 168 |
+
self.toolbox_client = ToolboxClient(config.MCP_TOOLBOX_SERVER_URL)
|
| 169 |
+
# Manually enter the async context manager to keep connection alive
|
| 170 |
+
await self.toolbox_client.__aenter__()
|
| 171 |
+
|
| 172 |
+
# Load database tools from toolbox
|
| 173 |
+
print(" Loading tools from MCP Toolbox...")
|
| 174 |
+
self.tools = self.toolbox_client.load_toolset()
|
| 175 |
+
|
| 176 |
+
if not self.tools:
|
| 177 |
+
raise RuntimeError(
|
| 178 |
+
f"No tools available from MCP Toolbox server at {config.MCP_TOOLBOX_SERVER_URL}\n"
|
| 179 |
+
f"Make sure the Docker container is running: docker-compose up -d"
|
| 180 |
+
)
|
| 181 |
+
|
| 182 |
+
self.tool_map = {tool.name: tool for tool in self.tools}
|
| 183 |
+
|
| 184 |
+
# Initialize Gemini chat model bound to tools
|
| 185 |
+
self.model = ChatGoogleGenerativeAI(
|
| 186 |
+
model="gemini-2.5-flash",
|
| 187 |
+
temperature=0.1,
|
| 188 |
+
google_api_key=config.GOOGLE_API_KEY,
|
| 189 |
+
)
|
| 190 |
+
self.model_with_tools = self.model.bind_tools(self.tools)
|
| 191 |
+
|
| 192 |
+
# Initialize model with structured output for final responses
|
| 193 |
+
self.model_structured = self.model.with_structured_output(PortfolioResponse)
|
| 194 |
+
|
| 195 |
+
print(f" β
Connected to MCP Toolbox with {len(self.tools)} tools")
|
| 196 |
+
print(f" π Available MCP Toolbox capabilities:")
|
| 197 |
+
print(f" - Database queries (SELECT, INSERT, UPDATE, DELETE)")
|
| 198 |
+
print(f" - Schema introspection (tables, columns, relationships)")
|
| 199 |
+
print(f" - Transaction management")
|
| 200 |
+
print(f" - Natural language to SQL conversion")
|
| 201 |
+
print(f" Total: {len(self.tools)} tools available")
|
| 202 |
+
|
| 203 |
+
self.initialized = True
|
| 204 |
+
print(f" β
{self.name} ready!")
|
| 205 |
+
|
| 206 |
+
except Exception as e:
|
| 207 |
+
import traceback
|
| 208 |
+
print(f" β Error initializing {self.name}: {e}")
|
| 209 |
+
print(f" π Full error details:")
|
| 210 |
+
traceback.print_exc()
|
| 211 |
+
print(f"\nπ‘ Troubleshooting:")
|
| 212 |
+
print(f" 1. Make sure Docker is running")
|
| 213 |
+
print(f" 2. Start MCP Toolbox container: docker-compose up -d")
|
| 214 |
+
print(f" 3. Check container status: docker-compose ps")
|
| 215 |
+
print(f" 4. View container logs: docker-compose logs mcp-toolbox")
|
| 216 |
+
print(f" 5. Verify server is accessible: curl {config.MCP_TOOLBOX_SERVER_URL}/health")
|
| 217 |
+
raise
|
| 218 |
+
|
| 219 |
+
async def process(self, query: str, history: Optional[List[Dict[str, str]]] = None) -> Dict[str, Any]:
|
| 220 |
+
"""Process a query using MCP Toolbox for Cloud SQL operations."""
|
| 221 |
+
try:
|
| 222 |
+
if not self.initialized:
|
| 223 |
+
await self.initialize()
|
| 224 |
+
|
| 225 |
+
print(f"\nπΌ {self.name} processing: '{query}'")
|
| 226 |
+
|
| 227 |
+
# Build system message with portfolio context
|
| 228 |
+
system_message = """You are a Finance Tracker Agent specialized in portfolio management.
|
| 229 |
+
|
| 230 |
+
You have access to a Cloud SQL PostgreSQL database through MCP Toolbox with the following schema:
|
| 231 |
+
|
| 232 |
+
Tables:
|
| 233 |
+
- stock_transactions: Stores all BUY/SELL transactions (symbol, transaction_type, quantity, price, transaction_date, notes)
|
| 234 |
+
- portfolio_positions: Current aggregated positions (symbol, total_quantity, avg_cost_basis, total_invested, realized_gains)
|
| 235 |
+
- portfolio_snapshots: Historical portfolio value tracking
|
| 236 |
+
- stock_metadata: Cached stock information (company_name, sector, industry, market_cap)
|
| 237 |
+
|
| 238 |
+
When users want to:
|
| 239 |
+
- ADD a transaction: INSERT into stock_transactions (triggers will update portfolio_positions automatically)
|
| 240 |
+
- VIEW portfolio: SELECT from portfolio_positions
|
| 241 |
+
- CHECK transaction history: SELECT from stock_transactions
|
| 242 |
+
- ANALYZE performance: Query portfolio_positions with calculations
|
| 243 |
+
|
| 244 |
+
Always use the MCP Toolbox database tools to:
|
| 245 |
+
1. Query the database for current data
|
| 246 |
+
2. Insert/update records as needed
|
| 247 |
+
3. Calculate metrics (gains, losses, allocations)
|
| 248 |
+
4. Provide clear, actionable insights
|
| 249 |
+
|
| 250 |
+
Be helpful, accurate, and provide investment insights based on their data."""
|
| 251 |
+
|
| 252 |
+
messages: List[Any] = [HumanMessage(content=system_message)]
|
| 253 |
+
|
| 254 |
+
# Add conversation history
|
| 255 |
+
if history:
|
| 256 |
+
trimmed_history = history[-10:]
|
| 257 |
+
for turn in trimmed_history:
|
| 258 |
+
user_text = turn.get("user")
|
| 259 |
+
if user_text:
|
| 260 |
+
messages.append(HumanMessage(content=user_text))
|
| 261 |
+
assistant_text = turn.get("assistant")
|
| 262 |
+
if assistant_text:
|
| 263 |
+
messages.append(AIMessage(content=assistant_text))
|
| 264 |
+
|
| 265 |
+
messages.append(HumanMessage(content=query))
|
| 266 |
+
|
| 267 |
+
# Tool calling loop - gather data from database
|
| 268 |
+
tool_calls_info = []
|
| 269 |
+
while True:
|
| 270 |
+
if not self.model_with_tools:
|
| 271 |
+
raise RuntimeError("Model not initialized with tools")
|
| 272 |
+
|
| 273 |
+
ai_message = await self.model_with_tools.ainvoke(messages)
|
| 274 |
+
messages.append(ai_message)
|
| 275 |
+
|
| 276 |
+
tool_calls = getattr(ai_message, "tool_calls", [])
|
| 277 |
+
if not tool_calls:
|
| 278 |
+
# No more tool calls - exit loop
|
| 279 |
+
break
|
| 280 |
+
|
| 281 |
+
for call in tool_calls:
|
| 282 |
+
tool_name = call.get("name")
|
| 283 |
+
tool_args = call.get("args", {})
|
| 284 |
+
tool_call_id = call.get("id")
|
| 285 |
+
print(f" π§ MCP Toolbox call: {tool_name}({json.dumps(tool_args, indent=2)})")
|
| 286 |
+
tool_calls_info.append(f"π§ MCP Toolbox call: {tool_name}({json.dumps(tool_args)})")
|
| 287 |
+
|
| 288 |
+
tool = self.tool_map.get(tool_name)
|
| 289 |
+
if not tool:
|
| 290 |
+
tool_result = {"error": f"Tool '{tool_name}' not found"}
|
| 291 |
+
else:
|
| 292 |
+
tool_result = await tool.ainvoke(tool_args)
|
| 293 |
+
|
| 294 |
+
messages.append(
|
| 295 |
+
ToolMessage(
|
| 296 |
+
content=_to_text(tool_result),
|
| 297 |
+
tool_call_id=tool_call_id or "",
|
| 298 |
+
)
|
| 299 |
+
)
|
| 300 |
+
|
| 301 |
+
# Generate structured response based on all gathered information
|
| 302 |
+
print(" π Generating structured response...")
|
| 303 |
+
structured_prompt = """Based on the database query results, provide a structured response.
|
| 304 |
+
|
| 305 |
+
Guidelines:
|
| 306 |
+
- For portfolio viewing: Set response_type to "portfolio_view" and populate holdings list
|
| 307 |
+
- For adding transactions: Set response_type to "transaction_added" and populate transaction field
|
| 308 |
+
- For transaction history: Set response_type to "transaction_history" and populate transactions list
|
| 309 |
+
- For other queries: Set response_type to "general_response"
|
| 310 |
+
- Always provide a clear summary
|
| 311 |
+
- Include relevant insights when possible"""
|
| 312 |
+
|
| 313 |
+
messages.append(HumanMessage(content=structured_prompt))
|
| 314 |
+
|
| 315 |
+
if not self.model_structured:
|
| 316 |
+
raise RuntimeError("Structured output model not initialized")
|
| 317 |
+
|
| 318 |
+
structured_response = await self.model_structured.ainvoke(messages)
|
| 319 |
+
|
| 320 |
+
# Format the structured response into readable text
|
| 321 |
+
final_response = format_portfolio_response(structured_response)
|
| 322 |
+
|
| 323 |
+
return {
|
| 324 |
+
"success": True,
|
| 325 |
+
"agent": self.name,
|
| 326 |
+
"response": final_response,
|
| 327 |
+
"query": query,
|
| 328 |
+
"structured_data": structured_response.model_dump(), # Include raw structured data
|
| 329 |
+
"tool_calls": tool_calls_info
|
| 330 |
+
}
|
| 331 |
+
|
| 332 |
+
except Exception as e:
|
| 333 |
+
print(f" β Error in {self.name}: {e}")
|
| 334 |
+
import traceback
|
| 335 |
+
traceback.print_exc()
|
| 336 |
+
return {
|
| 337 |
+
"success": False,
|
| 338 |
+
"agent": self.name,
|
| 339 |
+
"error": str(e),
|
| 340 |
+
"query": query
|
| 341 |
+
}
|
| 342 |
+
|
| 343 |
+
async def cleanup(self) -> None:
|
| 344 |
+
"""Cleanup resources."""
|
| 345 |
+
if self.toolbox_client:
|
| 346 |
+
try:
|
| 347 |
+
# Exit the async context manager
|
| 348 |
+
await self.toolbox_client.__aexit__(None, None, None)
|
| 349 |
+
except Exception as e:
|
| 350 |
+
print(f" β οΈ Warning during cleanup: {e}")
|
| 351 |
+
print(f"π§Ή {self.name} cleaned up")
|
src/agents/rag_agent_mcp.py
ADDED
|
@@ -0,0 +1,449 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""RAG Agent using Chroma MCP Server via LangChain MCP adapters."""
|
| 2 |
+
import json
|
| 3 |
+
import os
|
| 4 |
+
import shutil
|
| 5 |
+
import sys
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from typing import Any, Dict, List, Optional
|
| 8 |
+
|
| 9 |
+
import chromadb
|
| 10 |
+
from src.core.config import config
|
| 11 |
+
from src.utils.file_processors import process_document
|
| 12 |
+
from langchain_core.tools import BaseTool
|
| 13 |
+
from langchain_google_genai import ChatGoogleGenerativeAI
|
| 14 |
+
from langchain_mcp_adapters.client import MultiServerMCPClient
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class RAGAgentMCP:
|
| 18 |
+
"""Agent specialized in document retrieval and Q&A using Chroma MCP Server."""
|
| 19 |
+
|
| 20 |
+
@staticmethod
|
| 21 |
+
def _to_text(payload: Any) -> str:
|
| 22 |
+
"""Convert tool or model output into a displayable string."""
|
| 23 |
+
if isinstance(payload, str):
|
| 24 |
+
return payload
|
| 25 |
+
try:
|
| 26 |
+
return json.dumps(payload, ensure_ascii=False)
|
| 27 |
+
except TypeError:
|
| 28 |
+
return str(payload)
|
| 29 |
+
|
| 30 |
+
def __init__(self):
|
| 31 |
+
self.name = "RAG Agent (MCP)"
|
| 32 |
+
self.description = "Document storage, retrieval, and question answering expert using Chroma MCP Server"
|
| 33 |
+
# MCP Server client
|
| 34 |
+
self.mcp_client: Optional[MultiServerMCPClient] = None
|
| 35 |
+
|
| 36 |
+
# Direct ChromaDB client (for document upload only)
|
| 37 |
+
self.chroma_direct_client = None
|
| 38 |
+
self.collection = None
|
| 39 |
+
|
| 40 |
+
# Gemini model
|
| 41 |
+
self.model: Optional[ChatGoogleGenerativeAI] = None
|
| 42 |
+
self.model_with_tools = None
|
| 43 |
+
self.tools: List[BaseTool] = []
|
| 44 |
+
self.tool_map: Dict[str, BaseTool] = {}
|
| 45 |
+
|
| 46 |
+
async def initialize(self) -> None:
|
| 47 |
+
"""Initialize the agent with direct ChromaDB client."""
|
| 48 |
+
print(f"π§ Initializing {self.name}...")
|
| 49 |
+
|
| 50 |
+
try:
|
| 51 |
+
# MCP Server integration commented out - using direct client instead
|
| 52 |
+
# # 1. Connect to Chroma MCP Server for tool access
|
| 53 |
+
# print(f" π‘ Connecting to Chroma MCP Server...")
|
| 54 |
+
# chroma_connection = self._build_chroma_connection()
|
| 55 |
+
# self.mcp_client = MultiServerMCPClient({"chroma": chroma_connection})
|
| 56 |
+
#
|
| 57 |
+
# # Get tools from MCP server
|
| 58 |
+
# self.tools = await self.mcp_client.get_tools(server_name="chroma")
|
| 59 |
+
# if not self.tools:
|
| 60 |
+
# raise RuntimeError("No tools returned by Chroma MCP server")
|
| 61 |
+
#
|
| 62 |
+
# self.tool_map = {tool.name: tool for tool in self.tools}
|
| 63 |
+
#
|
| 64 |
+
# print(f" β
Connected to Chroma MCP Server with {len(self.tools)} tools")
|
| 65 |
+
|
| 66 |
+
# 1. Connect to ChromaDB Cloud with direct client
|
| 67 |
+
print(f" π Connecting to ChromaDB Cloud...")
|
| 68 |
+
self.chroma_direct_client = chromadb.CloudClient(
|
| 69 |
+
tenant=config.CHROMA_TENANT,
|
| 70 |
+
database=config.CHROMA_DATABASE,
|
| 71 |
+
api_key=config.CHROMA_API_KEY
|
| 72 |
+
)
|
| 73 |
+
|
| 74 |
+
# 2. Use Google Gemini embedding function
|
| 75 |
+
print(f" π§ Setting up Gemini embeddings...")
|
| 76 |
+
from chromadb.utils.embedding_functions import GoogleGenerativeAiEmbeddingFunction
|
| 77 |
+
embedding_function = GoogleGenerativeAiEmbeddingFunction(
|
| 78 |
+
api_key=config.GOOGLE_API_KEY,
|
| 79 |
+
model_name="models/embedding-001"
|
| 80 |
+
)
|
| 81 |
+
|
| 82 |
+
# 3. Get or create collection with Gemini embeddings
|
| 83 |
+
collection_name = "gemini-embed"
|
| 84 |
+
self.collection = self.chroma_direct_client.get_or_create_collection(
|
| 85 |
+
name=collection_name,
|
| 86 |
+
metadata={"description": "Document storage for RAG with Gemini embeddings"},
|
| 87 |
+
embedding_function=embedding_function
|
| 88 |
+
)
|
| 89 |
+
print(f" π¦ Using collection: {collection_name}")
|
| 90 |
+
print(f" π Current documents in collection: {self.collection.count()}")
|
| 91 |
+
|
| 92 |
+
# 4. Initialize Gemini model for answer generation
|
| 93 |
+
self.model = ChatGoogleGenerativeAI(
|
| 94 |
+
model="gemini-2.5-flash",
|
| 95 |
+
temperature=0.1,
|
| 96 |
+
google_api_key=config.GOOGLE_API_KEY,
|
| 97 |
+
)
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
print(f" β
{self.name} ready!")
|
| 101 |
+
|
| 102 |
+
except Exception as e:
|
| 103 |
+
import traceback
|
| 104 |
+
print(f" β Error initializing {self.name}: {e}")
|
| 105 |
+
print(f" π Full error details:")
|
| 106 |
+
traceback.print_exc()
|
| 107 |
+
raise
|
| 108 |
+
|
| 109 |
+
async def add_document(
|
| 110 |
+
self,
|
| 111 |
+
file_path: str,
|
| 112 |
+
metadata: Optional[Dict[str, Any]] = None,
|
| 113 |
+
progress_callback: Optional[callable] = None
|
| 114 |
+
) -> Dict[str, Any]:
|
| 115 |
+
"""
|
| 116 |
+
Add a document to ChromaDB via MCP server.
|
| 117 |
+
|
| 118 |
+
Note: This uses direct ChromaDB client as MCP add_documents tool
|
| 119 |
+
requires the collection to already exist and documents to be processed.
|
| 120 |
+
|
| 121 |
+
Args:
|
| 122 |
+
file_path: Path to the document file
|
| 123 |
+
metadata: Optional metadata for the document
|
| 124 |
+
progress_callback: Optional callback for progress updates
|
| 125 |
+
|
| 126 |
+
Returns:
|
| 127 |
+
Dictionary with status and document info
|
| 128 |
+
"""
|
| 129 |
+
try:
|
| 130 |
+
doc_path = Path(file_path)
|
| 131 |
+
|
| 132 |
+
# Validate file type
|
| 133 |
+
if doc_path.suffix.lower() not in config.ALLOWED_FILE_TYPES:
|
| 134 |
+
return {
|
| 135 |
+
"success": False,
|
| 136 |
+
"error": f"Unsupported file type: {doc_path.suffix}. Allowed: {config.ALLOWED_FILE_TYPES}"
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
print(f"\nπ Processing {doc_path.suffix.upper()}: {doc_path.name}")
|
| 140 |
+
|
| 141 |
+
if progress_callback:
|
| 142 |
+
progress_callback(0.1, "Extracting text from document...")
|
| 143 |
+
|
| 144 |
+
# Process document using file_processors
|
| 145 |
+
doc_info = process_document(doc_path, chunk_size=500, overlap=50)
|
| 146 |
+
|
| 147 |
+
if progress_callback:
|
| 148 |
+
progress_callback(0.4, f"Extracted {doc_info['num_chunks']} chunks...")
|
| 149 |
+
|
| 150 |
+
# Generate document ID
|
| 151 |
+
doc_id = doc_path.stem
|
| 152 |
+
|
| 153 |
+
# Prepare metadata
|
| 154 |
+
doc_metadata = {
|
| 155 |
+
"filename": doc_info["filename"],
|
| 156 |
+
"file_type": doc_info["file_type"],
|
| 157 |
+
"file_size": doc_info["file_size"],
|
| 158 |
+
"num_chunks": doc_info["num_chunks"],
|
| 159 |
+
"source": "user_upload",
|
| 160 |
+
**(metadata or {})
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
# MCP Server upload (commented out - using direct client instead)
|
| 164 |
+
# if progress_callback:
|
| 165 |
+
# progress_callback(0.6, "Uploading via Chroma MCP Server...")
|
| 166 |
+
#
|
| 167 |
+
# # Prepare data for MCP add_documents tool
|
| 168 |
+
# chunks = doc_info["chunks"]
|
| 169 |
+
# ids = [f"{doc_id}_chunk_{i}" for i in range(len(chunks))]
|
| 170 |
+
# metadatas = [
|
| 171 |
+
# {**doc_metadata, "chunk_index": i, "chunk_id": ids[i]}
|
| 172 |
+
# for i in range(len(chunks))
|
| 173 |
+
# ]
|
| 174 |
+
#
|
| 175 |
+
# # Try using MCP server's add_documents tool
|
| 176 |
+
# add_tool = (
|
| 177 |
+
# self.tool_map.get("chroma_add_documents")
|
| 178 |
+
# or self.tool_map.get("add_documents")
|
| 179 |
+
# )
|
| 180 |
+
# if not add_tool:
|
| 181 |
+
# raise KeyError("add_documents tool not available in Chroma MCP")
|
| 182 |
+
#
|
| 183 |
+
# tool_payload = {
|
| 184 |
+
# "collection_name": "gemini-embed", # Use dedicated Gemini collection
|
| 185 |
+
# "documents": chunks,
|
| 186 |
+
# "ids": ids,
|
| 187 |
+
# "metadatas": metadatas,
|
| 188 |
+
# }
|
| 189 |
+
#
|
| 190 |
+
# result = await add_tool.ainvoke(tool_payload)
|
| 191 |
+
#
|
| 192 |
+
# if isinstance(result, dict) and result.get("success"):
|
| 193 |
+
# print(f" β
Added via MCP: {len(chunks)} chunks from {doc_path.name}")
|
| 194 |
+
# else:
|
| 195 |
+
# raise ValueError(result)
|
| 196 |
+
|
| 197 |
+
# Direct ChromaDB client upload
|
| 198 |
+
if progress_callback:
|
| 199 |
+
progress_callback(0.6, "Uploading to ChromaDB with Google embeddings...")
|
| 200 |
+
|
| 201 |
+
chunks = doc_info["chunks"]
|
| 202 |
+
ids = [f"{doc_id}_chunk_{i}" for i in range(len(chunks))]
|
| 203 |
+
metadatas = [
|
| 204 |
+
{**doc_metadata, "chunk_index": i, "chunk_id": ids[i]}
|
| 205 |
+
for i in range(len(chunks))
|
| 206 |
+
]
|
| 207 |
+
|
| 208 |
+
self.collection.add(
|
| 209 |
+
documents=chunks,
|
| 210 |
+
ids=ids,
|
| 211 |
+
metadatas=metadatas
|
| 212 |
+
)
|
| 213 |
+
print(f" β
Added {len(chunks)} chunks from {doc_path.name} to '{self.collection.name}'")
|
| 214 |
+
|
| 215 |
+
if progress_callback:
|
| 216 |
+
progress_callback(1.0, "Upload complete!")
|
| 217 |
+
|
| 218 |
+
return {
|
| 219 |
+
"success": True,
|
| 220 |
+
"document_id": doc_id,
|
| 221 |
+
"filename": doc_path.name,
|
| 222 |
+
"file_type": doc_info["file_type"],
|
| 223 |
+
"chunks_added": len(chunks),
|
| 224 |
+
"total_documents": self.collection.count()
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
except Exception as e:
|
| 228 |
+
print(f" β Error adding document: {e}")
|
| 229 |
+
import traceback
|
| 230 |
+
traceback.print_exc()
|
| 231 |
+
return {
|
| 232 |
+
"success": False,
|
| 233 |
+
"error": str(e)
|
| 234 |
+
}
|
| 235 |
+
|
| 236 |
+
async def process(self, query: str, history: Optional[List[Dict[str, str]]] = None) -> Dict[str, Any]:
|
| 237 |
+
"""Process a query using direct ChromaDB client and Gemini."""
|
| 238 |
+
try:
|
| 239 |
+
print(f"\nπ {self.name} processing: '{query}'")
|
| 240 |
+
|
| 241 |
+
# MCP Tool-based approach (commented out - using direct client instead)
|
| 242 |
+
# if not self.model_with_tools:
|
| 243 |
+
# raise RuntimeError("Model not initialized with tools")
|
| 244 |
+
#
|
| 245 |
+
# messages: List[Any] = [HumanMessage(content=query)]
|
| 246 |
+
# final_response = ""
|
| 247 |
+
#
|
| 248 |
+
# while True:
|
| 249 |
+
# ai_message = await self.model_with_tools.ainvoke(messages)
|
| 250 |
+
# messages.append(ai_message)
|
| 251 |
+
#
|
| 252 |
+
# tool_calls = getattr(ai_message, "tool_calls", [])
|
| 253 |
+
# if not tool_calls:
|
| 254 |
+
# final_response = self._to_text(ai_message.content)
|
| 255 |
+
# break
|
| 256 |
+
#
|
| 257 |
+
# for call in tool_calls:
|
| 258 |
+
# tool_name = call.get("name")
|
| 259 |
+
# tool_args = call.get("args", {})
|
| 260 |
+
# tool_call_id = call.get("id")
|
| 261 |
+
# print(f" π§ MCP Tool call: {tool_name}({tool_args})")
|
| 262 |
+
#
|
| 263 |
+
# tool = self.tool_map.get(tool_name)
|
| 264 |
+
# if not tool:
|
| 265 |
+
# tool_result = {"error": f"Tool '{tool_name}' not found"}
|
| 266 |
+
# else:
|
| 267 |
+
# tool_result = await tool.ainvoke(tool_args)
|
| 268 |
+
#
|
| 269 |
+
# messages.append(
|
| 270 |
+
# ToolMessage(
|
| 271 |
+
# content=self._to_text(tool_result),
|
| 272 |
+
# tool_call_id=tool_call_id or "",
|
| 273 |
+
# )
|
| 274 |
+
# )
|
| 275 |
+
|
| 276 |
+
# Direct ChromaDB query approach
|
| 277 |
+
if not self.model:
|
| 278 |
+
raise RuntimeError("Model not initialized")
|
| 279 |
+
|
| 280 |
+
conversation_context = ""
|
| 281 |
+
if history:
|
| 282 |
+
trimmed_history = history[-6:]
|
| 283 |
+
history_lines: List[str] = []
|
| 284 |
+
for turn in trimmed_history:
|
| 285 |
+
user_text = turn.get("user", "")
|
| 286 |
+
# Handle both string and list types
|
| 287 |
+
if isinstance(user_text, str):
|
| 288 |
+
user_text = user_text.strip()
|
| 289 |
+
elif isinstance(user_text, list):
|
| 290 |
+
user_text = " ".join(str(x) for x in user_text).strip()
|
| 291 |
+
else:
|
| 292 |
+
user_text = str(user_text).strip()
|
| 293 |
+
|
| 294 |
+
if user_text:
|
| 295 |
+
history_lines.append(f"User: {user_text}")
|
| 296 |
+
|
| 297 |
+
assistant_text = turn.get("assistant", "")
|
| 298 |
+
# Handle both string and list types
|
| 299 |
+
if isinstance(assistant_text, str):
|
| 300 |
+
assistant_text = assistant_text.strip()
|
| 301 |
+
elif isinstance(assistant_text, list):
|
| 302 |
+
assistant_text = " ".join(str(x) for x in assistant_text).strip()
|
| 303 |
+
else:
|
| 304 |
+
assistant_text = str(assistant_text).strip()
|
| 305 |
+
|
| 306 |
+
if assistant_text:
|
| 307 |
+
history_lines.append(f"Assistant: {assistant_text}")
|
| 308 |
+
if history_lines:
|
| 309 |
+
conversation_context = "\n".join(history_lines)
|
| 310 |
+
|
| 311 |
+
conversational_query = query
|
| 312 |
+
if conversation_context:
|
| 313 |
+
try:
|
| 314 |
+
rephrase_prompt = f"""You are a helpful assistant that rewrites follow-up questions.
|
| 315 |
+
Use the provided conversation history to rewrite the latest user input so it is a standalone question for document retrieval.
|
| 316 |
+
|
| 317 |
+
Conversation history:
|
| 318 |
+
{conversation_context}
|
| 319 |
+
|
| 320 |
+
Latest user input: {query}
|
| 321 |
+
|
| 322 |
+
Respond with only the rewritten standalone question."""
|
| 323 |
+
|
| 324 |
+
# Stream the rephrase response
|
| 325 |
+
candidate_content = ""
|
| 326 |
+
async for chunk in self.model.astream(rephrase_prompt):
|
| 327 |
+
if hasattr(chunk, 'content') and chunk.content:
|
| 328 |
+
candidate_content += chunk.content
|
| 329 |
+
|
| 330 |
+
candidate = candidate_content.strip()
|
| 331 |
+
if candidate:
|
| 332 |
+
conversational_query = candidate
|
| 333 |
+
except Exception as rephrase_error:
|
| 334 |
+
print(f" β οΈ Could not rewrite query from history: {rephrase_error}")
|
| 335 |
+
|
| 336 |
+
# Query the collection with Google embeddings
|
| 337 |
+
print(f" π Querying collection '{self.collection.name}'...")
|
| 338 |
+
results = self.collection.query(
|
| 339 |
+
query_texts=[conversational_query],
|
| 340 |
+
n_results=5 # Get top 5 most relevant chunks
|
| 341 |
+
)
|
| 342 |
+
|
| 343 |
+
# Extract documents and metadata
|
| 344 |
+
documents = results.get('documents', [[]])[0]
|
| 345 |
+
metadatas = results.get('metadatas', [[]])[0]
|
| 346 |
+
distances = results.get('distances', [[]])[0]
|
| 347 |
+
|
| 348 |
+
if not documents:
|
| 349 |
+
print(f" β οΈ No relevant documents found")
|
| 350 |
+
return {
|
| 351 |
+
"success": True,
|
| 352 |
+
"agent": self.name,
|
| 353 |
+
"response": "I couldn't find any relevant information in the uploaded documents to answer your question.",
|
| 354 |
+
"query": query
|
| 355 |
+
}
|
| 356 |
+
|
| 357 |
+
print(f" π Found {len(documents)} relevant chunks")
|
| 358 |
+
|
| 359 |
+
# Build context from retrieved documents
|
| 360 |
+
context = "\n\n".join([
|
| 361 |
+
f"[Document: {meta.get('filename', 'Unknown')} - Chunk {meta.get('chunk_index', '?')}]\n{doc}"
|
| 362 |
+
for doc, meta in zip(documents, metadatas)
|
| 363 |
+
])
|
| 364 |
+
|
| 365 |
+
# Create prompt with context
|
| 366 |
+
history_section = f"Conversation history:\n{conversation_context}\n\n" if conversation_context else ""
|
| 367 |
+
prompt = f"""Based on the following document excerpts, please answer the question.
|
| 368 |
+
|
| 369 |
+
{history_section}Context from documents:
|
| 370 |
+
{context}
|
| 371 |
+
|
| 372 |
+
Question: {conversational_query}
|
| 373 |
+
Original user input: {query}
|
| 374 |
+
|
| 375 |
+
Please provide a comprehensive answer based on the context above. If the context doesn't contain enough information to answer the question, please say so."""
|
| 376 |
+
|
| 377 |
+
# Generate answer using Gemini with streaming
|
| 378 |
+
print(f" π€ Streaming answer from Gemini...")
|
| 379 |
+
final_response = ""
|
| 380 |
+
async for chunk in self.model.astream(prompt):
|
| 381 |
+
if hasattr(chunk, 'content') and chunk.content:
|
| 382 |
+
final_response += chunk.content
|
| 383 |
+
|
| 384 |
+
return {
|
| 385 |
+
"success": True,
|
| 386 |
+
"agent": self.name,
|
| 387 |
+
"response": final_response,
|
| 388 |
+
"query": query,
|
| 389 |
+
"interpreted_query": conversational_query,
|
| 390 |
+
"sources": [meta.get('filename') for meta in metadatas]
|
| 391 |
+
}
|
| 392 |
+
|
| 393 |
+
except Exception as e:
|
| 394 |
+
print(f" β Error in {self.name}: {e}")
|
| 395 |
+
import traceback
|
| 396 |
+
traceback.print_exc()
|
| 397 |
+
return {
|
| 398 |
+
"success": False,
|
| 399 |
+
"agent": self.name,
|
| 400 |
+
"error": str(e),
|
| 401 |
+
"query": query
|
| 402 |
+
}
|
| 403 |
+
|
| 404 |
+
async def cleanup(self) -> None:
|
| 405 |
+
"""Cleanup resources."""
|
| 406 |
+
print(f"π§Ή {self.name} cleaned up")
|
| 407 |
+
|
| 408 |
+
def _build_chroma_connection(self) -> Dict[str, Any]:
|
| 409 |
+
"""Create connection configuration for the Chroma MCP server."""
|
| 410 |
+
env = {
|
| 411 |
+
"CHROMA_CLIENT_TYPE": "cloud",
|
| 412 |
+
"CHROMA_TENANT": config.CHROMA_TENANT or "",
|
| 413 |
+
"CHROMA_DATABASE": config.CHROMA_DATABASE or "",
|
| 414 |
+
"CHROMA_API_KEY": config.CHROMA_API_KEY or "",
|
| 415 |
+
"CHROMA_CLOUD_HOST": config.CHROMA_CLOUD_HOST,
|
| 416 |
+
}
|
| 417 |
+
|
| 418 |
+
scripts_dir = Path(sys.prefix) / ("Scripts" if os.name == "nt" else "bin")
|
| 419 |
+
candidates = [
|
| 420 |
+
str(scripts_dir / "uvx.exe"),
|
| 421 |
+
str(scripts_dir / "uvx.cmd"),
|
| 422 |
+
str(scripts_dir / "uvx"),
|
| 423 |
+
"uvx.exe",
|
| 424 |
+
"uvx.cmd",
|
| 425 |
+
"uvx",
|
| 426 |
+
]
|
| 427 |
+
|
| 428 |
+
uvx_path: Optional[str] = None
|
| 429 |
+
for candidate in candidates:
|
| 430 |
+
if not candidate:
|
| 431 |
+
continue
|
| 432 |
+
resolved = shutil.which(candidate)
|
| 433 |
+
if resolved:
|
| 434 |
+
uvx_path = resolved
|
| 435 |
+
break
|
| 436 |
+
|
| 437 |
+
if not uvx_path:
|
| 438 |
+
msg = (
|
| 439 |
+
"Could not locate 'uvx'. Install the 'uv' package inside the virtual "
|
| 440 |
+
"environment or ensure it is on PATH."
|
| 441 |
+
)
|
| 442 |
+
raise FileNotFoundError(msg)
|
| 443 |
+
|
| 444 |
+
return {
|
| 445 |
+
"transport": "stdio",
|
| 446 |
+
"command": uvx_path,
|
| 447 |
+
"args": ["--with", "onnxruntime", "chroma-mcp", "--client-type", "cloud"],
|
| 448 |
+
"env": env,
|
| 449 |
+
}
|
src/agents/search_agent_mcp.py
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Search Agent using DuckDuckGo MCP Server via uvx."""
|
| 2 |
+
import asyncio
|
| 3 |
+
from typing import Dict, Any, Optional, List
|
| 4 |
+
from langchain_google_genai import ChatGoogleGenerativeAI
|
| 5 |
+
from langchain_core.messages import HumanMessage, SystemMessage, AIMessage, ToolMessage
|
| 6 |
+
from langchain_core.tools import BaseTool
|
| 7 |
+
from langchain_mcp_adapters.client import MultiServerMCPClient
|
| 8 |
+
from src.core.config import config
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class SearchAgentMCP:
|
| 12 |
+
"""
|
| 13 |
+
Agent for performing web searches using DuckDuckGo MCP Server via uvx.
|
| 14 |
+
Follows the same pattern as StockAgentMCP using langchain-mcp-adapters.
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
def __init__(self):
|
| 18 |
+
"""Initialize the DuckDuckGo search agent."""
|
| 19 |
+
self.name = "Search Agent (MCP)"
|
| 20 |
+
self.description = "Web search expert using DuckDuckGo MCP Server via uvx"
|
| 21 |
+
self.mcp_client: Optional[MultiServerMCPClient] = None
|
| 22 |
+
self.llm: Optional[ChatGoogleGenerativeAI] = None
|
| 23 |
+
self.llm_with_tools = None
|
| 24 |
+
self.tools: List[BaseTool] = []
|
| 25 |
+
self.tool_map: Dict[str, BaseTool] = {}
|
| 26 |
+
self.initialized = False
|
| 27 |
+
|
| 28 |
+
async def initialize(self):
|
| 29 |
+
"""Initialize the agent with MCP client and LLM."""
|
| 30 |
+
if not self.initialized:
|
| 31 |
+
print("π Initializing Search Agent (MCP)...")
|
| 32 |
+
|
| 33 |
+
try:
|
| 34 |
+
# Connect to DuckDuckGo MCP Server via uvx
|
| 35 |
+
print(" π‘ Connecting to DuckDuckGo MCP Server...")
|
| 36 |
+
|
| 37 |
+
connection_name = "duckduckgo"
|
| 38 |
+
connections: Dict[str, Dict[str, Any]] = {}
|
| 39 |
+
|
| 40 |
+
# DuckDuckGo MCP Server via uvx (Python package)
|
| 41 |
+
# This works inside Docker containers without Docker-in-Docker
|
| 42 |
+
connections[connection_name] = {
|
| 43 |
+
"transport": "stdio",
|
| 44 |
+
"command": "uvx",
|
| 45 |
+
"args": ["duckduckgo-mcp-server"],
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
print(" Using DuckDuckGo MCP server via uvx...")
|
| 49 |
+
|
| 50 |
+
self.mcp_client = MultiServerMCPClient(connections)
|
| 51 |
+
|
| 52 |
+
# Load MCP tools as LangChain tools
|
| 53 |
+
print(" Loading tools from DuckDuckGo MCP Server...")
|
| 54 |
+
self.tools = await self.mcp_client.get_tools(server_name=connection_name)
|
| 55 |
+
|
| 56 |
+
if not self.tools:
|
| 57 |
+
raise RuntimeError(
|
| 58 |
+
"No tools available from DuckDuckGo MCP Server\n"
|
| 59 |
+
"Make sure uvx is available and duckduckgo-mcp-server can be installed:\n"
|
| 60 |
+
" - Check: uvx --version\n"
|
| 61 |
+
" - Test: uvx duckduckgo-mcp-server --help"
|
| 62 |
+
)
|
| 63 |
+
|
| 64 |
+
self.tool_map = {tool.name: tool for tool in self.tools}
|
| 65 |
+
|
| 66 |
+
# Initialize Gemini chat model bound to tools
|
| 67 |
+
self.llm = ChatGoogleGenerativeAI(
|
| 68 |
+
model="gemini-2.5-flash",
|
| 69 |
+
temperature=0.3,
|
| 70 |
+
google_api_key=config.GOOGLE_API_KEY,
|
| 71 |
+
)
|
| 72 |
+
self.llm_with_tools = self.llm.bind_tools(self.tools)
|
| 73 |
+
|
| 74 |
+
print(f" β
Connected to DuckDuckGo MCP Server")
|
| 75 |
+
print(f" π Available tools: {len(self.tools)}")
|
| 76 |
+
for tool in self.tools:
|
| 77 |
+
print(f" - {tool.name}")
|
| 78 |
+
print(f" β
Bound {len(self.tools)} tools to LLM")
|
| 79 |
+
|
| 80 |
+
self.initialized = True
|
| 81 |
+
print(" β
Search Agent (MCP) ready!")
|
| 82 |
+
|
| 83 |
+
except Exception as e:
|
| 84 |
+
import traceback
|
| 85 |
+
print(f" β Error initializing Search Agent: {e}")
|
| 86 |
+
print(f" π Full error details:")
|
| 87 |
+
traceback.print_exc()
|
| 88 |
+
print(f"\nπ‘ Troubleshooting:")
|
| 89 |
+
print(f" 1. Make sure Docker is running: docker ps")
|
| 90 |
+
print(f" 2. Test Docker: docker run --rm hello-world")
|
| 91 |
+
print(f" 3. Pull the image manually: docker pull mcp/duckduckgo")
|
| 92 |
+
print(f" 4. Check Docker is in PATH: where docker (Windows) or which docker (Linux/Mac)")
|
| 93 |
+
raise
|
| 94 |
+
|
| 95 |
+
async def process(
|
| 96 |
+
self,
|
| 97 |
+
query: str,
|
| 98 |
+
history: Optional[List[Dict[str, str]]] = None
|
| 99 |
+
) -> Dict[str, Any]:
|
| 100 |
+
"""
|
| 101 |
+
Process a search query through the DuckDuckGo MCP server.
|
| 102 |
+
|
| 103 |
+
Args:
|
| 104 |
+
query: User's search query
|
| 105 |
+
history: Optional conversation history
|
| 106 |
+
|
| 107 |
+
Returns:
|
| 108 |
+
Dictionary with agent response and optional search_urls
|
| 109 |
+
"""
|
| 110 |
+
try:
|
| 111 |
+
if not self.initialized:
|
| 112 |
+
await self.initialize()
|
| 113 |
+
|
| 114 |
+
print(f"\nπ Search Agent processing: '{query}'")
|
| 115 |
+
|
| 116 |
+
# Build system prompt
|
| 117 |
+
system_prompt = """You are a web search assistant with access to DuckDuckGo search.
|
| 118 |
+
|
| 119 |
+
CRITICAL: You MUST use the available tools to find current information. DO NOT answer from memory.
|
| 120 |
+
|
| 121 |
+
Your process:
|
| 122 |
+
1. Use the search tool with the user's query
|
| 123 |
+
2. Read the search results carefully
|
| 124 |
+
3. Synthesize a clear, accurate answer with source citations
|
| 125 |
+
|
| 126 |
+
Always use the search tool first before answering."""
|
| 127 |
+
|
| 128 |
+
# Prepare messages
|
| 129 |
+
messages = [SystemMessage(content=system_prompt)]
|
| 130 |
+
|
| 131 |
+
# Add conversation history if provided (limit to last 2 turns)
|
| 132 |
+
if history:
|
| 133 |
+
for turn in history[-2:]:
|
| 134 |
+
if "user" in turn:
|
| 135 |
+
messages.append(HumanMessage(content=turn["user"]))
|
| 136 |
+
if "assistant" in turn:
|
| 137 |
+
messages.append(AIMessage(content=turn["assistant"]))
|
| 138 |
+
|
| 139 |
+
# Add current query
|
| 140 |
+
messages.append(HumanMessage(content=query))
|
| 141 |
+
|
| 142 |
+
# Track search URLs for references
|
| 143 |
+
search_urls = []
|
| 144 |
+
tool_calls_info = []
|
| 145 |
+
|
| 146 |
+
# Tool calling loop
|
| 147 |
+
max_iterations = 3
|
| 148 |
+
for iteration in range(max_iterations):
|
| 149 |
+
# Get LLM response
|
| 150 |
+
response = await self.llm_with_tools.ainvoke(messages)
|
| 151 |
+
messages.append(response)
|
| 152 |
+
|
| 153 |
+
# Check for tool calls
|
| 154 |
+
tool_calls = getattr(response, 'tool_calls', None) or []
|
| 155 |
+
|
| 156 |
+
if not tool_calls:
|
| 157 |
+
# No more tool calls, return final response
|
| 158 |
+
final_content = response.content if hasattr(response, 'content') else str(response)
|
| 159 |
+
print(f" β
Search complete")
|
| 160 |
+
result = {
|
| 161 |
+
"success": True,
|
| 162 |
+
"response": final_content
|
| 163 |
+
}
|
| 164 |
+
if search_urls:
|
| 165 |
+
result["search_urls"] = search_urls
|
| 166 |
+
if tool_calls_info:
|
| 167 |
+
result["tool_calls"] = tool_calls_info
|
| 168 |
+
return result
|
| 169 |
+
|
| 170 |
+
# Execute tool calls
|
| 171 |
+
for tool_call in tool_calls:
|
| 172 |
+
# Handle both dict and object formats
|
| 173 |
+
if isinstance(tool_call, dict):
|
| 174 |
+
tool_name = tool_call.get('name', '')
|
| 175 |
+
tool_args = tool_call.get('args', {})
|
| 176 |
+
tool_id = tool_call.get('id', '')
|
| 177 |
+
else:
|
| 178 |
+
tool_name = getattr(tool_call, 'name', '')
|
| 179 |
+
tool_args = getattr(tool_call, 'args', {})
|
| 180 |
+
tool_id = getattr(tool_call, 'id', '')
|
| 181 |
+
|
| 182 |
+
print(f" π§ Executing: {tool_name}({tool_args})")
|
| 183 |
+
tool_calls_info.append(f"π§ DuckDuckGo call: {tool_name}({tool_args})")
|
| 184 |
+
|
| 185 |
+
# Get the tool
|
| 186 |
+
tool = self.tool_map.get(tool_name)
|
| 187 |
+
if not tool:
|
| 188 |
+
tool_result = f"Error: Tool '{tool_name}' not found"
|
| 189 |
+
else:
|
| 190 |
+
try:
|
| 191 |
+
# Call the tool with timeout
|
| 192 |
+
tool_result = await asyncio.wait_for(
|
| 193 |
+
tool.ainvoke(tool_args),
|
| 194 |
+
timeout=30.0
|
| 195 |
+
)
|
| 196 |
+
print(f" β
Tool executed successfully")
|
| 197 |
+
|
| 198 |
+
# Extract URLs from search results (safely)
|
| 199 |
+
if tool_name == "search":
|
| 200 |
+
try:
|
| 201 |
+
# Print the raw result to see format
|
| 202 |
+
print(f" π Search result type: {type(tool_result)}")
|
| 203 |
+
|
| 204 |
+
# Convert to string if needed
|
| 205 |
+
result_str = str(tool_result) if not isinstance(tool_result, str) else tool_result
|
| 206 |
+
|
| 207 |
+
# Parse search results to extract URLs
|
| 208 |
+
import re
|
| 209 |
+
# Look for URL patterns in the result
|
| 210 |
+
url_pattern = r'https?://[^\s<>"{}|\\^`\[\]]+'
|
| 211 |
+
found_urls = re.findall(url_pattern, result_str)
|
| 212 |
+
|
| 213 |
+
# Add unique URLs
|
| 214 |
+
for url in found_urls[:5]: # Top 5 URLs
|
| 215 |
+
try:
|
| 216 |
+
# Extract domain as title
|
| 217 |
+
domain = url.split('/')[2] if len(url.split('/')) > 2 else url
|
| 218 |
+
url_data = {"url": url, "title": domain}
|
| 219 |
+
if url_data not in search_urls:
|
| 220 |
+
search_urls.append(url_data)
|
| 221 |
+
print(f" π Found URL: {url}")
|
| 222 |
+
except Exception as e:
|
| 223 |
+
print(f" β οΈ Error parsing URL {url}: {e}")
|
| 224 |
+
continue
|
| 225 |
+
except Exception as e:
|
| 226 |
+
print(f" β οΈ Error extracting URLs: {e}")
|
| 227 |
+
# Continue anyway, don't break search functionality
|
| 228 |
+
|
| 229 |
+
# Truncate if too long
|
| 230 |
+
if isinstance(tool_result, str) and len(tool_result) > 5000:
|
| 231 |
+
tool_result = tool_result[:5000] + "\n\n[Results truncated]"
|
| 232 |
+
elif isinstance(tool_result, dict):
|
| 233 |
+
result_str = str(tool_result)
|
| 234 |
+
if len(result_str) > 5000:
|
| 235 |
+
tool_result = result_str[:5000] + "\n\n[Results truncated]"
|
| 236 |
+
|
| 237 |
+
except asyncio.TimeoutError:
|
| 238 |
+
tool_result = "Error: Search timed out"
|
| 239 |
+
print(f" β οΈ Tool call timed out")
|
| 240 |
+
except Exception as e:
|
| 241 |
+
tool_result = f"Error: {str(e)}"
|
| 242 |
+
print(f" β Tool execution failed: {e}")
|
| 243 |
+
|
| 244 |
+
# Add tool result to messages
|
| 245 |
+
messages.append(
|
| 246 |
+
ToolMessage(
|
| 247 |
+
content=str(tool_result),
|
| 248 |
+
tool_call_id=tool_id
|
| 249 |
+
)
|
| 250 |
+
)
|
| 251 |
+
|
| 252 |
+
# If we hit max iterations, return last response
|
| 253 |
+
print(f" β οΈ Max iterations reached")
|
| 254 |
+
result = {
|
| 255 |
+
"success": True,
|
| 256 |
+
"response": "Search completed but may be incomplete. Try a more specific query."
|
| 257 |
+
}
|
| 258 |
+
if search_urls:
|
| 259 |
+
result["search_urls"] = search_urls
|
| 260 |
+
if tool_calls_info:
|
| 261 |
+
result["tool_calls"] = tool_calls_info
|
| 262 |
+
return result
|
| 263 |
+
|
| 264 |
+
except Exception as e:
|
| 265 |
+
error_msg = f"Error processing search query: {str(e)}"
|
| 266 |
+
print(f" β {error_msg}")
|
| 267 |
+
import traceback
|
| 268 |
+
print(f" Traceback: {traceback.format_exc()}")
|
| 269 |
+
return {
|
| 270 |
+
"success": False,
|
| 271 |
+
"error": error_msg,
|
| 272 |
+
"response": f"I encountered an error: {str(e)}"
|
| 273 |
+
}
|
| 274 |
+
|
| 275 |
+
async def cleanup(self):
|
| 276 |
+
"""Cleanup resources."""
|
| 277 |
+
if self.mcp_client:
|
| 278 |
+
try:
|
| 279 |
+
await self.mcp_client.cleanup()
|
| 280 |
+
except Exception as e:
|
| 281 |
+
print(f" β οΈ Warning during cleanup: {e}")
|
| 282 |
+
self.initialized = False
|
| 283 |
+
print("π§Ή Search Agent cleanup complete")
|
src/agents/stock_agent_mcp.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Stock Agent using Alpha Vantage MCP Server via LangChain MCP adapters."""
|
| 2 |
+
import json
|
| 3 |
+
import shutil
|
| 4 |
+
from typing import Any, Dict, List, Optional
|
| 5 |
+
|
| 6 |
+
from src.core.config import config
|
| 7 |
+
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
|
| 8 |
+
from langchain_core.tools import BaseTool
|
| 9 |
+
from langchain_google_genai import ChatGoogleGenerativeAI
|
| 10 |
+
from langchain_mcp_adapters.client import MultiServerMCPClient
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def _resolve_executable(candidates: List[str]) -> str:
|
| 14 |
+
"""Return first executable path found in PATH."""
|
| 15 |
+
for name in candidates:
|
| 16 |
+
resolved = shutil.which(name)
|
| 17 |
+
if resolved:
|
| 18 |
+
return resolved
|
| 19 |
+
raise FileNotFoundError(f"Unable to locate any of: {', '.join(candidates)}")
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def _to_text(payload: Any) -> str:
|
| 23 |
+
"""Convert model or tool output into a printable string."""
|
| 24 |
+
if isinstance(payload, str):
|
| 25 |
+
return payload
|
| 26 |
+
try:
|
| 27 |
+
return json.dumps(payload, ensure_ascii=False)
|
| 28 |
+
except TypeError:
|
| 29 |
+
return str(payload)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
class StockAgentMCP:
|
| 33 |
+
"""Agent specialized in stock market data using Alpha Vantage MCP Server."""
|
| 34 |
+
|
| 35 |
+
def __init__(self):
|
| 36 |
+
self.name = "Stock Agent (MCP)"
|
| 37 |
+
self.description = "Stock market data and analysis expert using Alpha Vantage MCP Server"
|
| 38 |
+
self.mcp_client: Optional[MultiServerMCPClient] = None
|
| 39 |
+
self.model: Optional[ChatGoogleGenerativeAI] = None
|
| 40 |
+
self.model_with_tools = None
|
| 41 |
+
self.tools: List[BaseTool] = []
|
| 42 |
+
self.tool_map: Dict[str, BaseTool] = {}
|
| 43 |
+
|
| 44 |
+
async def initialize(self) -> None:
|
| 45 |
+
"""Initialize the agent with Alpha Vantage MCP Server."""
|
| 46 |
+
print(f"π§ Initializing {self.name}...")
|
| 47 |
+
|
| 48 |
+
try:
|
| 49 |
+
# Connect to Alpha Vantage MCP Server
|
| 50 |
+
print(f" π‘ Connecting to Alpha Vantage MCP Server...")
|
| 51 |
+
|
| 52 |
+
connection_name = "alphavantage"
|
| 53 |
+
connections: Dict[str, Dict[str, Any]] = {}
|
| 54 |
+
|
| 55 |
+
api_key = (config.ALPHA_VANTAGE_API_KEY or "").strip()
|
| 56 |
+
if not api_key:
|
| 57 |
+
raise ValueError(
|
| 58 |
+
"ALPHA_VANTAGE_API_KEY not configured in .env file.\n"
|
| 59 |
+
"Get your free API key from: https://www.alphavantage.co/support/#api-key"
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
print(" Using Alpha Vantage local MCP server via uvx...")
|
| 63 |
+
|
| 64 |
+
# Try to find uvx executable (uv's command runner)
|
| 65 |
+
try:
|
| 66 |
+
uvx_executable = _resolve_executable(["uvx.exe", "uvx.cmd", "uvx"])
|
| 67 |
+
print(f" Found uvx at: {uvx_executable}")
|
| 68 |
+
except FileNotFoundError:
|
| 69 |
+
raise ValueError(
|
| 70 |
+
"uvx not found. Please install uv (Python package manager) from:\n"
|
| 71 |
+
" Windows: pip install uv\n"
|
| 72 |
+
" Or visit: https://docs.astral.sh/uv/getting-started/installation/\n"
|
| 73 |
+
"After installing, restart your terminal/IDE."
|
| 74 |
+
)
|
| 75 |
+
|
| 76 |
+
# Alpha Vantage MCP uses stdio transport with uvx av-mcp
|
| 77 |
+
connections[connection_name] = {
|
| 78 |
+
"transport": "stdio",
|
| 79 |
+
"command": uvx_executable,
|
| 80 |
+
"args": ["av-mcp", api_key],
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
self.mcp_client = MultiServerMCPClient(connections)
|
| 84 |
+
|
| 85 |
+
# Load MCP tools as LangChain tools
|
| 86 |
+
print(" Loading tools from Alpha Vantage MCP Server...")
|
| 87 |
+
all_tools = await self.mcp_client.get_tools(server_name=connection_name)
|
| 88 |
+
if not all_tools:
|
| 89 |
+
raise RuntimeError("No tools available from Alpha Vantage MCP Server")
|
| 90 |
+
|
| 91 |
+
# Exclude REALTIME_BULK_QUOTES tool
|
| 92 |
+
self.tools = [tool for tool in all_tools if tool.name != "REALTIME_BULK_QUOTES"]
|
| 93 |
+
print(f" Excluded REALTIME_BULK_QUOTES tool")
|
| 94 |
+
|
| 95 |
+
self.tool_map = {tool.name: tool for tool in self.tools}
|
| 96 |
+
|
| 97 |
+
# Initialize Gemini chat model bound to tools
|
| 98 |
+
self.model = ChatGoogleGenerativeAI(
|
| 99 |
+
model="gemini-2.5-flash",
|
| 100 |
+
temperature=0.1,
|
| 101 |
+
google_api_key=config.GOOGLE_API_KEY,
|
| 102 |
+
)
|
| 103 |
+
self.model_with_tools = self.model.bind_tools(self.tools)
|
| 104 |
+
|
| 105 |
+
print(f" β
Connected to Alpha Vantage MCP Server with {len(self.tools)} tools")
|
| 106 |
+
print(f" π Available tool categories:")
|
| 107 |
+
|
| 108 |
+
# Group tools by category for better display
|
| 109 |
+
tool_names = [tool.name for tool in self.tools]
|
| 110 |
+
print(f" - Core Stock APIs: TIME_SERIES_*, GLOBAL_QUOTE, SYMBOL_SEARCH, etc.")
|
| 111 |
+
print(f" - Fundamental Data: COMPANY_OVERVIEW, INCOME_STATEMENT, etc.")
|
| 112 |
+
print(f" - Alpha Intelligence: NEWS_SENTIMENT, TOP_GAINERS_LOSERS, etc.")
|
| 113 |
+
print(f" - Technical Indicators: RSI, MACD, SMA, EMA, etc.")
|
| 114 |
+
print(f" Total: {len(self.tools)} tools available")
|
| 115 |
+
|
| 116 |
+
print(f" β
{self.name} ready!")
|
| 117 |
+
|
| 118 |
+
except Exception as e:
|
| 119 |
+
import traceback
|
| 120 |
+
print(f" β Error initializing {self.name}: {e}")
|
| 121 |
+
print(f" π Full error details:")
|
| 122 |
+
traceback.print_exc()
|
| 123 |
+
raise
|
| 124 |
+
|
| 125 |
+
async def process(self, query: str, history: Optional[List[Dict[str, str]]] = None) -> Dict[str, Any]:
|
| 126 |
+
"""Process a query using Alpha Vantage MCP Server tools."""
|
| 127 |
+
try:
|
| 128 |
+
print(f"\nπ {self.name} processing: '{query}'")
|
| 129 |
+
messages: List[Any] = []
|
| 130 |
+
if history:
|
| 131 |
+
trimmed_history = history[-10:]
|
| 132 |
+
for turn in trimmed_history:
|
| 133 |
+
user_text = turn.get("user")
|
| 134 |
+
if user_text:
|
| 135 |
+
messages.append(HumanMessage(content=user_text))
|
| 136 |
+
assistant_text = turn.get("assistant")
|
| 137 |
+
if assistant_text:
|
| 138 |
+
messages.append(AIMessage(content=assistant_text))
|
| 139 |
+
messages.append(HumanMessage(content=query))
|
| 140 |
+
final_response = ""
|
| 141 |
+
tool_calls_info = []
|
| 142 |
+
|
| 143 |
+
while True:
|
| 144 |
+
if not self.model_with_tools:
|
| 145 |
+
raise RuntimeError("Model not initialized with tools")
|
| 146 |
+
|
| 147 |
+
ai_message = await self.model_with_tools.ainvoke(messages)
|
| 148 |
+
messages.append(ai_message)
|
| 149 |
+
|
| 150 |
+
tool_calls = getattr(ai_message, "tool_calls", [])
|
| 151 |
+
if not tool_calls:
|
| 152 |
+
final_response = _to_text(ai_message.content)
|
| 153 |
+
break
|
| 154 |
+
|
| 155 |
+
for call in tool_calls:
|
| 156 |
+
tool_name = call.get("name")
|
| 157 |
+
tool_args = call.get("args", {})
|
| 158 |
+
tool_call_id = call.get("id")
|
| 159 |
+
print(f" π§ MCP Tool call: {tool_name}({tool_args})")
|
| 160 |
+
tool_calls_info.append(f"π§ MCP Tool call: {tool_name}({tool_args})")
|
| 161 |
+
|
| 162 |
+
tool = self.tool_map.get(tool_name)
|
| 163 |
+
if not tool:
|
| 164 |
+
tool_result = {"error": f"Tool '{tool_name}' not found"}
|
| 165 |
+
else:
|
| 166 |
+
tool_result = await tool.ainvoke(tool_args)
|
| 167 |
+
|
| 168 |
+
messages.append(
|
| 169 |
+
ToolMessage(
|
| 170 |
+
content=_to_text(tool_result),
|
| 171 |
+
tool_call_id=tool_call_id or "",
|
| 172 |
+
)
|
| 173 |
+
)
|
| 174 |
+
|
| 175 |
+
return {
|
| 176 |
+
"success": True,
|
| 177 |
+
"agent": self.name,
|
| 178 |
+
"response": final_response,
|
| 179 |
+
"query": query,
|
| 180 |
+
"tool_calls": tool_calls_info
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
except Exception as e:
|
| 184 |
+
print(f" β Error in {self.name}: {e}")
|
| 185 |
+
import traceback
|
| 186 |
+
traceback.print_exc()
|
| 187 |
+
return {
|
| 188 |
+
"success": False,
|
| 189 |
+
"agent": self.name,
|
| 190 |
+
"error": str(e),
|
| 191 |
+
"query": query
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
async def cleanup(self) -> None:
|
| 195 |
+
"""Cleanup resources."""
|
| 196 |
+
print(f"π§Ή {self.name} cleaned up")
|
src/api/__init__.py
ADDED
|
File without changes
|
src/api/main.py
ADDED
|
@@ -0,0 +1,450 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""FastAPI application exposing multi-agent system via REST API."""
|
| 2 |
+
import asyncio
|
| 3 |
+
import json
|
| 4 |
+
from typing import List, Dict, Optional
|
| 5 |
+
from fastapi import FastAPI, UploadFile, File, HTTPException, WebSocket, WebSocketDisconnect
|
| 6 |
+
from fastapi.responses import StreamingResponse
|
| 7 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 8 |
+
from pydantic import BaseModel, Field
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
import tempfile
|
| 11 |
+
import os
|
| 12 |
+
|
| 13 |
+
from src.agents.crypto_agent_mcp import CryptoAgentMCP
|
| 14 |
+
from src.agents.rag_agent_mcp import RAGAgentMCP
|
| 15 |
+
from src.agents.stock_agent_mcp import StockAgentMCP
|
| 16 |
+
from src.agents.search_agent_mcp import SearchAgentMCP
|
| 17 |
+
from src.agents.finance_tracker_agent_mcp import FinanceTrackerMCP
|
| 18 |
+
from src.core.langgraph_supervisor import ReActSupervisor
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
# ============================================================================
|
| 22 |
+
# Pydantic Models
|
| 23 |
+
# ============================================================================
|
| 24 |
+
|
| 25 |
+
class ChatMessage(BaseModel):
|
| 26 |
+
"""Chat message model."""
|
| 27 |
+
role: str = Field(..., description="Role: 'user' or 'assistant'")
|
| 28 |
+
content: str = Field(..., description="Message content")
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class ChatRequest(BaseModel):
|
| 32 |
+
"""Request model for chat endpoint."""
|
| 33 |
+
message: str = Field(..., description="User's query message")
|
| 34 |
+
history: Optional[List[ChatMessage]] = Field(default=[], description="Chat history")
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
class ChatResponse(BaseModel):
|
| 38 |
+
"""Response model for non-streaming chat."""
|
| 39 |
+
response: str = Field(..., description="Assistant's response")
|
| 40 |
+
history: List[ChatMessage] = Field(..., description="Updated chat history")
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
class StreamEvent(BaseModel):
|
| 44 |
+
"""Streaming event model."""
|
| 45 |
+
type: str = Field(..., description="Event type: thinking, action, observation, final_start, final_token, final_complete, error")
|
| 46 |
+
data: Dict = Field(default={}, description="Event data")
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
class UploadResponse(BaseModel):
|
| 50 |
+
"""Response model for document upload."""
|
| 51 |
+
success: bool = Field(..., description="Upload success status")
|
| 52 |
+
message: str = Field(..., description="Status message")
|
| 53 |
+
details: Optional[Dict] = Field(default=None, description="Upload details")
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
class HealthResponse(BaseModel):
|
| 57 |
+
"""Health check response."""
|
| 58 |
+
status: str = Field(..., description="System status")
|
| 59 |
+
initialized: bool = Field(..., description="Whether system is initialized")
|
| 60 |
+
agents: Dict[str, bool] = Field(..., description="Status of each agent")
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
# ============================================================================
|
| 64 |
+
# Multi-Agent Application
|
| 65 |
+
# ============================================================================
|
| 66 |
+
|
| 67 |
+
class MultiAgentApp:
|
| 68 |
+
"""Main application orchestrating LLM supervisor and agents."""
|
| 69 |
+
|
| 70 |
+
def __init__(self):
|
| 71 |
+
self.crypto_agent = CryptoAgentMCP()
|
| 72 |
+
self.rag_agent = RAGAgentMCP()
|
| 73 |
+
self.stock_agent = StockAgentMCP()
|
| 74 |
+
self.search_agent = SearchAgentMCP()
|
| 75 |
+
self.finance_tracker = FinanceTrackerMCP()
|
| 76 |
+
self.supervisor = None
|
| 77 |
+
self.chat_history: List[Dict[str, str]] = []
|
| 78 |
+
self.initialized = False
|
| 79 |
+
|
| 80 |
+
async def initialize(self):
|
| 81 |
+
"""Initialize all agents and supervisor with parallel initialization."""
|
| 82 |
+
if not self.initialized:
|
| 83 |
+
print("π Initializing Multi-Agent System...")
|
| 84 |
+
print("β‘ Using parallel initialization for faster startup...")
|
| 85 |
+
|
| 86 |
+
# Initialize all agents in parallel
|
| 87 |
+
init_tasks = [
|
| 88 |
+
self.crypto_agent.initialize(),
|
| 89 |
+
self.rag_agent.initialize(),
|
| 90 |
+
self.stock_agent.initialize(),
|
| 91 |
+
self.search_agent.initialize(),
|
| 92 |
+
self.finance_tracker.initialize()
|
| 93 |
+
]
|
| 94 |
+
|
| 95 |
+
# Execute all initializations concurrently
|
| 96 |
+
results = await asyncio.gather(*init_tasks, return_exceptions=True)
|
| 97 |
+
|
| 98 |
+
# Check for initialization failures
|
| 99 |
+
failed_agents = []
|
| 100 |
+
agent_names = ["Crypto", "RAG", "Stock", "Search", "Finance Tracker"]
|
| 101 |
+
for i, result in enumerate(results):
|
| 102 |
+
if isinstance(result, Exception):
|
| 103 |
+
print(f" β οΈ {agent_names[i]} agent initialization failed: {result}")
|
| 104 |
+
failed_agents.append(agent_names[i])
|
| 105 |
+
|
| 106 |
+
if failed_agents:
|
| 107 |
+
print(f" β οΈ Some agents failed to initialize: {', '.join(failed_agents)}")
|
| 108 |
+
print(" βΉοΈ System will continue with available agents")
|
| 109 |
+
|
| 110 |
+
# Initialize supervisor with agent references
|
| 111 |
+
self.supervisor = ReActSupervisor(
|
| 112 |
+
crypto_agent=self.crypto_agent,
|
| 113 |
+
rag_agent=self.rag_agent,
|
| 114 |
+
stock_agent=self.stock_agent,
|
| 115 |
+
search_agent=self.search_agent,
|
| 116 |
+
finance_tracker=self.finance_tracker
|
| 117 |
+
)
|
| 118 |
+
|
| 119 |
+
self.initialized = True
|
| 120 |
+
print("β
System initialized with LangGraph supervisor!")
|
| 121 |
+
return "β
All agents initialized and ready!"
|
| 122 |
+
|
| 123 |
+
async def process_query_streaming(self, message: str, history: List[Dict[str, str]]):
|
| 124 |
+
"""
|
| 125 |
+
Process user query with streaming updates.
|
| 126 |
+
|
| 127 |
+
Args:
|
| 128 |
+
message: User's input message
|
| 129 |
+
history: Chat history in internal format [{"user": "...", "assistant": "..."}]
|
| 130 |
+
|
| 131 |
+
Yields:
|
| 132 |
+
Dictionary updates from supervisor
|
| 133 |
+
"""
|
| 134 |
+
if not message.strip():
|
| 135 |
+
yield {"type": "error", "error": "Please enter a query."}
|
| 136 |
+
return
|
| 137 |
+
|
| 138 |
+
try:
|
| 139 |
+
# Check if system is initialized
|
| 140 |
+
if not self.initialized:
|
| 141 |
+
yield {"type": "error", "error": "System not initialized."}
|
| 142 |
+
return
|
| 143 |
+
|
| 144 |
+
# Stream updates from supervisor
|
| 145 |
+
async for update in self.supervisor.process_streaming(message, history=history):
|
| 146 |
+
yield update
|
| 147 |
+
|
| 148 |
+
# Update chat history
|
| 149 |
+
self.chat_history.append({"user": message})
|
| 150 |
+
if len(self.chat_history) > 20:
|
| 151 |
+
self.chat_history = self.chat_history[-20:]
|
| 152 |
+
|
| 153 |
+
except Exception as e:
|
| 154 |
+
yield {"type": "error", "error": str(e)}
|
| 155 |
+
|
| 156 |
+
async def upload_document(self, file_path: str, filename: str) -> Dict:
|
| 157 |
+
"""
|
| 158 |
+
Handle document upload to ChromaDB Cloud.
|
| 159 |
+
|
| 160 |
+
Args:
|
| 161 |
+
file_path: Path to the uploaded file
|
| 162 |
+
filename: Original filename
|
| 163 |
+
|
| 164 |
+
Returns:
|
| 165 |
+
Upload result dictionary
|
| 166 |
+
"""
|
| 167 |
+
try:
|
| 168 |
+
if not self.initialized:
|
| 169 |
+
return {
|
| 170 |
+
"success": False,
|
| 171 |
+
"error": "System not initialized"
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
# Validate file exists
|
| 175 |
+
if not os.path.exists(file_path):
|
| 176 |
+
return {
|
| 177 |
+
"success": False,
|
| 178 |
+
"error": f"File not found: {file_path}"
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
# Validate file type
|
| 182 |
+
file_extension = Path(filename).suffix.lower()
|
| 183 |
+
if file_extension not in ['.pdf', '.txt', '.docx']:
|
| 184 |
+
return {
|
| 185 |
+
"success": False,
|
| 186 |
+
"error": f"Unsupported file type: {file_extension}. Supported: PDF, TXT, DOCX"
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
# Upload to RAG agent
|
| 190 |
+
result = await self.rag_agent.add_document(file_path)
|
| 191 |
+
|
| 192 |
+
return result
|
| 193 |
+
|
| 194 |
+
except Exception as e:
|
| 195 |
+
return {
|
| 196 |
+
"success": False,
|
| 197 |
+
"error": str(e)
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
async def cleanup(self):
|
| 201 |
+
"""Cleanup resources."""
|
| 202 |
+
if self.initialized:
|
| 203 |
+
await self.crypto_agent.cleanup()
|
| 204 |
+
await self.rag_agent.cleanup()
|
| 205 |
+
await self.stock_agent.cleanup()
|
| 206 |
+
await self.search_agent.cleanup()
|
| 207 |
+
await self.finance_tracker.cleanup()
|
| 208 |
+
print("π§Ή Cleanup complete")
|
| 209 |
+
self.chat_history.clear()
|
| 210 |
+
|
| 211 |
+
def get_agent_status(self) -> Dict[str, bool]:
|
| 212 |
+
"""Get status of all agents."""
|
| 213 |
+
return {
|
| 214 |
+
"crypto": self.crypto_agent is not None,
|
| 215 |
+
"rag": self.rag_agent is not None,
|
| 216 |
+
"stock": self.stock_agent is not None,
|
| 217 |
+
"search": self.search_agent is not None,
|
| 218 |
+
"finance_tracker": self.finance_tracker is not None
|
| 219 |
+
}
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
# ============================================================================
|
| 223 |
+
# FastAPI Application
|
| 224 |
+
# ============================================================================
|
| 225 |
+
|
| 226 |
+
app = FastAPI(
|
| 227 |
+
title="Multi-Agent Assistant API",
|
| 228 |
+
description="FastAPI interface for multi-agent LLM system with ReAct supervisor",
|
| 229 |
+
version="1.0.0"
|
| 230 |
+
)
|
| 231 |
+
|
| 232 |
+
# Add CORS middleware
|
| 233 |
+
app.add_middleware(
|
| 234 |
+
CORSMiddleware,
|
| 235 |
+
allow_origins=["*"], # Adjust in production
|
| 236 |
+
allow_credentials=True,
|
| 237 |
+
allow_methods=["*"],
|
| 238 |
+
allow_headers=["*"],
|
| 239 |
+
)
|
| 240 |
+
|
| 241 |
+
# Initialize multi-agent app
|
| 242 |
+
multi_agent_app = MultiAgentApp()
|
| 243 |
+
|
| 244 |
+
|
| 245 |
+
# ============================================================================
|
| 246 |
+
# Startup/Shutdown Events
|
| 247 |
+
# ============================================================================
|
| 248 |
+
|
| 249 |
+
@app.on_event("startup")
|
| 250 |
+
async def startup_event():
|
| 251 |
+
"""Initialize system on startup."""
|
| 252 |
+
print("=" * 60)
|
| 253 |
+
print("π Starting FastAPI Multi-Agent Assistant")
|
| 254 |
+
print("=" * 60)
|
| 255 |
+
|
| 256 |
+
# Validate configuration
|
| 257 |
+
try:
|
| 258 |
+
from src.core.config import config
|
| 259 |
+
config.validate()
|
| 260 |
+
print("β
Configuration validated")
|
| 261 |
+
except ValueError as e:
|
| 262 |
+
print(f"β Configuration Error: {e}")
|
| 263 |
+
raise
|
| 264 |
+
|
| 265 |
+
# Initialize all agents
|
| 266 |
+
await multi_agent_app.initialize()
|
| 267 |
+
print("=" * 60)
|
| 268 |
+
|
| 269 |
+
|
| 270 |
+
@app.on_event("shutdown")
|
| 271 |
+
async def shutdown_event():
|
| 272 |
+
"""Cleanup on shutdown."""
|
| 273 |
+
print("\nπ Shutting down...")
|
| 274 |
+
await multi_agent_app.cleanup()
|
| 275 |
+
print("π Goodbye!")
|
| 276 |
+
|
| 277 |
+
|
| 278 |
+
# ============================================================================
|
| 279 |
+
# API Endpoints
|
| 280 |
+
# ============================================================================
|
| 281 |
+
|
| 282 |
+
@app.get("/", tags=["Root"])
|
| 283 |
+
async def root():
|
| 284 |
+
"""Root endpoint."""
|
| 285 |
+
return {
|
| 286 |
+
"message": "Multi-Agent Assistant API",
|
| 287 |
+
"version": "1.0.0",
|
| 288 |
+
"docs": "/docs",
|
| 289 |
+
"health": "/health"
|
| 290 |
+
}
|
| 291 |
+
|
| 292 |
+
|
| 293 |
+
@app.get("/health", response_model=HealthResponse, tags=["Health"])
|
| 294 |
+
async def health_check():
|
| 295 |
+
"""
|
| 296 |
+
Health check endpoint.
|
| 297 |
+
|
| 298 |
+
Returns system status and agent availability.
|
| 299 |
+
"""
|
| 300 |
+
return HealthResponse(
|
| 301 |
+
status="healthy" if multi_agent_app.initialized else "initializing",
|
| 302 |
+
initialized=multi_agent_app.initialized,
|
| 303 |
+
agents=multi_agent_app.get_agent_status()
|
| 304 |
+
)
|
| 305 |
+
|
| 306 |
+
|
| 307 |
+
@app.post("/api/v1/chat/stream", tags=["Chat"])
|
| 308 |
+
async def stream_chat(request: ChatRequest):
|
| 309 |
+
"""
|
| 310 |
+
Stream chat responses with intermediate reasoning steps.
|
| 311 |
+
|
| 312 |
+
Uses Server-Sent Events (SSE) to stream updates in real-time.
|
| 313 |
+
|
| 314 |
+
Args:
|
| 315 |
+
request: ChatRequest with message and optional history
|
| 316 |
+
|
| 317 |
+
Returns:
|
| 318 |
+
StreamingResponse with SSE events
|
| 319 |
+
"""
|
| 320 |
+
# Convert ChatMessage models to internal format
|
| 321 |
+
internal_history = []
|
| 322 |
+
for msg in request.history:
|
| 323 |
+
if msg.role == "user":
|
| 324 |
+
internal_history.append({"user": msg.content})
|
| 325 |
+
elif msg.role == "assistant":
|
| 326 |
+
internal_history.append({"assistant": msg.content})
|
| 327 |
+
|
| 328 |
+
async def event_generator():
|
| 329 |
+
"""Generate SSE events with explicit flushing."""
|
| 330 |
+
try:
|
| 331 |
+
async for update in multi_agent_app.process_query_streaming(
|
| 332 |
+
request.message,
|
| 333 |
+
internal_history
|
| 334 |
+
):
|
| 335 |
+
# Format as SSE with explicit newlines
|
| 336 |
+
event_data = json.dumps(update)
|
| 337 |
+
yield f"data: {event_data}\n\n"
|
| 338 |
+
# Force flush by yielding empty string (triggers send)
|
| 339 |
+
await asyncio.sleep(0) # Allow event loop to process
|
| 340 |
+
except Exception as e:
|
| 341 |
+
error_event = json.dumps({"type": "error", "error": str(e)})
|
| 342 |
+
yield f"data: {error_event}\n\n"
|
| 343 |
+
|
| 344 |
+
return StreamingResponse(
|
| 345 |
+
event_generator(),
|
| 346 |
+
media_type="text/event-stream",
|
| 347 |
+
headers={
|
| 348 |
+
"Cache-Control": "no-cache",
|
| 349 |
+
"Connection": "keep-alive",
|
| 350 |
+
"X-Accel-Buffering": "no", # Disable nginx buffering
|
| 351 |
+
}
|
| 352 |
+
)
|
| 353 |
+
|
| 354 |
+
|
| 355 |
+
@app.websocket("/ws/v1/chat")
|
| 356 |
+
async def websocket_chat(websocket: WebSocket):
|
| 357 |
+
"""
|
| 358 |
+
WebSocket endpoint for chat with streaming updates.
|
| 359 |
+
|
| 360 |
+
Client sends: {"message": "query", "history": [...]}
|
| 361 |
+
Server streams: {"type": "...", "data": {...}}
|
| 362 |
+
"""
|
| 363 |
+
await websocket.accept()
|
| 364 |
+
|
| 365 |
+
try:
|
| 366 |
+
while True:
|
| 367 |
+
# Receive message from client
|
| 368 |
+
data = await websocket.receive_json()
|
| 369 |
+
message = data.get("message", "")
|
| 370 |
+
history = data.get("history", [])
|
| 371 |
+
|
| 372 |
+
# Convert to internal format
|
| 373 |
+
internal_history = []
|
| 374 |
+
for msg in history:
|
| 375 |
+
if msg.get("role") == "user":
|
| 376 |
+
internal_history.append({"user": msg.get("content", "")})
|
| 377 |
+
elif msg.get("role") == "assistant":
|
| 378 |
+
internal_history.append({"assistant": msg.get("content", "")})
|
| 379 |
+
|
| 380 |
+
# Stream responses
|
| 381 |
+
async for update in multi_agent_app.process_query_streaming(message, internal_history):
|
| 382 |
+
await websocket.send_json(update)
|
| 383 |
+
|
| 384 |
+
except WebSocketDisconnect:
|
| 385 |
+
print("WebSocket client disconnected")
|
| 386 |
+
except Exception as e:
|
| 387 |
+
print(f"WebSocket error: {e}")
|
| 388 |
+
await websocket.close(code=1011, reason=str(e))
|
| 389 |
+
|
| 390 |
+
|
| 391 |
+
@app.post("/api/v1/documents/upload", response_model=UploadResponse, tags=["Documents"])
|
| 392 |
+
async def upload_document(file: UploadFile = File(...)):
|
| 393 |
+
"""
|
| 394 |
+
Upload a document to the RAG agent.
|
| 395 |
+
|
| 396 |
+
Supported file types: PDF, TXT, DOCX
|
| 397 |
+
|
| 398 |
+
Args:
|
| 399 |
+
file: Uploaded file
|
| 400 |
+
|
| 401 |
+
Returns:
|
| 402 |
+
UploadResponse with status and details
|
| 403 |
+
"""
|
| 404 |
+
# Save uploaded file temporarily
|
| 405 |
+
with tempfile.NamedTemporaryFile(delete=False, suffix=Path(file.filename).suffix) as tmp_file:
|
| 406 |
+
content = await file.read()
|
| 407 |
+
tmp_file.write(content)
|
| 408 |
+
tmp_file_path = tmp_file.name
|
| 409 |
+
|
| 410 |
+
try:
|
| 411 |
+
# Process upload
|
| 412 |
+
result = await multi_agent_app.upload_document(tmp_file_path, file.filename)
|
| 413 |
+
|
| 414 |
+
if result.get("success"):
|
| 415 |
+
return UploadResponse(
|
| 416 |
+
success=True,
|
| 417 |
+
message="Document uploaded successfully",
|
| 418 |
+
details={
|
| 419 |
+
"filename": result.get("filename"),
|
| 420 |
+
"file_type": result.get("file_type"),
|
| 421 |
+
"chunks_added": result.get("chunks_added"),
|
| 422 |
+
"total_documents": result.get("total_documents")
|
| 423 |
+
}
|
| 424 |
+
)
|
| 425 |
+
else:
|
| 426 |
+
raise HTTPException(
|
| 427 |
+
status_code=400,
|
| 428 |
+
detail=result.get("error", "Upload failed")
|
| 429 |
+
)
|
| 430 |
+
|
| 431 |
+
finally:
|
| 432 |
+
# Clean up temp file
|
| 433 |
+
if os.path.exists(tmp_file_path):
|
| 434 |
+
os.unlink(tmp_file_path)
|
| 435 |
+
|
| 436 |
+
|
| 437 |
+
# ============================================================================
|
| 438 |
+
# Main Entry Point
|
| 439 |
+
# ============================================================================
|
| 440 |
+
|
| 441 |
+
if __name__ == "__main__":
|
| 442 |
+
import uvicorn
|
| 443 |
+
|
| 444 |
+
uvicorn.run(
|
| 445 |
+
"api:app",
|
| 446 |
+
host="0.0.0.0",
|
| 447 |
+
port=8000,
|
| 448 |
+
reload=True,
|
| 449 |
+
log_level="info"
|
| 450 |
+
)
|
src/core/__init__.py
ADDED
|
File without changes
|
src/core/config.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Configuration for multi-agent RAG system."""
|
| 2 |
+
import os
|
| 3 |
+
from typing import Optional
|
| 4 |
+
from dataclasses import dataclass
|
| 5 |
+
from dotenv import load_dotenv
|
| 6 |
+
|
| 7 |
+
# Load environment variables from .env file
|
| 8 |
+
load_dotenv()
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
@dataclass
|
| 12 |
+
class Config:
|
| 13 |
+
"""Application configuration."""
|
| 14 |
+
|
| 15 |
+
# API Keys
|
| 16 |
+
GOOGLE_API_KEY: Optional[str] = None
|
| 17 |
+
COINGECKO_API_KEY: Optional[str] = None
|
| 18 |
+
ALPHA_VANTAGE_API_KEY: Optional[str] = None
|
| 19 |
+
# ChromaDB Cloud Configuration
|
| 20 |
+
CHROMA_API_KEY: Optional[str] = None
|
| 21 |
+
CHROMA_TENANT: Optional[str] = None
|
| 22 |
+
CHROMA_DATABASE: Optional[str] = None
|
| 23 |
+
CHROMA_CLOUD_HOST: str = "api.trychroma.com"
|
| 24 |
+
|
| 25 |
+
# ChromaDB Collection Names
|
| 26 |
+
DOCUMENTS_COLLECTION: str = "test-embed"
|
| 27 |
+
|
| 28 |
+
# Embedding Configuration
|
| 29 |
+
EMBEDDING_FUNCTION: str = "default" # Options: default, openai, cohere, jina, voyageai
|
| 30 |
+
|
| 31 |
+
# Google Cloud SQL Configuration
|
| 32 |
+
CLOUD_SQL_INSTANCE_CONNECTION_NAME: Optional[str] = None # Format: project:region:instance
|
| 33 |
+
CLOUD_SQL_DB_USER: Optional[str] = None
|
| 34 |
+
CLOUD_SQL_DB_PASS: Optional[str] = None
|
| 35 |
+
CLOUD_SQL_DB_NAME: Optional[str] = None
|
| 36 |
+
CLOUD_SQL_PRIVATE_IP: bool = False # Set to True to use private IP
|
| 37 |
+
|
| 38 |
+
# MCP Toolbox Configuration (for Finance Tracker Agent)
|
| 39 |
+
GCP_PROJECT_ID: Optional[str] = None # Google Cloud Project ID
|
| 40 |
+
CLOUD_SQL_REGION: Optional[str] = None # e.g., us-central1
|
| 41 |
+
CLOUD_SQL_INSTANCE: Optional[str] = None # Instance name only (not full connection string)
|
| 42 |
+
MCP_TOOLBOX_SERVER_URL: str = "http://localhost:5000" # MCP Toolbox HTTP server URL
|
| 43 |
+
|
| 44 |
+
# CoinGecko MCP Server Configuration
|
| 45 |
+
COINGECKO_MCP_URL: str = "https://mcp.pro-api.coingecko.com/mcp"
|
| 46 |
+
COINGECKO_MCP_AUTH_URL: str = "https://mcp.pro-api.coingecko.com/auth"
|
| 47 |
+
|
| 48 |
+
# UI Configuration
|
| 49 |
+
UI_PORT: int = 7860
|
| 50 |
+
UI_HOST: str = "0.0.0.0"
|
| 51 |
+
MAX_FILE_SIZE_MB: int = 50
|
| 52 |
+
ALLOWED_FILE_TYPES: list = None
|
| 53 |
+
|
| 54 |
+
def __post_init__(self):
|
| 55 |
+
"""Initialize mutable default values."""
|
| 56 |
+
if self.ALLOWED_FILE_TYPES is None:
|
| 57 |
+
self.ALLOWED_FILE_TYPES = [".pdf", ".txt", ".docx"]
|
| 58 |
+
|
| 59 |
+
@classmethod
|
| 60 |
+
def from_env(cls) -> "Config":
|
| 61 |
+
"""Load configuration from environment variables."""
|
| 62 |
+
return cls(
|
| 63 |
+
ALPHA_VANTAGE_API_KEY=os.getenv("ALPHA_VANTAGE_API_KEY"),
|
| 64 |
+
GOOGLE_API_KEY=os.getenv("GOOGLE_API_KEY"),
|
| 65 |
+
COINGECKO_API_KEY=os.getenv("COINGECKO_API_KEY"),
|
| 66 |
+
COINGECKO_MCP_URL=os.getenv(
|
| 67 |
+
"COINGECKO_MCP_URL",
|
| 68 |
+
"https://mcp.pro-api.coingecko.com/mcp"
|
| 69 |
+
),
|
| 70 |
+
COINGECKO_MCP_AUTH_URL=os.getenv(
|
| 71 |
+
"COINGECKO_MCP_AUTH_URL",
|
| 72 |
+
"https://mcp.pro-api.coingecko.com/auth"
|
| 73 |
+
),
|
| 74 |
+
CHROMA_API_KEY=os.getenv("CHROMA_API_KEY"),
|
| 75 |
+
CHROMA_TENANT=os.getenv("CHROMA_TENANT"),
|
| 76 |
+
CHROMA_DATABASE=os.getenv("CHROMA_DATABASE"),
|
| 77 |
+
CHROMA_CLOUD_HOST=os.getenv("CHROMA_CLOUD_HOST", "api.trychroma.com"),
|
| 78 |
+
EMBEDDING_FUNCTION=os.getenv("CHROMA_EMBEDDING_FUNCTION", "default"),
|
| 79 |
+
DOCUMENTS_COLLECTION=os.getenv("DOCUMENTS_COLLECTION", "mcp-test"),
|
| 80 |
+
MAX_FILE_SIZE_MB=int(os.getenv("MAX_FILE_SIZE_MB", "50")),
|
| 81 |
+
CLOUD_SQL_INSTANCE_CONNECTION_NAME=os.getenv("CLOUD_SQL_INSTANCE_CONNECTION_NAME"),
|
| 82 |
+
CLOUD_SQL_DB_USER=os.getenv("CLOUD_SQL_DB_USER"),
|
| 83 |
+
CLOUD_SQL_DB_PASS=os.getenv("CLOUD_SQL_DB_PASS"),
|
| 84 |
+
CLOUD_SQL_DB_NAME=os.getenv("CLOUD_SQL_DB_NAME", "finance_tracker"),
|
| 85 |
+
CLOUD_SQL_PRIVATE_IP=os.getenv("CLOUD_SQL_PRIVATE_IP", "False").lower() == "true",
|
| 86 |
+
GCP_PROJECT_ID=os.getenv("GCP_PROJECT_ID"),
|
| 87 |
+
CLOUD_SQL_REGION=os.getenv("CLOUD_SQL_REGION"),
|
| 88 |
+
CLOUD_SQL_INSTANCE=os.getenv("CLOUD_SQL_INSTANCE"),
|
| 89 |
+
MCP_TOOLBOX_SERVER_URL=os.getenv("MCP_TOOLBOX_SERVER_URL", "http://localhost:5000"),
|
| 90 |
+
)
|
| 91 |
+
|
| 92 |
+
def validate(self) -> None:
|
| 93 |
+
"""Validate required configuration."""
|
| 94 |
+
errors = []
|
| 95 |
+
|
| 96 |
+
if not self.GOOGLE_API_KEY:
|
| 97 |
+
errors.append("GOOGLE_API_KEY environment variable is required")
|
| 98 |
+
|
| 99 |
+
if not self.CHROMA_API_KEY:
|
| 100 |
+
errors.append("CHROMA_API_KEY environment variable is required for ChromaDB Cloud")
|
| 101 |
+
|
| 102 |
+
if not self.CHROMA_TENANT:
|
| 103 |
+
errors.append("CHROMA_TENANT environment variable is required for ChromaDB Cloud")
|
| 104 |
+
|
| 105 |
+
if not self.CHROMA_DATABASE:
|
| 106 |
+
errors.append("CHROMA_DATABASE environment variable is required for ChromaDB Cloud")
|
| 107 |
+
|
| 108 |
+
if not self.ALPHA_VANTAGE_API_KEY:
|
| 109 |
+
raise ValueError(
|
| 110 |
+
"ALPHA_VANTAGE_API_KEY not configured. "
|
| 111 |
+
"Add it to your .env file. "
|
| 112 |
+
"Get your API key from: https://www.alphavantage.co/support/#api-key"
|
| 113 |
+
)
|
| 114 |
+
if errors:
|
| 115 |
+
raise ValueError(
|
| 116 |
+
"Configuration validation failed:\n" + "\n".join(f" - {e}" for e in errors)
|
| 117 |
+
)
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
# Global config instance
|
| 121 |
+
config = Config.from_env()
|
src/core/langgraph_supervisor.py
ADDED
|
@@ -0,0 +1,616 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Any, Dict, List, Optional, TypedDict, Annotated, Sequence, AsyncGenerator
|
| 2 |
+
import operator
|
| 3 |
+
|
| 4 |
+
import asyncio
|
| 5 |
+
from asyncio import Queue
|
| 6 |
+
|
| 7 |
+
from langgraph.graph import StateGraph, END
|
| 8 |
+
from langchain_core.messages import BaseMessage, HumanMessage, AIMessage, SystemMessage
|
| 9 |
+
from langchain_google_genai import ChatGoogleGenerativeAI
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
from src.core.config import config
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class AgentState(TypedDict):
|
| 16 |
+
"""State for the ReAct agent pattern."""
|
| 17 |
+
messages: Annotated[Sequence[BaseMessage], operator.add]
|
| 18 |
+
query: str
|
| 19 |
+
agent_outputs: Dict[str, Any]
|
| 20 |
+
reasoning_steps: List[str]
|
| 21 |
+
final_answer: Optional[str]
|
| 22 |
+
current_step: int
|
| 23 |
+
max_steps: int
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class ReActSupervisor:
|
| 27 |
+
"""Supervisor using ReAct pattern for multi-agent orchestration with streaming."""
|
| 28 |
+
|
| 29 |
+
def __init__(
|
| 30 |
+
self,
|
| 31 |
+
crypto_agent=None,
|
| 32 |
+
rag_agent=None,
|
| 33 |
+
stock_agent=None,
|
| 34 |
+
search_agent=None,
|
| 35 |
+
finance_tracker=None,
|
| 36 |
+
max_steps: int = 5
|
| 37 |
+
):
|
| 38 |
+
"""
|
| 39 |
+
Initialize the ReAct supervisor.
|
| 40 |
+
|
| 41 |
+
Args:
|
| 42 |
+
crypto_agent: Crypto agent instance
|
| 43 |
+
rag_agent: RAG agent instance
|
| 44 |
+
stock_agent: Stock agent instance
|
| 45 |
+
search_agent: Web search agent instance (DuckDuckGo)
|
| 46 |
+
finance_tracker: Finance tracker agent instance
|
| 47 |
+
max_steps: Maximum reasoning steps before forcing completion
|
| 48 |
+
"""
|
| 49 |
+
self.crypto_agent = crypto_agent
|
| 50 |
+
self.rag_agent = rag_agent
|
| 51 |
+
self.stock_agent = stock_agent
|
| 52 |
+
self.search_agent = search_agent
|
| 53 |
+
self.finance_tracker = finance_tracker
|
| 54 |
+
self.max_steps = max_steps
|
| 55 |
+
self.streaming_callback = None # For streaming updates
|
| 56 |
+
|
| 57 |
+
# Initialize supervisor LLM with structured output
|
| 58 |
+
self.supervisor_llm = ChatGoogleGenerativeAI(
|
| 59 |
+
model="gemini-2.5-flash",
|
| 60 |
+
temperature=0.1,
|
| 61 |
+
google_api_key=config.GOOGLE_API_KEY,
|
| 62 |
+
)
|
| 63 |
+
|
| 64 |
+
# Build the ReAct workflow
|
| 65 |
+
self.graph = self._build_react_graph()
|
| 66 |
+
self.compiled_graph = self.graph.compile()
|
| 67 |
+
|
| 68 |
+
def _build_react_graph(self) -> StateGraph:
|
| 69 |
+
"""Build the ReAct pattern graph."""
|
| 70 |
+
|
| 71 |
+
# Create the state graph
|
| 72 |
+
workflow = StateGraph(AgentState)
|
| 73 |
+
|
| 74 |
+
# Add nodes
|
| 75 |
+
workflow.add_node("think", self.think_node)
|
| 76 |
+
workflow.add_node("act_crypto", self.act_crypto_node)
|
| 77 |
+
workflow.add_node("act_rag", self.act_rag_node)
|
| 78 |
+
workflow.add_node("act_stock", self.act_stock_node)
|
| 79 |
+
workflow.add_node("act_search", self.act_search_node)
|
| 80 |
+
workflow.add_node("act_finance_tracker", self.act_finance_tracker_node)
|
| 81 |
+
workflow.add_node("observe", self.observe_node)
|
| 82 |
+
workflow.add_node("finish", self.finish_node)
|
| 83 |
+
|
| 84 |
+
# Set entry point
|
| 85 |
+
workflow.set_entry_point("think")
|
| 86 |
+
|
| 87 |
+
# Add routing from think node
|
| 88 |
+
workflow.add_conditional_edges(
|
| 89 |
+
"think",
|
| 90 |
+
self.route_from_thinking,
|
| 91 |
+
{
|
| 92 |
+
"crypto": "act_crypto",
|
| 93 |
+
"rag": "act_rag",
|
| 94 |
+
"stock": "act_stock",
|
| 95 |
+
"search": "act_search",
|
| 96 |
+
"finance_tracker": "act_finance_tracker",
|
| 97 |
+
"finish": "finish",
|
| 98 |
+
}
|
| 99 |
+
)
|
| 100 |
+
|
| 101 |
+
# Actions lead to observe
|
| 102 |
+
workflow.add_edge("act_crypto", "observe")
|
| 103 |
+
workflow.add_edge("act_rag", "observe")
|
| 104 |
+
workflow.add_edge("act_stock", "observe")
|
| 105 |
+
workflow.add_edge("act_search", "observe")
|
| 106 |
+
workflow.add_edge("act_finance_tracker", "observe")
|
| 107 |
+
|
| 108 |
+
# Observe leads back to think (or finish if max steps)
|
| 109 |
+
workflow.add_conditional_edges(
|
| 110 |
+
"observe",
|
| 111 |
+
self.should_continue,
|
| 112 |
+
{
|
| 113 |
+
"continue": "think",
|
| 114 |
+
"finish": "finish"
|
| 115 |
+
}
|
| 116 |
+
)
|
| 117 |
+
|
| 118 |
+
# Finish ends the graph
|
| 119 |
+
workflow.add_edge("finish", END)
|
| 120 |
+
|
| 121 |
+
return workflow
|
| 122 |
+
|
| 123 |
+
async def _emit_update(self, update: Dict[str, Any]):
|
| 124 |
+
"""Emit streaming update if callback is set."""
|
| 125 |
+
if self.streaming_callback:
|
| 126 |
+
await self.streaming_callback(update)
|
| 127 |
+
|
| 128 |
+
async def think_node(self, state: AgentState) -> Dict[str, Any]:
|
| 129 |
+
"""Reasoning step: Analyze current state and decide next action."""
|
| 130 |
+
current_step = state.get("current_step", 0) + 1
|
| 131 |
+
|
| 132 |
+
# Build context from previous outputs
|
| 133 |
+
context = self._build_context(state)
|
| 134 |
+
|
| 135 |
+
# Create reasoning prompt
|
| 136 |
+
think_prompt = f"""You are a ReAct supervisor orchestrating multiple agents to answer user queries.
|
| 137 |
+
|
| 138 |
+
Current Query: {state['query']}
|
| 139 |
+
|
| 140 |
+
Available Actions:
|
| 141 |
+
- CALL_CRYPTO: Get cryptocurrency market data, prices, trends
|
| 142 |
+
- CALL_STOCK: Get stock market data, company information, financial data
|
| 143 |
+
- CALL_FINANCE_TRACKER: Manage personal stock portfolio (add transactions, view positions, analyze performance, get portfolio news)
|
| 144 |
+
- CALL_RAG: Search and retrieve information from uploaded documents
|
| 145 |
+
- CALL_SEARCH: Search the web for current information, news, or general knowledge
|
| 146 |
+
- FINISH: Provide final answer (use when you have sufficient information)
|
| 147 |
+
|
| 148 |
+
Current Step: {current_step}/{self.max_steps}
|
| 149 |
+
|
| 150 |
+
Information Gathered So Far:
|
| 151 |
+
{context}
|
| 152 |
+
|
| 153 |
+
IMPORTANT INSTRUCTIONS:
|
| 154 |
+
1. Check what information you ALREADY HAVE in the context above
|
| 155 |
+
2. Do NOT call the same agent twice unless you need different information
|
| 156 |
+
3. If you already have an answer from any agent, move to FINISH
|
| 157 |
+
4. Only call another agent if you need ADDITIONAL different information
|
| 158 |
+
5. Use CALL_SEARCH for general knowledge, current events, and news
|
| 159 |
+
6. FINISH when you have enough information to answer the user's query
|
| 160 |
+
|
| 161 |
+
Based on what you know so far, reason about what to do next.
|
| 162 |
+
Format your response as:
|
| 163 |
+
|
| 164 |
+
THOUGHT: [Analyze what you have and what you still need]
|
| 165 |
+
ACTION: [CALL_CRYPTO | CALL_STOCK | CALL_RAG | CALL_SEARCH | FINISH]
|
| 166 |
+
JUSTIFICATION: [Why this action will help]"""
|
| 167 |
+
|
| 168 |
+
response = await self.supervisor_llm.ainvoke([
|
| 169 |
+
SystemMessage(content="You are a ReAct supervisor. Think step by step and avoid redundant actions."),
|
| 170 |
+
HumanMessage(content=think_prompt)
|
| 171 |
+
])
|
| 172 |
+
|
| 173 |
+
# Parse the response
|
| 174 |
+
content = response.content
|
| 175 |
+
thought = ""
|
| 176 |
+
action = "finish"
|
| 177 |
+
justification = ""
|
| 178 |
+
|
| 179 |
+
if "THOUGHT:" in content:
|
| 180 |
+
thought = content.split("THOUGHT:")[1].split("ACTION:")[0].strip()
|
| 181 |
+
|
| 182 |
+
if "ACTION:" in content:
|
| 183 |
+
action_text = content.split("ACTION:")[1].split("\n")[0].strip().upper()
|
| 184 |
+
if "CRYPTO" in action_text:
|
| 185 |
+
action = "crypto"
|
| 186 |
+
elif "FINANCE_TRACKER" in action_text or "FINANCE" in action_text:
|
| 187 |
+
action = "finance_tracker"
|
| 188 |
+
elif "STOCK" in action_text:
|
| 189 |
+
action = "stock"
|
| 190 |
+
elif "RAG" in action_text:
|
| 191 |
+
action = "rag"
|
| 192 |
+
elif "SEARCH" in action_text:
|
| 193 |
+
action = "search"
|
| 194 |
+
else:
|
| 195 |
+
action = "finish"
|
| 196 |
+
|
| 197 |
+
if "JUSTIFICATION:" in content:
|
| 198 |
+
justification = content.split("JUSTIFICATION:")[1].strip()
|
| 199 |
+
|
| 200 |
+
# Add reasoning step
|
| 201 |
+
reasoning_steps = state.get("reasoning_steps", [])
|
| 202 |
+
reasoning_steps.append(f"Step {current_step}: {thought} -> Action: {action}")
|
| 203 |
+
|
| 204 |
+
print(f"\nThinking (Step {current_step}):")
|
| 205 |
+
print(f" Thought: {thought}")
|
| 206 |
+
print(f" Action: {action}")
|
| 207 |
+
print(f" Justification: {justification}")
|
| 208 |
+
|
| 209 |
+
# Emit streaming update
|
| 210 |
+
await self._emit_update({
|
| 211 |
+
"type": "thinking",
|
| 212 |
+
"step": current_step,
|
| 213 |
+
"thought": thought,
|
| 214 |
+
"action": action,
|
| 215 |
+
"justification": justification
|
| 216 |
+
})
|
| 217 |
+
|
| 218 |
+
return {
|
| 219 |
+
"current_step": current_step,
|
| 220 |
+
"reasoning_steps": reasoning_steps,
|
| 221 |
+
"messages": [AIMessage(content=f"Thought: {thought}\nAction: {action}")],
|
| 222 |
+
"next_action": action
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
async def act_crypto_node(self, state: AgentState) -> Dict[str, Any]:
|
| 226 |
+
"""Execute crypto agent and return raw output."""
|
| 227 |
+
if not self.crypto_agent:
|
| 228 |
+
return {"agent_outputs": {"crypto_error": "Crypto agent not available"}}
|
| 229 |
+
|
| 230 |
+
print(" Calling Crypto Agent...")
|
| 231 |
+
await self._emit_update({"type": "action", "agent": "crypto"})
|
| 232 |
+
|
| 233 |
+
result = await self.crypto_agent.process(
|
| 234 |
+
state["query"],
|
| 235 |
+
history=self._extract_history(state["messages"])
|
| 236 |
+
)
|
| 237 |
+
|
| 238 |
+
agent_outputs = state.get("agent_outputs", {})
|
| 239 |
+
agent_outputs["crypto"] = result
|
| 240 |
+
|
| 241 |
+
return {"agent_outputs": agent_outputs}
|
| 242 |
+
|
| 243 |
+
async def act_rag_node(self, state: AgentState) -> Dict[str, Any]:
|
| 244 |
+
"""Execute RAG agent and return raw output."""
|
| 245 |
+
if not self.rag_agent:
|
| 246 |
+
return {"agent_outputs": {"rag_error": "RAG agent not available"}}
|
| 247 |
+
|
| 248 |
+
print(" Calling RAG Agent...")
|
| 249 |
+
await self._emit_update({"type": "action", "agent": "rag"})
|
| 250 |
+
|
| 251 |
+
result = await self.rag_agent.process(
|
| 252 |
+
state["query"],
|
| 253 |
+
history=self._extract_history(state["messages"])
|
| 254 |
+
)
|
| 255 |
+
|
| 256 |
+
agent_outputs = state.get("agent_outputs", {})
|
| 257 |
+
agent_outputs["rag"] = result
|
| 258 |
+
|
| 259 |
+
return {"agent_outputs": agent_outputs}
|
| 260 |
+
|
| 261 |
+
async def act_stock_node(self, state: AgentState) -> Dict[str, Any]:
|
| 262 |
+
"""Execute stock agent and return raw output."""
|
| 263 |
+
if not self.stock_agent:
|
| 264 |
+
return {"agent_outputs": {"stock_error": "Stock agent not available"}}
|
| 265 |
+
|
| 266 |
+
print(" Calling Stock Agent...")
|
| 267 |
+
await self._emit_update({"type": "action", "agent": "stock"})
|
| 268 |
+
|
| 269 |
+
result = await self.stock_agent.process(
|
| 270 |
+
state["query"],
|
| 271 |
+
history=self._extract_history(state["messages"])
|
| 272 |
+
)
|
| 273 |
+
|
| 274 |
+
agent_outputs = state.get("agent_outputs", {})
|
| 275 |
+
agent_outputs["stock"] = result
|
| 276 |
+
|
| 277 |
+
return {"agent_outputs": agent_outputs}
|
| 278 |
+
|
| 279 |
+
async def act_search_node(self, state: AgentState) -> Dict[str, Any]:
|
| 280 |
+
"""Execute web search agent and return raw output."""
|
| 281 |
+
if not self.search_agent:
|
| 282 |
+
return {"agent_outputs": {"search_error": "Search agent not available"}}
|
| 283 |
+
|
| 284 |
+
print(" Calling Web Search Agent...")
|
| 285 |
+
await self._emit_update({"type": "action", "agent": "search"})
|
| 286 |
+
|
| 287 |
+
result = await self.search_agent.process(
|
| 288 |
+
state["query"],
|
| 289 |
+
history=self._extract_history(state["messages"])
|
| 290 |
+
)
|
| 291 |
+
|
| 292 |
+
agent_outputs = state.get("agent_outputs", {})
|
| 293 |
+
agent_outputs["search"] = result
|
| 294 |
+
|
| 295 |
+
return {"agent_outputs": agent_outputs}
|
| 296 |
+
|
| 297 |
+
async def act_finance_tracker_node(self, state: AgentState) -> Dict[str, Any]:
|
| 298 |
+
"""Execute finance tracker agent and return raw output."""
|
| 299 |
+
if not self.finance_tracker:
|
| 300 |
+
return {"agent_outputs": {"finance_tracker_error": "Finance Tracker agent not available"}}
|
| 301 |
+
|
| 302 |
+
print(" Calling Finance Tracker Agent...")
|
| 303 |
+
await self._emit_update({"type": "action", "agent": "finance_tracker"})
|
| 304 |
+
|
| 305 |
+
result = await self.finance_tracker.process(
|
| 306 |
+
state["query"],
|
| 307 |
+
history=self._extract_history(state["messages"])
|
| 308 |
+
)
|
| 309 |
+
|
| 310 |
+
agent_outputs = state.get("agent_outputs", {})
|
| 311 |
+
agent_outputs["finance_tracker"] = result
|
| 312 |
+
|
| 313 |
+
return {"agent_outputs": agent_outputs}
|
| 314 |
+
|
| 315 |
+
async def observe_node(self, state: AgentState) -> Dict[str, Any]:
|
| 316 |
+
"""Process and observe the latest agent output."""
|
| 317 |
+
agent_outputs = state.get("agent_outputs", {})
|
| 318 |
+
|
| 319 |
+
latest_output = "No new observations"
|
| 320 |
+
latest_agent = "unknown"
|
| 321 |
+
search_urls = None
|
| 322 |
+
tool_calls = None
|
| 323 |
+
|
| 324 |
+
if agent_outputs:
|
| 325 |
+
for agent_name, output in list(agent_outputs.items())[-1:]:
|
| 326 |
+
if isinstance(output, dict) and output.get("success"):
|
| 327 |
+
response = output.get('response', 'No response')
|
| 328 |
+
latest_output = response
|
| 329 |
+
latest_agent = agent_name
|
| 330 |
+
# Extract search URLs if available (from search agent)
|
| 331 |
+
if "search_urls" in output:
|
| 332 |
+
search_urls = output["search_urls"]
|
| 333 |
+
# Extract tool calls if available (from MCP agents)
|
| 334 |
+
if "tool_calls" in output:
|
| 335 |
+
tool_calls = output["tool_calls"]
|
| 336 |
+
break
|
| 337 |
+
|
| 338 |
+
# Prepend tool calls to the summary if available
|
| 339 |
+
summary = latest_output
|
| 340 |
+
if tool_calls:
|
| 341 |
+
tool_calls_text = "\n".join(tool_calls)
|
| 342 |
+
summary = f"{tool_calls_text}\n Observation from {latest_agent}: {latest_output}"
|
| 343 |
+
|
| 344 |
+
# Apply length limit
|
| 345 |
+
summary = summary[:2000] + "..." if len(summary) > 2000 else summary
|
| 346 |
+
print(f" Observation from {latest_agent}: {summary}")
|
| 347 |
+
|
| 348 |
+
# Emit streaming update with search URLs if available
|
| 349 |
+
update_data = {
|
| 350 |
+
"type": "observation",
|
| 351 |
+
"agent": latest_agent,
|
| 352 |
+
"summary": summary
|
| 353 |
+
}
|
| 354 |
+
if search_urls:
|
| 355 |
+
update_data["search_urls"] = search_urls
|
| 356 |
+
|
| 357 |
+
await self._emit_update(update_data)
|
| 358 |
+
|
| 359 |
+
return {
|
| 360 |
+
"messages": [AIMessage(content=f"Observation from {latest_agent}:\n{latest_output}")]
|
| 361 |
+
}
|
| 362 |
+
|
| 363 |
+
async def finish_node(self, state: AgentState) -> Dict[str, Any]:
|
| 364 |
+
"""Synthesize all agent outputs and generate final answer with streaming."""
|
| 365 |
+
agent_outputs = state.get("agent_outputs", {})
|
| 366 |
+
reasoning_steps = state.get("reasoning_steps", [])
|
| 367 |
+
|
| 368 |
+
print("\nSupervisor Synthesizing Final Answer...")
|
| 369 |
+
|
| 370 |
+
# Build synthesis prompt with conversation history
|
| 371 |
+
synthesis_prompt = f"""You are synthesizing information to answer this query: {state['query']}
|
| 372 |
+
|
| 373 |
+
"""
|
| 374 |
+
|
| 375 |
+
# Include conversation history for context
|
| 376 |
+
messages = state.get("messages", [])
|
| 377 |
+
if messages:
|
| 378 |
+
synthesis_prompt += "CONVERSATION HISTORY:\n"
|
| 379 |
+
for msg in messages:
|
| 380 |
+
if isinstance(msg, HumanMessage):
|
| 381 |
+
synthesis_prompt += f"User: {msg.content}\n"
|
| 382 |
+
elif isinstance(msg, AIMessage):
|
| 383 |
+
synthesis_prompt += f"Assistant: {msg.content}\n"
|
| 384 |
+
synthesis_prompt += "\n"
|
| 385 |
+
|
| 386 |
+
synthesis_prompt += f"""Your reasoning process:
|
| 387 |
+
{chr(10).join(reasoning_steps)}
|
| 388 |
+
|
| 389 |
+
Information gathered from agents:"""
|
| 390 |
+
|
| 391 |
+
for agent_name, output in agent_outputs.items():
|
| 392 |
+
if isinstance(output, dict) and output.get("success"):
|
| 393 |
+
synthesis_prompt += f"\n\n{agent_name.upper()} Agent Response:\n{output.get('response', 'No response')}"
|
| 394 |
+
|
| 395 |
+
synthesis_prompt += """
|
| 396 |
+
|
| 397 |
+
Now provide a comprehensive, well-structured answer that:
|
| 398 |
+
1. Directly addresses the user's query (considering the conversation history if present)
|
| 399 |
+
2. Integrates insights from all relevant agent outputs
|
| 400 |
+
3. Is clear and actionable
|
| 401 |
+
4. Highlights any important findings or recommendations
|
| 402 |
+
5. Cites sources when appropriate
|
| 403 |
+
|
| 404 |
+
Final Answer:"""
|
| 405 |
+
|
| 406 |
+
# Emit start of final answer
|
| 407 |
+
await self._emit_update({
|
| 408 |
+
"type": "final_start"
|
| 409 |
+
})
|
| 410 |
+
|
| 411 |
+
# Stream the final answer token by token
|
| 412 |
+
# Stream the final answer token by token
|
| 413 |
+
final_answer = ""
|
| 414 |
+
async for chunk in self.supervisor_llm.astream([
|
| 415 |
+
SystemMessage(content="You are providing the final, synthesized answer."),
|
| 416 |
+
HumanMessage(content=synthesis_prompt)
|
| 417 |
+
]):
|
| 418 |
+
if hasattr(chunk, 'content') and chunk.content:
|
| 419 |
+
# Clean unicode artifacts from Gemini streaming
|
| 420 |
+
clean_content = chunk.content.replace('β', '*')
|
| 421 |
+
final_answer += clean_content
|
| 422 |
+
# Emit each token/chunk as it arrives
|
| 423 |
+
await self._emit_update({
|
| 424 |
+
"type": "final_token",
|
| 425 |
+
"token": clean_content,
|
| 426 |
+
"accumulated": final_answer
|
| 427 |
+
})
|
| 428 |
+
|
| 429 |
+
# Emit completion of final answer
|
| 430 |
+
await self._emit_update({
|
| 431 |
+
"type": "final_complete",
|
| 432 |
+
"response": final_answer
|
| 433 |
+
})
|
| 434 |
+
|
| 435 |
+
return {
|
| 436 |
+
"final_answer": final_answer,
|
| 437 |
+
"messages": [AIMessage(content=final_answer)]
|
| 438 |
+
}
|
| 439 |
+
|
| 440 |
+
def route_from_thinking(self, state: AgentState) -> str:
|
| 441 |
+
"""Route based on thinking decision."""
|
| 442 |
+
last_message = state["messages"][-1] if state["messages"] else None
|
| 443 |
+
|
| 444 |
+
if last_message and "Action:" in last_message.content:
|
| 445 |
+
try:
|
| 446 |
+
action_line = last_message.content.split("Action:")[1].split("\n")[0].strip().upper()
|
| 447 |
+
|
| 448 |
+
if "CRYPTO" in action_line or "CALL_CRYPTO" in action_line:
|
| 449 |
+
return "crypto"
|
| 450 |
+
elif "FINANCE_TRACKER" in action_line or "CALL_FINANCE_TRACKER" in action_line or "FINANCE" in action_line:
|
| 451 |
+
return "finance_tracker"
|
| 452 |
+
elif "STOCK" in action_line or "CALL_STOCK" in action_line:
|
| 453 |
+
return "stock"
|
| 454 |
+
elif "RAG" in action_line or "CALL_RAG" in action_line:
|
| 455 |
+
return "rag"
|
| 456 |
+
elif "SEARCH" in action_line or "CALL_SEARCH" in action_line:
|
| 457 |
+
return "search"
|
| 458 |
+
elif "FINISH" in action_line:
|
| 459 |
+
return "finish"
|
| 460 |
+
except (IndexError, AttributeError):
|
| 461 |
+
pass
|
| 462 |
+
|
| 463 |
+
return "finish"
|
| 464 |
+
|
| 465 |
+
def should_continue(self, state: AgentState) -> str:
|
| 466 |
+
"""Decide whether to continue reasoning or finish."""
|
| 467 |
+
current_step = state.get("current_step", 0)
|
| 468 |
+
|
| 469 |
+
if current_step >= self.max_steps:
|
| 470 |
+
print(f" Max steps ({self.max_steps}) reached, finishing...")
|
| 471 |
+
return "finish"
|
| 472 |
+
|
| 473 |
+
return "continue"
|
| 474 |
+
|
| 475 |
+
def _build_context(self, state: AgentState) -> str:
|
| 476 |
+
"""Build context string from current state, including conversation history."""
|
| 477 |
+
context_parts = []
|
| 478 |
+
|
| 479 |
+
# Include conversation history for context
|
| 480 |
+
messages = state.get("messages", [])
|
| 481 |
+
if messages:
|
| 482 |
+
history_text = "=== CONVERSATION HISTORY ===\n"
|
| 483 |
+
for msg in messages:
|
| 484 |
+
if isinstance(msg, HumanMessage):
|
| 485 |
+
history_text += f"User: {msg.content}\n"
|
| 486 |
+
elif isinstance(msg, AIMessage):
|
| 487 |
+
history_text += f"Assistant: {msg.content}\n"
|
| 488 |
+
context_parts.append(history_text)
|
| 489 |
+
|
| 490 |
+
# Include agent outputs from current query
|
| 491 |
+
agent_outputs = state.get("agent_outputs", {})
|
| 492 |
+
if agent_outputs:
|
| 493 |
+
for agent_name, output in agent_outputs.items():
|
| 494 |
+
if isinstance(output, dict) and output.get("success"):
|
| 495 |
+
response = output.get("response", "No response")
|
| 496 |
+
# Increased limit to provide more context to LLM
|
| 497 |
+
if len(response) > 5000:
|
| 498 |
+
response = response[:5000] + f"... [Response continues for {len(response)} total chars]"
|
| 499 |
+
context_parts.append(f"=== {agent_name.upper()} Agent ===\n{response}")
|
| 500 |
+
|
| 501 |
+
return "\n\n".join(context_parts) if context_parts else "No information gathered yet."
|
| 502 |
+
|
| 503 |
+
def _extract_history(self, messages: Sequence[BaseMessage]) -> List[Dict[str, str]]:
|
| 504 |
+
"""Extract chat history from messages."""
|
| 505 |
+
history = []
|
| 506 |
+
for msg in messages:
|
| 507 |
+
if isinstance(msg, HumanMessage):
|
| 508 |
+
history.append({"user": msg.content})
|
| 509 |
+
elif isinstance(msg, AIMessage):
|
| 510 |
+
history.append({"assistant": msg.content})
|
| 511 |
+
return history[-10:]
|
| 512 |
+
|
| 513 |
+
async def process(self, query: str, history: Optional[List[Dict[str, str]]] = None) -> Dict[str, Any]:
|
| 514 |
+
"""Process query through ReAct supervisor pattern."""
|
| 515 |
+
initial_state: AgentState = {
|
| 516 |
+
"messages": [],
|
| 517 |
+
"query": query,
|
| 518 |
+
"agent_outputs": {},
|
| 519 |
+
"reasoning_steps": [],
|
| 520 |
+
"final_answer": None,
|
| 521 |
+
"current_step": 0,
|
| 522 |
+
"max_steps": self.max_steps
|
| 523 |
+
}
|
| 524 |
+
|
| 525 |
+
if history:
|
| 526 |
+
for turn in history[-3:]:
|
| 527 |
+
if "user" in turn:
|
| 528 |
+
initial_state["messages"].append(HumanMessage(content=turn["user"]))
|
| 529 |
+
if "assistant" in turn:
|
| 530 |
+
initial_state["messages"].append(AIMessage(content=turn["assistant"]))
|
| 531 |
+
|
| 532 |
+
try:
|
| 533 |
+
print(f"\nReAct Supervisor starting for query: '{query}'")
|
| 534 |
+
print(f" Max steps: {self.max_steps}")
|
| 535 |
+
|
| 536 |
+
final_state = await self.compiled_graph.ainvoke(initial_state)
|
| 537 |
+
|
| 538 |
+
return {
|
| 539 |
+
"success": True,
|
| 540 |
+
"response": final_state.get("final_answer", "No answer generated"),
|
| 541 |
+
"reasoning_steps": final_state.get("reasoning_steps", []),
|
| 542 |
+
"agent_outputs": final_state.get("agent_outputs", {}),
|
| 543 |
+
"steps_taken": final_state.get("current_step", 0)
|
| 544 |
+
}
|
| 545 |
+
|
| 546 |
+
except Exception as e:
|
| 547 |
+
print(f" ReAct Supervisor error: {e}")
|
| 548 |
+
return {
|
| 549 |
+
"success": False,
|
| 550 |
+
"error": str(e),
|
| 551 |
+
"response": f"Supervisor error: {str(e)}"
|
| 552 |
+
}
|
| 553 |
+
|
| 554 |
+
async def process_streaming(
|
| 555 |
+
self,
|
| 556 |
+
query: str,
|
| 557 |
+
history: Optional[List[Dict[str, str]]] = None
|
| 558 |
+
) -> AsyncGenerator[Dict[str, Any], None]:
|
| 559 |
+
"""
|
| 560 |
+
Process query with true async streaming using Queue.
|
| 561 |
+
|
| 562 |
+
Yields update dictionaries with types: thinking, action, observation, final, error
|
| 563 |
+
"""
|
| 564 |
+
updates_queue = Queue()
|
| 565 |
+
|
| 566 |
+
async def callback(update: Dict[str, Any]):
|
| 567 |
+
"""Non-blocking callback to queue updates."""
|
| 568 |
+
await updates_queue.put(update)
|
| 569 |
+
|
| 570 |
+
# Set streaming callback
|
| 571 |
+
self.streaming_callback = callback
|
| 572 |
+
|
| 573 |
+
# Start processing in background
|
| 574 |
+
result_task = asyncio.create_task(self.process(query, history))
|
| 575 |
+
|
| 576 |
+
# Stream updates efficiently without polling
|
| 577 |
+
try:
|
| 578 |
+
while not result_task.done():
|
| 579 |
+
try:
|
| 580 |
+
# Non-blocking wait with short timeout
|
| 581 |
+
update = await asyncio.wait_for(
|
| 582 |
+
updates_queue.get(),
|
| 583 |
+
timeout=0.01 # Very short timeout for responsiveness
|
| 584 |
+
)
|
| 585 |
+
yield update
|
| 586 |
+
except asyncio.TimeoutError:
|
| 587 |
+
# No update available yet, continue loop
|
| 588 |
+
continue
|
| 589 |
+
except Exception as e:
|
| 590 |
+
print(f"Warning: Error getting update from queue: {e}")
|
| 591 |
+
continue
|
| 592 |
+
|
| 593 |
+
# Drain any remaining updates from the queue
|
| 594 |
+
while not updates_queue.empty():
|
| 595 |
+
try:
|
| 596 |
+
update = await updates_queue.get()
|
| 597 |
+
yield update
|
| 598 |
+
except Exception as e:
|
| 599 |
+
print(f"Warning: Error draining queue: {e}")
|
| 600 |
+
break
|
| 601 |
+
|
| 602 |
+
# Handle final result
|
| 603 |
+
result = await result_task
|
| 604 |
+
if not result.get("success"):
|
| 605 |
+
yield {
|
| 606 |
+
"type": "error",
|
| 607 |
+
"error": result.get("error", "Unknown error")
|
| 608 |
+
}
|
| 609 |
+
|
| 610 |
+
except Exception as e:
|
| 611 |
+
yield {
|
| 612 |
+
"type": "error",
|
| 613 |
+
"error": str(e)
|
| 614 |
+
}
|
| 615 |
+
finally:
|
| 616 |
+
self.streaming_callback = None
|
src/core/mcp_client.py
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Proper MCP client using official Python SDK for MCP servers."""
|
| 2 |
+
import asyncio
|
| 3 |
+
import os
|
| 4 |
+
import sys
|
| 5 |
+
from copy import deepcopy
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from typing import Dict, Any, List, Optional
|
| 8 |
+
from mcp import ClientSession, StdioServerParameters
|
| 9 |
+
from mcp.client.stdio import stdio_client
|
| 10 |
+
from src.core.config import config
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class ProperMCPClient:
|
| 14 |
+
"""Base class for connecting to MCP servers using official SDK."""
|
| 15 |
+
|
| 16 |
+
def __init__(self, name: str, server_command: str, server_args: List[str], env: Optional[Dict[str, str]] = None):
|
| 17 |
+
"""
|
| 18 |
+
Initialize MCP client.
|
| 19 |
+
|
| 20 |
+
Args:
|
| 21 |
+
name: Name of the MCP server
|
| 22 |
+
server_command: Command to start the MCP server (e.g., 'npx', 'uvx')
|
| 23 |
+
server_args: Arguments for the server command
|
| 24 |
+
env: Optional environment variables
|
| 25 |
+
"""
|
| 26 |
+
self.name = name
|
| 27 |
+
self.server_command = server_command
|
| 28 |
+
self.server_args = server_args
|
| 29 |
+
self.env = env or {}
|
| 30 |
+
self.session: Optional[ClientSession] = None
|
| 31 |
+
self.exit_stack = None
|
| 32 |
+
self.available_tools: List[Dict[str, Any]] = []
|
| 33 |
+
|
| 34 |
+
async def connect(self) -> bool:
|
| 35 |
+
"""
|
| 36 |
+
Connect to the MCP server using stdio transport.
|
| 37 |
+
|
| 38 |
+
Returns:
|
| 39 |
+
True if connection successful
|
| 40 |
+
"""
|
| 41 |
+
try:
|
| 42 |
+
# Prepare environment for subprocess (ensure venv scripts on PATH)
|
| 43 |
+
env_vars = os.environ.copy()
|
| 44 |
+
if sys.prefix:
|
| 45 |
+
scripts_dir = Path(sys.prefix) / ("Scripts" if os.name == "nt" else "bin")
|
| 46 |
+
if scripts_dir.exists():
|
| 47 |
+
current_path = env_vars.get("PATH", "")
|
| 48 |
+
env_vars["PATH"] = f"{scripts_dir}{os.pathsep}{current_path}" if current_path else str(scripts_dir)
|
| 49 |
+
|
| 50 |
+
if self.env:
|
| 51 |
+
env_vars.update(self.env)
|
| 52 |
+
|
| 53 |
+
# Create server parameters
|
| 54 |
+
server_params = StdioServerParameters(
|
| 55 |
+
command=self.server_command,
|
| 56 |
+
args=self.server_args,
|
| 57 |
+
env=env_vars
|
| 58 |
+
)
|
| 59 |
+
|
| 60 |
+
# Create stdio client and session
|
| 61 |
+
from contextlib import AsyncExitStack
|
| 62 |
+
self.exit_stack = AsyncExitStack()
|
| 63 |
+
|
| 64 |
+
# Connect to server via stdio
|
| 65 |
+
stdio_transport = await self.exit_stack.enter_async_context(
|
| 66 |
+
stdio_client(server_params)
|
| 67 |
+
)
|
| 68 |
+
|
| 69 |
+
read, write = stdio_transport
|
| 70 |
+
self.session = await self.exit_stack.enter_async_context(
|
| 71 |
+
ClientSession(read, write)
|
| 72 |
+
)
|
| 73 |
+
|
| 74 |
+
# Initialize the session
|
| 75 |
+
await self.session.initialize()
|
| 76 |
+
|
| 77 |
+
# List available tools
|
| 78 |
+
tools_result = await self.session.list_tools()
|
| 79 |
+
self.available_tools = tools_result.tools if hasattr(tools_result, 'tools') else []
|
| 80 |
+
|
| 81 |
+
print(f" β
Connected to {self.name} MCP Server")
|
| 82 |
+
print(f" π Available tools: {len(self.available_tools)}")
|
| 83 |
+
|
| 84 |
+
# Print tool names for debugging
|
| 85 |
+
for tool in self.available_tools:
|
| 86 |
+
tool_name = tool.name if hasattr(tool, 'name') else str(tool)
|
| 87 |
+
print(f" - {tool_name}")
|
| 88 |
+
|
| 89 |
+
return True
|
| 90 |
+
|
| 91 |
+
except Exception as e:
|
| 92 |
+
print(f" β Failed to connect to {self.name}: {e}")
|
| 93 |
+
import traceback
|
| 94 |
+
traceback.print_exc()
|
| 95 |
+
return False
|
| 96 |
+
|
| 97 |
+
async def call_tool(self, tool_name: str, arguments: Dict[str, Any]) -> Dict[str, Any]:
|
| 98 |
+
"""
|
| 99 |
+
Call a tool on the MCP server.
|
| 100 |
+
|
| 101 |
+
Args:
|
| 102 |
+
tool_name: Name of the tool to call
|
| 103 |
+
arguments: Arguments for the tool
|
| 104 |
+
|
| 105 |
+
Returns:
|
| 106 |
+
Tool execution result
|
| 107 |
+
"""
|
| 108 |
+
if not self.session:
|
| 109 |
+
raise RuntimeError(f"MCP client not connected. Call connect() first.")
|
| 110 |
+
|
| 111 |
+
try:
|
| 112 |
+
# Call the tool using the session
|
| 113 |
+
result = await self.session.call_tool(tool_name, arguments)
|
| 114 |
+
|
| 115 |
+
# Parse result
|
| 116 |
+
if hasattr(result, 'content'):
|
| 117 |
+
# Extract content from MCP response
|
| 118 |
+
content = []
|
| 119 |
+
for item in result.content:
|
| 120 |
+
if hasattr(item, 'text'):
|
| 121 |
+
content.append(item.text)
|
| 122 |
+
elif hasattr(item, 'data'):
|
| 123 |
+
content.append(item.data)
|
| 124 |
+
|
| 125 |
+
return {
|
| 126 |
+
"success": True,
|
| 127 |
+
"result": content[0] if len(content) == 1 else content
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
return {
|
| 131 |
+
"success": True,
|
| 132 |
+
"result": str(result)
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
except Exception as e:
|
| 136 |
+
return {
|
| 137 |
+
"success": False,
|
| 138 |
+
"error": f"Tool execution failed: {str(e)}"
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
def get_tools_for_gemini(self) -> List[Dict[str, Any]]:
|
| 142 |
+
"""
|
| 143 |
+
Convert MCP tools to Gemini function declarations format.
|
| 144 |
+
|
| 145 |
+
Returns:
|
| 146 |
+
List of function declarations for Gemini
|
| 147 |
+
"""
|
| 148 |
+
def sanitize_schema(node: Any) -> None:
|
| 149 |
+
if isinstance(node, dict):
|
| 150 |
+
node.pop("title", None)
|
| 151 |
+
any_of = node.pop("anyOf", None)
|
| 152 |
+
if any_of:
|
| 153 |
+
replacement = any_of[0] if isinstance(any_of, list) and any_of else None
|
| 154 |
+
if isinstance(replacement, dict):
|
| 155 |
+
# merge first option into current node
|
| 156 |
+
for key, value in replacement.items():
|
| 157 |
+
node.setdefault(key, deepcopy(value))
|
| 158 |
+
sanitize_schema(replacement)
|
| 159 |
+
|
| 160 |
+
for key, value in list(node.items()):
|
| 161 |
+
sanitize_schema(value)
|
| 162 |
+
elif isinstance(node, list):
|
| 163 |
+
for item in node:
|
| 164 |
+
sanitize_schema(item)
|
| 165 |
+
|
| 166 |
+
function_declarations = []
|
| 167 |
+
|
| 168 |
+
for tool in self.available_tools:
|
| 169 |
+
# Extract tool information
|
| 170 |
+
tool_name = tool.name if hasattr(tool, 'name') else str(tool)
|
| 171 |
+
tool_description = tool.description if hasattr(tool, 'description') else f"MCP tool: {tool_name}"
|
| 172 |
+
|
| 173 |
+
# Extract input schema
|
| 174 |
+
tool_schema = tool.inputSchema if hasattr(tool, 'inputSchema') else {}
|
| 175 |
+
|
| 176 |
+
if tool_schema:
|
| 177 |
+
if hasattr(tool_schema, "to_dict"):
|
| 178 |
+
parameters = tool_schema.to_dict()
|
| 179 |
+
elif isinstance(tool_schema, dict):
|
| 180 |
+
parameters = deepcopy(tool_schema)
|
| 181 |
+
else:
|
| 182 |
+
try:
|
| 183 |
+
parameters = deepcopy(tool_schema)
|
| 184 |
+
except Exception:
|
| 185 |
+
parameters = tool_schema
|
| 186 |
+
else:
|
| 187 |
+
parameters = {
|
| 188 |
+
"type": "object",
|
| 189 |
+
"properties": {}
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
sanitize_schema(parameters)
|
| 193 |
+
|
| 194 |
+
# Convert to Gemini format
|
| 195 |
+
function_decl = {
|
| 196 |
+
"name": tool_name,
|
| 197 |
+
"description": tool_description,
|
| 198 |
+
"parameters": parameters
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
function_declarations.append(function_decl)
|
| 202 |
+
|
| 203 |
+
return [{"function_declarations": function_declarations}]
|
| 204 |
+
|
| 205 |
+
async def cleanup(self) -> None:
|
| 206 |
+
"""Close the MCP session."""
|
| 207 |
+
if self.exit_stack:
|
| 208 |
+
await self.exit_stack.aclose()
|
| 209 |
+
print(f" π§Ή {self.name} MCP client closed")
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
class ChromaMCPClient(ProperMCPClient):
|
| 213 |
+
"""MCP client for Chroma MCP Server."""
|
| 214 |
+
|
| 215 |
+
def __init__(self):
|
| 216 |
+
"""Initialize Chroma MCP client."""
|
| 217 |
+
# Prepare environment variables for Chroma Cloud
|
| 218 |
+
env = {
|
| 219 |
+
"CHROMA_CLIENT_TYPE": "cloud",
|
| 220 |
+
"CHROMA_TENANT": config.CHROMA_TENANT,
|
| 221 |
+
"CHROMA_DATABASE": config.CHROMA_DATABASE,
|
| 222 |
+
"CHROMA_API_KEY": config.CHROMA_API_KEY,
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
# Initialize with uvx command to run chroma-mcp
|
| 226 |
+
super().__init__(
|
| 227 |
+
name="Chroma",
|
| 228 |
+
server_command="uvx",
|
| 229 |
+
server_args=[
|
| 230 |
+
"chroma-mcp",
|
| 231 |
+
"--client-type", "cloud"
|
| 232 |
+
],
|
| 233 |
+
env=env
|
| 234 |
+
)
|
| 235 |
+
|
| 236 |
+
|
| 237 |
+
class CoinGeckoMCPClient(ProperMCPClient):
|
| 238 |
+
"""MCP client for CoinGecko MCP Server."""
|
| 239 |
+
|
| 240 |
+
def __init__(self):
|
| 241 |
+
"""Initialize CoinGecko MCP client."""
|
| 242 |
+
# Prepare environment variables
|
| 243 |
+
env = {}
|
| 244 |
+
if config.COINGECKO_API_KEY:
|
| 245 |
+
env["COINGECKO_PRO_API_KEY"] = config.COINGECKO_API_KEY
|
| 246 |
+
env["COINGECKO_ENVIRONMENT"] = "pro"
|
| 247 |
+
|
| 248 |
+
# Initialize with npx command to run CoinGecko MCP
|
| 249 |
+
super().__init__(
|
| 250 |
+
name="CoinGecko",
|
| 251 |
+
server_command="npx",
|
| 252 |
+
server_args=[
|
| 253 |
+
"-y",
|
| 254 |
+
"@coingecko/coingecko-mcp"
|
| 255 |
+
],
|
| 256 |
+
env=env if env else None
|
| 257 |
+
)
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
# Alternative: Use public CoinGecko MCP endpoint
|
| 261 |
+
class CoinGeckoPublicMCPClient(ProperMCPClient):
|
| 262 |
+
"""MCP client for CoinGecko Public MCP Server."""
|
| 263 |
+
|
| 264 |
+
def __init__(self):
|
| 265 |
+
"""Initialize CoinGecko Public MCP client."""
|
| 266 |
+
# Use the public endpoint via mcp-remote
|
| 267 |
+
super().__init__(
|
| 268 |
+
name="CoinGecko Public",
|
| 269 |
+
server_command="npx",
|
| 270 |
+
server_args=[
|
| 271 |
+
"mcp-remote",
|
| 272 |
+
"https://mcp.api.coingecko.com/sse"
|
| 273 |
+
],
|
| 274 |
+
env=None
|
| 275 |
+
)
|
| 276 |
+
|
| 277 |
+
# Have to provide limits to the DuckDuckGo MCP server process to avoid excessive resource usage
|
| 278 |
+
class DuckDuckGoMCPClient(ProperMCPClient):
|
| 279 |
+
"""MCP client for DuckDuckGo MCP Server."""
|
| 280 |
+
|
| 281 |
+
def __init__(self):
|
| 282 |
+
"""Initialize DuckDuckGo MCP client."""
|
| 283 |
+
super().__init__(
|
| 284 |
+
name="DuckDuckGo",
|
| 285 |
+
server_command="uvx",
|
| 286 |
+
server_args=[
|
| 287 |
+
"duckduckgo-mcp-server"
|
| 288 |
+
],
|
| 289 |
+
env=None
|
| 290 |
+
)
|
src/utils/__init__.py
ADDED
|
File without changes
|
src/utils/file_processors.py
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""File processing utilities for different document types."""
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
from typing import List, Dict, Any, Optional
|
| 4 |
+
import PyPDF2
|
| 5 |
+
from docx import Document
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class FileProcessor:
|
| 9 |
+
"""Base class for file processing."""
|
| 10 |
+
|
| 11 |
+
@staticmethod
|
| 12 |
+
def get_processor(file_path: Path):
|
| 13 |
+
"""Get appropriate processor based on file extension."""
|
| 14 |
+
extension = file_path.suffix.lower()
|
| 15 |
+
|
| 16 |
+
if extension == '.pdf':
|
| 17 |
+
return PDFProcessor()
|
| 18 |
+
elif extension == '.txt':
|
| 19 |
+
return TXTProcessor()
|
| 20 |
+
elif extension == '.docx':
|
| 21 |
+
return DOCXProcessor()
|
| 22 |
+
else:
|
| 23 |
+
raise ValueError(f"Unsupported file type: {extension}")
|
| 24 |
+
|
| 25 |
+
def extract_text(self, file_path: Path) -> str:
|
| 26 |
+
"""Extract text from file. Override in subclasses."""
|
| 27 |
+
raise NotImplementedError
|
| 28 |
+
|
| 29 |
+
def chunk_text(self, text: str, chunk_size: int = 500, overlap: int = 50) -> List[str]:
|
| 30 |
+
"""
|
| 31 |
+
Split text into overlapping chunks.
|
| 32 |
+
|
| 33 |
+
Args:
|
| 34 |
+
text: Text to chunk
|
| 35 |
+
chunk_size: Target size of each chunk in characters
|
| 36 |
+
overlap: Number of characters to overlap between chunks
|
| 37 |
+
|
| 38 |
+
Returns:
|
| 39 |
+
List of text chunks
|
| 40 |
+
"""
|
| 41 |
+
if not text.strip():
|
| 42 |
+
return []
|
| 43 |
+
|
| 44 |
+
chunks = []
|
| 45 |
+
words = text.split()
|
| 46 |
+
|
| 47 |
+
current_chunk = []
|
| 48 |
+
current_length = 0
|
| 49 |
+
|
| 50 |
+
for word in words:
|
| 51 |
+
word_length = len(word) + 1 # +1 for space
|
| 52 |
+
|
| 53 |
+
if current_length + word_length > chunk_size and current_chunk:
|
| 54 |
+
# Save current chunk
|
| 55 |
+
chunk_text = " ".join(current_chunk)
|
| 56 |
+
chunks.append(chunk_text)
|
| 57 |
+
|
| 58 |
+
# Start new chunk with overlap
|
| 59 |
+
# Keep last few words for context
|
| 60 |
+
overlap_words = []
|
| 61 |
+
overlap_length = 0
|
| 62 |
+
for w in reversed(current_chunk):
|
| 63 |
+
if overlap_length + len(w) + 1 <= overlap:
|
| 64 |
+
overlap_words.insert(0, w)
|
| 65 |
+
overlap_length += len(w) + 1
|
| 66 |
+
else:
|
| 67 |
+
break
|
| 68 |
+
|
| 69 |
+
current_chunk = overlap_words
|
| 70 |
+
current_length = overlap_length
|
| 71 |
+
|
| 72 |
+
current_chunk.append(word)
|
| 73 |
+
current_length += word_length
|
| 74 |
+
|
| 75 |
+
# Add remaining words
|
| 76 |
+
if current_chunk:
|
| 77 |
+
chunks.append(" ".join(current_chunk))
|
| 78 |
+
|
| 79 |
+
return chunks
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
class PDFProcessor(FileProcessor):
|
| 83 |
+
"""Processor for PDF files."""
|
| 84 |
+
|
| 85 |
+
def extract_text(self, file_path: Path) -> str:
|
| 86 |
+
"""Extract text from PDF file."""
|
| 87 |
+
text = []
|
| 88 |
+
|
| 89 |
+
try:
|
| 90 |
+
with open(file_path, 'rb') as file:
|
| 91 |
+
pdf_reader = PyPDF2.PdfReader(file)
|
| 92 |
+
|
| 93 |
+
for page_num, page in enumerate(pdf_reader.pages):
|
| 94 |
+
page_text = page.extract_text()
|
| 95 |
+
if page_text.strip():
|
| 96 |
+
text.append(page_text)
|
| 97 |
+
|
| 98 |
+
return "\n\n".join(text)
|
| 99 |
+
|
| 100 |
+
except Exception as e:
|
| 101 |
+
raise ValueError(f"Error extracting PDF text: {e}")
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
class TXTProcessor(FileProcessor):
|
| 105 |
+
"""Processor for plain text files."""
|
| 106 |
+
|
| 107 |
+
def extract_text(self, file_path: Path) -> str:
|
| 108 |
+
"""Extract text from TXT file."""
|
| 109 |
+
try:
|
| 110 |
+
# Try different encodings
|
| 111 |
+
encodings = ['utf-8', 'utf-16', 'latin-1', 'cp1252']
|
| 112 |
+
|
| 113 |
+
for encoding in encodings:
|
| 114 |
+
try:
|
| 115 |
+
with open(file_path, 'r', encoding=encoding) as file:
|
| 116 |
+
return file.read()
|
| 117 |
+
except UnicodeDecodeError:
|
| 118 |
+
continue
|
| 119 |
+
|
| 120 |
+
raise ValueError("Could not decode text file with any supported encoding")
|
| 121 |
+
|
| 122 |
+
except Exception as e:
|
| 123 |
+
raise ValueError(f"Error reading TXT file: {e}")
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
class DOCXProcessor(FileProcessor):
|
| 127 |
+
"""Processor for DOCX files."""
|
| 128 |
+
|
| 129 |
+
def extract_text(self, file_path: Path) -> str:
|
| 130 |
+
"""Extract text from DOCX file."""
|
| 131 |
+
try:
|
| 132 |
+
doc = Document(file_path)
|
| 133 |
+
|
| 134 |
+
# Extract text from paragraphs
|
| 135 |
+
paragraphs = [para.text for para in doc.paragraphs if para.text.strip()]
|
| 136 |
+
|
| 137 |
+
# Extract text from tables
|
| 138 |
+
table_text = []
|
| 139 |
+
for table in doc.tables:
|
| 140 |
+
for row in table.rows:
|
| 141 |
+
row_text = [cell.text.strip() for cell in row.cells if cell.text.strip()]
|
| 142 |
+
if row_text:
|
| 143 |
+
table_text.append(" | ".join(row_text))
|
| 144 |
+
|
| 145 |
+
# Combine all text
|
| 146 |
+
all_text = paragraphs + table_text
|
| 147 |
+
return "\n\n".join(all_text)
|
| 148 |
+
|
| 149 |
+
except Exception as e:
|
| 150 |
+
raise ValueError(f"Error extracting DOCX text: {e}")
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def process_document(
|
| 154 |
+
file_path: Path,
|
| 155 |
+
chunk_size: int = 500,
|
| 156 |
+
overlap: int = 50
|
| 157 |
+
) -> Dict[str, Any]:
|
| 158 |
+
"""
|
| 159 |
+
Process a document file and return chunks with metadata.
|
| 160 |
+
|
| 161 |
+
Args:
|
| 162 |
+
file_path: Path to the document file
|
| 163 |
+
chunk_size: Target size of each chunk
|
| 164 |
+
overlap: Overlap between chunks
|
| 165 |
+
|
| 166 |
+
Returns:
|
| 167 |
+
Dictionary with chunks and metadata
|
| 168 |
+
"""
|
| 169 |
+
processor = FileProcessor.get_processor(file_path)
|
| 170 |
+
|
| 171 |
+
# Extract text
|
| 172 |
+
text = processor.extract_text(file_path)
|
| 173 |
+
|
| 174 |
+
if not text.strip():
|
| 175 |
+
raise ValueError("No text content found in document")
|
| 176 |
+
|
| 177 |
+
# Chunk text
|
| 178 |
+
chunks = processor.chunk_text(text, chunk_size, overlap)
|
| 179 |
+
|
| 180 |
+
return {
|
| 181 |
+
"filename": file_path.name,
|
| 182 |
+
"file_type": file_path.suffix.lower(),
|
| 183 |
+
"file_size": file_path.stat().st_size,
|
| 184 |
+
"text_length": len(text),
|
| 185 |
+
"chunks": chunks,
|
| 186 |
+
"num_chunks": len(chunks)
|
| 187 |
+
}
|
tests/__init__.py
ADDED
|
File without changes
|
tests/test_unit_cases.py
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Test suite for FastAPI Multi-Agent Assistant API.
|
| 3 |
+
|
| 4 |
+
This module tests the core API endpoints including health checks,
|
| 5 |
+
root endpoint, and system initialization.
|
| 6 |
+
"""
|
| 7 |
+
import pytest
|
| 8 |
+
from fastapi.testclient import TestClient
|
| 9 |
+
from src.api.main import app, multi_agent_app
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
# ============================================================================
|
| 13 |
+
# Test Client Setup
|
| 14 |
+
# ============================================================================
|
| 15 |
+
|
| 16 |
+
@pytest.fixture
|
| 17 |
+
def client():
|
| 18 |
+
"""Create test client for FastAPI application."""
|
| 19 |
+
return TestClient(app)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
# ============================================================================
|
| 23 |
+
# Root Endpoint Tests
|
| 24 |
+
# ============================================================================
|
| 25 |
+
|
| 26 |
+
def test_root_endpoint(client):
|
| 27 |
+
"""Test that root endpoint returns correct API information."""
|
| 28 |
+
response = client.get("/")
|
| 29 |
+
|
| 30 |
+
assert response.status_code == 200
|
| 31 |
+
data = response.json()
|
| 32 |
+
|
| 33 |
+
# Check response structure
|
| 34 |
+
assert "message" in data
|
| 35 |
+
assert "version" in data
|
| 36 |
+
assert "docs" in data
|
| 37 |
+
assert "health" in data
|
| 38 |
+
|
| 39 |
+
# Check specific values
|
| 40 |
+
assert data["message"] == "Multi-Agent Assistant API"
|
| 41 |
+
assert data["version"] == "1.0.0"
|
| 42 |
+
assert data["docs"] == "/docs"
|
| 43 |
+
assert data["health"] == "/health"
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def test_root_endpoint_content_type(client):
|
| 47 |
+
"""Test that root endpoint returns JSON content."""
|
| 48 |
+
response = client.get("/")
|
| 49 |
+
|
| 50 |
+
assert response.status_code == 200
|
| 51 |
+
assert response.headers["content-type"] == "application/json"
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
# ============================================================================
|
| 55 |
+
# Health Check Tests
|
| 56 |
+
# ============================================================================
|
| 57 |
+
|
| 58 |
+
def test_health_check_endpoint(client):
|
| 59 |
+
"""Test health check endpoint returns proper structure."""
|
| 60 |
+
response = client.get("/health")
|
| 61 |
+
|
| 62 |
+
assert response.status_code == 200
|
| 63 |
+
data = response.json()
|
| 64 |
+
|
| 65 |
+
# Check response structure
|
| 66 |
+
assert "status" in data
|
| 67 |
+
assert "initialized" in data
|
| 68 |
+
assert "agents" in data
|
| 69 |
+
|
| 70 |
+
# Check status values
|
| 71 |
+
assert data["status"] in ["healthy", "initializing"]
|
| 72 |
+
assert isinstance(data["initialized"], bool)
|
| 73 |
+
assert isinstance(data["agents"], dict)
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def test_health_check_agents_status(client):
|
| 77 |
+
"""Test that health check returns all agent statuses."""
|
| 78 |
+
response = client.get("/health")
|
| 79 |
+
|
| 80 |
+
assert response.status_code == 200
|
| 81 |
+
data = response.json()
|
| 82 |
+
|
| 83 |
+
# Check all agents are present
|
| 84 |
+
expected_agents = ["crypto", "rag", "stock", "search", "finance_tracker"]
|
| 85 |
+
for agent in expected_agents:
|
| 86 |
+
assert agent in data["agents"]
|
| 87 |
+
assert isinstance(data["agents"][agent], bool)
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def test_health_check_response_model(client):
|
| 91 |
+
"""Test health check response matches Pydantic model."""
|
| 92 |
+
response = client.get("/health")
|
| 93 |
+
|
| 94 |
+
assert response.status_code == 200
|
| 95 |
+
data = response.json()
|
| 96 |
+
|
| 97 |
+
# Validate against expected model structure
|
| 98 |
+
assert set(data.keys()) == {"status", "initialized", "agents"}
|
| 99 |
+
|
| 100 |
+
# Check types
|
| 101 |
+
assert isinstance(data["status"], str)
|
| 102 |
+
assert isinstance(data["initialized"], bool)
|
| 103 |
+
assert isinstance(data["agents"], dict)
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
# ============================================================================
|
| 107 |
+
# API Documentation Tests
|
| 108 |
+
# ============================================================================
|
| 109 |
+
|
| 110 |
+
def test_openapi_schema_available(client):
|
| 111 |
+
"""Test that OpenAPI schema is accessible."""
|
| 112 |
+
response = client.get("/openapi.json")
|
| 113 |
+
|
| 114 |
+
assert response.status_code == 200
|
| 115 |
+
schema = response.json()
|
| 116 |
+
|
| 117 |
+
assert "openapi" in schema
|
| 118 |
+
assert "info" in schema
|
| 119 |
+
assert schema["info"]["title"] == "Multi-Agent Assistant API"
|
| 120 |
+
assert schema["info"]["version"] == "1.0.0"
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def test_docs_endpoint_available(client):
|
| 124 |
+
"""Test that Swagger docs are accessible."""
|
| 125 |
+
response = client.get("/docs")
|
| 126 |
+
|
| 127 |
+
assert response.status_code == 200
|
| 128 |
+
assert "swagger" in response.text.lower() or "html" in response.headers["content-type"]
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def test_redoc_endpoint_available(client):
|
| 132 |
+
"""Test that ReDoc documentation is accessible."""
|
| 133 |
+
response = client.get("/redoc")
|
| 134 |
+
|
| 135 |
+
assert response.status_code == 200
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
# ============================================================================
|
| 140 |
+
# Error Handling Tests
|
| 141 |
+
# ============================================================================
|
| 142 |
+
|
| 143 |
+
def test_nonexistent_endpoint(client):
|
| 144 |
+
"""Test that nonexistent endpoints return 404."""
|
| 145 |
+
response = client.get("/nonexistent")
|
| 146 |
+
|
| 147 |
+
assert response.status_code == 404
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
def test_invalid_method_on_root(client):
|
| 151 |
+
"""Test that invalid HTTP methods return 405."""
|
| 152 |
+
response = client.post("/")
|
| 153 |
+
|
| 154 |
+
assert response.status_code == 405
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
# ============================================================================
|
| 158 |
+
# Chat Endpoint Tests (Basic)
|
| 159 |
+
# ============================================================================
|
| 160 |
+
|
| 161 |
+
def test_chat_stream_endpoint_requires_post(client):
|
| 162 |
+
"""Test that chat stream endpoint only accepts POST."""
|
| 163 |
+
response = client.get("/api/v1/chat/stream")
|
| 164 |
+
|
| 165 |
+
assert response.status_code == 405 # Method Not Allowed
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
def test_chat_stream_endpoint_exists(client):
|
| 169 |
+
"""Test that chat stream endpoint exists."""
|
| 170 |
+
# Send minimal valid request
|
| 171 |
+
response = client.post(
|
| 172 |
+
"/api/v1/chat/stream",
|
| 173 |
+
json={"message": "test"}
|
| 174 |
+
)
|
| 175 |
+
|
| 176 |
+
# Should return 200 or process the request (not 404)
|
| 177 |
+
assert response.status_code != 404
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
# ============================================================================
|
| 181 |
+
# Upload Endpoint Tests (Basic)
|
| 182 |
+
# ============================================================================
|
| 183 |
+
|
| 184 |
+
def test_upload_endpoint_requires_post(client):
|
| 185 |
+
"""Test that upload endpoint only accepts POST."""
|
| 186 |
+
response = client.get("/api/v1/documents/upload")
|
| 187 |
+
|
| 188 |
+
assert response.status_code == 405 # Method Not Allowed
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
def test_upload_endpoint_exists(client):
|
| 192 |
+
"""Test that upload endpoint exists."""
|
| 193 |
+
# Try to access without file (should fail validation, not 404)
|
| 194 |
+
response = client.post("/api/v1/documents/upload")
|
| 195 |
+
|
| 196 |
+
# Should return 422 (validation error) or other, but not 404
|
| 197 |
+
assert response.status_code != 404
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
# ============================================================================
|
| 201 |
+
# Integration Tests
|
| 202 |
+
# ============================================================================
|
| 203 |
+
|
| 204 |
+
@pytest.mark.asyncio
|
| 205 |
+
async def test_system_initialization():
|
| 206 |
+
"""Test that multi-agent system can initialize."""
|
| 207 |
+
# Reset initialization state
|
| 208 |
+
multi_agent_app.initialized = False
|
| 209 |
+
|
| 210 |
+
# Initialize
|
| 211 |
+
result = await multi_agent_app.initialize()
|
| 212 |
+
|
| 213 |
+
# Check initialization
|
| 214 |
+
assert multi_agent_app.initialized is True
|
| 215 |
+
assert multi_agent_app.supervisor is not None
|
| 216 |
+
assert "initialized" in result.lower() or "ready" in result.lower()
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
def test_agent_status_method():
|
| 220 |
+
"""Test get_agent_status returns correct structure."""
|
| 221 |
+
status = multi_agent_app.get_agent_status()
|
| 222 |
+
|
| 223 |
+
assert isinstance(status, dict)
|
| 224 |
+
expected_agents = ["crypto", "rag", "stock", "search", "finance_tracker"]
|
| 225 |
+
|
| 226 |
+
for agent in expected_agents:
|
| 227 |
+
assert agent in status
|
| 228 |
+
assert isinstance(status[agent], bool)
|
| 229 |
+
|
| 230 |
+
|
| 231 |
+
# ============================================================================
|
| 232 |
+
# Performance Tests
|
| 233 |
+
# ============================================================================
|
| 234 |
+
|
| 235 |
+
def test_health_check_response_time(client):
|
| 236 |
+
"""Test that health check responds quickly."""
|
| 237 |
+
import time
|
| 238 |
+
|
| 239 |
+
start = time.time()
|
| 240 |
+
response = client.get("/health")
|
| 241 |
+
elapsed = time.time() - start
|
| 242 |
+
|
| 243 |
+
assert response.status_code == 200
|
| 244 |
+
assert elapsed < 1.0 # Should respond in less than 1 second
|
| 245 |
+
|
| 246 |
+
|
| 247 |
+
def test_root_endpoint_response_time(client):
|
| 248 |
+
"""Test that root endpoint responds quickly."""
|
| 249 |
+
import time
|
| 250 |
+
|
| 251 |
+
start = time.time()
|
| 252 |
+
response = client.get("/")
|
| 253 |
+
elapsed = time.time() - start
|
| 254 |
+
|
| 255 |
+
assert response.status_code == 200
|
| 256 |
+
assert elapsed < 0.5 # Should respond in less than 0.5 seconds
|
| 257 |
+
|
| 258 |
+
|
| 259 |
+
# ============================================================================
|
| 260 |
+
# Run Tests
|
| 261 |
+
# ============================================================================
|
| 262 |
+
|
| 263 |
+
if __name__ == "__main__":
|
| 264 |
+
pytest.main([__file__, "-v", "--tb=short"])
|
ui/__init__.py
ADDED
|
File without changes
|
ui/gradio_app.py
ADDED
|
@@ -0,0 +1,542 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Main application with Gradio Chat UI for multi-agent LLM system."""
|
| 2 |
+
import asyncio
|
| 3 |
+
import gradio as gr
|
| 4 |
+
from gradio import ChatMessage
|
| 5 |
+
from src.agents.crypto_agent_mcp import CryptoAgentMCP
|
| 6 |
+
from src.agents.rag_agent_mcp import RAGAgentMCP
|
| 7 |
+
from src.agents.stock_agent_mcp import StockAgentMCP
|
| 8 |
+
from src.agents.search_agent_mcp import SearchAgentMCP
|
| 9 |
+
from src.agents.finance_tracker_agent_mcp import FinanceTrackerMCP
|
| 10 |
+
from typing import Dict, List, AsyncGenerator
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
import os
|
| 13 |
+
from src.core.langgraph_supervisor import ReActSupervisor
|
| 14 |
+
|
| 15 |
+
class MultiAgentApp:
|
| 16 |
+
"""Main application orchestrating LLM supervisor and agents."""
|
| 17 |
+
|
| 18 |
+
def __init__(self):
|
| 19 |
+
self.crypto_agent = CryptoAgentMCP()
|
| 20 |
+
self.rag_agent = RAGAgentMCP()
|
| 21 |
+
self.stock_agent = StockAgentMCP()
|
| 22 |
+
self.search_agent = SearchAgentMCP()
|
| 23 |
+
self.finance_tracker = FinanceTrackerMCP()
|
| 24 |
+
self.supervisor = None
|
| 25 |
+
self.chat_history: List[Dict[str, str]] = []
|
| 26 |
+
self.initialized = False
|
| 27 |
+
|
| 28 |
+
async def initialize(self):
|
| 29 |
+
"""Initialize all agents and supervisor."""
|
| 30 |
+
if not self.initialized:
|
| 31 |
+
print("π Initializing Multi-Agent System...")
|
| 32 |
+
|
| 33 |
+
# Initialize agents first
|
| 34 |
+
await self.crypto_agent.initialize()
|
| 35 |
+
await self.rag_agent.initialize()
|
| 36 |
+
await self.stock_agent.initialize()
|
| 37 |
+
await self.search_agent.initialize()
|
| 38 |
+
await self.finance_tracker.initialize()
|
| 39 |
+
|
| 40 |
+
# Initialize supervisor with agent references
|
| 41 |
+
self.supervisor = ReActSupervisor(
|
| 42 |
+
crypto_agent=self.crypto_agent,
|
| 43 |
+
rag_agent=self.rag_agent,
|
| 44 |
+
stock_agent=self.stock_agent,
|
| 45 |
+
search_agent=self.search_agent,
|
| 46 |
+
finance_tracker=self.finance_tracker
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
self.initialized = True
|
| 50 |
+
print("β
System initialized with LangGraph supervisor!")
|
| 51 |
+
return "β
All agents initialized and ready!"
|
| 52 |
+
|
| 53 |
+
async def process_query_streaming(
|
| 54 |
+
self,
|
| 55 |
+
message: str,
|
| 56 |
+
history: List[Dict[str, str]]
|
| 57 |
+
) -> AsyncGenerator[ChatMessage, None]:
|
| 58 |
+
"""
|
| 59 |
+
Process user query with streaming updates showing intermediate steps.
|
| 60 |
+
|
| 61 |
+
Args:
|
| 62 |
+
message: User's input message
|
| 63 |
+
history: Chat history in Gradio messages format [{"role": "user/assistant", "content": "..."}]
|
| 64 |
+
|
| 65 |
+
Yields:
|
| 66 |
+
ChatMessage objects with metadata for intermediate steps
|
| 67 |
+
"""
|
| 68 |
+
if not message.strip():
|
| 69 |
+
yield ChatMessage(role="assistant", content="Please enter a query.")
|
| 70 |
+
return
|
| 71 |
+
|
| 72 |
+
try:
|
| 73 |
+
# Check if system is initialized
|
| 74 |
+
if not self.initialized:
|
| 75 |
+
yield ChatMessage(role="assistant", content="β System not initialized. Please restart the application.")
|
| 76 |
+
return
|
| 77 |
+
|
| 78 |
+
# Convert Gradio messages format to internal format
|
| 79 |
+
internal_history = []
|
| 80 |
+
for msg in history:
|
| 81 |
+
role = msg.get("role", "")
|
| 82 |
+
content = msg.get("content", "")
|
| 83 |
+
if role == "user":
|
| 84 |
+
internal_history.append({"user": content})
|
| 85 |
+
elif role == "assistant":
|
| 86 |
+
internal_history.append({"assistant": content})
|
| 87 |
+
|
| 88 |
+
# Track message IDs and action messages for checkmark updates
|
| 89 |
+
message_id = 0
|
| 90 |
+
action_message_ids = {} # Map agent names to their action message IDs
|
| 91 |
+
final_answer_message_id = None # Track final answer message ID
|
| 92 |
+
final_answer_accumulated = "" # Accumulate streaming final answer
|
| 93 |
+
|
| 94 |
+
async for update in self.supervisor.process_streaming(message, history=internal_history):
|
| 95 |
+
if update.get("type") == "thinking":
|
| 96 |
+
# Collect thinking step
|
| 97 |
+
step = update.get("step", 0)
|
| 98 |
+
thought = update.get("thought", "")
|
| 99 |
+
action = update.get("action", "")
|
| 100 |
+
justification = update.get("justification", "")
|
| 101 |
+
|
| 102 |
+
step_content = f"**Thought:** {thought}\n\n"
|
| 103 |
+
step_content += f"**Action:** {action.upper()}\n\n"
|
| 104 |
+
step_content += f"**Justification:** {justification}"
|
| 105 |
+
|
| 106 |
+
message_id += 1
|
| 107 |
+
# Yield intermediate step as ChatMessage with metadata
|
| 108 |
+
thinking_msg = ChatMessage(
|
| 109 |
+
role="assistant",
|
| 110 |
+
content=step_content,
|
| 111 |
+
metadata={
|
| 112 |
+
"title": f"π Step {step}: Reasoning",
|
| 113 |
+
"id": message_id,
|
| 114 |
+
"status": "done"
|
| 115 |
+
}
|
| 116 |
+
)
|
| 117 |
+
yield thinking_msg
|
| 118 |
+
|
| 119 |
+
elif update.get("type") == "action":
|
| 120 |
+
# Show agent call as intermediate step with pending status
|
| 121 |
+
agent = update.get("agent", "unknown")
|
| 122 |
+
action_content = f"Calling **{agent.upper()}** agent to gather information..."
|
| 123 |
+
|
| 124 |
+
message_id += 1
|
| 125 |
+
action_msg_id = message_id
|
| 126 |
+
action_message_ids[agent] = action_msg_id # Store ID for later update
|
| 127 |
+
|
| 128 |
+
action_msg = ChatMessage(
|
| 129 |
+
role="assistant",
|
| 130 |
+
content=action_content,
|
| 131 |
+
metadata={
|
| 132 |
+
"title": f"π§ Calling {agent.title()} Agent",
|
| 133 |
+
"id": action_msg_id,
|
| 134 |
+
"status": "pending"
|
| 135 |
+
}
|
| 136 |
+
)
|
| 137 |
+
yield action_msg
|
| 138 |
+
|
| 139 |
+
elif update.get("type") == "observation":
|
| 140 |
+
# Extract tool calls from summary
|
| 141 |
+
agent = update.get("agent", "unknown")
|
| 142 |
+
summary = update.get("summary", "")
|
| 143 |
+
|
| 144 |
+
# Split summary into tool calls and results
|
| 145 |
+
tool_calls = []
|
| 146 |
+
results = []
|
| 147 |
+
for line in summary.split('\n'):
|
| 148 |
+
# Match all tool call prefixes from different agents
|
| 149 |
+
if any(prefix in line for prefix in ['π§ MCP Tool call:', 'π§ DuckDuckGo call:', 'π§ MCP Toolbox call:']):
|
| 150 |
+
tool_calls.append(line)
|
| 151 |
+
elif line.strip():
|
| 152 |
+
results.append(line)
|
| 153 |
+
|
| 154 |
+
# Update the action message with tool calls
|
| 155 |
+
if agent in action_message_ids:
|
| 156 |
+
action_msg_id = action_message_ids[agent]
|
| 157 |
+
tool_calls_text = '\n'.join(tool_calls) if tool_calls else ""
|
| 158 |
+
updated_content = f"β Called **{agent.upper()}** agent\n\n{tool_calls_text}"
|
| 159 |
+
|
| 160 |
+
updated_action_msg = ChatMessage(
|
| 161 |
+
role="assistant",
|
| 162 |
+
content=updated_content,
|
| 163 |
+
metadata={
|
| 164 |
+
"title": f"π§ Calling {agent.title()} Agent",
|
| 165 |
+
"id": action_msg_id,
|
| 166 |
+
"status": "done"
|
| 167 |
+
}
|
| 168 |
+
)
|
| 169 |
+
yield updated_action_msg
|
| 170 |
+
|
| 171 |
+
# Show only the results in observation (without tool calls)
|
| 172 |
+
if results:
|
| 173 |
+
results_text = '\n'.join(results)
|
| 174 |
+
message_id += 1
|
| 175 |
+
|
| 176 |
+
# Map agent names to emojis
|
| 177 |
+
agent_emojis = {
|
| 178 |
+
"finance_tracker": "πΌ",
|
| 179 |
+
"crypto": "πͺ",
|
| 180 |
+
"search": "π",
|
| 181 |
+
"stock": "π",
|
| 182 |
+
"rag": "π"
|
| 183 |
+
}
|
| 184 |
+
agent_emoji = agent_emojis.get(agent.lower(), "π")
|
| 185 |
+
|
| 186 |
+
obs_msg = ChatMessage(
|
| 187 |
+
role="assistant",
|
| 188 |
+
content=results_text,
|
| 189 |
+
metadata={
|
| 190 |
+
"title": f"{agent_emoji} {agent.title()} Agent Results",
|
| 191 |
+
"id": message_id,
|
| 192 |
+
"status": "done"
|
| 193 |
+
}
|
| 194 |
+
)
|
| 195 |
+
yield obs_msg
|
| 196 |
+
|
| 197 |
+
elif update.get("type") == "final_start":
|
| 198 |
+
# Start of final answer - create placeholder message
|
| 199 |
+
final_answer_accumulated = ""
|
| 200 |
+
message_id += 1
|
| 201 |
+
final_answer_message_id = message_id
|
| 202 |
+
# Yield initial empty final answer message
|
| 203 |
+
yield ChatMessage(
|
| 204 |
+
role="assistant",
|
| 205 |
+
content="",
|
| 206 |
+
metadata={"id": final_answer_message_id}
|
| 207 |
+
)
|
| 208 |
+
|
| 209 |
+
elif update.get("type") == "final_token":
|
| 210 |
+
# Stream each token of the final answer - update same message
|
| 211 |
+
final_answer_accumulated = update.get("accumulated", "")
|
| 212 |
+
# Update the same final answer message
|
| 213 |
+
yield ChatMessage(
|
| 214 |
+
role="assistant",
|
| 215 |
+
content=final_answer_accumulated,
|
| 216 |
+
metadata={"id": final_answer_message_id}
|
| 217 |
+
)
|
| 218 |
+
|
| 219 |
+
elif update.get("type") == "final_complete":
|
| 220 |
+
# Final answer is complete
|
| 221 |
+
if final_answer_accumulated:
|
| 222 |
+
yield ChatMessage(
|
| 223 |
+
role="assistant",
|
| 224 |
+
content=final_answer_accumulated,
|
| 225 |
+
metadata={"id": final_answer_message_id}
|
| 226 |
+
)
|
| 227 |
+
|
| 228 |
+
elif update.get("type") == "error":
|
| 229 |
+
# Show error
|
| 230 |
+
error = update.get("error", "Unknown error")
|
| 231 |
+
yield ChatMessage(
|
| 232 |
+
role="assistant",
|
| 233 |
+
content=f"**Error:** {error}"
|
| 234 |
+
)
|
| 235 |
+
|
| 236 |
+
# Update chat history
|
| 237 |
+
self.chat_history.append({"user": message})
|
| 238 |
+
if len(self.chat_history) > 20:
|
| 239 |
+
self.chat_history = self.chat_history[-20:]
|
| 240 |
+
|
| 241 |
+
except Exception as e:
|
| 242 |
+
error_msg = f"β **Error processing query:** {str(e)}"
|
| 243 |
+
yield ChatMessage(role="assistant", content=error_msg)
|
| 244 |
+
|
| 245 |
+
async def upload_document(
|
| 246 |
+
self,
|
| 247 |
+
file_obj,
|
| 248 |
+
progress=gr.Progress()
|
| 249 |
+
) -> str:
|
| 250 |
+
"""
|
| 251 |
+
Handle document upload to ChromaDB Cloud.
|
| 252 |
+
|
| 253 |
+
Args:
|
| 254 |
+
file_obj: Gradio file object
|
| 255 |
+
progress: Gradio progress tracker
|
| 256 |
+
|
| 257 |
+
Returns:
|
| 258 |
+
Status message
|
| 259 |
+
"""
|
| 260 |
+
try:
|
| 261 |
+
if not self.initialized:
|
| 262 |
+
return "β System not initialized. Please restart the application."
|
| 263 |
+
|
| 264 |
+
if file_obj is None:
|
| 265 |
+
return "β No file selected"
|
| 266 |
+
|
| 267 |
+
# Get file path from Gradio file object
|
| 268 |
+
file_path = file_obj.name if hasattr(file_obj, 'name') else str(file_obj)
|
| 269 |
+
|
| 270 |
+
# Validate file exists
|
| 271 |
+
if not os.path.exists(file_path):
|
| 272 |
+
return f"β File not found: {file_path}"
|
| 273 |
+
|
| 274 |
+
# Validate file type
|
| 275 |
+
file_extension = Path(file_path).suffix.lower()
|
| 276 |
+
if file_extension not in ['.pdf', '.txt', '.docx']:
|
| 277 |
+
return f"β Unsupported file type: {file_extension}. Supported: PDF, TXT, DOCX"
|
| 278 |
+
|
| 279 |
+
# Progress callback
|
| 280 |
+
def update_progress(percent, message):
|
| 281 |
+
progress(percent, desc=message)
|
| 282 |
+
|
| 283 |
+
# Upload to RAG agent
|
| 284 |
+
result = await self.rag_agent.add_document(
|
| 285 |
+
file_path,
|
| 286 |
+
progress_callback=update_progress
|
| 287 |
+
)
|
| 288 |
+
|
| 289 |
+
if result.get("success"):
|
| 290 |
+
return (
|
| 291 |
+
f"β
Successfully uploaded {result['filename']}\n"
|
| 292 |
+
f"π Type: {result['file_type']}\n"
|
| 293 |
+
f"π¦ Chunks created: {result['chunks_added']}\n"
|
| 294 |
+
f"π Total documents in collection: {result['total_documents']}"
|
| 295 |
+
)
|
| 296 |
+
else:
|
| 297 |
+
return f"β Upload failed: {result.get('error', 'Unknown error')}"
|
| 298 |
+
|
| 299 |
+
except Exception as e:
|
| 300 |
+
return f"β Error uploading document: {str(e)}"
|
| 301 |
+
|
| 302 |
+
async def cleanup(self):
|
| 303 |
+
"""Cleanup resources."""
|
| 304 |
+
if self.initialized:
|
| 305 |
+
await self.crypto_agent.cleanup()
|
| 306 |
+
await self.rag_agent.cleanup()
|
| 307 |
+
await self.stock_agent.cleanup()
|
| 308 |
+
await self.search_agent.cleanup()
|
| 309 |
+
await self.finance_tracker.cleanup()
|
| 310 |
+
print("π§Ή Cleanup complete")
|
| 311 |
+
self.chat_history.clear()
|
| 312 |
+
|
| 313 |
+
app = MultiAgentApp()
|
| 314 |
+
|
| 315 |
+
try:
|
| 316 |
+
event_loop = asyncio.get_running_loop()
|
| 317 |
+
except RuntimeError:
|
| 318 |
+
event_loop = asyncio.new_event_loop()
|
| 319 |
+
asyncio.set_event_loop(event_loop)
|
| 320 |
+
|
| 321 |
+
def create_ui():
|
| 322 |
+
"""Create Gradio interface."""
|
| 323 |
+
|
| 324 |
+
with gr.Blocks(title="PortfolioMind") as interface:
|
| 325 |
+
gr.Markdown("""
|
| 326 |
+
# π€ PortfolioMind MCP - The Multi-Agent FinAssistant
|
| 327 |
+
""")
|
| 328 |
+
|
| 329 |
+
with gr.Row():
|
| 330 |
+
with gr.Column(scale=3):
|
| 331 |
+
# Chat Interface
|
| 332 |
+
chatbot = gr.Chatbot(
|
| 333 |
+
label="Multi-Agent Assistant",
|
| 334 |
+
height=600,
|
| 335 |
+
show_label=True,
|
| 336 |
+
)
|
| 337 |
+
|
| 338 |
+
with gr.Row():
|
| 339 |
+
msg = gr.Textbox(
|
| 340 |
+
label="Your Message",
|
| 341 |
+
placeholder="Ask about crypto, stocks, documents, or search the web...",
|
| 342 |
+
lines=1,
|
| 343 |
+
scale=4
|
| 344 |
+
)
|
| 345 |
+
with gr.Column(scale=1):
|
| 346 |
+
submit_btn = gr.Button("Send", variant="primary")
|
| 347 |
+
clear_btn = gr.Button("Clear Chat")
|
| 348 |
+
|
| 349 |
+
with gr.Row():
|
| 350 |
+
with gr.Column(scale=1):
|
| 351 |
+
gr.Markdown("""
|
| 352 |
+
### π€ Available Agents:
|
| 353 |
+
- πͺ **Crypto Agent**: Real-time crypto prices, market data, and trends | Uses Coingecko MCP Server
|
| 354 |
+
- π **Stock Agent**: Stock prices, company info, financial data, and market analysis | Uses Alphavantage MCP Server
|
| 355 |
+
- πΌ **Finance Tracker**: Manage your personal stock portfolio (add transactions, track performance, get portfolio news)
|
| 356 |
+
- π **RAG Agent**: Query your uploaded documents with AI | Uses ChromaDB API
|
| 357 |
+
- π **Search Agent**: Search the web using DuckDuckGo | Uses DuckDuckGo MCP Server
|
| 358 |
+
""")
|
| 359 |
+
|
| 360 |
+
with gr.Column(scale=1):
|
| 361 |
+
gr.Markdown("""
|
| 362 |
+
### β Example queries
|
| 363 |
+
- What's the current price of Bitcoin and Ethereum?
|
| 364 |
+
- Add 10 shares of AAPL I bought at $150
|
| 365 |
+
- What's my current portfolio value?
|
| 366 |
+
- Show me news on my portfolio holdings
|
| 367 |
+
- What did Jerome Powell say in his latest speech?
|
| 368 |
+
- Show me Tesla's financial overview
|
| 369 |
+
- Search for latest AI developments
|
| 370 |
+
- What does my document say about [topic]?
|
| 371 |
+
""")
|
| 372 |
+
with gr.Column(scale=1):
|
| 373 |
+
gr.Markdown("""
|
| 374 |
+
### π€ How it works
|
| 375 |
+
1. You ask a question
|
| 376 |
+
2. The ReAct supervisor analyzes your query and plans a strategy
|
| 377 |
+
3. It calls relevant agents to gather information
|
| 378 |
+
4. It synthesizes a comprehensive answer from all sources
|
| 379 |
+
""")
|
| 380 |
+
with gr.Column(scale=1):
|
| 381 |
+
# Document Upload
|
| 382 |
+
gr.Markdown("### π Upload Documents")
|
| 383 |
+
file_upload = gr.File(
|
| 384 |
+
label="Upload PDF, TXT, or DOCX",
|
| 385 |
+
file_types=[".pdf", ".txt", ".docx"],
|
| 386 |
+
type="filepath"
|
| 387 |
+
)
|
| 388 |
+
upload_btn = gr.Button("π€ Upload to RAG", variant="secondary")
|
| 389 |
+
upload_status = gr.Textbox(
|
| 390 |
+
label="Upload Status",
|
| 391 |
+
lines=4,
|
| 392 |
+
interactive=False
|
| 393 |
+
)
|
| 394 |
+
|
| 395 |
+
# System Status
|
| 396 |
+
status_box = gr.Textbox(
|
| 397 |
+
label="Initialization Status",
|
| 398 |
+
value="β
All agents initialized and ready!" if app.initialized else "β³ Initializing...",
|
| 399 |
+
lines=2,
|
| 400 |
+
interactive=False
|
| 401 |
+
)
|
| 402 |
+
|
| 403 |
+
gr.Markdown("""
|
| 404 |
+
---
|
| 405 |
+
### ποΈ System Architecture
|
| 406 |
+
|
| 407 |
+
**ReAct Pattern Supervisor** β Analyzes queries and orchestrates agents through reasoning loops
|
| 408 |
+
|
| 409 |
+
**Agent Workflow:**
|
| 410 |
+
1. **Think**: Supervisor reasons about what information is needed
|
| 411 |
+
2. **Act**: Calls appropriate agent(s) to gather information
|
| 412 |
+
3. **Observe**: Reviews agent responses
|
| 413 |
+
4. **Repeat**: Continues until sufficient information is gathered
|
| 414 |
+
5. **Synthesize**: Generates comprehensive final answer
|
| 415 |
+
|
| 416 |
+
Each agent uses Gemini LLM with specialized MCP server tools for their domain.
|
| 417 |
+
""")
|
| 418 |
+
|
| 419 |
+
# Define async wrappers for Gradio
|
| 420 |
+
async def respond_stream(message, chat_history):
|
| 421 |
+
"""Streaming response handler with intermediate steps."""
|
| 422 |
+
# Create the full message list once (Gradio pattern: yield same list object)
|
| 423 |
+
messages = chat_history + [{"role": "user", "content": message}]
|
| 424 |
+
final_answer_index = None
|
| 425 |
+
message_id_to_index = {} # Map message IDs to their index
|
| 426 |
+
|
| 427 |
+
async for chat_msg in app.process_query_streaming(message, chat_history):
|
| 428 |
+
# Check if message has an ID (for updating existing messages)
|
| 429 |
+
msg_id = None
|
| 430 |
+
if hasattr(chat_msg, 'metadata') and chat_msg.metadata:
|
| 431 |
+
msg_id = chat_msg.metadata.get('id')
|
| 432 |
+
|
| 433 |
+
# Check if this is a final answer message (has ID but minimal metadata)
|
| 434 |
+
is_final_answer = msg_id and (not chat_msg.metadata.get('title'))
|
| 435 |
+
|
| 436 |
+
if msg_id and msg_id in message_id_to_index:
|
| 437 |
+
# Update existing message (e.g., changing status from pending to done)
|
| 438 |
+
existing_index = message_id_to_index[msg_id]
|
| 439 |
+
messages[existing_index] = chat_msg
|
| 440 |
+
elif is_final_answer:
|
| 441 |
+
if final_answer_index is None:
|
| 442 |
+
# First token of final answer - append once
|
| 443 |
+
final_answer_index = len(messages)
|
| 444 |
+
message_id_to_index[msg_id] = final_answer_index
|
| 445 |
+
messages.append(chat_msg)
|
| 446 |
+
else:
|
| 447 |
+
# Subsequent tokens - replace in place
|
| 448 |
+
messages[final_answer_index] = chat_msg
|
| 449 |
+
else:
|
| 450 |
+
# New intermediate step message
|
| 451 |
+
if msg_id:
|
| 452 |
+
message_id_to_index[msg_id] = len(messages)
|
| 453 |
+
messages.append(chat_msg)
|
| 454 |
+
|
| 455 |
+
# Yield the SAME messages list (following Gradio streaming pattern)
|
| 456 |
+
yield messages
|
| 457 |
+
|
| 458 |
+
def upload_document_sync(file_obj, progress=gr.Progress()):
|
| 459 |
+
"""Synchronous wrapper for async document upload."""
|
| 460 |
+
return event_loop.run_until_complete(app.upload_document(file_obj, progress))
|
| 461 |
+
|
| 462 |
+
def clear_chat():
|
| 463 |
+
"""Clear chat history."""
|
| 464 |
+
app.chat_history.clear()
|
| 465 |
+
return []
|
| 466 |
+
|
| 467 |
+
# Connect button actions
|
| 468 |
+
msg.submit(
|
| 469 |
+
respond_stream,
|
| 470 |
+
inputs=[msg, chatbot],
|
| 471 |
+
outputs=[chatbot]
|
| 472 |
+
).then(
|
| 473 |
+
lambda: "",
|
| 474 |
+
outputs=[msg]
|
| 475 |
+
)
|
| 476 |
+
|
| 477 |
+
submit_btn.click(
|
| 478 |
+
respond_stream,
|
| 479 |
+
inputs=[msg, chatbot],
|
| 480 |
+
outputs=[chatbot]
|
| 481 |
+
).then(
|
| 482 |
+
lambda: "",
|
| 483 |
+
outputs=[msg]
|
| 484 |
+
)
|
| 485 |
+
|
| 486 |
+
clear_btn.click(
|
| 487 |
+
clear_chat,
|
| 488 |
+
outputs=[chatbot]
|
| 489 |
+
)
|
| 490 |
+
|
| 491 |
+
upload_btn.click(
|
| 492 |
+
upload_document_sync,
|
| 493 |
+
inputs=[file_upload],
|
| 494 |
+
outputs=[upload_status]
|
| 495 |
+
)
|
| 496 |
+
|
| 497 |
+
return interface
|
| 498 |
+
|
| 499 |
+
|
| 500 |
+
def main():
|
| 501 |
+
"""Main entry point."""
|
| 502 |
+
print("=" * 60)
|
| 503 |
+
print("π Starting Multi-Agent Assistant")
|
| 504 |
+
print("=" * 60)
|
| 505 |
+
|
| 506 |
+
# Validate configuration
|
| 507 |
+
try:
|
| 508 |
+
from src.core.config import config
|
| 509 |
+
config.validate()
|
| 510 |
+
print("β
Configuration validated")
|
| 511 |
+
except ValueError as e:
|
| 512 |
+
print(f"β Configuration Error: {e}")
|
| 513 |
+
return
|
| 514 |
+
|
| 515 |
+
# Initialize all agents at startup
|
| 516 |
+
print("\nβ‘ Initializing all agents at startup...")
|
| 517 |
+
event_loop.run_until_complete(app.initialize())
|
| 518 |
+
|
| 519 |
+
interface = create_ui()
|
| 520 |
+
|
| 521 |
+
print("\nπ± Launching Gradio interface...")
|
| 522 |
+
print("π Access the app at: http://localhost:7860")
|
| 523 |
+
print("\nPress Ctrl+C to stop the server")
|
| 524 |
+
print("=" * 60)
|
| 525 |
+
|
| 526 |
+
try:
|
| 527 |
+
interface.launch(
|
| 528 |
+
server_name="0.0.0.0",
|
| 529 |
+
server_port=7860,
|
| 530 |
+
share=True,
|
| 531 |
+
theme=gr.themes.Citrus()
|
| 532 |
+
)
|
| 533 |
+
except KeyboardInterrupt:
|
| 534 |
+
print("\n\nπ Shutting down...")
|
| 535 |
+
# Cleanup
|
| 536 |
+
event_loop.run_until_complete(app.cleanup())
|
| 537 |
+
event_loop.close()
|
| 538 |
+
print("π Goodbye!")
|
| 539 |
+
|
| 540 |
+
|
| 541 |
+
if __name__ == "__main__":
|
| 542 |
+
main()
|
ui/streamlit_app.py
ADDED
|
@@ -0,0 +1,473 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
"""Streamlit UI for Multi-Agent Assistant connecting to FastAPI backend."""
|
| 3 |
+
import streamlit as st
|
| 4 |
+
import requests
|
| 5 |
+
import json
|
| 6 |
+
import os
|
| 7 |
+
from typing import Dict, List, Optional
|
| 8 |
+
import time
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
|
| 11 |
+
# ============================================================================
|
| 12 |
+
# Configuration
|
| 13 |
+
# ============================================================================
|
| 14 |
+
|
| 15 |
+
# FastAPI backend URL
|
| 16 |
+
API_BASE_URL = os.getenv("API_BASE_URL", "http://localhost:8000")
|
| 17 |
+
|
| 18 |
+
# Page configuration
|
| 19 |
+
st.set_page_config(
|
| 20 |
+
page_title="Multi-Agent Assistant",
|
| 21 |
+
page_icon="π€",
|
| 22 |
+
layout="wide",
|
| 23 |
+
initial_sidebar_state="expanded"
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
# ============================================================================
|
| 27 |
+
# API Client Functions
|
| 28 |
+
# ============================================================================
|
| 29 |
+
|
| 30 |
+
def check_api_health() -> Dict:
|
| 31 |
+
"""Check if FastAPI backend is available."""
|
| 32 |
+
try:
|
| 33 |
+
response = requests.get(f"{API_BASE_URL}/health", timeout=5)
|
| 34 |
+
response.raise_for_status()
|
| 35 |
+
return response.json()
|
| 36 |
+
except Exception as e:
|
| 37 |
+
return {"status": "offline", "error": str(e)}
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def stream_chat_response(message: str, history: List[Dict]) -> Dict:
|
| 41 |
+
"""
|
| 42 |
+
Stream chat response from FastAPI using Server-Sent Events.
|
| 43 |
+
|
| 44 |
+
Yields update dictionaries as they arrive.
|
| 45 |
+
"""
|
| 46 |
+
payload = {
|
| 47 |
+
"message": message,
|
| 48 |
+
"history": history
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
try:
|
| 52 |
+
response = requests.post(
|
| 53 |
+
f"{API_BASE_URL}/api/v1/chat/stream",
|
| 54 |
+
json=payload,
|
| 55 |
+
headers={
|
| 56 |
+
"Accept": "text/event-stream",
|
| 57 |
+
"Cache-Control": "no-cache",
|
| 58 |
+
},
|
| 59 |
+
stream=True,
|
| 60 |
+
timeout=120
|
| 61 |
+
)
|
| 62 |
+
response.raise_for_status()
|
| 63 |
+
|
| 64 |
+
# Use iter_lines with smaller chunk size for faster streaming
|
| 65 |
+
for line in response.iter_lines(chunk_size=1, decode_unicode=True):
|
| 66 |
+
if line:
|
| 67 |
+
if line.startswith('data: '):
|
| 68 |
+
data_str = line[6:] # Remove 'data: ' prefix
|
| 69 |
+
try:
|
| 70 |
+
event = json.loads(data_str)
|
| 71 |
+
yield event
|
| 72 |
+
except json.JSONDecodeError:
|
| 73 |
+
continue
|
| 74 |
+
except Exception as e:
|
| 75 |
+
yield {"type": "error", "error": str(e)}
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def upload_document(file) -> Dict:
|
| 79 |
+
"""Upload document to FastAPI backend."""
|
| 80 |
+
try:
|
| 81 |
+
files = {'file': (file.name, file, file.type)}
|
| 82 |
+
response = requests.post(
|
| 83 |
+
f"{API_BASE_URL}/api/v1/documents/upload",
|
| 84 |
+
files=files,
|
| 85 |
+
timeout=60
|
| 86 |
+
)
|
| 87 |
+
response.raise_for_status()
|
| 88 |
+
return response.json()
|
| 89 |
+
except Exception as e:
|
| 90 |
+
return {"success": False, "message": str(e)}
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
# ============================================================================
|
| 94 |
+
# Session State Initialization
|
| 95 |
+
# ============================================================================
|
| 96 |
+
|
| 97 |
+
if "messages" not in st.session_state:
|
| 98 |
+
st.session_state.messages = []
|
| 99 |
+
|
| 100 |
+
if "chat_history" not in st.session_state:
|
| 101 |
+
st.session_state.chat_history = []
|
| 102 |
+
|
| 103 |
+
if "show_intermediate_steps" not in st.session_state:
|
| 104 |
+
st.session_state.show_intermediate_steps = True
|
| 105 |
+
|
| 106 |
+
if "processing" not in st.session_state:
|
| 107 |
+
st.session_state.processing = False
|
| 108 |
+
|
| 109 |
+
# ============================================================================
|
| 110 |
+
# Custom CSS
|
| 111 |
+
# ============================================================================
|
| 112 |
+
|
| 113 |
+
st.markdown("""
|
| 114 |
+
<style>
|
| 115 |
+
/* Fixed bottom input container */
|
| 116 |
+
.stChatFloatingInputContainer {
|
| 117 |
+
position: sticky;
|
| 118 |
+
bottom: 0;
|
| 119 |
+
background-color: var(--background-color);
|
| 120 |
+
padding: 1rem 0;
|
| 121 |
+
z-index: 100;
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
.stChatMessage {
|
| 125 |
+
padding: 1rem;
|
| 126 |
+
border-radius: 0.5rem;
|
| 127 |
+
margin-bottom: 0.5rem;
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
.thinking-step {
|
| 131 |
+
background-color: #f0f2f6;
|
| 132 |
+
padding: 1rem;
|
| 133 |
+
border-radius: 0.5rem;
|
| 134 |
+
border-left: 3px solid #4CAF50;
|
| 135 |
+
margin: 0.5rem 0;
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
.action-step {
|
| 139 |
+
background-color: #fff3cd;
|
| 140 |
+
padding: 0.75rem 1rem;
|
| 141 |
+
border-radius: 0.5rem;
|
| 142 |
+
border-left: 3px solid #ffc107;
|
| 143 |
+
margin: 0.5rem 0;
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
.action-step-done {
|
| 147 |
+
background-color: #d4edda;
|
| 148 |
+
padding: 0.75rem 1rem;
|
| 149 |
+
border-radius: 0.5rem;
|
| 150 |
+
border-left: 3px solid #28a745;
|
| 151 |
+
margin: 0.5rem 0;
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
.observation-step {
|
| 155 |
+
background-color: #d1ecf1;
|
| 156 |
+
padding: 1rem;
|
| 157 |
+
border-radius: 0.5rem;
|
| 158 |
+
border-left: 3px solid #17a2b8;
|
| 159 |
+
margin: 0.5rem 0;
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
.agent-badge {
|
| 163 |
+
display: inline-block;
|
| 164 |
+
padding: 0.25rem 0.5rem;
|
| 165 |
+
border-radius: 0.25rem;
|
| 166 |
+
background-color: #6c757d;
|
| 167 |
+
color: white;
|
| 168 |
+
font-size: 0.875rem;
|
| 169 |
+
font-weight: 500;
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
/* Search references styling */
|
| 173 |
+
.search-references {
|
| 174 |
+
margin-top: 1rem;
|
| 175 |
+
padding: 0.75rem;
|
| 176 |
+
background-color: #f8f9fa;
|
| 177 |
+
border-radius: 0.5rem;
|
| 178 |
+
border-left: 3px solid #007bff;
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
.search-references h4 {
|
| 182 |
+
margin-top: 0;
|
| 183 |
+
font-size: 0.9rem;
|
| 184 |
+
color: #495057;
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
.search-references a {
|
| 188 |
+
display: block;
|
| 189 |
+
color: #007bff;
|
| 190 |
+
text-decoration: none;
|
| 191 |
+
margin: 0.25rem 0;
|
| 192 |
+
font-size: 0.85rem;
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
.search-references a:hover {
|
| 196 |
+
text-decoration: underline;
|
| 197 |
+
}
|
| 198 |
+
</style>
|
| 199 |
+
""", unsafe_allow_html=True)
|
| 200 |
+
|
| 201 |
+
# ============================================================================
|
| 202 |
+
# Sidebar
|
| 203 |
+
# ============================================================================
|
| 204 |
+
|
| 205 |
+
with st.sidebar:
|
| 206 |
+
st.title("π€ Multi-Agent Assistant")
|
| 207 |
+
st.markdown("---")
|
| 208 |
+
|
| 209 |
+
# Settings
|
| 210 |
+
st.subheader("βοΈ Settings")
|
| 211 |
+
st.session_state.show_intermediate_steps = st.checkbox(
|
| 212 |
+
"Show Reasoning Steps",
|
| 213 |
+
value=st.session_state.show_intermediate_steps,
|
| 214 |
+
help="Display intermediate thinking, actions, and observations"
|
| 215 |
+
)
|
| 216 |
+
|
| 217 |
+
if st.button("ποΈ Clear Chat", use_container_width=True):
|
| 218 |
+
st.session_state.messages = []
|
| 219 |
+
st.session_state.chat_history = []
|
| 220 |
+
st.rerun()
|
| 221 |
+
|
| 222 |
+
st.markdown("---")
|
| 223 |
+
|
| 224 |
+
# Information
|
| 225 |
+
st.subheader("βΉοΈ Available Agents")
|
| 226 |
+
st.markdown("""
|
| 227 |
+
- πͺ **Crypto Agent**: Cryptocurrency prices & data
|
| 228 |
+
- π **Stock Agent**: Stock market information
|
| 229 |
+
- πΌ **Finance Tracker**: Portfolio management
|
| 230 |
+
- π **RAG Agent**: Document Q&A
|
| 231 |
+
- π **Search Agent**: Web search
|
| 232 |
+
""")
|
| 233 |
+
|
| 234 |
+
st.markdown("---")
|
| 235 |
+
st.caption("Powered by FastAPI + Gemini 2.5 Pro")
|
| 236 |
+
|
| 237 |
+
# ============================================================================
|
| 238 |
+
# Main Chat Interface
|
| 239 |
+
# ============================================================================
|
| 240 |
+
|
| 241 |
+
st.title("π¬ Multi-Agent Chat Assistant")
|
| 242 |
+
st.markdown("Ask questions about crypto, stocks, documents, or search the web!")
|
| 243 |
+
|
| 244 |
+
# Container for chat messages
|
| 245 |
+
chat_container = st.container()
|
| 246 |
+
|
| 247 |
+
# Display chat history in the container
|
| 248 |
+
with chat_container:
|
| 249 |
+
for message in st.session_state.messages:
|
| 250 |
+
role = message["role"]
|
| 251 |
+
content = message["content"]
|
| 252 |
+
|
| 253 |
+
with st.chat_message(role):
|
| 254 |
+
if role == "assistant" and "metadata" in message:
|
| 255 |
+
metadata = message["metadata"]
|
| 256 |
+
|
| 257 |
+
# Intermediate steps
|
| 258 |
+
if metadata.get("type") == "thinking":
|
| 259 |
+
if st.session_state.show_intermediate_steps:
|
| 260 |
+
with st.expander(f"π Step {metadata.get('step', '?')}: Reasoning", expanded=False):
|
| 261 |
+
st.markdown(f"**Thought:** {metadata.get('thought', 'N/A')}")
|
| 262 |
+
st.markdown(f"**Action:** `{metadata.get('action', 'N/A').upper()}`")
|
| 263 |
+
st.markdown(f"**Justification:** {metadata.get('justification', 'N/A')}")
|
| 264 |
+
|
| 265 |
+
elif metadata.get("type") == "action":
|
| 266 |
+
if st.session_state.show_intermediate_steps:
|
| 267 |
+
agent = metadata.get('agent', 'unknown')
|
| 268 |
+
status = metadata.get('status', 'running')
|
| 269 |
+
if status == 'done':
|
| 270 |
+
st.success(f"β
**{agent.title()}** Agent - Done")
|
| 271 |
+
else:
|
| 272 |
+
st.info(f"π§ Calling **{agent.title()}** Agent...")
|
| 273 |
+
|
| 274 |
+
elif metadata.get("type") == "observation":
|
| 275 |
+
if st.session_state.show_intermediate_steps:
|
| 276 |
+
agent = metadata.get('agent', 'unknown')
|
| 277 |
+
with st.expander(f"π {agent.title()} Agent Results", expanded=False):
|
| 278 |
+
st.write(content)
|
| 279 |
+
|
| 280 |
+
else:
|
| 281 |
+
# Regular assistant message
|
| 282 |
+
st.markdown(content)
|
| 283 |
+
|
| 284 |
+
# Display search references if available
|
| 285 |
+
if metadata.get("search_references"):
|
| 286 |
+
refs = metadata["search_references"]
|
| 287 |
+
st.markdown("---")
|
| 288 |
+
st.markdown("**π References:**")
|
| 289 |
+
for ref in refs:
|
| 290 |
+
st.markdown(f"- [{ref['title']}]({ref['url']})")
|
| 291 |
+
else:
|
| 292 |
+
# User or regular message
|
| 293 |
+
st.markdown(content)
|
| 294 |
+
|
| 295 |
+
# Fixed bottom container for input and file upload
|
| 296 |
+
st.markdown("---")
|
| 297 |
+
input_col, upload_col = st.columns([4, 1])
|
| 298 |
+
|
| 299 |
+
with input_col:
|
| 300 |
+
prompt = st.chat_input("Ask me anything...", key="chat_input")
|
| 301 |
+
|
| 302 |
+
with upload_col:
|
| 303 |
+
uploaded_file = st.file_uploader(
|
| 304 |
+
"π",
|
| 305 |
+
type=["pdf", "txt", "docx"],
|
| 306 |
+
help="Upload documents to the RAG agent",
|
| 307 |
+
label_visibility="collapsed",
|
| 308 |
+
key="file_uploader"
|
| 309 |
+
)
|
| 310 |
+
|
| 311 |
+
# Handle document upload
|
| 312 |
+
if uploaded_file and not st.session_state.processing:
|
| 313 |
+
with st.spinner("Uploading document..."):
|
| 314 |
+
result = upload_document(uploaded_file)
|
| 315 |
+
if result.get("success"):
|
| 316 |
+
st.success(f"β
{result.get('message', 'Upload successful')}")
|
| 317 |
+
else:
|
| 318 |
+
st.error(f"β {result.get('message', 'Upload failed')}")
|
| 319 |
+
|
| 320 |
+
# Handle chat input
|
| 321 |
+
if prompt and not st.session_state.processing:
|
| 322 |
+
st.session_state.processing = True
|
| 323 |
+
|
| 324 |
+
# Add user message to chat
|
| 325 |
+
st.session_state.messages.append({"role": "user", "content": prompt})
|
| 326 |
+
st.session_state.chat_history.append({"role": "user", "content": prompt})
|
| 327 |
+
|
| 328 |
+
# Display user message
|
| 329 |
+
with chat_container:
|
| 330 |
+
with st.chat_message("user"):
|
| 331 |
+
st.markdown(prompt)
|
| 332 |
+
|
| 333 |
+
# Get response from API
|
| 334 |
+
with st.chat_message("assistant"):
|
| 335 |
+
# Create placeholders for streaming (thinking steps first, then final answer)
|
| 336 |
+
thinking_placeholder = st.container()
|
| 337 |
+
response_placeholder = st.empty()
|
| 338 |
+
|
| 339 |
+
final_answer = ""
|
| 340 |
+
current_step = 0
|
| 341 |
+
agent_status_placeholders = {}
|
| 342 |
+
search_references = []
|
| 343 |
+
first_event_received = False
|
| 344 |
+
initial_status = None
|
| 345 |
+
|
| 346 |
+
# Show initial status while supervisor starts processing
|
| 347 |
+
if st.session_state.show_intermediate_steps:
|
| 348 |
+
with thinking_placeholder:
|
| 349 |
+
initial_status = st.empty()
|
| 350 |
+
initial_status.info("π€ Supervisor is analyzing your query...")
|
| 351 |
+
|
| 352 |
+
try:
|
| 353 |
+
for event in stream_chat_response(prompt, st.session_state.chat_history[:-1]):
|
| 354 |
+
event_type = event.get("type", "unknown")
|
| 355 |
+
|
| 356 |
+
# Clear initial status on first event
|
| 357 |
+
if not first_event_received and initial_status is not None:
|
| 358 |
+
initial_status.empty()
|
| 359 |
+
first_event_received = True
|
| 360 |
+
|
| 361 |
+
if event_type == "thinking":
|
| 362 |
+
current_step = event.get("step", current_step + 1)
|
| 363 |
+
if st.session_state.show_intermediate_steps:
|
| 364 |
+
with thinking_placeholder:
|
| 365 |
+
with st.expander(f"π Step {current_step}: Reasoning", expanded=False):
|
| 366 |
+
st.markdown(f"**Thought:** {event.get('thought', 'N/A')}")
|
| 367 |
+
st.markdown(f"**Action:** `{event.get('action', 'N/A').upper()}`")
|
| 368 |
+
st.markdown(f"**Justification:** {event.get('justification', 'N/A')}")
|
| 369 |
+
|
| 370 |
+
st.session_state.messages.append({
|
| 371 |
+
"role": "assistant",
|
| 372 |
+
"content": "",
|
| 373 |
+
"metadata": {
|
| 374 |
+
"type": "thinking",
|
| 375 |
+
"step": current_step,
|
| 376 |
+
"thought": event.get('thought', ''),
|
| 377 |
+
"action": event.get('action', ''),
|
| 378 |
+
"justification": event.get('justification', '')
|
| 379 |
+
}
|
| 380 |
+
})
|
| 381 |
+
|
| 382 |
+
elif event_type == "action":
|
| 383 |
+
agent = event.get("agent", "unknown")
|
| 384 |
+
if st.session_state.show_intermediate_steps:
|
| 385 |
+
with thinking_placeholder:
|
| 386 |
+
# Create a placeholder for this agent's status
|
| 387 |
+
agent_status_placeholders[agent] = st.empty()
|
| 388 |
+
agent_status_placeholders[agent].info(f"π§ Calling **{agent.title()}** Agent...")
|
| 389 |
+
|
| 390 |
+
st.session_state.messages.append({
|
| 391 |
+
"role": "assistant",
|
| 392 |
+
"content": f"Calling {agent} agent...",
|
| 393 |
+
"metadata": {"type": "action", "agent": agent, "status": "running"}
|
| 394 |
+
})
|
| 395 |
+
|
| 396 |
+
elif event_type == "observation":
|
| 397 |
+
agent = event.get("agent", "unknown")
|
| 398 |
+
summary = event.get("summary", "")
|
| 399 |
+
|
| 400 |
+
# Update the agent status to done
|
| 401 |
+
if agent in agent_status_placeholders:
|
| 402 |
+
agent_status_placeholders[agent].success(f"β
**{agent.title()}** Agent - Done")
|
| 403 |
+
|
| 404 |
+
# Extract search URLs if this is the search agent
|
| 405 |
+
if agent == "search" and event.get("search_urls"):
|
| 406 |
+
for url_data in event.get("search_urls", []):
|
| 407 |
+
if url_data not in search_references:
|
| 408 |
+
search_references.append(url_data)
|
| 409 |
+
|
| 410 |
+
if st.session_state.show_intermediate_steps:
|
| 411 |
+
with thinking_placeholder:
|
| 412 |
+
with st.expander(f"π {agent.title()} Agent Results", expanded=False):
|
| 413 |
+
st.write(summary)
|
| 414 |
+
|
| 415 |
+
# Update the message to mark as done
|
| 416 |
+
for msg in reversed(st.session_state.messages):
|
| 417 |
+
if (msg.get("role") == "assistant" and
|
| 418 |
+
msg.get("metadata", {}).get("type") == "action" and
|
| 419 |
+
msg.get("metadata", {}).get("agent") == agent):
|
| 420 |
+
msg["metadata"]["status"] = "done"
|
| 421 |
+
break
|
| 422 |
+
|
| 423 |
+
st.session_state.messages.append({
|
| 424 |
+
"role": "assistant",
|
| 425 |
+
"content": summary,
|
| 426 |
+
"metadata": {"type": "observation", "agent": agent}
|
| 427 |
+
})
|
| 428 |
+
|
| 429 |
+
elif event_type == "final_start":
|
| 430 |
+
# Display synthesis status in thinking area, not at top
|
| 431 |
+
if st.session_state.show_intermediate_steps:
|
| 432 |
+
with thinking_placeholder:
|
| 433 |
+
st.info("π Synthesizing final answer...")
|
| 434 |
+
|
| 435 |
+
elif event_type == "final_token":
|
| 436 |
+
# Stream token by token (use write() to handle incomplete markdown)
|
| 437 |
+
final_answer = event.get("accumulated", "")
|
| 438 |
+
response_placeholder.write(final_answer)
|
| 439 |
+
|
| 440 |
+
elif event_type == "final_complete":
|
| 441 |
+
if final_answer:
|
| 442 |
+
# Render final version with proper markdown
|
| 443 |
+
response_placeholder.markdown(final_answer)
|
| 444 |
+
|
| 445 |
+
elif event_type == "error":
|
| 446 |
+
error_msg = event.get("error", "Unknown error")
|
| 447 |
+
response_placeholder.error(f"β Error: {error_msg}")
|
| 448 |
+
final_answer = f"Error: {error_msg}"
|
| 449 |
+
|
| 450 |
+
except Exception as e:
|
| 451 |
+
response_placeholder.error(f"β Connection Error: {str(e)}")
|
| 452 |
+
final_answer = f"Error: {str(e)}"
|
| 453 |
+
|
| 454 |
+
# Add final answer to chat history
|
| 455 |
+
if final_answer:
|
| 456 |
+
message_data = {
|
| 457 |
+
"role": "assistant",
|
| 458 |
+
"content": final_answer,
|
| 459 |
+
"metadata": {}
|
| 460 |
+
}
|
| 461 |
+
|
| 462 |
+
# Add search references if available
|
| 463 |
+
if search_references:
|
| 464 |
+
message_data["metadata"]["search_references"] = search_references
|
| 465 |
+
|
| 466 |
+
st.session_state.messages.append(message_data)
|
| 467 |
+
st.session_state.chat_history.append({
|
| 468 |
+
"role": "assistant",
|
| 469 |
+
"content": final_answer
|
| 470 |
+
})
|
| 471 |
+
|
| 472 |
+
st.session_state.processing = False
|
| 473 |
+
st.rerun()
|