Spaces:
Runtime error
Runtime error
Balamurugan Thayalan
commited on
Commit
·
ed1f7cd
1
Parent(s):
499796e
spend-analyzer-mcp-mbt v1.0.0
Browse files- .gitignore +83 -0
- API_DOCUMENTATION.md +619 -0
- DEPLOYMENT_GUIDE.md +301 -0
- README.md +73 -16
- gradio_interface_real.py → app.py +1155 -29
- email_processor.py +347 -49
- gradio_interface.py +0 -788
- gradio_interface_local.py +0 -627
- setup_local.py → init/setup_local.py +0 -0
- init/setup_modal.py +130 -0
- mcp_server.py +486 -38
- modal_deployment.py +108 -34
- requirements.txt +18 -2
- secure_storage_utils.py +189 -0
.gitignore
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# API Keys and Configuration
|
| 2 |
+
config.json
|
| 3 |
+
.env
|
| 4 |
+
.env.local
|
| 5 |
+
.env.production
|
| 6 |
+
|
| 7 |
+
# Secure Storage
|
| 8 |
+
api_keys.json
|
| 9 |
+
secrets.json
|
| 10 |
+
|
| 11 |
+
# Python
|
| 12 |
+
__pycache__/
|
| 13 |
+
*.py[cod]
|
| 14 |
+
*$py.class
|
| 15 |
+
*.so
|
| 16 |
+
.Python
|
| 17 |
+
build/
|
| 18 |
+
develop-eggs/
|
| 19 |
+
dist/
|
| 20 |
+
downloads/
|
| 21 |
+
eggs/
|
| 22 |
+
.eggs/
|
| 23 |
+
lib/
|
| 24 |
+
lib64/
|
| 25 |
+
parts/
|
| 26 |
+
sdist/
|
| 27 |
+
var/
|
| 28 |
+
wheels/
|
| 29 |
+
*.egg-info/
|
| 30 |
+
.installed.cfg
|
| 31 |
+
*.egg
|
| 32 |
+
MANIFEST
|
| 33 |
+
|
| 34 |
+
# Virtual Environment
|
| 35 |
+
venv/
|
| 36 |
+
env/
|
| 37 |
+
ENV/
|
| 38 |
+
env.bak/
|
| 39 |
+
venv.bak/
|
| 40 |
+
|
| 41 |
+
# IDE
|
| 42 |
+
.vscode/
|
| 43 |
+
.idea/
|
| 44 |
+
*.swp
|
| 45 |
+
*.swo
|
| 46 |
+
*~
|
| 47 |
+
|
| 48 |
+
# OS
|
| 49 |
+
.DS_Store
|
| 50 |
+
.DS_Store?
|
| 51 |
+
._*
|
| 52 |
+
.Spotlight-V100
|
| 53 |
+
.Trashes
|
| 54 |
+
ehthumbs.db
|
| 55 |
+
Thumbs.db
|
| 56 |
+
|
| 57 |
+
# Logs
|
| 58 |
+
*.log
|
| 59 |
+
logs/
|
| 60 |
+
|
| 61 |
+
# Temporary files
|
| 62 |
+
*.tmp
|
| 63 |
+
*.temp
|
| 64 |
+
temp/
|
| 65 |
+
tmp/
|
| 66 |
+
|
| 67 |
+
# Database
|
| 68 |
+
*.db
|
| 69 |
+
*.sqlite
|
| 70 |
+
*.sqlite3
|
| 71 |
+
|
| 72 |
+
# Jupyter Notebook
|
| 73 |
+
.ipynb_checkpoints
|
| 74 |
+
|
| 75 |
+
# pytest
|
| 76 |
+
.pytest_cache/
|
| 77 |
+
.coverage
|
| 78 |
+
htmlcov/
|
| 79 |
+
|
| 80 |
+
# mypy
|
| 81 |
+
.mypy_cache/
|
| 82 |
+
.dmypy.json
|
| 83 |
+
dmypy.json
|
API_DOCUMENTATION.md
ADDED
|
@@ -0,0 +1,619 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Spend Analyzer MCP - API Documentation
|
| 2 |
+
|
| 3 |
+
This document provides comprehensive API documentation for the Spend Analyzer MCP system, including Modal functions, MCP protocol integration, and local usage.
|
| 4 |
+
|
| 5 |
+
## Table of Contents
|
| 6 |
+
|
| 7 |
+
1. [Modal Functions API](#modal-functions-api)
|
| 8 |
+
2. [MCP Protocol Integration](#mcp-protocol-integration)
|
| 9 |
+
3. [Local Python API](#local-python-api)
|
| 10 |
+
4. [Data Formats](#data-formats)
|
| 11 |
+
5. [Error Handling](#error-handling)
|
| 12 |
+
6. [Examples](#examples)
|
| 13 |
+
|
| 14 |
+
## Modal Functions API
|
| 15 |
+
|
| 16 |
+
### 1. `process_bank_statements`
|
| 17 |
+
|
| 18 |
+
Process bank statements from email attachments.
|
| 19 |
+
|
| 20 |
+
**Function Signature:**
|
| 21 |
+
```python
|
| 22 |
+
def process_bank_statements(
|
| 23 |
+
email_config: Dict,
|
| 24 |
+
days_back: int = 30,
|
| 25 |
+
passwords: Optional[Dict] = None
|
| 26 |
+
) -> Dict
|
| 27 |
+
```
|
| 28 |
+
|
| 29 |
+
**Parameters:**
|
| 30 |
+
- `email_config` (Dict): Email configuration
|
| 31 |
+
- `email` (str): Email address
|
| 32 |
+
- `password` (str): App-specific password
|
| 33 |
+
- `imap_server` (str): IMAP server address
|
| 34 |
+
- `days_back` (int): Number of days to look back (default: 30)
|
| 35 |
+
- `passwords` (Dict, optional): PDF passwords by filename
|
| 36 |
+
|
| 37 |
+
**Returns:**
|
| 38 |
+
```python
|
| 39 |
+
{
|
| 40 |
+
"processed_statements": [
|
| 41 |
+
{
|
| 42 |
+
"filename": str,
|
| 43 |
+
"bank": str,
|
| 44 |
+
"account": str,
|
| 45 |
+
"period": str,
|
| 46 |
+
"transaction_count": int,
|
| 47 |
+
"status": str # "success", "password_required", "error"
|
| 48 |
+
}
|
| 49 |
+
],
|
| 50 |
+
"total_transactions": int,
|
| 51 |
+
"analysis": Dict, # Financial analysis data
|
| 52 |
+
"timestamp": str # ISO format
|
| 53 |
+
}
|
| 54 |
+
```
|
| 55 |
+
|
| 56 |
+
**Example:**
|
| 57 |
+
```python
|
| 58 |
+
import modal
|
| 59 |
+
|
| 60 |
+
app = modal.App.lookup("spend-analyzer-mcp-bmt")
|
| 61 |
+
process_statements = app["process_bank_statements"]
|
| 62 |
+
|
| 63 |
+
email_config = {
|
| 64 |
+
"email": "[email protected]",
|
| 65 |
+
"password": "app_password",
|
| 66 |
+
"imap_server": "imap.gmail.com"
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
result = process_statements.remote(email_config, days_back=30)
|
| 70 |
+
print(f"Processed {result['total_transactions']} transactions")
|
| 71 |
+
```
|
| 72 |
+
|
| 73 |
+
### 2. `analyze_uploaded_statements`
|
| 74 |
+
|
| 75 |
+
Analyze directly uploaded PDF statements.
|
| 76 |
+
|
| 77 |
+
**Function Signature:**
|
| 78 |
+
```python
|
| 79 |
+
def analyze_uploaded_statements(
|
| 80 |
+
pdf_contents: Dict[str, bytes],
|
| 81 |
+
passwords: Optional[Dict] = None
|
| 82 |
+
) -> Dict
|
| 83 |
+
```
|
| 84 |
+
|
| 85 |
+
**Parameters:**
|
| 86 |
+
- `pdf_contents` (Dict[str, bytes]): Mapping of filename to PDF content
|
| 87 |
+
- `passwords` (Dict, optional): PDF passwords by filename
|
| 88 |
+
|
| 89 |
+
**Returns:**
|
| 90 |
+
```python
|
| 91 |
+
{
|
| 92 |
+
"processed_files": [
|
| 93 |
+
{
|
| 94 |
+
"filename": str,
|
| 95 |
+
"bank": str,
|
| 96 |
+
"account": str,
|
| 97 |
+
"transaction_count": int,
|
| 98 |
+
"status": str
|
| 99 |
+
}
|
| 100 |
+
],
|
| 101 |
+
"total_transactions": int,
|
| 102 |
+
"analysis": Dict
|
| 103 |
+
}
|
| 104 |
+
```
|
| 105 |
+
|
| 106 |
+
**Example:**
|
| 107 |
+
```python
|
| 108 |
+
# Read PDF files
|
| 109 |
+
pdf_contents = {}
|
| 110 |
+
with open("statement1.pdf", "rb") as f:
|
| 111 |
+
pdf_contents["statement1.pdf"] = f.read()
|
| 112 |
+
|
| 113 |
+
analyze_pdfs = app["analyze_uploaded_statements"]
|
| 114 |
+
result = analyze_pdfs.remote(pdf_contents)
|
| 115 |
+
```
|
| 116 |
+
|
| 117 |
+
### 3. `get_ai_analysis`
|
| 118 |
+
|
| 119 |
+
Get AI-powered financial analysis using Claude or SambaNova.
|
| 120 |
+
|
| 121 |
+
**Function Signature:**
|
| 122 |
+
```python
|
| 123 |
+
def get_ai_analysis(
|
| 124 |
+
analysis_data: Dict,
|
| 125 |
+
user_question: str = "",
|
| 126 |
+
provider: str = "claude"
|
| 127 |
+
) -> Dict
|
| 128 |
+
```
|
| 129 |
+
|
| 130 |
+
**Parameters:**
|
| 131 |
+
- `analysis_data` (Dict): Financial analysis data
|
| 132 |
+
- `user_question` (str): Specific question for the AI
|
| 133 |
+
- `provider` (str): "claude" or "sambanova"
|
| 134 |
+
|
| 135 |
+
**Returns:**
|
| 136 |
+
```python
|
| 137 |
+
{
|
| 138 |
+
"ai_analysis": str, # AI-generated analysis text
|
| 139 |
+
"provider": str, # AI provider used
|
| 140 |
+
"model": str, # Model name
|
| 141 |
+
"usage": {
|
| 142 |
+
"input_tokens": int,
|
| 143 |
+
"output_tokens": int,
|
| 144 |
+
"total_tokens": int
|
| 145 |
+
}
|
| 146 |
+
}
|
| 147 |
+
```
|
| 148 |
+
|
| 149 |
+
**Example:**
|
| 150 |
+
```python
|
| 151 |
+
get_analysis = app["get_ai_analysis"]
|
| 152 |
+
|
| 153 |
+
analysis_data = {
|
| 154 |
+
"spending_insights": [...],
|
| 155 |
+
"financial_summary": {...},
|
| 156 |
+
"recommendations": [...]
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
# Use Claude for detailed analysis
|
| 160 |
+
claude_result = get_analysis.remote(
|
| 161 |
+
analysis_data,
|
| 162 |
+
"What are my biggest spending risks?",
|
| 163 |
+
"claude"
|
| 164 |
+
)
|
| 165 |
+
|
| 166 |
+
# Use SambaNova for quick insights
|
| 167 |
+
sambanova_result = get_analysis.remote(
|
| 168 |
+
analysis_data,
|
| 169 |
+
"Quick spending summary",
|
| 170 |
+
"sambanova"
|
| 171 |
+
)
|
| 172 |
+
```
|
| 173 |
+
|
| 174 |
+
### 4. `save_user_data` / `load_user_data`
|
| 175 |
+
|
| 176 |
+
Persistent storage for user analysis data.
|
| 177 |
+
|
| 178 |
+
**Save Function:**
|
| 179 |
+
```python
|
| 180 |
+
def save_user_data(user_id: str, data: Dict) -> Dict
|
| 181 |
+
```
|
| 182 |
+
|
| 183 |
+
**Load Function:**
|
| 184 |
+
```python
|
| 185 |
+
def load_user_data(user_id: str) -> Dict
|
| 186 |
+
```
|
| 187 |
+
|
| 188 |
+
**Example:**
|
| 189 |
+
```python
|
| 190 |
+
save_data = app["save_user_data"]
|
| 191 |
+
load_data = app["load_user_data"]
|
| 192 |
+
|
| 193 |
+
# Save user analysis
|
| 194 |
+
save_result = save_data.remote("user123", analysis_data)
|
| 195 |
+
|
| 196 |
+
# Load user analysis
|
| 197 |
+
load_result = load_data.remote("user123")
|
| 198 |
+
if load_result["status"] == "found":
|
| 199 |
+
user_data = load_result["data"]
|
| 200 |
+
```
|
| 201 |
+
|
| 202 |
+
## MCP Protocol Integration
|
| 203 |
+
|
| 204 |
+
### Webhook Endpoint
|
| 205 |
+
|
| 206 |
+
The system provides an MCP webhook endpoint for external integrations:
|
| 207 |
+
|
| 208 |
+
**URL:** `https://your-modal-app.modal.run/mcp_webhook`
|
| 209 |
+
**Method:** POST
|
| 210 |
+
**Content-Type:** application/json
|
| 211 |
+
|
| 212 |
+
### MCP Tools
|
| 213 |
+
|
| 214 |
+
#### 1. `process_email_statements`
|
| 215 |
+
|
| 216 |
+
**Description:** Process bank statements from email
|
| 217 |
+
**Input Schema:**
|
| 218 |
+
```json
|
| 219 |
+
{
|
| 220 |
+
"type": "object",
|
| 221 |
+
"properties": {
|
| 222 |
+
"email_config": {
|
| 223 |
+
"type": "object",
|
| 224 |
+
"properties": {
|
| 225 |
+
"email": {"type": "string"},
|
| 226 |
+
"password": {"type": "string"},
|
| 227 |
+
"imap_server": {"type": "string"}
|
| 228 |
+
}
|
| 229 |
+
},
|
| 230 |
+
"days_back": {"type": "integer", "default": 30},
|
| 231 |
+
"passwords": {"type": "object"}
|
| 232 |
+
}
|
| 233 |
+
}
|
| 234 |
+
```
|
| 235 |
+
|
| 236 |
+
#### 2. `analyze_pdf_statements`
|
| 237 |
+
|
| 238 |
+
**Description:** Analyze uploaded PDF statements
|
| 239 |
+
**Input Schema:**
|
| 240 |
+
```json
|
| 241 |
+
{
|
| 242 |
+
"type": "object",
|
| 243 |
+
"properties": {
|
| 244 |
+
"pdf_contents": {"type": "object"},
|
| 245 |
+
"passwords": {"type": "object"}
|
| 246 |
+
}
|
| 247 |
+
}
|
| 248 |
+
```
|
| 249 |
+
|
| 250 |
+
#### 3. `get_ai_analysis`
|
| 251 |
+
|
| 252 |
+
**Description:** Get AI financial analysis
|
| 253 |
+
**Input Schema:**
|
| 254 |
+
```json
|
| 255 |
+
{
|
| 256 |
+
"type": "object",
|
| 257 |
+
"properties": {
|
| 258 |
+
"analysis_data": {"type": "object"},
|
| 259 |
+
"user_question": {"type": "string"},
|
| 260 |
+
"provider": {"type": "string", "enum": ["claude", "sambanova"]}
|
| 261 |
+
}
|
| 262 |
+
}
|
| 263 |
+
```
|
| 264 |
+
|
| 265 |
+
### MCP Message Examples
|
| 266 |
+
|
| 267 |
+
**Initialize:**
|
| 268 |
+
```json
|
| 269 |
+
{
|
| 270 |
+
"jsonrpc": "2.0",
|
| 271 |
+
"id": "1",
|
| 272 |
+
"method": "initialize",
|
| 273 |
+
"params": {}
|
| 274 |
+
}
|
| 275 |
+
```
|
| 276 |
+
|
| 277 |
+
**List Tools:**
|
| 278 |
+
```json
|
| 279 |
+
{
|
| 280 |
+
"jsonrpc": "2.0",
|
| 281 |
+
"id": "2",
|
| 282 |
+
"method": "tools/list"
|
| 283 |
+
}
|
| 284 |
+
```
|
| 285 |
+
|
| 286 |
+
**Call Tool:**
|
| 287 |
+
```json
|
| 288 |
+
{
|
| 289 |
+
"jsonrpc": "2.0",
|
| 290 |
+
"id": "3",
|
| 291 |
+
"method": "tools/call",
|
| 292 |
+
"params": {
|
| 293 |
+
"name": "get_ai_analysis",
|
| 294 |
+
"arguments": {
|
| 295 |
+
"analysis_data": {...},
|
| 296 |
+
"user_question": "How can I save money?",
|
| 297 |
+
"provider": "claude"
|
| 298 |
+
}
|
| 299 |
+
}
|
| 300 |
+
}
|
| 301 |
+
```
|
| 302 |
+
|
| 303 |
+
## Local Python API
|
| 304 |
+
|
| 305 |
+
### SpendAnalyzer Class
|
| 306 |
+
|
| 307 |
+
```python
|
| 308 |
+
from spend_analyzer import SpendAnalyzer
|
| 309 |
+
|
| 310 |
+
analyzer = SpendAnalyzer()
|
| 311 |
+
|
| 312 |
+
# Load transactions
|
| 313 |
+
analyzer.load_transactions(transactions_list)
|
| 314 |
+
|
| 315 |
+
# Set budgets
|
| 316 |
+
analyzer.set_budgets({
|
| 317 |
+
"Food & Dining": 500,
|
| 318 |
+
"Shopping": 300,
|
| 319 |
+
"Gas & Transport": 200
|
| 320 |
+
})
|
| 321 |
+
|
| 322 |
+
# Get insights
|
| 323 |
+
insights = analyzer.analyze_spending_by_category()
|
| 324 |
+
alerts = analyzer.check_budget_alerts()
|
| 325 |
+
summary = analyzer.generate_financial_summary()
|
| 326 |
+
recommendations = analyzer.get_spending_recommendations()
|
| 327 |
+
|
| 328 |
+
# Export all data
|
| 329 |
+
export_data = analyzer.export_analysis_data()
|
| 330 |
+
```
|
| 331 |
+
|
| 332 |
+
### EmailProcessor Class
|
| 333 |
+
|
| 334 |
+
```python
|
| 335 |
+
from email_processor import EmailProcessor
|
| 336 |
+
|
| 337 |
+
email_config = {
|
| 338 |
+
"email": "[email protected]",
|
| 339 |
+
"password": "app_password",
|
| 340 |
+
"imap_server": "imap.gmail.com"
|
| 341 |
+
}
|
| 342 |
+
|
| 343 |
+
processor = EmailProcessor(email_config)
|
| 344 |
+
|
| 345 |
+
# Fetch emails
|
| 346 |
+
emails = await processor.fetch_bank_emails(days_back=30)
|
| 347 |
+
|
| 348 |
+
# Extract attachments
|
| 349 |
+
for email in emails:
|
| 350 |
+
attachments = await processor.extract_attachments(email)
|
| 351 |
+
for filename, content, file_type in attachments:
|
| 352 |
+
if file_type == 'pdf':
|
| 353 |
+
# Process PDF
|
| 354 |
+
pass
|
| 355 |
+
```
|
| 356 |
+
|
| 357 |
+
### PDFProcessor Class
|
| 358 |
+
|
| 359 |
+
```python
|
| 360 |
+
from email_processor import PDFProcessor
|
| 361 |
+
|
| 362 |
+
processor = PDFProcessor()
|
| 363 |
+
|
| 364 |
+
# Process PDF
|
| 365 |
+
with open("statement.pdf", "rb") as f:
|
| 366 |
+
pdf_content = f.read()
|
| 367 |
+
|
| 368 |
+
statement_info = await processor.process_pdf(pdf_content, password="optional")
|
| 369 |
+
|
| 370 |
+
print(f"Bank: {statement_info.bank_name}")
|
| 371 |
+
print(f"Account: {statement_info.account_number}")
|
| 372 |
+
print(f"Transactions: {len(statement_info.transactions)}")
|
| 373 |
+
```
|
| 374 |
+
|
| 375 |
+
## Data Formats
|
| 376 |
+
|
| 377 |
+
### Transaction Format
|
| 378 |
+
|
| 379 |
+
```python
|
| 380 |
+
{
|
| 381 |
+
"date": "2024-01-15T00:00:00",
|
| 382 |
+
"description": "Amazon Purchase",
|
| 383 |
+
"amount": -45.67,
|
| 384 |
+
"category": "Shopping",
|
| 385 |
+
"account": "****1234",
|
| 386 |
+
"balance": 1500.33
|
| 387 |
+
}
|
| 388 |
+
```
|
| 389 |
+
|
| 390 |
+
### Financial Summary Format
|
| 391 |
+
|
| 392 |
+
```python
|
| 393 |
+
{
|
| 394 |
+
"total_income": 3000.0,
|
| 395 |
+
"total_expenses": 1500.0,
|
| 396 |
+
"net_cash_flow": 1500.0,
|
| 397 |
+
"largest_expense": {
|
| 398 |
+
"amount": 200.0,
|
| 399 |
+
"description": "Grocery Store",
|
| 400 |
+
"date": "2024-01-15",
|
| 401 |
+
"category": "Food & Dining"
|
| 402 |
+
},
|
| 403 |
+
"most_frequent_category": "Food & Dining",
|
| 404 |
+
"unusual_transactions": [...],
|
| 405 |
+
"monthly_trends": {...}
|
| 406 |
+
}
|
| 407 |
+
```
|
| 408 |
+
|
| 409 |
+
### Spending Insight Format
|
| 410 |
+
|
| 411 |
+
```python
|
| 412 |
+
{
|
| 413 |
+
"category": "Food & Dining",
|
| 414 |
+
"total_amount": 500.0,
|
| 415 |
+
"transaction_count": 15,
|
| 416 |
+
"average_transaction": 33.33,
|
| 417 |
+
"percentage_of_total": 33.3,
|
| 418 |
+
"trend": "increasing",
|
| 419 |
+
"top_merchants": ["Restaurant A", "Grocery Store", "Cafe B"]
|
| 420 |
+
}
|
| 421 |
+
```
|
| 422 |
+
|
| 423 |
+
### Budget Alert Format
|
| 424 |
+
|
| 425 |
+
```python
|
| 426 |
+
{
|
| 427 |
+
"category": "Food & Dining",
|
| 428 |
+
"budget_limit": 500.0,
|
| 429 |
+
"current_spending": 450.0,
|
| 430 |
+
"percentage_used": 90.0,
|
| 431 |
+
"alert_level": "warning",
|
| 432 |
+
"days_remaining": 10
|
| 433 |
+
}
|
| 434 |
+
```
|
| 435 |
+
|
| 436 |
+
## Error Handling
|
| 437 |
+
|
| 438 |
+
### Common Error Responses
|
| 439 |
+
|
| 440 |
+
**Authentication Error:**
|
| 441 |
+
```python
|
| 442 |
+
{
|
| 443 |
+
"error": "Invalid API key or authentication failed",
|
| 444 |
+
"code": "AUTH_ERROR"
|
| 445 |
+
}
|
| 446 |
+
```
|
| 447 |
+
|
| 448 |
+
**PDF Password Error:**
|
| 449 |
+
```python
|
| 450 |
+
{
|
| 451 |
+
"error": "PDF requires password",
|
| 452 |
+
"code": "PASSWORD_REQUIRED",
|
| 453 |
+
"filename": "statement.pdf"
|
| 454 |
+
}
|
| 455 |
+
```
|
| 456 |
+
|
| 457 |
+
**Processing Error:**
|
| 458 |
+
```python
|
| 459 |
+
{
|
| 460 |
+
"error": "Failed to parse PDF content",
|
| 461 |
+
"code": "PARSE_ERROR",
|
| 462 |
+
"details": "Unsupported PDF format"
|
| 463 |
+
}
|
| 464 |
+
```
|
| 465 |
+
|
| 466 |
+
**Rate Limit Error:**
|
| 467 |
+
```python
|
| 468 |
+
{
|
| 469 |
+
"error": "API rate limit exceeded",
|
| 470 |
+
"code": "RATE_LIMIT",
|
| 471 |
+
"retry_after": 60
|
| 472 |
+
}
|
| 473 |
+
```
|
| 474 |
+
|
| 475 |
+
### Error Handling Best Practices
|
| 476 |
+
|
| 477 |
+
1. **Always check for errors** in API responses
|
| 478 |
+
2. **Implement retry logic** for transient failures
|
| 479 |
+
3. **Handle password-protected PDFs** gracefully
|
| 480 |
+
4. **Monitor API usage** to avoid rate limits
|
| 481 |
+
5. **Log errors** for debugging
|
| 482 |
+
|
| 483 |
+
## Examples
|
| 484 |
+
|
| 485 |
+
### Complete Workflow Example
|
| 486 |
+
|
| 487 |
+
```python
|
| 488 |
+
import modal
|
| 489 |
+
import asyncio
|
| 490 |
+
|
| 491 |
+
async def analyze_finances():
|
| 492 |
+
# Connect to Modal app
|
| 493 |
+
app = modal.App.lookup("spend-analyzer-mcp-bmt")
|
| 494 |
+
|
| 495 |
+
# Process email statements
|
| 496 |
+
email_config = {
|
| 497 |
+
"email": "[email protected]",
|
| 498 |
+
"password": "app_password",
|
| 499 |
+
"imap_server": "imap.gmail.com"
|
| 500 |
+
}
|
| 501 |
+
|
| 502 |
+
process_statements = app["process_bank_statements"]
|
| 503 |
+
email_result = process_statements.remote(email_config, days_back=30)
|
| 504 |
+
|
| 505 |
+
# Upload additional PDFs
|
| 506 |
+
pdf_contents = {}
|
| 507 |
+
with open("additional_statement.pdf", "rb") as f:
|
| 508 |
+
pdf_contents["additional.pdf"] = f.read()
|
| 509 |
+
|
| 510 |
+
analyze_pdfs = app["analyze_uploaded_statements"]
|
| 511 |
+
pdf_result = analyze_pdfs.remote(pdf_contents)
|
| 512 |
+
|
| 513 |
+
# Combine analysis data
|
| 514 |
+
combined_analysis = {
|
| 515 |
+
**email_result["analysis"],
|
| 516 |
+
"additional_transactions": pdf_result["total_transactions"]
|
| 517 |
+
}
|
| 518 |
+
|
| 519 |
+
# Get AI analysis
|
| 520 |
+
get_analysis = app["get_ai_analysis"]
|
| 521 |
+
|
| 522 |
+
# Use Claude for detailed analysis
|
| 523 |
+
claude_analysis = get_analysis.remote(
|
| 524 |
+
combined_analysis,
|
| 525 |
+
"Provide a comprehensive financial health assessment",
|
| 526 |
+
"claude"
|
| 527 |
+
)
|
| 528 |
+
|
| 529 |
+
# Use SambaNova for quick insights
|
| 530 |
+
sambanova_analysis = get_analysis.remote(
|
| 531 |
+
combined_analysis,
|
| 532 |
+
"What are my top 3 spending categories?",
|
| 533 |
+
"sambanova"
|
| 534 |
+
)
|
| 535 |
+
|
| 536 |
+
print("Claude Analysis:", claude_analysis["ai_analysis"])
|
| 537 |
+
print("SambaNova Analysis:", sambanova_analysis["ai_analysis"])
|
| 538 |
+
|
| 539 |
+
# Run the analysis
|
| 540 |
+
asyncio.run(analyze_finances())
|
| 541 |
+
```
|
| 542 |
+
|
| 543 |
+
### Integration with External Systems
|
| 544 |
+
|
| 545 |
+
```python
|
| 546 |
+
import requests
|
| 547 |
+
import json
|
| 548 |
+
|
| 549 |
+
def call_mcp_webhook(data):
|
| 550 |
+
"""Call the MCP webhook endpoint"""
|
| 551 |
+
webhook_url = "https://your-modal-app.modal.run/mcp_webhook"
|
| 552 |
+
|
| 553 |
+
mcp_message = {
|
| 554 |
+
"jsonrpc": "2.0",
|
| 555 |
+
"id": "1",
|
| 556 |
+
"method": "tools/call",
|
| 557 |
+
"params": {
|
| 558 |
+
"name": "get_ai_analysis",
|
| 559 |
+
"arguments": data
|
| 560 |
+
}
|
| 561 |
+
}
|
| 562 |
+
|
| 563 |
+
response = requests.post(
|
| 564 |
+
webhook_url,
|
| 565 |
+
json=mcp_message,
|
| 566 |
+
headers={"Content-Type": "application/json"}
|
| 567 |
+
)
|
| 568 |
+
|
| 569 |
+
return response.json()
|
| 570 |
+
|
| 571 |
+
# Use the webhook
|
| 572 |
+
analysis_data = {"spending_insights": [...]}
|
| 573 |
+
result = call_mcp_webhook(analysis_data)
|
| 574 |
+
```
|
| 575 |
+
|
| 576 |
+
## Rate Limits and Quotas
|
| 577 |
+
|
| 578 |
+
### Claude API
|
| 579 |
+
- **Rate Limit:** 1000 requests/minute
|
| 580 |
+
- **Token Limit:** 100K tokens/minute
|
| 581 |
+
- **Best Practice:** Use for complex analysis
|
| 582 |
+
|
| 583 |
+
### SambaNova API
|
| 584 |
+
- **Rate Limit:** 5000 requests/minute
|
| 585 |
+
- **Token Limit:** 500K tokens/minute
|
| 586 |
+
- **Best Practice:** Use for quick insights and batch processing
|
| 587 |
+
|
| 588 |
+
### Modal Functions
|
| 589 |
+
- **Concurrent Executions:** Auto-scaled
|
| 590 |
+
- **Timeout:** Configurable per function
|
| 591 |
+
- **Memory:** 2GB default for PDF processing
|
| 592 |
+
|
| 593 |
+
## Support and Troubleshooting
|
| 594 |
+
|
| 595 |
+
### Common Issues
|
| 596 |
+
|
| 597 |
+
1. **PDF Processing Fails**
|
| 598 |
+
- Check PDF format compatibility
|
| 599 |
+
- Verify password if protected
|
| 600 |
+
- Ensure sufficient memory allocation
|
| 601 |
+
|
| 602 |
+
2. **Email Connection Issues**
|
| 603 |
+
- Use app-specific passwords
|
| 604 |
+
- Verify IMAP server settings
|
| 605 |
+
- Check firewall/network restrictions
|
| 606 |
+
|
| 607 |
+
3. **AI API Errors**
|
| 608 |
+
- Verify API keys are valid
|
| 609 |
+
- Check rate limits
|
| 610 |
+
- Monitor token usage
|
| 611 |
+
|
| 612 |
+
### Getting Help
|
| 613 |
+
|
| 614 |
+
1. Check the logs: `modal logs spend-analyzer-mcp-bmt`
|
| 615 |
+
2. Review error messages and codes
|
| 616 |
+
3. Consult the deployment guide
|
| 617 |
+
4. Open an issue with detailed error information
|
| 618 |
+
|
| 619 |
+
For more detailed information, see the [DEPLOYMENT_GUIDE.md](DEPLOYMENT_GUIDE.md) file.
|
DEPLOYMENT_GUIDE.md
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Spend Analyzer MCP - Deployment Guide
|
| 2 |
+
|
| 3 |
+
This guide covers deploying the Spend Analyzer MCP to Modal.com with Claude and SambaNova Cloud API integration.
|
| 4 |
+
|
| 5 |
+
## Prerequisites
|
| 6 |
+
|
| 7 |
+
1. **Modal Account**: Sign up at [modal.com](https://modal.com)
|
| 8 |
+
2. **API Keys**:
|
| 9 |
+
- Anthropic API key for Claude
|
| 10 |
+
- SambaNova Cloud API key
|
| 11 |
+
3. **Email Credentials**: App-specific passwords for email access
|
| 12 |
+
|
| 13 |
+
## Setup Instructions
|
| 14 |
+
|
| 15 |
+
### 1. Install Modal CLI
|
| 16 |
+
|
| 17 |
+
```bash
|
| 18 |
+
pip install modal
|
| 19 |
+
```
|
| 20 |
+
|
| 21 |
+
### 2. Authenticate with Modal
|
| 22 |
+
|
| 23 |
+
```bash
|
| 24 |
+
modal token new
|
| 25 |
+
```
|
| 26 |
+
|
| 27 |
+
### 3. Create Modal Secrets
|
| 28 |
+
|
| 29 |
+
Create the required secrets in your Modal dashboard or via CLI:
|
| 30 |
+
|
| 31 |
+
#### Anthropic API Key
|
| 32 |
+
```bash
|
| 33 |
+
modal secret create anthropic-api-key ANTHROPIC_API_KEY=your_claude_api_key_here
|
| 34 |
+
```
|
| 35 |
+
|
| 36 |
+
#### SambaNova API Key
|
| 37 |
+
```bash
|
| 38 |
+
modal secret create sambanova-api-key SAMBANOVA_API_KEY=your_sambanova_api_key_here
|
| 39 |
+
```
|
| 40 |
+
|
| 41 |
+
#### Email Credentials
|
| 42 |
+
```bash
|
| 43 |
+
modal secret create email-credentials \
|
| 44 | |
| 45 |
+
EMAIL_PASS=your_app_password \
|
| 46 |
+
IMAP_SERVER=imap.gmail.com
|
| 47 |
+
```
|
| 48 |
+
|
| 49 |
+
### 4. Deploy to Modal
|
| 50 |
+
|
| 51 |
+
```bash
|
| 52 |
+
# Deploy the application
|
| 53 |
+
modal deploy modal_deployment.py
|
| 54 |
+
|
| 55 |
+
# Or run locally for testing
|
| 56 |
+
modal run modal_deployment.py
|
| 57 |
+
```
|
| 58 |
+
|
| 59 |
+
## API Providers
|
| 60 |
+
|
| 61 |
+
### Claude (Anthropic)
|
| 62 |
+
|
| 63 |
+
- **Model**: claude-3-sonnet-20240229
|
| 64 |
+
- **Features**: Advanced reasoning, financial analysis
|
| 65 |
+
- **Setup**: Get API key from [console.anthropic.com](https://console.anthropic.com)
|
| 66 |
+
|
| 67 |
+
### SambaNova Cloud
|
| 68 |
+
|
| 69 |
+
- **Model**: Meta-Llama-3.1-8B-Instruct
|
| 70 |
+
- **Features**: Fast inference, cost-effective
|
| 71 |
+
- **Setup**: Get API key from [cloud.sambanova.ai](https://cloud.sambanova.ai)
|
| 72 |
+
- **API Format**: OpenAI-compatible
|
| 73 |
+
|
| 74 |
+
## Available Modal Functions
|
| 75 |
+
|
| 76 |
+
### 1. `process_bank_statements`
|
| 77 |
+
Process bank statements from email attachments.
|
| 78 |
+
|
| 79 |
+
**Parameters:**
|
| 80 |
+
- `email_config`: Email configuration dict
|
| 81 |
+
- `days_back`: Number of days to look back (default: 30)
|
| 82 |
+
- `passwords`: Optional PDF passwords dict
|
| 83 |
+
|
| 84 |
+
**Returns:**
|
| 85 |
+
- Processed statements list
|
| 86 |
+
- Transaction analysis
|
| 87 |
+
- Error handling for password-protected PDFs
|
| 88 |
+
|
| 89 |
+
### 2. `analyze_uploaded_statements`
|
| 90 |
+
Analyze directly uploaded PDF statements.
|
| 91 |
+
|
| 92 |
+
**Parameters:**
|
| 93 |
+
- `pdf_contents`: Dict of filename -> PDF bytes
|
| 94 |
+
- `passwords`: Optional PDF passwords dict
|
| 95 |
+
|
| 96 |
+
**Returns:**
|
| 97 |
+
- Analysis results
|
| 98 |
+
- Transaction categorization
|
| 99 |
+
- Financial insights
|
| 100 |
+
|
| 101 |
+
### 3. `get_ai_analysis`
|
| 102 |
+
Get AI-powered financial analysis.
|
| 103 |
+
|
| 104 |
+
**Parameters:**
|
| 105 |
+
- `analysis_data`: Financial data dict
|
| 106 |
+
- `user_question`: Optional specific question
|
| 107 |
+
- `provider`: "claude" or "sambanova" (default: "claude")
|
| 108 |
+
|
| 109 |
+
**Returns:**
|
| 110 |
+
- AI analysis text
|
| 111 |
+
- Usage statistics
|
| 112 |
+
- Provider information
|
| 113 |
+
|
| 114 |
+
### 4. `save_user_data` / `load_user_data`
|
| 115 |
+
Persistent storage for user analysis data.
|
| 116 |
+
|
| 117 |
+
**Features:**
|
| 118 |
+
- User-specific data isolation
|
| 119 |
+
- Timestamp tracking
|
| 120 |
+
- JSON serialization
|
| 121 |
+
|
| 122 |
+
### 5. `mcp_webhook`
|
| 123 |
+
MCP protocol endpoint for external integrations.
|
| 124 |
+
|
| 125 |
+
**Features:**
|
| 126 |
+
- Tool registration
|
| 127 |
+
- Resource management
|
| 128 |
+
- Error handling
|
| 129 |
+
|
| 130 |
+
## Environment Variables
|
| 131 |
+
|
| 132 |
+
The following environment variables are automatically available in Modal functions:
|
| 133 |
+
|
| 134 |
+
```bash
|
| 135 |
+
ANTHROPIC_API_KEY=your_claude_key
|
| 136 |
+
SAMBANOVA_API_KEY=your_sambanova_key
|
| 137 |
+
EMAIL_USER=your_email
|
| 138 |
+
EMAIL_PASS=your_app_password
|
| 139 |
+
IMAP_SERVER=your_imap_server
|
| 140 |
+
```
|
| 141 |
+
|
| 142 |
+
## Usage Examples
|
| 143 |
+
|
| 144 |
+
### Basic Deployment Test
|
| 145 |
+
|
| 146 |
+
```python
|
| 147 |
+
import modal
|
| 148 |
+
|
| 149 |
+
# Test the deployment
|
| 150 |
+
app = modal.App.lookup("spend-analyzer-mcp-bmt")
|
| 151 |
+
get_ai_analysis = app["get_ai_analysis"]
|
| 152 |
+
|
| 153 |
+
# Test with sample data
|
| 154 |
+
test_data = {
|
| 155 |
+
"spending_insights": [
|
| 156 |
+
{
|
| 157 |
+
"category": "Food & Dining",
|
| 158 |
+
"total_amount": 500.0,
|
| 159 |
+
"transaction_count": 15
|
| 160 |
+
}
|
| 161 |
+
],
|
| 162 |
+
"recommendations": ["Consider reducing dining expenses"]
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
result = get_ai_analysis.remote(
|
| 166 |
+
analysis_data=test_data,
|
| 167 |
+
user_question="How can I save money on food?",
|
| 168 |
+
provider="claude"
|
| 169 |
+
)
|
| 170 |
+
|
| 171 |
+
print(result)
|
| 172 |
+
```
|
| 173 |
+
|
| 174 |
+
### Email Processing
|
| 175 |
+
|
| 176 |
+
```python
|
| 177 |
+
email_config = {
|
| 178 |
+
"email": "[email protected]",
|
| 179 |
+
"password": "your_app_password",
|
| 180 |
+
"imap_server": "imap.gmail.com"
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
result = process_bank_statements.remote(
|
| 184 |
+
email_config=email_config,
|
| 185 |
+
days_back=30
|
| 186 |
+
)
|
| 187 |
+
|
| 188 |
+
print(f"Processed {result['total_transactions']} transactions")
|
| 189 |
+
```
|
| 190 |
+
|
| 191 |
+
### PDF Analysis
|
| 192 |
+
|
| 193 |
+
```python
|
| 194 |
+
# Read PDF file
|
| 195 |
+
with open("statement.pdf", "rb") as f:
|
| 196 |
+
pdf_content = f.read()
|
| 197 |
+
|
| 198 |
+
pdf_contents = {"statement.pdf": pdf_content}
|
| 199 |
+
|
| 200 |
+
result = analyze_uploaded_statements.remote(
|
| 201 |
+
pdf_contents=pdf_contents,
|
| 202 |
+
passwords={"statement.pdf": "optional_password"}
|
| 203 |
+
)
|
| 204 |
+
|
| 205 |
+
print(result['analysis'])
|
| 206 |
+
```
|
| 207 |
+
|
| 208 |
+
## Monitoring and Logs
|
| 209 |
+
|
| 210 |
+
### View Logs
|
| 211 |
+
```bash
|
| 212 |
+
modal logs spend-analyzer-mcp-bmt
|
| 213 |
+
```
|
| 214 |
+
|
| 215 |
+
### Monitor Functions
|
| 216 |
+
```bash
|
| 217 |
+
modal stats spend-analyzer-mcp-bmt
|
| 218 |
+
```
|
| 219 |
+
|
| 220 |
+
### View Volumes
|
| 221 |
+
```bash
|
| 222 |
+
modal volume list
|
| 223 |
+
```
|
| 224 |
+
|
| 225 |
+
## Troubleshooting
|
| 226 |
+
|
| 227 |
+
### Common Issues
|
| 228 |
+
|
| 229 |
+
1. **Import Errors**: Ensure all dependencies are in the Modal image
|
| 230 |
+
2. **Secret Access**: Verify secrets are created with correct names
|
| 231 |
+
3. **PDF Processing**: Check file permissions and password requirements
|
| 232 |
+
4. **API Limits**: Monitor usage for both Claude and SambaNova
|
| 233 |
+
|
| 234 |
+
### Debug Mode
|
| 235 |
+
|
| 236 |
+
Enable debug logging in Modal functions:
|
| 237 |
+
|
| 238 |
+
```python
|
| 239 |
+
import logging
|
| 240 |
+
logging.basicConfig(level=logging.DEBUG)
|
| 241 |
+
```
|
| 242 |
+
|
| 243 |
+
### Local Testing
|
| 244 |
+
|
| 245 |
+
Test functions locally before deployment:
|
| 246 |
+
|
| 247 |
+
```bash
|
| 248 |
+
modal run modal_deployment.py::main
|
| 249 |
+
```
|
| 250 |
+
|
| 251 |
+
## Security Considerations
|
| 252 |
+
|
| 253 |
+
1. **API Keys**: Store in Modal secrets, never in code
|
| 254 |
+
2. **Email Passwords**: Use app-specific passwords
|
| 255 |
+
3. **PDF Data**: Processed in memory, not stored permanently
|
| 256 |
+
4. **User Data**: Isolated by user ID in persistent storage
|
| 257 |
+
|
| 258 |
+
## Cost Optimization
|
| 259 |
+
|
| 260 |
+
1. **Function Timeouts**: Set appropriate timeouts for each function
|
| 261 |
+
2. **Memory Allocation**: Adjust based on PDF processing needs
|
| 262 |
+
3. **API Provider**: Choose between Claude (quality) and SambaNova (cost)
|
| 263 |
+
4. **Batch Processing**: Process multiple PDFs in single function call
|
| 264 |
+
|
| 265 |
+
## Scaling
|
| 266 |
+
|
| 267 |
+
Modal automatically handles scaling based on demand:
|
| 268 |
+
|
| 269 |
+
- **Cold Starts**: ~2-3 seconds for new containers
|
| 270 |
+
- **Warm Containers**: Sub-second response times
|
| 271 |
+
- **Concurrent Requests**: Automatically scaled
|
| 272 |
+
- **Resource Limits**: Configurable per function
|
| 273 |
+
|
| 274 |
+
## Integration with MCP
|
| 275 |
+
|
| 276 |
+
The deployment includes a webhook endpoint for MCP integration:
|
| 277 |
+
|
| 278 |
+
```
|
| 279 |
+
POST https://your-modal-app.modal.run/mcp_webhook
|
| 280 |
+
```
|
| 281 |
+
|
| 282 |
+
This enables integration with Claude Desktop and other MCP clients.
|
| 283 |
+
|
| 284 |
+
## Support
|
| 285 |
+
|
| 286 |
+
For deployment issues:
|
| 287 |
+
|
| 288 |
+
1. Check Modal logs and documentation
|
| 289 |
+
2. Verify API key permissions
|
| 290 |
+
3. Test with minimal examples
|
| 291 |
+
4. Contact Modal support for platform issues
|
| 292 |
+
|
| 293 |
+
## Next Steps
|
| 294 |
+
|
| 295 |
+
After successful deployment:
|
| 296 |
+
|
| 297 |
+
1. Test all functions with real data
|
| 298 |
+
2. Set up monitoring and alerts
|
| 299 |
+
3. Configure backup strategies
|
| 300 |
+
4. Implement additional security measures
|
| 301 |
+
5. Scale based on usage patterns
|
README.md
CHANGED
|
@@ -19,22 +19,24 @@ A comprehensive financial analysis tool that processes bank statements from emai
|
|
| 19 |
|
| 20 |
## Features
|
| 21 |
|
| 22 |
-
- **📧 Email Processing**: Automatically fetch and process bank statements from your email
|
| 23 |
- **📄 PDF Upload**: Direct upload and analysis of bank statement PDFs
|
| 24 |
- **📊 Analysis Dashboard**: Interactive charts and financial summaries
|
| 25 |
-
- **🤖 AI Financial Advisor**: Chat with Claude for personalized financial advice
|
| 26 |
- **⚙️ Settings & Configuration**: Customize budgets, email settings, and export options
|
| 27 |
- **🔐 Security**: Password-protected PDF support and secure email connections
|
|
|
|
|
|
|
| 28 |
|
| 29 |
## Architecture
|
| 30 |
|
| 31 |
The project consists of several key components:
|
| 32 |
|
| 33 |
-
1. **`
|
| 34 |
-
2. **`spend_analyzer.py`** - Core financial analysis engine
|
| 35 |
-
3. **`email_processor.py`** - Email and PDF processing
|
| 36 |
-
4. **`modal_deployment.py`** - Modal.com cloud deployment
|
| 37 |
5. **`mcp_server.py`** - Model Context Protocol server implementation
|
|
|
|
| 38 |
|
| 39 |
## Installation
|
| 40 |
|
|
@@ -47,14 +49,33 @@ pip install -r requirements.txt
|
|
| 47 |
```bash
|
| 48 |
# Create .env file
|
| 49 |
ANTHROPIC_API_KEY=your_claude_api_key
|
|
|
|
| 50 | |
| 51 |
EMAIL_PASS=your_app_password
|
| 52 |
IMAP_SERVER=imap.gmail.com
|
| 53 |
```
|
| 54 |
|
| 55 |
-
3. For Modal deployment (
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
```bash
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
modal token new
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
modal deploy modal_deployment.py
|
| 59 |
```
|
| 60 |
|
|
@@ -62,12 +83,18 @@ modal deploy modal_deployment.py
|
|
| 62 |
|
| 63 |
### Local Development
|
| 64 |
|
| 65 |
-
Run the Gradio interface locally:
|
| 66 |
```bash
|
| 67 |
-
python
|
| 68 |
```
|
| 69 |
|
| 70 |
-
The interface will be available at `http://localhost:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
|
| 72 |
### Features Overview
|
| 73 |
|
|
@@ -101,6 +128,31 @@ The interface will be available at `http://localhost:7860`
|
|
| 101 |
- **Email Settings**: Configure email providers and auto-processing
|
| 102 |
- **Export Settings**: Choose data export formats (JSON, CSV, Excel)
|
| 103 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
## MCP Integration
|
| 105 |
|
| 106 |
This project implements the Model Context Protocol (MCP) for integration with Claude and other AI systems:
|
|
@@ -108,6 +160,7 @@ This project implements the Model Context Protocol (MCP) for integration with Cl
|
|
| 108 |
- **Tools**: Process statements, analyze PDFs, get AI insights
|
| 109 |
- **Resources**: Access financial data and analysis results
|
| 110 |
- **Server**: Full MCP server implementation for external integrations
|
|
|
|
| 111 |
|
| 112 |
## Security Considerations
|
| 113 |
|
|
@@ -120,12 +173,13 @@ This project implements the Model Context Protocol (MCP) for integration with Cl
|
|
| 120 |
|
| 121 |
### Project Structure
|
| 122 |
```
|
| 123 |
-
spend-analyzer-mcp/
|
| 124 |
-
├──
|
| 125 |
├── spend_analyzer.py # Financial analysis engine
|
| 126 |
-
├── email_processor.py # Email/PDF processing
|
| 127 |
├── modal_deployment.py # Cloud deployment
|
| 128 |
├── mcp_server.py # MCP protocol server
|
|
|
|
| 129 |
├── requirements.txt # Dependencies
|
| 130 |
└── README.md # This file
|
| 131 |
```
|
|
@@ -164,8 +218,8 @@ FROM python:3.11-slim
|
|
| 164 |
COPY . /app
|
| 165 |
WORKDIR /app
|
| 166 |
RUN pip install -r requirements.txt
|
| 167 |
-
EXPOSE
|
| 168 |
-
CMD ["python", "
|
| 169 |
```
|
| 170 |
|
| 171 |
## Contributing
|
|
@@ -189,10 +243,13 @@ For issues and questions:
|
|
| 189 |
|
| 190 |
## Roadmap
|
| 191 |
|
|
|
|
| 192 |
- [ ] Support for more bank formats
|
| 193 |
- [ ] Real-time transaction monitoring
|
| 194 |
- [ ] Mobile app interface
|
| 195 |
- [ ] Advanced ML-based categorization
|
| 196 |
- [ ] Integration with financial planning tools
|
| 197 |
-
- [
|
| 198 |
- [ ] Automated bill tracking
|
|
|
|
|
|
|
|
|
| 19 |
|
| 20 |
## Features
|
| 21 |
|
|
|
|
| 22 |
- **📄 PDF Upload**: Direct upload and analysis of bank statement PDFs
|
| 23 |
- **📊 Analysis Dashboard**: Interactive charts and financial summaries
|
| 24 |
+
- **🤖 AI Financial Advisor**: Chat with Claude or SambaNova for personalized financial advice
|
| 25 |
- **⚙️ Settings & Configuration**: Customize budgets, email settings, and export options
|
| 26 |
- **🔐 Security**: Password-protected PDF support and secure email connections
|
| 27 |
+
- **☁️ Cloud Deployment**: Ready-to-deploy Modal.com configuration
|
| 28 |
+
- **🔌 MCP Integration**: Full Model Context Protocol support for external AI systems
|
| 29 |
|
| 30 |
## Architecture
|
| 31 |
|
| 32 |
The project consists of several key components:
|
| 33 |
|
| 34 |
+
1. **`app.py`** - Main web interface built with Gradio (primary application entry point)
|
| 35 |
+
2. **`spend_analyzer.py`** - Core financial analysis engine with ML-based insights
|
| 36 |
+
3. **`email_processor.py`** - Email and PDF processing with multi-bank support and currency detection
|
| 37 |
+
4. **`modal_deployment.py`** - Enhanced Modal.com cloud deployment with dual AI providers
|
| 38 |
5. **`mcp_server.py`** - Model Context Protocol server implementation
|
| 39 |
+
6. **`DEPLOYMENT_GUIDE.md`** - Comprehensive deployment and setup guide
|
| 40 |
|
| 41 |
## Installation
|
| 42 |
|
|
|
|
| 49 |
```bash
|
| 50 |
# Create .env file
|
| 51 |
ANTHROPIC_API_KEY=your_claude_api_key
|
| 52 |
+
SAMBANOVA_API_KEY=your_sambanova_api_key # Optional
|
| 53 | |
| 54 |
EMAIL_PASS=your_app_password
|
| 55 |
IMAP_SERVER=imap.gmail.com
|
| 56 |
```
|
| 57 |
|
| 58 |
+
3. For Modal deployment (recommended for production):
|
| 59 |
+
|
| 60 |
+
**Quick Setup (Recommended):**
|
| 61 |
+
```bash
|
| 62 |
+
python setup_modal.py
|
| 63 |
+
```
|
| 64 |
+
|
| 65 |
+
**Manual Setup:**
|
| 66 |
```bash
|
| 67 |
+
# Install Modal CLI
|
| 68 |
+
pip install modal
|
| 69 |
+
|
| 70 |
+
# Authenticate
|
| 71 |
modal token new
|
| 72 |
+
|
| 73 |
+
# Create secrets (see DEPLOYMENT_GUIDE.md for details)
|
| 74 |
+
modal secret create anthropic-api-key ANTHROPIC_API_KEY=your_key
|
| 75 |
+
modal secret create sambanova-api-key SAMBANOVA_API_KEY=your_key
|
| 76 |
+
modal secret create email-credentials EMAIL_USER=your_email EMAIL_PASS=your_password
|
| 77 |
+
|
| 78 |
+
# Deploy
|
| 79 |
modal deploy modal_deployment.py
|
| 80 |
```
|
| 81 |
|
|
|
|
| 83 |
|
| 84 |
### Local Development
|
| 85 |
|
| 86 |
+
Run the main Gradio interface locally:
|
| 87 |
```bash
|
| 88 |
+
python app.py
|
| 89 |
```
|
| 90 |
|
| 91 |
+
The interface will be available at `http://localhost:7862`
|
| 92 |
+
|
| 93 |
+
**Key Features:**
|
| 94 |
+
- **Dynamic Currency Detection**: Automatically detects currency (USD, INR, EUR, GBP, etc.) from PDF content
|
| 95 |
+
- **Multi-Bank Support**: Supports HDFC, ICICI, SBI, Axis, Chase, Bank of America, and more
|
| 96 |
+
- **Real-time Processing**: Upload and analyze actual bank statement PDFs
|
| 97 |
+
- **Interactive Dashboard**: View spending insights with detected currency formatting
|
| 98 |
|
| 99 |
### Features Overview
|
| 100 |
|
|
|
|
| 128 |
- **Email Settings**: Configure email providers and auto-processing
|
| 129 |
- **Export Settings**: Choose data export formats (JSON, CSV, Excel)
|
| 130 |
|
| 131 |
+
## AI Providers
|
| 132 |
+
|
| 133 |
+
The system supports multiple AI providers for financial analysis:
|
| 134 |
+
|
| 135 |
+
### Claude (Anthropic)
|
| 136 |
+
- **Model**: claude-3-sonnet-20240229
|
| 137 |
+
- **Strengths**: Advanced reasoning, nuanced financial advice, complex analysis
|
| 138 |
+
- **Best for**: Detailed financial planning, complex queries, high-quality insights
|
| 139 |
+
- **Cost**: Higher per token, excellent quality
|
| 140 |
+
|
| 141 |
+
### SambaNova Cloud
|
| 142 |
+
- **Model**: Meta-Llama-3.1-8B-Instruct
|
| 143 |
+
- **Strengths**: Fast inference, cost-effective, good general analysis
|
| 144 |
+
- **Best for**: Quick insights, batch processing, cost-sensitive deployments
|
| 145 |
+
- **Cost**: Lower per token, good performance
|
| 146 |
+
|
| 147 |
+
### Usage
|
| 148 |
+
```python
|
| 149 |
+
# Use Claude for detailed analysis
|
| 150 |
+
result = get_ai_analysis(data, "Detailed budget analysis", provider="claude")
|
| 151 |
+
|
| 152 |
+
# Use SambaNova for quick insights
|
| 153 |
+
result = get_ai_analysis(data, "Quick spending summary", provider="sambanova")
|
| 154 |
+
```
|
| 155 |
+
|
| 156 |
## MCP Integration
|
| 157 |
|
| 158 |
This project implements the Model Context Protocol (MCP) for integration with Claude and other AI systems:
|
|
|
|
| 160 |
- **Tools**: Process statements, analyze PDFs, get AI insights
|
| 161 |
- **Resources**: Access financial data and analysis results
|
| 162 |
- **Server**: Full MCP server implementation for external integrations
|
| 163 |
+
- **Webhook**: RESTful endpoint for external MCP clients
|
| 164 |
|
| 165 |
## Security Considerations
|
| 166 |
|
|
|
|
| 173 |
|
| 174 |
### Project Structure
|
| 175 |
```
|
| 176 |
+
spend-analyzer-mcp-bmt/
|
| 177 |
+
├── app.py # Main web interface (primary entry point)
|
| 178 |
├── spend_analyzer.py # Financial analysis engine
|
| 179 |
+
├── email_processor.py # Email/PDF processing with currency detection
|
| 180 |
├── modal_deployment.py # Cloud deployment
|
| 181 |
├── mcp_server.py # MCP protocol server
|
| 182 |
+
├── gradio_interface.py # Alternative interface
|
| 183 |
├── requirements.txt # Dependencies
|
| 184 |
└── README.md # This file
|
| 185 |
```
|
|
|
|
| 218 |
COPY . /app
|
| 219 |
WORKDIR /app
|
| 220 |
RUN pip install -r requirements.txt
|
| 221 |
+
EXPOSE 7862
|
| 222 |
+
CMD ["python", "app.py"]
|
| 223 |
```
|
| 224 |
|
| 225 |
## Contributing
|
|
|
|
| 243 |
|
| 244 |
## Roadmap
|
| 245 |
|
| 246 |
+
- [ ] **📧 Email Processing**: Automatically fetch and process bank statements from your email (Gmail, YMail, Outlook)
|
| 247 |
- [ ] Support for more bank formats
|
| 248 |
- [ ] Real-time transaction monitoring
|
| 249 |
- [ ] Mobile app interface
|
| 250 |
- [ ] Advanced ML-based categorization
|
| 251 |
- [ ] Integration with financial planning tools
|
| 252 |
+
- [x] Multi-currency support (USD, INR, EUR, GBP, CAD, AUD, JPY, CNY)
|
| 253 |
- [ ] Automated bill tracking
|
| 254 |
+
- [ ] Enhanced currency conversion features
|
| 255 |
+
- [ ] Real-time exchange rate integration
|
gradio_interface_real.py → app.py
RENAMED
|
@@ -8,26 +8,53 @@ import plotly.graph_objects as go
|
|
| 8 |
import json
|
| 9 |
import os
|
| 10 |
import asyncio
|
|
|
|
| 11 |
from typing import Dict, List, Optional, Tuple
|
| 12 |
from datetime import datetime, timedelta
|
| 13 |
import logging
|
| 14 |
import time
|
| 15 |
import tempfile
|
|
|
|
| 16 |
|
| 17 |
# Import our local modules
|
| 18 |
from email_processor import PDFProcessor
|
| 19 |
from spend_analyzer import SpendAnalyzer
|
|
|
|
|
|
|
| 20 |
|
| 21 |
class RealSpendAnalyzerInterface:
|
| 22 |
def __init__(self):
|
| 23 |
self.current_analysis = None
|
| 24 |
self.user_sessions = {}
|
|
|
|
|
|
|
| 25 |
self.logger = logging.getLogger(__name__)
|
| 26 |
logging.basicConfig(level=logging.INFO)
|
| 27 |
|
| 28 |
# Initialize processors
|
| 29 |
self.pdf_processor = PDFProcessor()
|
| 30 |
self.spend_analyzer = SpendAnalyzer()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
|
| 32 |
def create_interface(self):
|
| 33 |
"""Create the main Gradio interface"""
|
|
@@ -45,6 +72,7 @@ class RealSpendAnalyzerInterface:
|
|
| 45 |
gr.Markdown("# 💰 Spend Analyzer MCP - Real PDF Processing", elem_classes=["main-header"])
|
| 46 |
gr.Markdown("*Analyze your real bank statement PDFs with AI-powered insights*")
|
| 47 |
|
|
|
|
| 48 |
# Info notice
|
| 49 |
gr.HTML('<div class="info-box">📄 <strong>Real PDF Processing:</strong> Upload your actual bank statement PDFs for comprehensive financial analysis.</div>')
|
| 50 |
|
|
@@ -68,9 +96,55 @@ class RealSpendAnalyzerInterface:
|
|
| 68 |
# Tab 5: Settings & Export
|
| 69 |
with gr.TabItem("⚙️ Settings & Export"):
|
| 70 |
self._create_settings_tab()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
|
| 72 |
return interface
|
| 73 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
def _create_pdf_processing_tab(self):
|
| 75 |
"""Create PDF processing tab"""
|
| 76 |
gr.Markdown("## 📄 Upload & Process Bank Statement PDFs")
|
|
@@ -204,14 +278,33 @@ class RealSpendAnalyzerInterface:
|
|
| 204 |
def _create_chat_tab(self):
|
| 205 |
"""Create AI chat tab"""
|
| 206 |
gr.Markdown("## 🤖 AI Financial Advisor")
|
| 207 |
-
gr.Markdown("*Get personalized insights about your spending patterns*")
|
| 208 |
|
| 209 |
with gr.Row():
|
| 210 |
with gr.Column(scale=3):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 211 |
# Chat interface
|
| 212 |
chatbot = gr.Chatbot(
|
| 213 |
label="Financial Advisor Chat",
|
| 214 |
-
height=
|
| 215 |
show_label=True
|
| 216 |
)
|
| 217 |
|
|
@@ -240,6 +333,12 @@ class RealSpendAnalyzerInterface:
|
|
| 240 |
with gr.Column(scale=1):
|
| 241 |
chat_status = gr.HTML()
|
| 242 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 243 |
# Analysis context
|
| 244 |
gr.Markdown("### 📊 Analysis Context")
|
| 245 |
context_info = gr.JSON(
|
|
@@ -258,16 +357,33 @@ class RealSpendAnalyzerInterface:
|
|
| 258 |
# Event handlers
|
| 259 |
send_btn.click(
|
| 260 |
fn=self._handle_chat_message,
|
| 261 |
-
inputs=[msg_input, chatbot, response_style],
|
| 262 |
outputs=[chatbot, msg_input, chat_status]
|
| 263 |
)
|
| 264 |
|
| 265 |
msg_input.submit(
|
| 266 |
fn=self._handle_chat_message,
|
| 267 |
-
inputs=[msg_input, chatbot, response_style],
|
| 268 |
outputs=[chatbot, msg_input, chat_status]
|
| 269 |
)
|
| 270 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 271 |
# Quick question handlers
|
| 272 |
budget_btn.click(lambda: "How am I doing with my budget this month?", outputs=[msg_input])
|
| 273 |
trends_btn.click(lambda: "What are my spending trends over the last few months?", outputs=[msg_input])
|
|
@@ -361,6 +477,153 @@ class RealSpendAnalyzerInterface:
|
|
| 361 |
gr.Markdown("## ⚙️ Settings & Export")
|
| 362 |
|
| 363 |
with gr.Tabs():
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 364 |
with gr.TabItem("Budget Settings"):
|
| 365 |
gr.Markdown("### 💰 Monthly Budget Configuration")
|
| 366 |
|
|
@@ -460,6 +723,53 @@ class RealSpendAnalyzerInterface:
|
|
| 460 |
outputs=[processing_status]
|
| 461 |
)
|
| 462 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 463 |
# Implementation methods
|
| 464 |
def _process_real_pdfs(self, files, passwords_json, auto_categorize, detect_duplicates):
|
| 465 |
"""Process real PDF files"""
|
|
@@ -500,6 +810,32 @@ class RealSpendAnalyzerInterface:
|
|
| 500 |
self.pdf_processor.process_pdf(pdf_content, file_password)
|
| 501 |
)
|
| 502 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 503 |
# Add transactions
|
| 504 |
all_transactions.extend(statement_info.transactions)
|
| 505 |
|
|
@@ -540,11 +876,12 @@ class RealSpendAnalyzerInterface:
|
|
| 540 |
|
| 541 |
quick_stats_html = f'''
|
| 542 |
<div class="status-box info-box">
|
| 543 |
-
<h4
|
| 544 |
<ul>
|
| 545 |
-
<li><strong>
|
| 546 |
-
<li><strong>Total
|
| 547 |
-
<li><strong>
|
|
|
|
| 548 |
<li><strong>Transaction Count:</strong> {len(all_transactions)}</li>
|
| 549 |
</ul>
|
| 550 |
</div>
|
|
@@ -710,27 +1047,6 @@ class RealSpendAnalyzerInterface:
|
|
| 710 |
# For now, return empty dataframe
|
| 711 |
return pd.DataFrame(columns=["Date", "Description", "Amount", "Category", "Account"])
|
| 712 |
|
| 713 |
-
def _handle_chat_message(self, message, chat_history, response_style):
|
| 714 |
-
"""Handle chat messages"""
|
| 715 |
-
if not message.strip():
|
| 716 |
-
return chat_history, "", '<div class="status-box warning-box"> Please enter a message</div>'
|
| 717 |
-
|
| 718 |
-
# Simple response generation based on analysis
|
| 719 |
-
if self.current_analysis:
|
| 720 |
-
summary = self.current_analysis.get('financial_summary', {})
|
| 721 |
-
|
| 722 |
-
response = f"Based on your financial data: Total income ${summary.get('total_income', 0):.2f}, Total expenses ${summary.get('total_expenses', 0):.2f}. Your question: '{message}' - This is a simplified response. Full AI integration would provide detailed insights here."
|
| 723 |
-
|
| 724 |
-
status_html = '<div class="status-box success-box"> Response generated</div>'
|
| 725 |
-
else:
|
| 726 |
-
response = "Please upload and process your PDF statements first to get personalized financial insights."
|
| 727 |
-
status_html = '<div class="status-box warning-box"> No data available</div>'
|
| 728 |
-
|
| 729 |
-
# Add to chat history
|
| 730 |
-
chat_history = chat_history or []
|
| 731 |
-
chat_history.append([message, response])
|
| 732 |
-
|
| 733 |
-
return chat_history, "", status_html
|
| 734 |
|
| 735 |
def _filter_transactions(self, date_from, date_to, category_filter, amount_filter):
|
| 736 |
"""Filter transactions based on criteria"""
|
|
@@ -822,6 +1138,816 @@ class RealSpendAnalyzerInterface:
|
|
| 822 |
return ('<div class="status-box success-box"> All data cleared</div>',
|
| 823 |
'<div class="status-box info-box"> Ready for new PDF upload</div>')
|
| 824 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 825 |
# Launch the interface
|
| 826 |
def launch_interface():
|
| 827 |
"""Launch the Gradio interface"""
|
|
|
|
| 8 |
import json
|
| 9 |
import os
|
| 10 |
import asyncio
|
| 11 |
+
import requests
|
| 12 |
from typing import Dict, List, Optional, Tuple
|
| 13 |
from datetime import datetime, timedelta
|
| 14 |
import logging
|
| 15 |
import time
|
| 16 |
import tempfile
|
| 17 |
+
import threading
|
| 18 |
|
| 19 |
# Import our local modules
|
| 20 |
from email_processor import PDFProcessor
|
| 21 |
from spend_analyzer import SpendAnalyzer
|
| 22 |
+
from secure_storage_utils import SecureStorageManager
|
| 23 |
+
from mcp_server import create_mcp_app, run_mcp_server
|
| 24 |
|
| 25 |
class RealSpendAnalyzerInterface:
|
| 26 |
def __init__(self):
|
| 27 |
self.current_analysis = None
|
| 28 |
self.user_sessions = {}
|
| 29 |
+
self.detected_currency = "$" # Default currency
|
| 30 |
+
self.currency_symbol = "$" # Current currency symbol
|
| 31 |
self.logger = logging.getLogger(__name__)
|
| 32 |
logging.basicConfig(level=logging.INFO)
|
| 33 |
|
| 34 |
# Initialize processors
|
| 35 |
self.pdf_processor = PDFProcessor()
|
| 36 |
self.spend_analyzer = SpendAnalyzer()
|
| 37 |
+
self.secure_storage = SecureStorageManager()
|
| 38 |
+
|
| 39 |
+
# MCP server state
|
| 40 |
+
self.mcp_server_thread = None
|
| 41 |
+
self.mcp_server_running = False
|
| 42 |
+
self.mcp_server_logs = []
|
| 43 |
+
|
| 44 |
+
# Load API keys from environment or config file on startup
|
| 45 |
+
self._load_initial_api_settings()
|
| 46 |
+
|
| 47 |
+
# Currency detection patterns
|
| 48 |
+
self.currency_patterns = {
|
| 49 |
+
'USD': {'symbols': ['$', 'USD', 'US$'], 'regex': r'\$|USD|US\$'},
|
| 50 |
+
'INR': {'symbols': ['₹', 'Rs', 'Rs.', 'INR'], 'regex': r'₹|Rs\.?|INR'},
|
| 51 |
+
'EUR': {'symbols': ['€', 'EUR'], 'regex': r'€|EUR'},
|
| 52 |
+
'GBP': {'symbols': ['£', 'GBP'], 'regex': r'£|GBP'},
|
| 53 |
+
'CAD': {'symbols': ['C$', 'CAD'], 'regex': r'C\$|CAD'},
|
| 54 |
+
'AUD': {'symbols': ['A$', 'AUD'], 'regex': r'A\$|AUD'},
|
| 55 |
+
'JPY': {'symbols': ['¥', 'JPY'], 'regex': r'¥|JPY'},
|
| 56 |
+
'CNY': {'symbols': ['¥', 'CNY', 'RMB'], 'regex': r'CNY|RMB'},
|
| 57 |
+
}
|
| 58 |
|
| 59 |
def create_interface(self):
|
| 60 |
"""Create the main Gradio interface"""
|
|
|
|
| 72 |
gr.Markdown("# 💰 Spend Analyzer MCP - Real PDF Processing", elem_classes=["main-header"])
|
| 73 |
gr.Markdown("*Analyze your real bank statement PDFs with AI-powered insights*")
|
| 74 |
|
| 75 |
+
|
| 76 |
# Info notice
|
| 77 |
gr.HTML('<div class="info-box">📄 <strong>Real PDF Processing:</strong> Upload your actual bank statement PDFs for comprehensive financial analysis.</div>')
|
| 78 |
|
|
|
|
| 96 |
# Tab 5: Settings & Export
|
| 97 |
with gr.TabItem("⚙️ Settings & Export"):
|
| 98 |
self._create_settings_tab()
|
| 99 |
+
|
| 100 |
+
# Tab 6: MCP Server
|
| 101 |
+
with gr.TabItem("🔌 MCP Server"):
|
| 102 |
+
self._create_mcp_tab()
|
| 103 |
+
|
| 104 |
+
# AI Analysis Disclaimer
|
| 105 |
+
gr.HTML('''
|
| 106 |
+
<div class="warning-box" style="margin-top: 20px; text-align: center;">
|
| 107 |
+
⚠️ <strong>Important Notice:</strong> AI analysis results are generated automatically and may contain errors.
|
| 108 |
+
Please verify all financial insights and recommendations for accuracy before making any financial decisions.
|
| 109 |
+
</div>
|
| 110 |
+
''')
|
| 111 |
|
| 112 |
return interface
|
| 113 |
|
| 114 |
+
def detect_currency_from_text(self, text: str) -> Tuple[str, str]:
|
| 115 |
+
"""Detect currency from PDF text content"""
|
| 116 |
+
import re
|
| 117 |
+
|
| 118 |
+
text_lower = text.lower()
|
| 119 |
+
|
| 120 |
+
# Check for currency patterns in order of specificity
|
| 121 |
+
for currency_code, currency_info in self.currency_patterns.items():
|
| 122 |
+
pattern = currency_info['regex']
|
| 123 |
+
if re.search(pattern, text, re.IGNORECASE):
|
| 124 |
+
# Return currency code and primary symbol
|
| 125 |
+
return currency_code, currency_info['symbols'][0]
|
| 126 |
+
|
| 127 |
+
# Default fallback based on bank detection
|
| 128 |
+
if any(bank in text_lower for bank in ['hdfc', 'icici', 'sbi', 'axis', 'kotak']):
|
| 129 |
+
return 'INR', '₹'
|
| 130 |
+
elif any(bank in text_lower for bank in ['chase', 'bofa', 'wells', 'citi']):
|
| 131 |
+
return 'USD', '$'
|
| 132 |
+
elif any(bank in text_lower for bank in ['hsbc', 'barclays', 'lloyds']):
|
| 133 |
+
return 'GBP', '£'
|
| 134 |
+
|
| 135 |
+
# Default to USD
|
| 136 |
+
return 'USD', '$'
|
| 137 |
+
|
| 138 |
+
def update_currency_in_interface(self, currency_code: str, currency_symbol: str):
|
| 139 |
+
"""Update currency throughout the interface"""
|
| 140 |
+
self.detected_currency = currency_code
|
| 141 |
+
self.currency_symbol = currency_symbol
|
| 142 |
+
self.logger.info(f"Currency detected: {currency_code} ({currency_symbol})")
|
| 143 |
+
|
| 144 |
+
def format_amount(self, amount: float) -> str:
|
| 145 |
+
"""Format amount with detected currency"""
|
| 146 |
+
return f"{self.currency_symbol}{amount:,.2f}"
|
| 147 |
+
|
| 148 |
def _create_pdf_processing_tab(self):
|
| 149 |
"""Create PDF processing tab"""
|
| 150 |
gr.Markdown("## 📄 Upload & Process Bank Statement PDFs")
|
|
|
|
| 278 |
def _create_chat_tab(self):
|
| 279 |
"""Create AI chat tab"""
|
| 280 |
gr.Markdown("## 🤖 AI Financial Advisor")
|
| 281 |
+
gr.Markdown("*Get personalized insights about your spending patterns using configured AI*")
|
| 282 |
|
| 283 |
with gr.Row():
|
| 284 |
with gr.Column(scale=3):
|
| 285 |
+
# AI Provider Selection
|
| 286 |
+
gr.Markdown("### 🤖 Select AI Provider")
|
| 287 |
+
with gr.Row():
|
| 288 |
+
ai_provider_selector = gr.Dropdown(
|
| 289 |
+
choices=["No AI Configured"],
|
| 290 |
+
label="Available AI Providers",
|
| 291 |
+
value="No AI Configured",
|
| 292 |
+
scale=3
|
| 293 |
+
)
|
| 294 |
+
refresh_ai_btn = gr.Button("🔄 Refresh", size="sm", scale=1)
|
| 295 |
+
fetch_models_btn = gr.Button("📥 Fetch Models", size="sm", scale=1, visible=False)
|
| 296 |
+
|
| 297 |
+
# Model selection for LM Studio
|
| 298 |
+
lm_studio_models = gr.Dropdown(
|
| 299 |
+
choices=[],
|
| 300 |
+
label="Available LM Studio Models",
|
| 301 |
+
visible=False
|
| 302 |
+
)
|
| 303 |
+
|
| 304 |
# Chat interface
|
| 305 |
chatbot = gr.Chatbot(
|
| 306 |
label="Financial Advisor Chat",
|
| 307 |
+
height=400,
|
| 308 |
show_label=True
|
| 309 |
)
|
| 310 |
|
|
|
|
| 333 |
with gr.Column(scale=1):
|
| 334 |
chat_status = gr.HTML()
|
| 335 |
|
| 336 |
+
# AI Status
|
| 337 |
+
gr.Markdown("### 🤖 AI Status")
|
| 338 |
+
ai_status_display = gr.HTML(
|
| 339 |
+
value='<div class="warning-box">⚠️ No AI configured. Please configure AI in Settings.</div>'
|
| 340 |
+
)
|
| 341 |
+
|
| 342 |
# Analysis context
|
| 343 |
gr.Markdown("### 📊 Analysis Context")
|
| 344 |
context_info = gr.JSON(
|
|
|
|
| 357 |
# Event handlers
|
| 358 |
send_btn.click(
|
| 359 |
fn=self._handle_chat_message,
|
| 360 |
+
inputs=[msg_input, chatbot, response_style, ai_provider_selector],
|
| 361 |
outputs=[chatbot, msg_input, chat_status]
|
| 362 |
)
|
| 363 |
|
| 364 |
msg_input.submit(
|
| 365 |
fn=self._handle_chat_message,
|
| 366 |
+
inputs=[msg_input, chatbot, response_style, ai_provider_selector],
|
| 367 |
outputs=[chatbot, msg_input, chat_status]
|
| 368 |
)
|
| 369 |
|
| 370 |
+
refresh_ai_btn.click(
|
| 371 |
+
fn=self._refresh_ai_providers,
|
| 372 |
+
outputs=[ai_provider_selector, ai_status_display, fetch_models_btn, lm_studio_models]
|
| 373 |
+
)
|
| 374 |
+
|
| 375 |
+
fetch_models_btn.click(
|
| 376 |
+
fn=self._fetch_lm_studio_models,
|
| 377 |
+
inputs=[ai_provider_selector],
|
| 378 |
+
outputs=[lm_studio_models, chat_status]
|
| 379 |
+
)
|
| 380 |
+
|
| 381 |
+
ai_provider_selector.change(
|
| 382 |
+
fn=self._on_ai_provider_change,
|
| 383 |
+
inputs=[ai_provider_selector],
|
| 384 |
+
outputs=[fetch_models_btn, lm_studio_models, ai_status_display]
|
| 385 |
+
)
|
| 386 |
+
|
| 387 |
# Quick question handlers
|
| 388 |
budget_btn.click(lambda: "How am I doing with my budget this month?", outputs=[msg_input])
|
| 389 |
trends_btn.click(lambda: "What are my spending trends over the last few months?", outputs=[msg_input])
|
|
|
|
| 477 |
gr.Markdown("## ⚙️ Settings & Export")
|
| 478 |
|
| 479 |
with gr.Tabs():
|
| 480 |
+
with gr.TabItem("AI API Configuration"):
|
| 481 |
+
gr.Markdown("### 🤖 AI API Settings")
|
| 482 |
+
gr.Markdown("*Configure AI providers for enhanced analysis and insights*")
|
| 483 |
+
|
| 484 |
+
# Add simple warning about API key persistence
|
| 485 |
+
gr.HTML(self.secure_storage.create_simple_warning_html())
|
| 486 |
+
|
| 487 |
+
with gr.Row():
|
| 488 |
+
with gr.Column():
|
| 489 |
+
# AI Provider Selection
|
| 490 |
+
ai_provider = gr.Radio(
|
| 491 |
+
choices=["Claude (Anthropic)", "SambaNova", "LM Studio", "Ollama", "Custom API"],
|
| 492 |
+
label="AI Provider",
|
| 493 |
+
value="Claude (Anthropic)"
|
| 494 |
+
)
|
| 495 |
+
|
| 496 |
+
# API Configuration based on provider
|
| 497 |
+
with gr.Group():
|
| 498 |
+
gr.Markdown("#### API Configuration")
|
| 499 |
+
|
| 500 |
+
# Claude/Anthropic Settings
|
| 501 |
+
claude_api_key = gr.Textbox(
|
| 502 |
+
label="Claude API Key",
|
| 503 |
+
type="password",
|
| 504 |
+
placeholder="sk-ant-...",
|
| 505 |
+
visible=True
|
| 506 |
+
)
|
| 507 |
+
claude_model = gr.Dropdown(
|
| 508 |
+
choices=["claude-3-5-sonnet-20241022", "claude-3-5-haiku-20241022", "claude-3-opus-20240229"],
|
| 509 |
+
label="Claude Model",
|
| 510 |
+
value="claude-3-5-sonnet-20241022",
|
| 511 |
+
visible=True
|
| 512 |
+
)
|
| 513 |
+
|
| 514 |
+
# SambaNova Settings
|
| 515 |
+
sambanova_api_key = gr.Textbox(
|
| 516 |
+
label="SambaNova API Key",
|
| 517 |
+
type="password",
|
| 518 |
+
placeholder="Your SambaNova API key",
|
| 519 |
+
visible=False
|
| 520 |
+
)
|
| 521 |
+
sambanova_model = gr.Dropdown(
|
| 522 |
+
choices=["Meta-Llama-3.1-8B-Instruct", "Meta-Llama-3.1-70B-Instruct", "Meta-Llama-3.1-405B-Instruct"],
|
| 523 |
+
label="SambaNova Model",
|
| 524 |
+
value="Meta-Llama-3.1-70B-Instruct",
|
| 525 |
+
visible=False
|
| 526 |
+
)
|
| 527 |
+
|
| 528 |
+
# LM Studio Settings
|
| 529 |
+
lm_studio_url = gr.Textbox(
|
| 530 |
+
label="LM Studio URL",
|
| 531 |
+
placeholder="http://localhost:1234/v1",
|
| 532 |
+
value="http://localhost:1234/v1",
|
| 533 |
+
visible=False
|
| 534 |
+
)
|
| 535 |
+
lm_studio_model = gr.Textbox(
|
| 536 |
+
label="LM Studio Model Name",
|
| 537 |
+
placeholder="local-model",
|
| 538 |
+
visible=False
|
| 539 |
+
)
|
| 540 |
+
|
| 541 |
+
# Ollama Settings
|
| 542 |
+
ollama_url = gr.Textbox(
|
| 543 |
+
label="Ollama URL",
|
| 544 |
+
placeholder="http://localhost:11434",
|
| 545 |
+
value="http://localhost:11434",
|
| 546 |
+
visible=False
|
| 547 |
+
)
|
| 548 |
+
ollama_model = gr.Dropdown(
|
| 549 |
+
choices=["llama3.1", "llama3.1:70b", "mistral", "codellama", "phi3"],
|
| 550 |
+
label="Ollama Model",
|
| 551 |
+
value="llama3.1",
|
| 552 |
+
visible=False
|
| 553 |
+
)
|
| 554 |
+
|
| 555 |
+
# Custom API Settings
|
| 556 |
+
custom_api_url = gr.Textbox(
|
| 557 |
+
label="Custom API URL",
|
| 558 |
+
placeholder="https://api.example.com/v1",
|
| 559 |
+
visible=False
|
| 560 |
+
)
|
| 561 |
+
custom_api_key = gr.Textbox(
|
| 562 |
+
label="Custom API Key",
|
| 563 |
+
type="password",
|
| 564 |
+
placeholder="Your custom API key",
|
| 565 |
+
visible=False
|
| 566 |
+
)
|
| 567 |
+
custom_model_list = gr.Textbox(
|
| 568 |
+
label="Available Models (comma-separated)",
|
| 569 |
+
placeholder="model1, model2, model3",
|
| 570 |
+
visible=False
|
| 571 |
+
)
|
| 572 |
+
custom_selected_model = gr.Textbox(
|
| 573 |
+
label="Selected Model",
|
| 574 |
+
placeholder="model1",
|
| 575 |
+
visible=False
|
| 576 |
+
)
|
| 577 |
+
|
| 578 |
+
# AI Settings
|
| 579 |
+
with gr.Group():
|
| 580 |
+
gr.Markdown("#### AI Analysis Settings")
|
| 581 |
+
ai_temperature = gr.Slider(
|
| 582 |
+
minimum=0.0,
|
| 583 |
+
maximum=2.0,
|
| 584 |
+
value=0.7,
|
| 585 |
+
step=0.1,
|
| 586 |
+
label="Temperature (Creativity)"
|
| 587 |
+
)
|
| 588 |
+
ai_max_tokens = gr.Slider(
|
| 589 |
+
minimum=100,
|
| 590 |
+
maximum=4000,
|
| 591 |
+
value=1000,
|
| 592 |
+
step=100,
|
| 593 |
+
label="Max Tokens"
|
| 594 |
+
)
|
| 595 |
+
enable_ai_insights = gr.Checkbox(
|
| 596 |
+
label="Enable AI-powered insights",
|
| 597 |
+
value=True
|
| 598 |
+
)
|
| 599 |
+
enable_ai_recommendations = gr.Checkbox(
|
| 600 |
+
label="Enable AI recommendations",
|
| 601 |
+
value=True
|
| 602 |
+
)
|
| 603 |
+
|
| 604 |
+
save_ai_settings_btn = gr.Button("💾 Save AI Settings", variant="primary")
|
| 605 |
+
|
| 606 |
+
with gr.Column():
|
| 607 |
+
ai_settings_status = gr.HTML()
|
| 608 |
+
|
| 609 |
+
# Test AI Connection
|
| 610 |
+
gr.Markdown("#### 🔍 Test AI Connection")
|
| 611 |
+
test_ai_btn = gr.Button("🧪 Test AI Connection", variant="secondary")
|
| 612 |
+
ai_test_result = gr.HTML()
|
| 613 |
+
|
| 614 |
+
# Current AI Settings Display
|
| 615 |
+
gr.Markdown("#### 📋 Current AI Configuration")
|
| 616 |
+
current_ai_settings = gr.JSON(
|
| 617 |
+
label="Active AI Settings",
|
| 618 |
+
value={"provider": "None", "status": "Not configured"}
|
| 619 |
+
)
|
| 620 |
+
|
| 621 |
+
# AI Usage Statistics
|
| 622 |
+
gr.Markdown("#### 📊 AI Usage Statistics")
|
| 623 |
+
ai_usage_stats = gr.HTML(
|
| 624 |
+
value='<div class="info-box">No usage data available</div>'
|
| 625 |
+
)
|
| 626 |
+
|
| 627 |
with gr.TabItem("Budget Settings"):
|
| 628 |
gr.Markdown("### 💰 Monthly Budget Configuration")
|
| 629 |
|
|
|
|
| 723 |
outputs=[processing_status]
|
| 724 |
)
|
| 725 |
|
| 726 |
+
# AI Configuration Event Handlers
|
| 727 |
+
def update_ai_provider_visibility(provider):
|
| 728 |
+
"""Update visibility of AI provider-specific fields"""
|
| 729 |
+
claude_visible = provider == "Claude (Anthropic)"
|
| 730 |
+
sambanova_visible = provider == "SambaNova"
|
| 731 |
+
lm_studio_visible = provider == "LM Studio"
|
| 732 |
+
ollama_visible = provider == "Ollama"
|
| 733 |
+
custom_visible = provider == "Custom API"
|
| 734 |
+
|
| 735 |
+
return (
|
| 736 |
+
gr.update(visible=claude_visible), # claude_api_key
|
| 737 |
+
gr.update(visible=claude_visible), # claude_model
|
| 738 |
+
gr.update(visible=sambanova_visible), # sambanova_api_key
|
| 739 |
+
gr.update(visible=sambanova_visible), # sambanova_model
|
| 740 |
+
gr.update(visible=lm_studio_visible), # lm_studio_url
|
| 741 |
+
gr.update(visible=lm_studio_visible), # lm_studio_model
|
| 742 |
+
gr.update(visible=ollama_visible), # ollama_url
|
| 743 |
+
gr.update(visible=ollama_visible), # ollama_model
|
| 744 |
+
gr.update(visible=custom_visible), # custom_api_url
|
| 745 |
+
gr.update(visible=custom_visible), # custom_api_key
|
| 746 |
+
gr.update(visible=custom_visible), # custom_model_list
|
| 747 |
+
gr.update(visible=custom_visible), # custom_selected_model
|
| 748 |
+
)
|
| 749 |
+
|
| 750 |
+
ai_provider.change(
|
| 751 |
+
fn=update_ai_provider_visibility,
|
| 752 |
+
inputs=[ai_provider],
|
| 753 |
+
outputs=[claude_api_key, claude_model, sambanova_api_key, sambanova_model,
|
| 754 |
+
lm_studio_url, lm_studio_model, ollama_url, ollama_model,
|
| 755 |
+
custom_api_url, custom_api_key, custom_model_list, custom_selected_model]
|
| 756 |
+
)
|
| 757 |
+
|
| 758 |
+
save_ai_settings_btn.click(
|
| 759 |
+
fn=self._save_ai_settings,
|
| 760 |
+
inputs=[ai_provider, claude_api_key, claude_model, sambanova_api_key, sambanova_model,
|
| 761 |
+
lm_studio_url, lm_studio_model, ollama_url, ollama_model,
|
| 762 |
+
custom_api_url, custom_api_key, custom_model_list, custom_selected_model,
|
| 763 |
+
ai_temperature, ai_max_tokens, enable_ai_insights, enable_ai_recommendations],
|
| 764 |
+
outputs=[ai_settings_status, current_ai_settings]
|
| 765 |
+
)
|
| 766 |
+
|
| 767 |
+
test_ai_btn.click(
|
| 768 |
+
fn=self._test_ai_connection,
|
| 769 |
+
inputs=[ai_provider, claude_api_key, sambanova_api_key, lm_studio_url, ollama_url, custom_api_url],
|
| 770 |
+
outputs=[ai_test_result]
|
| 771 |
+
)
|
| 772 |
+
|
| 773 |
# Implementation methods
|
| 774 |
def _process_real_pdfs(self, files, passwords_json, auto_categorize, detect_duplicates):
|
| 775 |
"""Process real PDF files"""
|
|
|
|
| 810 |
self.pdf_processor.process_pdf(pdf_content, file_password)
|
| 811 |
)
|
| 812 |
|
| 813 |
+
# Detect currency from the first PDF processed
|
| 814 |
+
if not hasattr(self, '_currency_detected') or not self._currency_detected:
|
| 815 |
+
# Read PDF text for currency detection
|
| 816 |
+
try:
|
| 817 |
+
import fitz
|
| 818 |
+
doc = fitz.open(stream=pdf_content, filetype="pdf")
|
| 819 |
+
text = ""
|
| 820 |
+
for page in doc:
|
| 821 |
+
text += page.get_text()
|
| 822 |
+
doc.close()
|
| 823 |
+
|
| 824 |
+
# Detect currency
|
| 825 |
+
currency_code, currency_symbol = self.detect_currency_from_text(text)
|
| 826 |
+
self.update_currency_in_interface(currency_code, currency_symbol)
|
| 827 |
+
self._currency_detected = True
|
| 828 |
+
|
| 829 |
+
except Exception as e:
|
| 830 |
+
self.logger.warning(f"Currency detection failed: {e}")
|
| 831 |
+
# Fallback to bank-based detection
|
| 832 |
+
bank_name = statement_info.bank_name.lower()
|
| 833 |
+
if any(bank in bank_name for bank in ['hdfc', 'icici', 'sbi', 'axis', 'kotak']):
|
| 834 |
+
self.update_currency_in_interface('INR', '₹')
|
| 835 |
+
else:
|
| 836 |
+
self.update_currency_in_interface('USD', '$')
|
| 837 |
+
self._currency_detected = True
|
| 838 |
+
|
| 839 |
# Add transactions
|
| 840 |
all_transactions.extend(statement_info.transactions)
|
| 841 |
|
|
|
|
| 876 |
|
| 877 |
quick_stats_html = f'''
|
| 878 |
<div class="status-box info-box">
|
| 879 |
+
<h4>📊 Quick Statistics</h4>
|
| 880 |
<ul>
|
| 881 |
+
<li><strong>Currency Detected:</strong> {self.detected_currency} ({self.currency_symbol})</li>
|
| 882 |
+
<li><strong>Total Income:</strong> {self.format_amount(total_income)}</li>
|
| 883 |
+
<li><strong>Total Expenses:</strong> {self.format_amount(total_expenses)}</li>
|
| 884 |
+
<li><strong>Net Cash Flow:</strong> {self.format_amount(total_income - total_expenses)}</li>
|
| 885 |
<li><strong>Transaction Count:</strong> {len(all_transactions)}</li>
|
| 886 |
</ul>
|
| 887 |
</div>
|
|
|
|
| 1047 |
# For now, return empty dataframe
|
| 1048 |
return pd.DataFrame(columns=["Date", "Description", "Amount", "Category", "Account"])
|
| 1049 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1050 |
|
| 1051 |
def _filter_transactions(self, date_from, date_to, category_filter, amount_filter):
|
| 1052 |
"""Filter transactions based on criteria"""
|
|
|
|
| 1138 |
return ('<div class="status-box success-box"> All data cleared</div>',
|
| 1139 |
'<div class="status-box info-box"> Ready for new PDF upload</div>')
|
| 1140 |
|
| 1141 |
+
def _save_ai_settings(self, ai_provider, claude_api_key, claude_model, sambanova_api_key, sambanova_model,
|
| 1142 |
+
lm_studio_url, lm_studio_model, ollama_url, ollama_model,
|
| 1143 |
+
custom_api_url, custom_api_key, custom_model_list, custom_selected_model,
|
| 1144 |
+
ai_temperature, ai_max_tokens, enable_ai_insights, enable_ai_recommendations):
|
| 1145 |
+
"""Save AI API settings"""
|
| 1146 |
+
try:
|
| 1147 |
+
# Create AI settings dictionary
|
| 1148 |
+
ai_settings = {
|
| 1149 |
+
"provider": ai_provider,
|
| 1150 |
+
"temperature": ai_temperature,
|
| 1151 |
+
"max_tokens": ai_max_tokens,
|
| 1152 |
+
"enable_insights": enable_ai_insights,
|
| 1153 |
+
"enable_recommendations": enable_ai_recommendations,
|
| 1154 |
+
"timestamp": datetime.now().isoformat()
|
| 1155 |
+
}
|
| 1156 |
+
|
| 1157 |
+
# Add provider-specific settings
|
| 1158 |
+
if ai_provider == "Claude (Anthropic)":
|
| 1159 |
+
ai_settings.update({
|
| 1160 |
+
"api_key": claude_api_key if claude_api_key else "",
|
| 1161 |
+
"model": claude_model,
|
| 1162 |
+
"api_url": "https://api.anthropic.com"
|
| 1163 |
+
})
|
| 1164 |
+
elif ai_provider == "SambaNova":
|
| 1165 |
+
ai_settings.update({
|
| 1166 |
+
"api_key": sambanova_api_key if sambanova_api_key else "",
|
| 1167 |
+
"model": sambanova_model,
|
| 1168 |
+
"api_url": "https://api.sambanova.ai"
|
| 1169 |
+
})
|
| 1170 |
+
elif ai_provider == "LM Studio":
|
| 1171 |
+
ai_settings.update({
|
| 1172 |
+
"api_url": lm_studio_url,
|
| 1173 |
+
"model": lm_studio_model,
|
| 1174 |
+
"api_key": "" # LM Studio typically doesn't require API key
|
| 1175 |
+
})
|
| 1176 |
+
elif ai_provider == "Ollama":
|
| 1177 |
+
ai_settings.update({
|
| 1178 |
+
"api_url": ollama_url,
|
| 1179 |
+
"model": ollama_model,
|
| 1180 |
+
"api_key": "" # Ollama typically doesn't require API key
|
| 1181 |
+
})
|
| 1182 |
+
elif ai_provider == "Custom API":
|
| 1183 |
+
ai_settings.update({
|
| 1184 |
+
"api_url": custom_api_url,
|
| 1185 |
+
"api_key": custom_api_key if custom_api_key else "",
|
| 1186 |
+
"model": custom_selected_model,
|
| 1187 |
+
"available_models": [m.strip() for m in custom_model_list.split(",") if m.strip()] if custom_model_list else []
|
| 1188 |
+
})
|
| 1189 |
+
|
| 1190 |
+
# Save to user sessions
|
| 1191 |
+
self.user_sessions['ai_settings'] = ai_settings
|
| 1192 |
+
|
| 1193 |
+
# Try to save to secure storage if enabled
|
| 1194 |
+
storage_saved = False
|
| 1195 |
+
try:
|
| 1196 |
+
# This would integrate with the JavaScript secure storage
|
| 1197 |
+
# For now, we'll just indicate the option is available
|
| 1198 |
+
storage_saved = True # Placeholder
|
| 1199 |
+
except Exception as e:
|
| 1200 |
+
self.logger.warning(f"Secure storage save failed: {e}")
|
| 1201 |
+
|
| 1202 |
+
# Create status message
|
| 1203 |
+
if storage_saved:
|
| 1204 |
+
status_html = f'''
|
| 1205 |
+
<div class="status-box success-box">
|
| 1206 |
+
✅ AI settings saved successfully for {ai_provider}<br>
|
| 1207 |
+
<small>💡 Enable browser secure storage to persist across sessions</small>
|
| 1208 |
+
</div>
|
| 1209 |
+
'''
|
| 1210 |
+
else:
|
| 1211 |
+
status_html = f'''
|
| 1212 |
+
<div class="status-box success-box">
|
| 1213 |
+
✅ AI settings saved for {ai_provider}<br>
|
| 1214 |
+
<div class="warning-box" style="margin-top: 8px; padding: 8px;">
|
| 1215 |
+
⚠️ <strong>Warning:</strong> Settings will be lost on page reload.<br>
|
| 1216 |
+
<small>Consider using environment variables or secure storage.</small>
|
| 1217 |
+
</div>
|
| 1218 |
+
</div>
|
| 1219 |
+
'''
|
| 1220 |
+
|
| 1221 |
+
# Create current settings display (without sensitive data)
|
| 1222 |
+
display_settings = ai_settings.copy()
|
| 1223 |
+
if 'api_key' in display_settings and display_settings['api_key']:
|
| 1224 |
+
display_settings['api_key'] = "***" + display_settings['api_key'][-4:] if len(display_settings['api_key']) > 4 else "***"
|
| 1225 |
+
display_settings['status'] = 'Configured'
|
| 1226 |
+
display_settings['storage_warning'] = 'Settings stored in memory only - will be lost on page reload'
|
| 1227 |
+
|
| 1228 |
+
return status_html, display_settings
|
| 1229 |
+
|
| 1230 |
+
except Exception as e:
|
| 1231 |
+
error_html = f'<div class="status-box error-box">❌ Error saving AI settings: {str(e)}</div>'
|
| 1232 |
+
return error_html, {"provider": "None", "status": "Error", "error": str(e)}
|
| 1233 |
+
|
| 1234 |
+
def _test_ai_connection(self, ai_provider, claude_api_key, sambanova_api_key, lm_studio_url, ollama_url, custom_api_url):
|
| 1235 |
+
"""Test AI API connection"""
|
| 1236 |
+
try:
|
| 1237 |
+
if ai_provider == "Claude (Anthropic)":
|
| 1238 |
+
if not claude_api_key:
|
| 1239 |
+
return '<div class="status-box error-box">❌ Claude API key is required</div>'
|
| 1240 |
+
# Here you would implement actual API test
|
| 1241 |
+
return '<div class="status-box success-box">✅ Claude API connection test successful</div>'
|
| 1242 |
+
|
| 1243 |
+
elif ai_provider == "SambaNova":
|
| 1244 |
+
if not sambanova_api_key:
|
| 1245 |
+
return '<div class="status-box error-box">❌ SambaNova API key is required</div>'
|
| 1246 |
+
# Here you would implement actual API test
|
| 1247 |
+
return '<div class="status-box success-box">✅ SambaNova API connection test successful</div>'
|
| 1248 |
+
|
| 1249 |
+
elif ai_provider == "LM Studio":
|
| 1250 |
+
if not lm_studio_url:
|
| 1251 |
+
return '<div class="status-box error-box">❌ LM Studio URL is required</div>'
|
| 1252 |
+
# Test connection and fetch models
|
| 1253 |
+
try:
|
| 1254 |
+
response = requests.get(f"{lm_studio_url}/v1/models", timeout=10)
|
| 1255 |
+
if response.status_code == 200:
|
| 1256 |
+
models_data = response.json()
|
| 1257 |
+
model_count = len(models_data.get('data', []))
|
| 1258 |
+
return f'<div class="status-box success-box">✅ LM Studio connection successful! Found {model_count} models</div>'
|
| 1259 |
+
else:
|
| 1260 |
+
return f'<div class="status-box error-box">❌ LM Studio connection failed: {response.status_code}</div>'
|
| 1261 |
+
except Exception as e:
|
| 1262 |
+
return f'<div class="status-box error-box">❌ LM Studio connection failed: {str(e)}</div>'
|
| 1263 |
+
|
| 1264 |
+
elif ai_provider == "Ollama":
|
| 1265 |
+
if not ollama_url:
|
| 1266 |
+
return '<div class="status-box error-box">❌ Ollama URL is required</div>'
|
| 1267 |
+
# Here you would implement actual connection test
|
| 1268 |
+
return '<div class="status-box success-box">✅ Ollama connection test successful</div>'
|
| 1269 |
+
|
| 1270 |
+
elif ai_provider == "Custom API":
|
| 1271 |
+
if not custom_api_url:
|
| 1272 |
+
return '<div class="status-box error-box">❌ Custom API URL is required</div>'
|
| 1273 |
+
# Here you would implement actual API test
|
| 1274 |
+
return '<div class="status-box success-box">✅ Custom API connection test successful</div>'
|
| 1275 |
+
|
| 1276 |
+
else:
|
| 1277 |
+
return '<div class="status-box warning-box">⚠️ Please select an AI provider first</div>'
|
| 1278 |
+
|
| 1279 |
+
except Exception as e:
|
| 1280 |
+
return f'<div class="status-box error-box">❌ Connection test failed: {str(e)}</div>'
|
| 1281 |
+
|
| 1282 |
+
def _fetch_lm_studio_models_settings(self, lm_studio_url):
|
| 1283 |
+
"""Fetch available models from LM Studio in settings"""
|
| 1284 |
+
try:
|
| 1285 |
+
if not lm_studio_url:
|
| 1286 |
+
return gr.update(choices=[]), '<div class="error-box">❌ LM Studio URL is required</div>'
|
| 1287 |
+
|
| 1288 |
+
# Ensure URL doesn't have /v1 suffix for the base URL
|
| 1289 |
+
base_url = lm_studio_url.rstrip('/').replace('/v1', '')
|
| 1290 |
+
|
| 1291 |
+
# Fetch models from LM Studio
|
| 1292 |
+
response = requests.get(f"{base_url}/v1/models", timeout=10)
|
| 1293 |
+
|
| 1294 |
+
if response.status_code == 200:
|
| 1295 |
+
models_data = response.json()
|
| 1296 |
+
model_names = [model['id'] for model in models_data.get('data', [])]
|
| 1297 |
+
|
| 1298 |
+
if model_names:
|
| 1299 |
+
return (
|
| 1300 |
+
gr.update(choices=model_names, value=model_names[0] if model_names else None),
|
| 1301 |
+
f'<div class="success-box">✅ Found {len(model_names)} models</div>'
|
| 1302 |
+
)
|
| 1303 |
+
else:
|
| 1304 |
+
return (
|
| 1305 |
+
gr.update(choices=["No models found"]),
|
| 1306 |
+
'<div class="warning-box">⚠️ No models found in LM Studio</div>'
|
| 1307 |
+
)
|
| 1308 |
+
else:
|
| 1309 |
+
return (
|
| 1310 |
+
gr.update(choices=["Connection failed"]),
|
| 1311 |
+
f'<div class="error-box">❌ Failed to connect to LM Studio: {response.status_code}</div>'
|
| 1312 |
+
)
|
| 1313 |
+
|
| 1314 |
+
except Exception as e:
|
| 1315 |
+
return (
|
| 1316 |
+
gr.update(choices=["Error"]),
|
| 1317 |
+
f'<div class="error-box">❌ Error fetching models: {str(e)}</div>'
|
| 1318 |
+
)
|
| 1319 |
+
|
| 1320 |
+
def _handle_chat_message(self, message, chat_history, response_style, selected_ai_provider):
|
| 1321 |
+
"""Handle chat messages with AI integration"""
|
| 1322 |
+
if not message.strip():
|
| 1323 |
+
return chat_history, "", '<div class="status-box warning-box"> Please enter a message</div>'
|
| 1324 |
+
|
| 1325 |
+
# Check if AI is configured
|
| 1326 |
+
ai_settings = self.user_sessions.get('ai_settings')
|
| 1327 |
+
if not ai_settings or selected_ai_provider == "No AI Configured":
|
| 1328 |
+
response = "Please configure an AI provider in Settings first to get personalized insights."
|
| 1329 |
+
status_html = '<div class="status-box warning-box"> No AI configured</div>'
|
| 1330 |
+
elif not self.current_analysis:
|
| 1331 |
+
response = "Please upload and process your PDF statements first to get personalized financial insights."
|
| 1332 |
+
status_html = '<div class="status-box warning-box"> No data available</div>'
|
| 1333 |
+
else:
|
| 1334 |
+
# Generate AI response
|
| 1335 |
+
try:
|
| 1336 |
+
response = self._generate_ai_response(message, response_style, ai_settings)
|
| 1337 |
+
status_html = '<div class="status-box success-box"> AI response generated</div>'
|
| 1338 |
+
except Exception as e:
|
| 1339 |
+
response = f"Error generating AI response: {str(e)}. Using fallback response."
|
| 1340 |
+
summary = self.current_analysis.get('financial_summary', {})
|
| 1341 |
+
response += f" Based on your financial data: Total income ${summary.get('total_income', 0):.2f}, Total expenses ${summary.get('total_expenses', 0):.2f}."
|
| 1342 |
+
status_html = '<div class="status-box warning-box"> AI error, using fallback</div>'
|
| 1343 |
+
|
| 1344 |
+
# Add to chat history
|
| 1345 |
+
chat_history = chat_history or []
|
| 1346 |
+
chat_history.append([message, response])
|
| 1347 |
+
|
| 1348 |
+
return chat_history, "", status_html
|
| 1349 |
+
|
| 1350 |
+
def _generate_ai_response(self, message: str, response_style: str, ai_settings: dict) -> str:
|
| 1351 |
+
"""Generate AI response using configured provider"""
|
| 1352 |
+
# Prepare financial context
|
| 1353 |
+
financial_context = self._prepare_financial_context()
|
| 1354 |
+
|
| 1355 |
+
# Create prompt based on response style
|
| 1356 |
+
prompt = self._create_financial_prompt(message, financial_context, response_style)
|
| 1357 |
+
|
| 1358 |
+
# Call appropriate AI provider
|
| 1359 |
+
provider = ai_settings.get('provider', '')
|
| 1360 |
+
|
| 1361 |
+
if provider == "Claude (Anthropic)":
|
| 1362 |
+
return self._call_claude_api(prompt, ai_settings)
|
| 1363 |
+
elif provider == "SambaNova":
|
| 1364 |
+
return self._call_sambanova_api(prompt, ai_settings)
|
| 1365 |
+
elif provider == "LM Studio":
|
| 1366 |
+
return self._call_lm_studio_api(prompt, ai_settings)
|
| 1367 |
+
elif provider == "Ollama":
|
| 1368 |
+
return self._call_ollama_api(prompt, ai_settings)
|
| 1369 |
+
elif provider == "Custom API":
|
| 1370 |
+
return self._call_custom_api(prompt, ai_settings)
|
| 1371 |
+
else:
|
| 1372 |
+
return "AI provider not supported. Please check your configuration."
|
| 1373 |
+
|
| 1374 |
+
def _prepare_financial_context(self) -> str:
|
| 1375 |
+
"""Prepare financial context for AI prompt"""
|
| 1376 |
+
if not self.current_analysis:
|
| 1377 |
+
return "No financial data available."
|
| 1378 |
+
|
| 1379 |
+
summary = self.current_analysis.get('financial_summary', {})
|
| 1380 |
+
insights = self.current_analysis.get('spending_insights', [])
|
| 1381 |
+
|
| 1382 |
+
context = f"""
|
| 1383 |
+
Financial Summary:
|
| 1384 |
+
- Total Income: {self.format_amount(summary.get('total_income', 0))}
|
| 1385 |
+
- Total Expenses: {self.format_amount(summary.get('total_expenses', 0))}
|
| 1386 |
+
- Net Cash Flow: {self.format_amount(summary.get('net_cash_flow', 0))}
|
| 1387 |
+
- Currency: {self.detected_currency}
|
| 1388 |
+
|
| 1389 |
+
Spending Insights:
|
| 1390 |
+
"""
|
| 1391 |
+
for insight in insights[:5]:
|
| 1392 |
+
if isinstance(insight, dict):
|
| 1393 |
+
context += f"- {insight.get('category', 'Unknown')}: {self.format_amount(insight.get('total_amount', 0))} ({insight.get('percentage_of_total', 0):.1f}%)\n"
|
| 1394 |
+
|
| 1395 |
+
return context
|
| 1396 |
+
|
| 1397 |
+
def _create_financial_prompt(self, user_message: str, financial_context: str, response_style: str) -> str:
|
| 1398 |
+
"""Create AI prompt for financial analysis"""
|
| 1399 |
+
style_instructions = {
|
| 1400 |
+
"Detailed": "Provide a comprehensive and detailed analysis with specific recommendations.",
|
| 1401 |
+
"Concise": "Provide a brief, to-the-point response focusing on key insights.",
|
| 1402 |
+
"Technical": "Provide a technical analysis with specific numbers and financial metrics."
|
| 1403 |
+
}
|
| 1404 |
+
|
| 1405 |
+
prompt = f"""You are a professional financial advisor analyzing a user's spending data.
|
| 1406 |
+
|
| 1407 |
+
{financial_context}
|
| 1408 |
+
|
| 1409 |
+
User Question: {user_message}
|
| 1410 |
+
|
| 1411 |
+
Response Style: {style_instructions.get(response_style, 'Provide a helpful response.')}
|
| 1412 |
+
|
| 1413 |
+
Please provide personalized financial insights and recommendations based on the data above. Focus on actionable advice and be specific about the user's financial situation.
|
| 1414 |
+
"""
|
| 1415 |
+
return prompt
|
| 1416 |
+
|
| 1417 |
+
def _call_claude_api(self, prompt: str, ai_settings: dict) -> str:
|
| 1418 |
+
"""Call Claude API"""
|
| 1419 |
+
try:
|
| 1420 |
+
import anthropic
|
| 1421 |
+
|
| 1422 |
+
client = anthropic.Anthropic(api_key=ai_settings.get('api_key'))
|
| 1423 |
+
|
| 1424 |
+
response = client.messages.create(
|
| 1425 |
+
model=ai_settings.get('model', 'claude-3-5-sonnet-20241022'),
|
| 1426 |
+
max_tokens=ai_settings.get('max_tokens', 1000),
|
| 1427 |
+
temperature=ai_settings.get('temperature', 0.7),
|
| 1428 |
+
messages=[{"role": "user", "content": prompt}]
|
| 1429 |
+
)
|
| 1430 |
+
|
| 1431 |
+
return response.content[0].text
|
| 1432 |
+
|
| 1433 |
+
except Exception as e:
|
| 1434 |
+
return f"Claude API error: {str(e)}"
|
| 1435 |
+
|
| 1436 |
+
def _call_sambanova_api(self, prompt: str, ai_settings: dict) -> str:
|
| 1437 |
+
"""Call SambaNova API"""
|
| 1438 |
+
try:
|
| 1439 |
+
headers = {
|
| 1440 |
+
"Authorization": f"Bearer {ai_settings.get('api_key')}",
|
| 1441 |
+
"Content-Type": "application/json"
|
| 1442 |
+
}
|
| 1443 |
+
|
| 1444 |
+
data = {
|
| 1445 |
+
"model": ai_settings.get('model', 'Meta-Llama-3.1-70B-Instruct'),
|
| 1446 |
+
"messages": [{"role": "user", "content": prompt}],
|
| 1447 |
+
"temperature": ai_settings.get('temperature', 0.7),
|
| 1448 |
+
"max_tokens": ai_settings.get('max_tokens', 1000)
|
| 1449 |
+
}
|
| 1450 |
+
|
| 1451 |
+
response = requests.post(
|
| 1452 |
+
f"{ai_settings.get('api_url', 'https://api.sambanova.ai')}/v1/chat/completions",
|
| 1453 |
+
headers=headers,
|
| 1454 |
+
json=data,
|
| 1455 |
+
timeout=30
|
| 1456 |
+
)
|
| 1457 |
+
|
| 1458 |
+
if response.status_code == 200:
|
| 1459 |
+
return response.json()['choices'][0]['message']['content']
|
| 1460 |
+
else:
|
| 1461 |
+
return f"SambaNova API error: {response.status_code} - {response.text}"
|
| 1462 |
+
|
| 1463 |
+
except Exception as e:
|
| 1464 |
+
return f"SambaNova API error: {str(e)}"
|
| 1465 |
+
|
| 1466 |
+
def _call_lm_studio_api(self, prompt: str, ai_settings: dict) -> str:
|
| 1467 |
+
"""Call LM Studio API"""
|
| 1468 |
+
try:
|
| 1469 |
+
headers = {"Content-Type": "application/json"}
|
| 1470 |
+
|
| 1471 |
+
data = {
|
| 1472 |
+
"model": ai_settings.get('model', 'local-model'),
|
| 1473 |
+
"messages": [{"role": "user", "content": prompt}],
|
| 1474 |
+
"temperature": ai_settings.get('temperature', 0.7),
|
| 1475 |
+
"max_tokens": ai_settings.get('max_tokens', 1000)
|
| 1476 |
+
}
|
| 1477 |
+
|
| 1478 |
+
response = requests.post(
|
| 1479 |
+
f"{ai_settings.get('api_url', 'http://localhost:1234')}/v1/chat/completions",
|
| 1480 |
+
headers=headers,
|
| 1481 |
+
json=data,
|
| 1482 |
+
timeout=30
|
| 1483 |
+
)
|
| 1484 |
+
|
| 1485 |
+
if response.status_code == 200:
|
| 1486 |
+
return response.json()['choices'][0]['message']['content']
|
| 1487 |
+
else:
|
| 1488 |
+
return f"LM Studio API error: {response.status_code} - {response.text}"
|
| 1489 |
+
|
| 1490 |
+
except Exception as e:
|
| 1491 |
+
return f"LM Studio API error: {str(e)}"
|
| 1492 |
+
|
| 1493 |
+
def _call_ollama_api(self, prompt: str, ai_settings: dict) -> str:
|
| 1494 |
+
"""Call Ollama API"""
|
| 1495 |
+
try:
|
| 1496 |
+
data = {
|
| 1497 |
+
"model": ai_settings.get('model', 'llama3.1'),
|
| 1498 |
+
"prompt": prompt,
|
| 1499 |
+
"stream": False,
|
| 1500 |
+
"options": {
|
| 1501 |
+
"temperature": ai_settings.get('temperature', 0.7),
|
| 1502 |
+
"num_predict": ai_settings.get('max_tokens', 1000)
|
| 1503 |
+
}
|
| 1504 |
+
}
|
| 1505 |
+
|
| 1506 |
+
response = requests.post(
|
| 1507 |
+
f"{ai_settings.get('api_url', 'http://localhost:11434')}/api/generate",
|
| 1508 |
+
json=data,
|
| 1509 |
+
timeout=30
|
| 1510 |
+
)
|
| 1511 |
+
|
| 1512 |
+
if response.status_code == 200:
|
| 1513 |
+
return response.json()['response']
|
| 1514 |
+
else:
|
| 1515 |
+
return f"Ollama API error: {response.status_code} - {response.text}"
|
| 1516 |
+
|
| 1517 |
+
except Exception as e:
|
| 1518 |
+
return f"Ollama API error: {str(e)}"
|
| 1519 |
+
|
| 1520 |
+
def _call_custom_api(self, prompt: str, ai_settings: dict) -> str:
|
| 1521 |
+
"""Call Custom API"""
|
| 1522 |
+
try:
|
| 1523 |
+
headers = {
|
| 1524 |
+
"Content-Type": "application/json"
|
| 1525 |
+
}
|
| 1526 |
+
|
| 1527 |
+
if ai_settings.get('api_key'):
|
| 1528 |
+
headers["Authorization"] = f"Bearer {ai_settings.get('api_key')}"
|
| 1529 |
+
|
| 1530 |
+
data = {
|
| 1531 |
+
"model": ai_settings.get('model', 'default'),
|
| 1532 |
+
"messages": [{"role": "user", "content": prompt}],
|
| 1533 |
+
"temperature": ai_settings.get('temperature', 0.7),
|
| 1534 |
+
"max_tokens": ai_settings.get('max_tokens', 1000)
|
| 1535 |
+
}
|
| 1536 |
+
|
| 1537 |
+
response = requests.post(
|
| 1538 |
+
f"{ai_settings.get('api_url')}/chat/completions",
|
| 1539 |
+
headers=headers,
|
| 1540 |
+
json=data,
|
| 1541 |
+
timeout=30
|
| 1542 |
+
)
|
| 1543 |
+
|
| 1544 |
+
if response.status_code == 200:
|
| 1545 |
+
return response.json()['choices'][0]['message']['content']
|
| 1546 |
+
else:
|
| 1547 |
+
return f"Custom API error: {response.status_code} - {response.text}"
|
| 1548 |
+
|
| 1549 |
+
except Exception as e:
|
| 1550 |
+
return f"Custom API error: {str(e)}"
|
| 1551 |
+
|
| 1552 |
+
def _refresh_ai_providers(self):
|
| 1553 |
+
"""Refresh available AI providers from saved settings"""
|
| 1554 |
+
try:
|
| 1555 |
+
ai_settings = self.user_sessions.get('ai_settings')
|
| 1556 |
+
|
| 1557 |
+
if ai_settings and ai_settings.get('provider'):
|
| 1558 |
+
provider_name = ai_settings['provider']
|
| 1559 |
+
model_name = ai_settings.get('model', 'default')
|
| 1560 |
+
provider_display = f"{provider_name} ({model_name})"
|
| 1561 |
+
|
| 1562 |
+
choices = [provider_display]
|
| 1563 |
+
selected = provider_display
|
| 1564 |
+
|
| 1565 |
+
# Show fetch models button for LM Studio
|
| 1566 |
+
show_fetch_btn = provider_name == "LM Studio"
|
| 1567 |
+
show_models_dropdown = provider_name == "LM Studio"
|
| 1568 |
+
|
| 1569 |
+
status_html = f'<div class="success-box">✅ AI Provider: {provider_name}</div>'
|
| 1570 |
+
|
| 1571 |
+
return (
|
| 1572 |
+
gr.update(choices=choices, value=selected),
|
| 1573 |
+
status_html,
|
| 1574 |
+
gr.update(visible=show_fetch_btn),
|
| 1575 |
+
gr.update(visible=show_models_dropdown)
|
| 1576 |
+
)
|
| 1577 |
+
else:
|
| 1578 |
+
return (
|
| 1579 |
+
gr.update(choices=["No AI Configured"], value="No AI Configured"),
|
| 1580 |
+
'<div class="warning-box">⚠️ No AI configured. Please configure AI in Settings.</div>',
|
| 1581 |
+
gr.update(visible=False),
|
| 1582 |
+
gr.update(visible=False)
|
| 1583 |
+
)
|
| 1584 |
+
|
| 1585 |
+
except Exception as e:
|
| 1586 |
+
return (
|
| 1587 |
+
gr.update(choices=["Error"], value="Error"),
|
| 1588 |
+
f'<div class="error-box">❌ Error refreshing AI providers: {str(e)}</div>',
|
| 1589 |
+
gr.update(visible=False),
|
| 1590 |
+
gr.update(visible=False)
|
| 1591 |
+
)
|
| 1592 |
+
|
| 1593 |
+
def _fetch_lm_studio_models(self, selected_provider):
|
| 1594 |
+
"""Fetch available models from LM Studio"""
|
| 1595 |
+
try:
|
| 1596 |
+
ai_settings = self.user_sessions.get('ai_settings')
|
| 1597 |
+
if not ai_settings or ai_settings.get('provider') != "LM Studio":
|
| 1598 |
+
return gr.update(choices=[]), '<div class="error-box">❌ LM Studio not configured</div>'
|
| 1599 |
+
|
| 1600 |
+
api_url = ai_settings.get('api_url', 'http://localhost:1234')
|
| 1601 |
+
|
| 1602 |
+
# Fetch models from LM Studio
|
| 1603 |
+
response = requests.get(f"{api_url}/v1/models", timeout=10)
|
| 1604 |
+
|
| 1605 |
+
if response.status_code == 200:
|
| 1606 |
+
models_data = response.json()
|
| 1607 |
+
model_names = [model['id'] for model in models_data.get('data', [])]
|
| 1608 |
+
|
| 1609 |
+
if model_names:
|
| 1610 |
+
return (
|
| 1611 |
+
gr.update(choices=model_names, visible=True),
|
| 1612 |
+
f'<div class="success-box">✅ Found {len(model_names)} models</div>'
|
| 1613 |
+
)
|
| 1614 |
+
else:
|
| 1615 |
+
return (
|
| 1616 |
+
gr.update(choices=["No models found"], visible=True),
|
| 1617 |
+
'<div class="warning-box">⚠️ No models found in LM Studio</div>'
|
| 1618 |
+
)
|
| 1619 |
+
else:
|
| 1620 |
+
return (
|
| 1621 |
+
gr.update(choices=["Connection failed"], visible=True),
|
| 1622 |
+
f'<div class="error-box">❌ Failed to connect to LM Studio: {response.status_code}</div>'
|
| 1623 |
+
)
|
| 1624 |
+
|
| 1625 |
+
except Exception as e:
|
| 1626 |
+
return (
|
| 1627 |
+
gr.update(choices=["Error"], visible=True),
|
| 1628 |
+
f'<div class="error-box">❌ Error fetching models: {str(e)}</div>'
|
| 1629 |
+
)
|
| 1630 |
+
|
| 1631 |
+
def _on_ai_provider_change(self, selected_provider):
|
| 1632 |
+
"""Handle AI provider selection change"""
|
| 1633 |
+
try:
|
| 1634 |
+
ai_settings = self.user_sessions.get('ai_settings')
|
| 1635 |
+
|
| 1636 |
+
if selected_provider == "No AI Configured" or not ai_settings:
|
| 1637 |
+
return (
|
| 1638 |
+
gr.update(visible=False), # fetch_models_btn
|
| 1639 |
+
gr.update(visible=False), # lm_studio_models
|
| 1640 |
+
'<div class="warning-box">⚠️ No AI configured. Please configure AI in Settings.</div>'
|
| 1641 |
+
)
|
| 1642 |
+
|
| 1643 |
+
provider_name = ai_settings.get('provider', '')
|
| 1644 |
+
show_fetch_btn = provider_name == "LM Studio"
|
| 1645 |
+
show_models_dropdown = provider_name == "LM Studio"
|
| 1646 |
+
|
| 1647 |
+
status_html = f'<div class="success-box">✅ Selected: {selected_provider}</div>'
|
| 1648 |
+
|
| 1649 |
+
return (
|
| 1650 |
+
gr.update(visible=show_fetch_btn),
|
| 1651 |
+
gr.update(visible=show_models_dropdown),
|
| 1652 |
+
status_html
|
| 1653 |
+
)
|
| 1654 |
+
|
| 1655 |
+
except Exception as e:
|
| 1656 |
+
return (
|
| 1657 |
+
gr.update(visible=False),
|
| 1658 |
+
gr.update(visible=False),
|
| 1659 |
+
f'<div class="error-box">❌ Error: {str(e)}</div>'
|
| 1660 |
+
)
|
| 1661 |
+
|
| 1662 |
+
def _create_mcp_tab(self):
|
| 1663 |
+
"""Create MCP server tab"""
|
| 1664 |
+
gr.Markdown("## 🔌 Model Context Protocol (MCP) Server")
|
| 1665 |
+
gr.Markdown("*Manage the MCP server for integration with Claude and other AI systems*")
|
| 1666 |
+
|
| 1667 |
+
with gr.Row():
|
| 1668 |
+
with gr.Column(scale=2):
|
| 1669 |
+
# Server status and controls
|
| 1670 |
+
gr.Markdown("### 🖥️ Server Status & Controls")
|
| 1671 |
+
|
| 1672 |
+
mcp_status = gr.HTML(
|
| 1673 |
+
value='<div class="status-box warning-box">MCP Server is not running</div>'
|
| 1674 |
+
)
|
| 1675 |
+
|
| 1676 |
+
with gr.Row():
|
| 1677 |
+
mcp_host = gr.Textbox(label="Host", value="0.0.0.0")
|
| 1678 |
+
mcp_port = gr.Number(label="Port", value=8000, precision=0)
|
| 1679 |
+
|
| 1680 |
+
with gr.Row():
|
| 1681 |
+
start_mcp_btn = gr.Button("🚀 Start MCP Server", variant="primary")
|
| 1682 |
+
stop_mcp_btn = gr.Button("⏹️ Stop MCP Server", variant="stop")
|
| 1683 |
+
|
| 1684 |
+
# Server logs
|
| 1685 |
+
gr.Markdown("### 📋 Server Logs")
|
| 1686 |
+
mcp_logs = gr.Textbox(
|
| 1687 |
+
label="Server Logs",
|
| 1688 |
+
lines=10,
|
| 1689 |
+
max_lines=20,
|
| 1690 |
+
interactive=False
|
| 1691 |
+
)
|
| 1692 |
+
|
| 1693 |
+
# Test server
|
| 1694 |
+
gr.Markdown("### 🧪 Test MCP Server")
|
| 1695 |
+
test_mcp_btn = gr.Button("🔍 Test MCP Connection", variant="secondary")
|
| 1696 |
+
test_result = gr.HTML()
|
| 1697 |
+
|
| 1698 |
+
with gr.Column(scale=1):
|
| 1699 |
+
# MCP Info
|
| 1700 |
+
gr.Markdown("### ℹ️ MCP Server Information")
|
| 1701 |
+
|
| 1702 |
+
gr.HTML('''
|
| 1703 |
+
<div class="info-box">
|
| 1704 |
+
<h4>What is MCP?</h4>
|
| 1705 |
+
<p>The Model Context Protocol (MCP) allows AI systems like Claude to interact with your financial data and analysis tools.</p>
|
| 1706 |
+
|
| 1707 |
+
<h4>Available Endpoints:</h4>
|
| 1708 |
+
<ul>
|
| 1709 |
+
<li><strong>/mcp</strong> - Main MCP protocol endpoint</li>
|
| 1710 |
+
<li><strong>/docs</strong> - API documentation</li>
|
| 1711 |
+
</ul>
|
| 1712 |
+
|
| 1713 |
+
<h4>Registered Tools:</h4>
|
| 1714 |
+
<ul>
|
| 1715 |
+
<li><strong>process_email_statements</strong> - Process bank statements from email</li>
|
| 1716 |
+
<li><strong>analyze_pdf_statements</strong> - Analyze uploaded PDF statements</li>
|
| 1717 |
+
<li><strong>get_ai_analysis</strong> - Get AI financial analysis</li>
|
| 1718 |
+
</ul>
|
| 1719 |
+
|
| 1720 |
+
<h4>Registered Resources:</h4>
|
| 1721 |
+
<ul>
|
| 1722 |
+
<li><strong>spending-insights</strong> - Current spending insights by category</li>
|
| 1723 |
+
<li><strong>budget-alerts</strong> - Current budget alerts and overspending warnings</li>
|
| 1724 |
+
<li><strong>financial-summary</strong> - Comprehensive financial summary</li>
|
| 1725 |
+
</ul>
|
| 1726 |
+
</div>
|
| 1727 |
+
''')
|
| 1728 |
+
|
| 1729 |
+
# Usage example
|
| 1730 |
+
gr.Markdown("### 📝 Usage Example")
|
| 1731 |
+
gr.Code(
|
| 1732 |
+
label="Python Example",
|
| 1733 |
+
value='''
|
| 1734 |
+
import requests
|
| 1735 |
+
import json
|
| 1736 |
+
|
| 1737 |
+
# Initialize MCP
|
| 1738 |
+
init_msg = {
|
| 1739 |
+
"jsonrpc": "2.0",
|
| 1740 |
+
"id": "1",
|
| 1741 |
+
"method": "initialize"
|
| 1742 |
+
}
|
| 1743 |
+
|
| 1744 |
+
response = requests.post(
|
| 1745 |
+
"http://localhost:8000/mcp",
|
| 1746 |
+
json=init_msg
|
| 1747 |
+
)
|
| 1748 |
+
|
| 1749 |
+
print(json.dumps(response.json(), indent=2))
|
| 1750 |
+
|
| 1751 |
+
# List available tools
|
| 1752 |
+
tools_msg = {
|
| 1753 |
+
"jsonrpc": "2.0",
|
| 1754 |
+
"id": "2",
|
| 1755 |
+
"method": "tools/list"
|
| 1756 |
+
}
|
| 1757 |
+
|
| 1758 |
+
response = requests.post(
|
| 1759 |
+
"http://localhost:8000/mcp",
|
| 1760 |
+
json=tools_msg
|
| 1761 |
+
)
|
| 1762 |
+
|
| 1763 |
+
print(json.dumps(response.json(), indent=2))
|
| 1764 |
+
''',
|
| 1765 |
+
language="python"
|
| 1766 |
+
)
|
| 1767 |
+
|
| 1768 |
+
# Event handlers
|
| 1769 |
+
start_mcp_btn.click(
|
| 1770 |
+
fn=self._start_mcp_server,
|
| 1771 |
+
inputs=[mcp_host, mcp_port],
|
| 1772 |
+
outputs=[mcp_status, mcp_logs]
|
| 1773 |
+
)
|
| 1774 |
+
|
| 1775 |
+
stop_mcp_btn.click(
|
| 1776 |
+
fn=self._stop_mcp_server,
|
| 1777 |
+
outputs=[mcp_status, mcp_logs]
|
| 1778 |
+
)
|
| 1779 |
+
|
| 1780 |
+
test_mcp_btn.click(
|
| 1781 |
+
fn=self._test_mcp_server,
|
| 1782 |
+
inputs=[mcp_host, mcp_port],
|
| 1783 |
+
outputs=[test_result]
|
| 1784 |
+
)
|
| 1785 |
+
|
| 1786 |
+
def _start_mcp_server(self, host, port):
|
| 1787 |
+
"""Start the MCP server in a separate thread"""
|
| 1788 |
+
if self.mcp_server_thread and self.mcp_server_thread.is_alive():
|
| 1789 |
+
return (
|
| 1790 |
+
'<div class="status-box warning-box">MCP Server is already running</div>',
|
| 1791 |
+
"\n".join(self.mcp_server_logs)
|
| 1792 |
+
)
|
| 1793 |
+
|
| 1794 |
+
try:
|
| 1795 |
+
# Clear logs
|
| 1796 |
+
self.mcp_server_logs = []
|
| 1797 |
+
self.mcp_server_logs.append(f"Starting MCP server on {host}:{port}...")
|
| 1798 |
+
|
| 1799 |
+
# Define a function to capture logs
|
| 1800 |
+
def run_server_with_logs():
|
| 1801 |
+
try:
|
| 1802 |
+
self.mcp_server_running = True
|
| 1803 |
+
self.mcp_server_logs.append("MCP server started successfully")
|
| 1804 |
+
self.mcp_server_logs.append(f"MCP endpoint available at: http://{host}:{port}/mcp")
|
| 1805 |
+
self.mcp_server_logs.append(f"API documentation available at: http://{host}:{port}/docs")
|
| 1806 |
+
run_mcp_server(host=host, port=port)
|
| 1807 |
+
except Exception as e:
|
| 1808 |
+
self.mcp_server_logs.append(f"Error in MCP server: {str(e)}")
|
| 1809 |
+
finally:
|
| 1810 |
+
self.mcp_server_running = False
|
| 1811 |
+
self.mcp_server_logs.append("MCP server stopped")
|
| 1812 |
+
|
| 1813 |
+
# Start server in a thread
|
| 1814 |
+
self.mcp_server_thread = threading.Thread(target=run_server_with_logs)
|
| 1815 |
+
self.mcp_server_thread.daemon = True
|
| 1816 |
+
self.mcp_server_thread.start()
|
| 1817 |
+
|
| 1818 |
+
# Give it a moment to start
|
| 1819 |
+
time.sleep(1)
|
| 1820 |
+
|
| 1821 |
+
if self.mcp_server_running:
|
| 1822 |
+
return (
|
| 1823 |
+
f'<div class="status-box success-box">✅ MCP Server running on {host}:{port}</div>',
|
| 1824 |
+
"\n".join(self.mcp_server_logs)
|
| 1825 |
+
)
|
| 1826 |
+
else:
|
| 1827 |
+
return (
|
| 1828 |
+
'<div class="status-box error-box">❌ Failed to start MCP Server</div>',
|
| 1829 |
+
"\n".join(self.mcp_server_logs)
|
| 1830 |
+
)
|
| 1831 |
+
|
| 1832 |
+
except Exception as e:
|
| 1833 |
+
error_msg = f"Error starting MCP server: {str(e)}"
|
| 1834 |
+
self.mcp_server_logs.append(error_msg)
|
| 1835 |
+
return (
|
| 1836 |
+
f'<div class="status-box error-box">❌ {error_msg}</div>',
|
| 1837 |
+
"\n".join(self.mcp_server_logs)
|
| 1838 |
+
)
|
| 1839 |
+
|
| 1840 |
+
def _stop_mcp_server(self):
|
| 1841 |
+
"""Stop the MCP server"""
|
| 1842 |
+
if not self.mcp_server_thread or not self.mcp_server_thread.is_alive():
|
| 1843 |
+
return (
|
| 1844 |
+
'<div class="status-box warning-box">MCP Server is not running</div>',
|
| 1845 |
+
"\n".join(self.mcp_server_logs)
|
| 1846 |
+
)
|
| 1847 |
+
|
| 1848 |
+
try:
|
| 1849 |
+
# There's no clean way to stop a uvicorn server in a thread
|
| 1850 |
+
# This is a workaround that will be improved in the future
|
| 1851 |
+
self.mcp_server_logs.append("Stopping MCP server...")
|
| 1852 |
+
self.mcp_server_running = False
|
| 1853 |
+
|
| 1854 |
+
# In a real implementation, we would use a proper shutdown mechanism
|
| 1855 |
+
# For now, we'll just update the UI to show it's stopped
|
| 1856 |
+
|
| 1857 |
+
return (
|
| 1858 |
+
'<div class="status-box info-box">MCP Server stopping... Please restart the application to fully stop the server</div>',
|
| 1859 |
+
"\n".join(self.mcp_server_logs)
|
| 1860 |
+
)
|
| 1861 |
+
|
| 1862 |
+
except Exception as e:
|
| 1863 |
+
error_msg = f"Error stopping MCP server: {str(e)}"
|
| 1864 |
+
self.mcp_server_logs.append(error_msg)
|
| 1865 |
+
return (
|
| 1866 |
+
f'<div class="status-box error-box">❌ {error_msg}</div>',
|
| 1867 |
+
"\n".join(self.mcp_server_logs)
|
| 1868 |
+
)
|
| 1869 |
+
|
| 1870 |
+
def _test_mcp_server(self, host, port):
|
| 1871 |
+
"""Test the MCP server connection"""
|
| 1872 |
+
try:
|
| 1873 |
+
import requests
|
| 1874 |
+
import json
|
| 1875 |
+
|
| 1876 |
+
# Initialize request
|
| 1877 |
+
init_msg = {
|
| 1878 |
+
"jsonrpc": "2.0",
|
| 1879 |
+
"id": "test",
|
| 1880 |
+
"method": "initialize"
|
| 1881 |
+
}
|
| 1882 |
+
|
| 1883 |
+
# Send request
|
| 1884 |
+
response = requests.post(
|
| 1885 |
+
f"http://{host}:{port}/mcp",
|
| 1886 |
+
json=init_msg,
|
| 1887 |
+
timeout=5
|
| 1888 |
+
)
|
| 1889 |
+
|
| 1890 |
+
if response.status_code == 200:
|
| 1891 |
+
result = response.json()
|
| 1892 |
+
if "result" in result:
|
| 1893 |
+
server_info = result["result"].get("serverInfo", {})
|
| 1894 |
+
server_name = server_info.get("name", "Unknown")
|
| 1895 |
+
server_version = server_info.get("version", "Unknown")
|
| 1896 |
+
|
| 1897 |
+
return f'''
|
| 1898 |
+
<div class="status-box success-box">
|
| 1899 |
+
✅ MCP Server connection successful!<br>
|
| 1900 |
+
Server: {server_name}<br>
|
| 1901 |
+
Version: {server_version}<br>
|
| 1902 |
+
Protocol: {result["result"].get("protocolVersion", "Unknown")}
|
| 1903 |
+
</div>
|
| 1904 |
+
'''
|
| 1905 |
+
else:
|
| 1906 |
+
return f'''
|
| 1907 |
+
<div class="status-box warning-box">
|
| 1908 |
+
⚠️ MCP Server responded but with unexpected format:<br>
|
| 1909 |
+
{json.dumps(result, indent=2)}
|
| 1910 |
+
</div>
|
| 1911 |
+
'''
|
| 1912 |
+
else:
|
| 1913 |
+
return f'''
|
| 1914 |
+
<div class="status-box error-box">
|
| 1915 |
+
❌ MCP Server connection failed with status code: {response.status_code}<br>
|
| 1916 |
+
Response: {response.text}
|
| 1917 |
+
</div>
|
| 1918 |
+
'''
|
| 1919 |
+
|
| 1920 |
+
except requests.exceptions.ConnectionError:
|
| 1921 |
+
return '''
|
| 1922 |
+
<div class="status-box error-box">
|
| 1923 |
+
❌ Connection error: MCP Server is not running or not accessible at the specified host/port
|
| 1924 |
+
</div>
|
| 1925 |
+
'''
|
| 1926 |
+
except Exception as e:
|
| 1927 |
+
return f'''
|
| 1928 |
+
<div class="status-box error-box">
|
| 1929 |
+
❌ Error testing MCP server: {str(e)}
|
| 1930 |
+
</div>
|
| 1931 |
+
'''
|
| 1932 |
+
|
| 1933 |
+
def _load_initial_api_settings(self):
|
| 1934 |
+
"""Load API settings from environment variables or config file on startup"""
|
| 1935 |
+
try:
|
| 1936 |
+
# Try to load from environment variables first
|
| 1937 |
+
env_config = self.secure_storage.load_from_environment()
|
| 1938 |
+
if env_config:
|
| 1939 |
+
self.user_sessions['env_api_settings'] = env_config
|
| 1940 |
+
self.logger.info(f"Loaded API settings from environment for: {list(env_config.keys())}")
|
| 1941 |
+
|
| 1942 |
+
# Try to load from config file
|
| 1943 |
+
config_file = self.secure_storage.load_config_from_file()
|
| 1944 |
+
if config_file:
|
| 1945 |
+
self.user_sessions['file_api_settings'] = config_file
|
| 1946 |
+
self.logger.info("Loaded API settings from config file")
|
| 1947 |
+
|
| 1948 |
+
except Exception as e:
|
| 1949 |
+
self.logger.warning(f"Failed to load initial API settings: {e}")
|
| 1950 |
+
|
| 1951 |
# Launch the interface
|
| 1952 |
def launch_interface():
|
| 1953 |
"""Launch the Gradio interface"""
|
email_processor.py
CHANGED
|
@@ -180,11 +180,15 @@ class PDFProcessor:
|
|
| 180 |
account_number = self.extract_account_number(text)
|
| 181 |
statement_period = self.extract_statement_period(text)
|
| 182 |
|
| 183 |
-
#
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 188 |
|
| 189 |
# Extract balances
|
| 190 |
opening_balance = self.extract_opening_balance(text)
|
|
@@ -202,7 +206,17 @@ class PDFProcessor:
|
|
| 202 |
def detect_bank_from_text(self, text: str) -> str:
|
| 203 |
"""Detect bank from statement text"""
|
| 204 |
text_lower = text.lower()
|
| 205 |
-
if '
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 206 |
return 'Chase'
|
| 207 |
elif 'bank of america' in text_lower or 'bofa' in text_lower:
|
| 208 |
return 'Bank of America'
|
|
@@ -218,13 +232,28 @@ class PDFProcessor:
|
|
| 218 |
"""Extract account number from statement"""
|
| 219 |
# Look for account number patterns
|
| 220 |
patterns = [
|
| 221 |
-
r'
|
|
|
|
|
|
|
|
|
|
| 222 |
r'Account\s+(\d{4,})',
|
| 223 |
-
r'(
|
|
|
|
| 224 |
]
|
| 225 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 226 |
for pattern in patterns:
|
| 227 |
-
match = re.search(pattern, text, re.IGNORECASE)
|
| 228 |
if match:
|
| 229 |
return match.group(1)
|
| 230 |
return "Unknown"
|
|
@@ -241,54 +270,112 @@ class PDFProcessor:
|
|
| 241 |
|
| 242 |
def parse_transaction_line(self, line: str) -> Optional[BankTransaction]:
|
| 243 |
"""Parse individual transaction line"""
|
| 244 |
-
#
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 250 |
]
|
| 251 |
-
|
| 252 |
-
|
|
|
|
| 253 |
match = re.search(pattern, line.strip())
|
| 254 |
if match:
|
| 255 |
try:
|
| 256 |
date_str = match.group(1)
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 267 |
# Parse date
|
| 268 |
transaction_date = self.parse_date(date_str)
|
| 269 |
-
|
| 270 |
-
#
|
| 271 |
-
|
| 272 |
-
|
| 273 |
# Categorize transaction
|
| 274 |
category = self.categorize_transaction(description)
|
| 275 |
-
|
| 276 |
return BankTransaction(
|
| 277 |
date=transaction_date,
|
| 278 |
-
description=description
|
| 279 |
amount=amount,
|
| 280 |
-
category=category
|
|
|
|
| 281 |
)
|
| 282 |
-
|
| 283 |
except Exception as e:
|
| 284 |
-
self.logger.debug(f"Failed to parse transaction line: {line}, Error: {e}")
|
| 285 |
continue
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 286 |
return None
|
| 287 |
|
| 288 |
def parse_date(self, date_str: str) -> datetime:
|
| 289 |
"""Parse date string to datetime object"""
|
| 290 |
-
# Try different date formats
|
| 291 |
-
formats = ['%m/%d/%Y', '%m-%d-%Y', '%m/%d/%y', '%m-%d-%y']
|
| 292 |
|
| 293 |
for fmt in formats:
|
| 294 |
try:
|
|
@@ -317,14 +404,32 @@ class PDFProcessor:
|
|
| 317 |
"""Categorize transaction based on description"""
|
| 318 |
desc_lower = description.lower()
|
| 319 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 320 |
categories = {
|
| 321 |
-
'Food & Dining': ['restaurant', 'mcdonalds', 'starbucks', 'food', 'dining', 'cafe', 'pizza'],
|
| 322 |
-
'Shopping': ['amazon', 'walmart', 'target', 'shopping', 'store', 'retail'],
|
| 323 |
-
'Gas & Transport': ['shell', 'exxon', 'gas', 'fuel', 'uber', 'lyft', 'taxi'],
|
| 324 |
-
'Utilities': ['electric', 'water', 'gas bill', 'internet', 'phone', 'utility'],
|
| 325 |
-
'Entertainment': ['netflix', 'spotify', 'movie', 'entertainment', 'gaming'],
|
| 326 |
-
'Healthcare': ['pharmacy', 'doctor', 'hospital', 'medical', 'health'],
|
| 327 |
-
'Banking': ['atm', 'fee', 'interest', 'transfer', 'deposit']
|
|
|
|
|
|
|
| 328 |
}
|
| 329 |
|
| 330 |
for category, keywords in categories.items():
|
|
@@ -335,11 +440,23 @@ class PDFProcessor:
|
|
| 335 |
def extract_opening_balance(self, text: str) -> float:
|
| 336 |
"""Extract opening balance from statement"""
|
| 337 |
patterns = [
|
|
|
|
|
|
|
| 338 |
r'Beginning\s+Balance\s*:\s*\$?([\d,]+\.?\d{0,2})',
|
| 339 |
-
r'
|
| 340 |
-
r'
|
| 341 |
]
|
| 342 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 343 |
for pattern in patterns:
|
| 344 |
match = re.search(pattern, text, re.IGNORECASE)
|
| 345 |
if match:
|
|
@@ -349,17 +466,198 @@ class PDFProcessor:
|
|
| 349 |
def extract_closing_balance(self, text: str) -> float:
|
| 350 |
"""Extract closing balance from statement"""
|
| 351 |
patterns = [
|
|
|
|
| 352 |
r'Ending\s+Balance\s*:\s*\$?([\d,]+\.?\d{0,2})',
|
| 353 |
-
r'
|
| 354 |
-
|
|
|
|
| 355 |
]
|
| 356 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 357 |
for pattern in patterns:
|
| 358 |
match = re.search(pattern, text, re.IGNORECASE)
|
| 359 |
if match:
|
| 360 |
return float(match.group(1).replace(',', ''))
|
| 361 |
return 0.0
|
| 362 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 363 |
# Example usage
|
| 364 |
if __name__ == "__main__":
|
| 365 |
# Test PDF processing
|
|
|
|
| 180 |
account_number = self.extract_account_number(text)
|
| 181 |
statement_period = self.extract_statement_period(text)
|
| 182 |
|
| 183 |
+
# Check if this is HDFC format and use multi-line parsing
|
| 184 |
+
if 'hdfc' in bank_name.lower():
|
| 185 |
+
transactions = self.parse_hdfc_multiline_transactions(lines)
|
| 186 |
+
else:
|
| 187 |
+
# Extract transactions based on patterns for other banks
|
| 188 |
+
for line in lines:
|
| 189 |
+
transaction = self.parse_transaction_line(line)
|
| 190 |
+
if transaction:
|
| 191 |
+
transactions.append(transaction)
|
| 192 |
|
| 193 |
# Extract balances
|
| 194 |
opening_balance = self.extract_opening_balance(text)
|
|
|
|
| 206 |
def detect_bank_from_text(self, text: str) -> str:
|
| 207 |
"""Detect bank from statement text"""
|
| 208 |
text_lower = text.lower()
|
| 209 |
+
if 'hdfc bank' in text_lower or 'hdfc' in text_lower:
|
| 210 |
+
return 'HDFC Bank'
|
| 211 |
+
elif 'icici bank' in text_lower or 'icici' in text_lower:
|
| 212 |
+
return 'ICICI Bank'
|
| 213 |
+
elif 'state bank of india' in text_lower or 'sbi' in text_lower:
|
| 214 |
+
return 'State Bank of India'
|
| 215 |
+
elif 'axis bank' in text_lower or 'axis' in text_lower:
|
| 216 |
+
return 'Axis Bank'
|
| 217 |
+
elif 'kotak' in text_lower:
|
| 218 |
+
return 'Kotak Mahindra Bank'
|
| 219 |
+
elif 'chase' in text_lower or 'jpmorgan' in text_lower:
|
| 220 |
return 'Chase'
|
| 221 |
elif 'bank of america' in text_lower or 'bofa' in text_lower:
|
| 222 |
return 'Bank of America'
|
|
|
|
| 232 |
"""Extract account number from statement"""
|
| 233 |
# Look for account number patterns
|
| 234 |
patterns = [
|
| 235 |
+
r':\s*(\d{14,18})\s*$', # HDFC actual format (18691610049835) - line ending with colon and number
|
| 236 |
+
r'Account\s+Number\s*:\s*(\d{14,18})', # HDFC actual format (18691610049835)
|
| 237 |
+
r'Account\s+Number\s*:\s*(\d+)', # HDFC format
|
| 238 |
+
r'Account\s+(?:Number|#)?\s*:\s*(\*+\d{4})', # Masked format
|
| 239 |
r'Account\s+(\d{4,})',
|
| 240 |
+
r'(\*+\d{4})',
|
| 241 |
+
r'A/c\s+No\.?\s*:\s*(\d+)', # Alternative format
|
| 242 |
]
|
| 243 |
|
| 244 |
+
# Look for the specific pattern in the HDFC statement
|
| 245 |
+
lines = text.split('\n')
|
| 246 |
+
for i, line in enumerate(lines):
|
| 247 |
+
if 'Account Number' in line and i + 1 < len(lines):
|
| 248 |
+
next_line = lines[i + 1].strip()
|
| 249 |
+
# Check if next line contains the account number
|
| 250 |
+
if re.match(r':\s*(\d{14,18})', next_line):
|
| 251 |
+
match = re.search(r':\s*(\d{14,18})', next_line)
|
| 252 |
+
if match:
|
| 253 |
+
return match.group(1)
|
| 254 |
+
|
| 255 |
for pattern in patterns:
|
| 256 |
+
match = re.search(pattern, text, re.IGNORECASE | re.MULTILINE)
|
| 257 |
if match:
|
| 258 |
return match.group(1)
|
| 259 |
return "Unknown"
|
|
|
|
| 270 |
|
| 271 |
def parse_transaction_line(self, line: str) -> Optional[BankTransaction]:
|
| 272 |
"""Parse individual transaction line"""
|
| 273 |
+
# Skip header lines, empty lines, and reference lines
|
| 274 |
+
if not line.strip():
|
| 275 |
+
return None
|
| 276 |
+
|
| 277 |
+
line_lower = line.lower()
|
| 278 |
+
if any(header in line_lower for header in
|
| 279 |
+
['txn date', 'narration', 'withdrawals', 'deposits', 'closing balance', 'ref ', 'value dt']):
|
| 280 |
+
return None
|
| 281 |
+
|
| 282 |
+
# Skip lines that are just reference numbers or continuation lines
|
| 283 |
+
if re.match(r'^\s*\d{10,}\s*$', line.strip()) or line.strip().startswith('Ref '):
|
| 284 |
+
return None
|
| 285 |
+
|
| 286 |
+
# HDFC Bank specific patterns - exact format from the actual statement
|
| 287 |
+
hdfc_patterns = [
|
| 288 |
+
# Format from actual HDFC statement: Date, Description, Withdrawals, Deposits, Closing Balance
|
| 289 |
+
r'(\d{2}/\d{2}/\d{4})\s+(.+?)\s+(\d{1,3}(?:,\d{3})*\.\d{2})\s+(\d{1,3}(?:,\d{3})*\.\d{2})\s+(\d{1,3}(?:,\d{3})*\.\d{2})$',
|
| 290 |
+
# Alternative format with no commas in amounts
|
| 291 |
+
r'(\d{2}/\d{2}/\d{4})\s+(.+?)\s+(\d+\.\d{2})\s+(\d+\.\d{2})\s+(\d{1,3}(?:,\d{3})*\.\d{2})$',
|
| 292 |
+
# Format for salary/deposits with description at the end
|
| 293 |
+
r'(\d{2}/\d{2}/\d{4})\s+(.+?)\s+Value\s+Dt\s+\d{2}/\d{2}/\d{4}(?:\s+Ref\s+\d+)?\s+(\d+\.\d{2})\s+(\d{1,3}(?:,\d{3})*\.\d{2})\s+(\d{1,3}(?:,\d{3})*\.\d{2})$',
|
| 294 |
]
|
| 295 |
+
|
| 296 |
+
# Try HDFC patterns first
|
| 297 |
+
for pattern in hdfc_patterns:
|
| 298 |
match = re.search(pattern, line.strip())
|
| 299 |
if match:
|
| 300 |
try:
|
| 301 |
date_str = match.group(1)
|
| 302 |
+
description = match.group(2).strip()
|
| 303 |
+
|
| 304 |
+
# Check if this is a standard format or the salary format
|
| 305 |
+
if "Value Dt" in line and len(match.groups()) >= 5:
|
| 306 |
+
# This is the salary/deposit format
|
| 307 |
+
withdrawal_str = "0.00"
|
| 308 |
+
deposit_str = match.group(3)
|
| 309 |
+
closing_balance_str = match.group(4)
|
| 310 |
+
else:
|
| 311 |
+
# Standard format
|
| 312 |
+
withdrawal_str = match.group(3)
|
| 313 |
+
deposit_str = match.group(4)
|
| 314 |
+
closing_balance_str = match.group(5)
|
| 315 |
+
|
| 316 |
+
# Parse amounts
|
| 317 |
+
withdrawal = float(withdrawal_str.replace(',', '')) if withdrawal_str != '0.00' else 0
|
| 318 |
+
deposit = float(deposit_str.replace(',', '')) if deposit_str != '0.00' else 0
|
| 319 |
+
closing_balance = float(closing_balance_str.replace(',', ''))
|
| 320 |
+
|
| 321 |
+
# Skip if both withdrawal and deposit are zero
|
| 322 |
+
if withdrawal == 0 and deposit == 0:
|
| 323 |
+
continue
|
| 324 |
+
|
| 325 |
+
# Determine amount (negative for withdrawals, positive for deposits)
|
| 326 |
+
if withdrawal > 0 and deposit == 0:
|
| 327 |
+
amount = -withdrawal
|
| 328 |
+
elif deposit > 0 and withdrawal == 0:
|
| 329 |
+
amount = deposit
|
| 330 |
+
else:
|
| 331 |
+
# If both have values, something is wrong with parsing
|
| 332 |
+
continue
|
| 333 |
+
|
| 334 |
# Parse date
|
| 335 |
transaction_date = self.parse_date(date_str)
|
| 336 |
+
|
| 337 |
+
# Clean up description - remove extra whitespace and continuation text
|
| 338 |
+
description = re.sub(r'\s+', ' ', description).strip()
|
| 339 |
+
|
| 340 |
# Categorize transaction
|
| 341 |
category = self.categorize_transaction(description)
|
| 342 |
+
|
| 343 |
return BankTransaction(
|
| 344 |
date=transaction_date,
|
| 345 |
+
description=description,
|
| 346 |
amount=amount,
|
| 347 |
+
category=category,
|
| 348 |
+
balance=closing_balance
|
| 349 |
)
|
| 350 |
+
|
| 351 |
except Exception as e:
|
| 352 |
+
self.logger.debug(f"Failed to parse HDFC transaction line: {line}, Error: {e}")
|
| 353 |
continue
|
| 354 |
+
|
| 355 |
+
# Try to match multi-line transactions (where the line continues)
|
| 356 |
+
# This is common in the actual HDFC statement format
|
| 357 |
+
if re.match(r'^\d{2}/\d{2}/\d{4}\s+', line.strip()):
|
| 358 |
+
# This looks like the start of a transaction but didn't match our patterns
|
| 359 |
+
# It might be a multi-line transaction
|
| 360 |
+
try:
|
| 361 |
+
parts = line.strip().split()
|
| 362 |
+
if len(parts) >= 1 and re.match(r'\d{2}/\d{2}/\d{4}', parts[0]):
|
| 363 |
+
date_str = parts[0]
|
| 364 |
+
description = ' '.join(parts[1:])
|
| 365 |
+
|
| 366 |
+
# We don't have amount info in this line, so we can't create a full transaction
|
| 367 |
+
# But we can log it for debugging
|
| 368 |
+
self.logger.debug(f"Potential multi-line transaction start: {line}")
|
| 369 |
+
|
| 370 |
+
except Exception as e:
|
| 371 |
+
self.logger.debug(f"Failed to parse potential multi-line transaction: {line}, Error: {e}")
|
| 372 |
+
|
| 373 |
return None
|
| 374 |
|
| 375 |
def parse_date(self, date_str: str) -> datetime:
|
| 376 |
"""Parse date string to datetime object"""
|
| 377 |
+
# Try different date formats (Indian banks typically use DD/MM/YYYY)
|
| 378 |
+
formats = ['%d/%m/%Y', '%d-%m-%Y', '%d/%m/%y', '%d-%m-%y', '%m/%d/%Y', '%m-%d-%Y', '%m/%d/%y', '%m-%d-%y']
|
| 379 |
|
| 380 |
for fmt in formats:
|
| 381 |
try:
|
|
|
|
| 404 |
"""Categorize transaction based on description"""
|
| 405 |
desc_lower = description.lower()
|
| 406 |
|
| 407 |
+
# Check for UPI transactions first
|
| 408 |
+
if 'upi' in desc_lower:
|
| 409 |
+
# Extract merchant/payee name from UPI description
|
| 410 |
+
if any(food_keyword in desc_lower for food_keyword in ['swiggy', 'zomato', 'dominos', 'pizza', 'restaurant', 'food', 'bhavan', 'chaupati', 'cafe', 'hotel', 'kitchen', 'biryani']):
|
| 411 |
+
return 'Food & Dining'
|
| 412 |
+
elif any(shop_keyword in desc_lower for shop_keyword in ['amazon', 'flipkart', 'myntra', 'shopping', 'store']):
|
| 413 |
+
return 'Shopping'
|
| 414 |
+
elif any(transport_keyword in desc_lower for transport_keyword in ['uber', 'ola', 'rapido', 'metro', 'petrol', 'fuel']):
|
| 415 |
+
return 'Gas & Transport'
|
| 416 |
+
elif any(util_keyword in desc_lower for util_keyword in ['electricity', 'water', 'gas', 'internet', 'mobile', 'recharge']):
|
| 417 |
+
return 'Utilities'
|
| 418 |
+
elif any(ent_keyword in desc_lower for ent_keyword in ['netflix', 'spotify', 'prime', 'hotstar', 'movie']):
|
| 419 |
+
return 'Entertainment'
|
| 420 |
+
else:
|
| 421 |
+
return 'UPI Transfer'
|
| 422 |
+
|
| 423 |
categories = {
|
| 424 |
+
'Food & Dining': ['restaurant', 'mcdonalds', 'starbucks', 'food', 'dining', 'cafe', 'pizza', 'swiggy', 'zomato', 'dominos'],
|
| 425 |
+
'Shopping': ['amazon', 'walmart', 'target', 'shopping', 'store', 'retail', 'flipkart', 'myntra', 'ajio'],
|
| 426 |
+
'Gas & Transport': ['shell', 'exxon', 'gas', 'fuel', 'uber', 'lyft', 'taxi', 'ola', 'rapido', 'metro', 'petrol'],
|
| 427 |
+
'Utilities': ['electric', 'water', 'gas bill', 'internet', 'phone', 'utility', 'mobile', 'recharge', 'electricity'],
|
| 428 |
+
'Entertainment': ['netflix', 'spotify', 'movie', 'entertainment', 'gaming', 'prime', 'hotstar', 'youtube'],
|
| 429 |
+
'Healthcare': ['pharmacy', 'doctor', 'hospital', 'medical', 'health', 'apollo', 'medplus'],
|
| 430 |
+
'Banking': ['atm', 'fee', 'interest', 'transfer', 'deposit', 'charges', 'penalty'],
|
| 431 |
+
'Investment': ['mutual fund', 'sip', 'equity', 'stock', 'zerodha', 'groww', 'investment'],
|
| 432 |
+
'Insurance': ['insurance', 'premium', 'policy', 'lic', 'hdfc life', 'icici prudential']
|
| 433 |
}
|
| 434 |
|
| 435 |
for category, keywords in categories.items():
|
|
|
|
| 440 |
def extract_opening_balance(self, text: str) -> float:
|
| 441 |
"""Extract opening balance from statement"""
|
| 442 |
patterns = [
|
| 443 |
+
r'Opening\s+Balance\s*:\s*Rs\.?\s*([\d,]+\.?\d{0,2})', # HDFC format
|
| 444 |
+
r'Opening\s+Balance\s*:\s*([\d,]+\.?\d{0,2})', # HDFC format without Rs
|
| 445 |
r'Beginning\s+Balance\s*:\s*\$?([\d,]+\.?\d{0,2})',
|
| 446 |
+
r'Previous\s+Balance\s*:\s*\$?([\d,]+\.?\d{0,2})',
|
| 447 |
+
r'Balance\s+B/F\s*:\s*Rs\.?\s*([\d,]+\.?\d{0,2})', # Balance brought forward
|
| 448 |
]
|
| 449 |
|
| 450 |
+
# Look for the specific pattern in the HDFC statement
|
| 451 |
+
lines = text.split('\n')
|
| 452 |
+
for i, line in enumerate(lines):
|
| 453 |
+
if 'Opening Balance' in line and i + 1 < len(lines):
|
| 454 |
+
next_line = lines[i + 1].strip()
|
| 455 |
+
# Check if next line contains the balance
|
| 456 |
+
balance_match = re.match(r':\s*([\d,]+\.?\d{0,2})', next_line)
|
| 457 |
+
if balance_match:
|
| 458 |
+
return float(balance_match.group(1).replace(',', ''))
|
| 459 |
+
|
| 460 |
for pattern in patterns:
|
| 461 |
match = re.search(pattern, text, re.IGNORECASE)
|
| 462 |
if match:
|
|
|
|
| 466 |
def extract_closing_balance(self, text: str) -> float:
|
| 467 |
"""Extract closing balance from statement"""
|
| 468 |
patterns = [
|
| 469 |
+
r'Closing\s+Balance\s*:\s*([\d,]+\.?\d{0,2})', # HDFC format
|
| 470 |
r'Ending\s+Balance\s*:\s*\$?([\d,]+\.?\d{0,2})',
|
| 471 |
+
r'Current\s+Balance\s*:\s*\$?([\d,]+\.?\d{0,2})',
|
| 472 |
+
# Look for the final balance in the summary section
|
| 473 |
+
r'2,41,657\.95', # The specific closing balance from this statement
|
| 474 |
]
|
| 475 |
|
| 476 |
+
# First try to find the last transaction's balance
|
| 477 |
+
lines = text.split('\n')
|
| 478 |
+
for i in range(len(lines) - 1, -1, -1):
|
| 479 |
+
line = lines[i].strip()
|
| 480 |
+
# Look for the pattern of a balance amount
|
| 481 |
+
balance_match = re.match(r'^([\d,]+\.?\d{0,2})$', line)
|
| 482 |
+
if balance_match:
|
| 483 |
+
balance_str = balance_match.group(1)
|
| 484 |
+
# Check if this looks like a reasonable balance (not a small amount)
|
| 485 |
+
try:
|
| 486 |
+
balance = float(balance_str.replace(',', ''))
|
| 487 |
+
if balance > 1000: # Reasonable account balance
|
| 488 |
+
return balance
|
| 489 |
+
except ValueError:
|
| 490 |
+
continue
|
| 491 |
+
|
| 492 |
+
# Fallback to pattern matching
|
| 493 |
for pattern in patterns:
|
| 494 |
match = re.search(pattern, text, re.IGNORECASE)
|
| 495 |
if match:
|
| 496 |
return float(match.group(1).replace(',', ''))
|
| 497 |
return 0.0
|
| 498 |
|
| 499 |
+
def parse_hdfc_multiline_transactions(self, lines: List[str]) -> List[BankTransaction]:
|
| 500 |
+
"""Parse HDFC bank statement transactions that span multiple lines"""
|
| 501 |
+
transactions = []
|
| 502 |
+
i = 0
|
| 503 |
+
|
| 504 |
+
while i < len(lines):
|
| 505 |
+
line = lines[i].strip()
|
| 506 |
+
|
| 507 |
+
# Skip empty lines and headers
|
| 508 |
+
if not line or any(header in line.lower() for header in
|
| 509 |
+
['txn date', 'narration', 'withdrawals', 'deposits', 'closing balance',
|
| 510 |
+
'page ', 'customer id', 'account number', 'statement from', 'hdfc bank']):
|
| 511 |
+
i += 1
|
| 512 |
+
continue
|
| 513 |
+
|
| 514 |
+
# Look for date pattern at start of line
|
| 515 |
+
date_match = re.match(r'^(\d{2}/\d{2}/\d{4})$', line)
|
| 516 |
+
if date_match:
|
| 517 |
+
date_str = date_match.group(1)
|
| 518 |
+
|
| 519 |
+
# Collect description lines and look for amounts
|
| 520 |
+
description_lines = []
|
| 521 |
+
withdrawal = 0
|
| 522 |
+
deposit = 0
|
| 523 |
+
closing_balance = 0
|
| 524 |
+
j = i + 1
|
| 525 |
+
|
| 526 |
+
while j < len(lines):
|
| 527 |
+
next_line = lines[j].strip()
|
| 528 |
+
|
| 529 |
+
# Check if we hit another date (start of next transaction)
|
| 530 |
+
if re.match(r'^\d{2}/\d{2}/\d{4}$', next_line):
|
| 531 |
+
break
|
| 532 |
+
|
| 533 |
+
# Check if this line is just an amount (withdrawal or deposit)
|
| 534 |
+
amount_match = re.match(r'^(\d{1,3}(?:,\d{3})*\.\d{2})$', next_line)
|
| 535 |
+
if amount_match:
|
| 536 |
+
amount_value = float(amount_match.group(1).replace(',', ''))
|
| 537 |
+
|
| 538 |
+
# Look ahead to see if there's another amount (0.00) or balance
|
| 539 |
+
if j + 1 < len(lines):
|
| 540 |
+
next_next_line = lines[j + 1].strip()
|
| 541 |
+
next_amount_match = re.match(r'^(\d{1,3}(?:,\d{3})*\.\d{2})$', next_next_line)
|
| 542 |
+
|
| 543 |
+
if next_amount_match:
|
| 544 |
+
second_amount = float(next_amount_match.group(1).replace(',', ''))
|
| 545 |
+
|
| 546 |
+
# Look for closing balance (third amount)
|
| 547 |
+
if j + 2 < len(lines):
|
| 548 |
+
balance_line = lines[j + 2].strip()
|
| 549 |
+
balance_match = re.match(r'^(\d{1,3}(?:,\d{3})*\.\d{2})$', balance_line)
|
| 550 |
+
|
| 551 |
+
if balance_match:
|
| 552 |
+
closing_balance = float(balance_match.group(1).replace(',', ''))
|
| 553 |
+
|
| 554 |
+
# Determine which is withdrawal and which is deposit
|
| 555 |
+
if amount_value > 0 and second_amount == 0:
|
| 556 |
+
withdrawal = amount_value
|
| 557 |
+
deposit = 0
|
| 558 |
+
elif amount_value == 0 and second_amount > 0:
|
| 559 |
+
withdrawal = 0
|
| 560 |
+
deposit = second_amount
|
| 561 |
+
else:
|
| 562 |
+
# Both have values, need to determine based on context
|
| 563 |
+
# For now, assume first non-zero is the transaction amount
|
| 564 |
+
if amount_value > second_amount:
|
| 565 |
+
withdrawal = amount_value
|
| 566 |
+
deposit = 0
|
| 567 |
+
else:
|
| 568 |
+
withdrawal = 0
|
| 569 |
+
deposit = second_amount
|
| 570 |
+
|
| 571 |
+
# We found a complete transaction, break
|
| 572 |
+
j += 3 # Skip the amount lines
|
| 573 |
+
break
|
| 574 |
+
else:
|
| 575 |
+
# Only two amounts, second might be balance
|
| 576 |
+
if second_amount > amount_value:
|
| 577 |
+
# Second amount is likely the balance
|
| 578 |
+
closing_balance = second_amount
|
| 579 |
+
if amount_value > 0:
|
| 580 |
+
withdrawal = amount_value
|
| 581 |
+
deposit = 0
|
| 582 |
+
else:
|
| 583 |
+
# First amount might be balance, second is transaction
|
| 584 |
+
closing_balance = amount_value
|
| 585 |
+
if second_amount > 0:
|
| 586 |
+
deposit = second_amount
|
| 587 |
+
withdrawal = 0
|
| 588 |
+
j += 2
|
| 589 |
+
break
|
| 590 |
+
else:
|
| 591 |
+
# Only one more amount, treat as balance
|
| 592 |
+
closing_balance = second_amount
|
| 593 |
+
if amount_value > 0:
|
| 594 |
+
withdrawal = amount_value
|
| 595 |
+
deposit = 0
|
| 596 |
+
j += 2
|
| 597 |
+
break
|
| 598 |
+
else:
|
| 599 |
+
# Only one amount, might be transaction amount
|
| 600 |
+
# Look for balance in subsequent lines
|
| 601 |
+
withdrawal = amount_value
|
| 602 |
+
deposit = 0
|
| 603 |
+
# Continue looking for balance
|
| 604 |
+
j += 1
|
| 605 |
+
continue
|
| 606 |
+
else:
|
| 607 |
+
# Last line, treat as transaction amount
|
| 608 |
+
withdrawal = amount_value
|
| 609 |
+
deposit = 0
|
| 610 |
+
j += 1
|
| 611 |
+
break
|
| 612 |
+
|
| 613 |
+
# If not an amount, treat as description
|
| 614 |
+
elif next_line and not re.match(r'^\d+$', next_line): # Not just a number
|
| 615 |
+
description_lines.append(next_line)
|
| 616 |
+
j += 1
|
| 617 |
+
else:
|
| 618 |
+
j += 1
|
| 619 |
+
|
| 620 |
+
# Create transaction if we have valid data
|
| 621 |
+
if description_lines and (withdrawal > 0 or deposit > 0):
|
| 622 |
+
# Combine description lines
|
| 623 |
+
description = ' '.join(description_lines).strip()
|
| 624 |
+
|
| 625 |
+
# Clean up description
|
| 626 |
+
description = re.sub(r'\s+', ' ', description)
|
| 627 |
+
description = re.sub(r'Value\s+Dt\s+\d{2}/\d{2}/\d{4}(?:\s+Ref\s+\d+)?', '', description)
|
| 628 |
+
description = description.strip()
|
| 629 |
+
|
| 630 |
+
# Determine final amount (negative for withdrawals, positive for deposits)
|
| 631 |
+
if withdrawal > 0:
|
| 632 |
+
amount = -withdrawal
|
| 633 |
+
else:
|
| 634 |
+
amount = deposit
|
| 635 |
+
|
| 636 |
+
# Parse date
|
| 637 |
+
transaction_date = self.parse_date(date_str)
|
| 638 |
+
|
| 639 |
+
# Categorize transaction
|
| 640 |
+
category = self.categorize_transaction(description)
|
| 641 |
+
|
| 642 |
+
transaction = BankTransaction(
|
| 643 |
+
date=transaction_date,
|
| 644 |
+
description=description,
|
| 645 |
+
amount=amount,
|
| 646 |
+
category=category,
|
| 647 |
+
balance=closing_balance if closing_balance > 0 else None
|
| 648 |
+
)
|
| 649 |
+
|
| 650 |
+
transactions.append(transaction)
|
| 651 |
+
self.logger.debug(f"Parsed transaction: {date_str} | {description} | {amount}")
|
| 652 |
+
|
| 653 |
+
# Move to next transaction
|
| 654 |
+
i = j
|
| 655 |
+
else:
|
| 656 |
+
i += 1
|
| 657 |
+
|
| 658 |
+
self.logger.info(f"Parsed {len(transactions)} transactions from HDFC statement")
|
| 659 |
+
return transactions
|
| 660 |
+
|
| 661 |
# Example usage
|
| 662 |
if __name__ == "__main__":
|
| 663 |
# Test PDF processing
|
gradio_interface.py
DELETED
|
@@ -1,788 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
Gradio Web Interface for Spend Analyzer MCP
|
| 3 |
-
"""
|
| 4 |
-
import gradio as gr
|
| 5 |
-
import pandas as pd
|
| 6 |
-
import plotly.express as px
|
| 7 |
-
import plotly.graph_objects as go
|
| 8 |
-
from plotly.subplots import make_subplots
|
| 9 |
-
import json
|
| 10 |
-
import os
|
| 11 |
-
from typing import Dict, List, Optional, Tuple
|
| 12 |
-
import asyncio
|
| 13 |
-
from datetime import datetime, timedelta
|
| 14 |
-
import modal
|
| 15 |
-
import logging
|
| 16 |
-
|
| 17 |
-
# Import our Modal functions
|
| 18 |
-
from modal_deployment import (
|
| 19 |
-
process_bank_statements,
|
| 20 |
-
analyze_uploaded_statements,
|
| 21 |
-
get_claude_analysis,
|
| 22 |
-
save_user_data,
|
| 23 |
-
load_user_data
|
| 24 |
-
)
|
| 25 |
-
|
| 26 |
-
class SpendAnalyzerInterface:
|
| 27 |
-
def __init__(self):
|
| 28 |
-
self.current_analysis = None
|
| 29 |
-
self.user_sessions = {}
|
| 30 |
-
self.logger = logging.getLogger(__name__)
|
| 31 |
-
logging.basicConfig(level=logging.INFO)
|
| 32 |
-
|
| 33 |
-
def create_interface(self):
|
| 34 |
-
"""Create the main Gradio interface"""
|
| 35 |
-
|
| 36 |
-
with gr.Blocks(
|
| 37 |
-
title="Spend Analyzer MCP",
|
| 38 |
-
theme=gr.themes.Soft(),
|
| 39 |
-
css="""
|
| 40 |
-
.main-header { text-align: center; margin: 20px 0; }
|
| 41 |
-
.status-box { padding: 10px; border-radius: 5px; margin: 10px 0; }
|
| 42 |
-
.success-box { background-color: #d4edda; border: 1px solid #c3e6cb; }
|
| 43 |
-
.error-box { background-color: #f8d7da; border: 1px solid #f5c6cb; }
|
| 44 |
-
.warning-box { background-color: #fff3cd; border: 1px solid #ffeaa7; }
|
| 45 |
-
"""
|
| 46 |
-
) as interface:
|
| 47 |
-
|
| 48 |
-
gr.Markdown("# 💰 Spend Analyzer MCP", elem_classes=["main-header"])
|
| 49 |
-
gr.Markdown("*Analyze your bank statements with AI-powered insights*")
|
| 50 |
-
|
| 51 |
-
with gr.Tabs():
|
| 52 |
-
# Tab 1: Email Processing
|
| 53 |
-
with gr.TabItem("📧 Email Processing"):
|
| 54 |
-
self._create_email_tab()
|
| 55 |
-
|
| 56 |
-
# Tab 2: PDF Upload
|
| 57 |
-
with gr.TabItem("📄 PDF Upload"):
|
| 58 |
-
self._create_pdf_tab()
|
| 59 |
-
|
| 60 |
-
# Tab 3: Analysis Dashboard
|
| 61 |
-
with gr.TabItem("📊 Analysis Dashboard"):
|
| 62 |
-
self._create_dashboard_tab()
|
| 63 |
-
|
| 64 |
-
# Tab 4: AI Chat
|
| 65 |
-
with gr.TabItem("🤖 AI Financial Advisor"):
|
| 66 |
-
self._create_chat_tab()
|
| 67 |
-
|
| 68 |
-
# Tab 5: Settings
|
| 69 |
-
with gr.TabItem("⚙️ Settings"):
|
| 70 |
-
self._create_settings_tab()
|
| 71 |
-
|
| 72 |
-
return interface
|
| 73 |
-
|
| 74 |
-
def _create_email_tab(self):
|
| 75 |
-
"""Create email processing tab"""
|
| 76 |
-
gr.Markdown("## Connect Your Email to Analyze Bank Statements")
|
| 77 |
-
gr.Markdown("*Securely connect to your email to automatically process bank statements*")
|
| 78 |
-
|
| 79 |
-
with gr.Row():
|
| 80 |
-
with gr.Column(scale=1):
|
| 81 |
-
email_provider = gr.Dropdown(
|
| 82 |
-
choices=["Gmail", "Outlook", "Yahoo", "Other"],
|
| 83 |
-
label="Email Provider",
|
| 84 |
-
value="Gmail"
|
| 85 |
-
)
|
| 86 |
-
|
| 87 |
-
email_address = gr.Textbox(
|
| 88 |
-
label="Email Address",
|
| 89 |
-
placeholder="[email protected]"
|
| 90 |
-
)
|
| 91 |
-
|
| 92 |
-
email_password = gr.Textbox(
|
| 93 |
-
label="Password/App Password",
|
| 94 |
-
type="password",
|
| 95 |
-
placeholder="App-specific password recommended"
|
| 96 |
-
)
|
| 97 |
-
|
| 98 |
-
days_back = gr.Slider(
|
| 99 |
-
minimum=7,
|
| 100 |
-
maximum=90,
|
| 101 |
-
value=30,
|
| 102 |
-
step=1,
|
| 103 |
-
label="Days to Look Back"
|
| 104 |
-
)
|
| 105 |
-
|
| 106 |
-
process_email_btn = gr.Button("🔍 Process Email Statements", variant="primary")
|
| 107 |
-
|
| 108 |
-
with gr.Column(scale=1):
|
| 109 |
-
email_status = gr.HTML()
|
| 110 |
-
|
| 111 |
-
password_inputs = gr.Column(visible=False)
|
| 112 |
-
with password_inputs:
|
| 113 |
-
gr.Markdown("### Password-Protected PDFs Found")
|
| 114 |
-
pdf_passwords = gr.JSON(
|
| 115 |
-
label="Enter passwords for protected files",
|
| 116 |
-
value={}
|
| 117 |
-
)
|
| 118 |
-
retry_with_passwords = gr.Button("🔐 Retry with Passwords")
|
| 119 |
-
|
| 120 |
-
email_results = gr.JSON(label="Processing Results", visible=False)
|
| 121 |
-
|
| 122 |
-
# Event handlers
|
| 123 |
-
process_email_btn.click(
|
| 124 |
-
fn=self._process_email_statements,
|
| 125 |
-
inputs=[email_provider, email_address, email_password, days_back],
|
| 126 |
-
outputs=[email_status, email_results, password_inputs]
|
| 127 |
-
)
|
| 128 |
-
|
| 129 |
-
retry_with_passwords.click(
|
| 130 |
-
fn=self._retry_with_passwords,
|
| 131 |
-
inputs=[email_provider, email_address, email_password, days_back, pdf_passwords],
|
| 132 |
-
outputs=[email_status, email_results]
|
| 133 |
-
)
|
| 134 |
-
|
| 135 |
-
def _create_pdf_tab(self):
|
| 136 |
-
"""Create PDF upload tab"""
|
| 137 |
-
gr.Markdown("## Upload Bank Statement PDFs")
|
| 138 |
-
gr.Markdown("*Upload your bank statement PDFs directly for analysis*")
|
| 139 |
-
|
| 140 |
-
with gr.Row():
|
| 141 |
-
with gr.Column():
|
| 142 |
-
pdf_upload = gr.File(
|
| 143 |
-
label="Upload Bank Statement PDFs",
|
| 144 |
-
file_count="multiple",
|
| 145 |
-
file_types=[".pdf"]
|
| 146 |
-
)
|
| 147 |
-
|
| 148 |
-
pdf_passwords_input = gr.JSON(
|
| 149 |
-
label="PDF Passwords (if needed)",
|
| 150 |
-
placeholder='{"statement1.pdf": "password123"}',
|
| 151 |
-
value={}
|
| 152 |
-
)
|
| 153 |
-
|
| 154 |
-
analyze_pdf_btn = gr.Button("📊 Analyze PDFs", variant="primary")
|
| 155 |
-
|
| 156 |
-
with gr.Column():
|
| 157 |
-
pdf_status = gr.HTML()
|
| 158 |
-
pdf_results = gr.JSON(label="Analysis Results", visible=False)
|
| 159 |
-
|
| 160 |
-
# Event handler
|
| 161 |
-
analyze_pdf_btn.click(
|
| 162 |
-
fn=self._analyze_pdf_files,
|
| 163 |
-
inputs=[pdf_upload, pdf_passwords_input],
|
| 164 |
-
outputs=[pdf_status, pdf_results]
|
| 165 |
-
)
|
| 166 |
-
|
| 167 |
-
def _create_dashboard_tab(self):
|
| 168 |
-
"""Create analysis dashboard tab"""
|
| 169 |
-
gr.Markdown("## 📊 Financial Analysis Dashboard")
|
| 170 |
-
|
| 171 |
-
with gr.Row():
|
| 172 |
-
refresh_btn = gr.Button("🔄 Refresh Dashboard")
|
| 173 |
-
export_btn = gr.Button("📤 Export Analysis")
|
| 174 |
-
|
| 175 |
-
# Summary cards
|
| 176 |
-
with gr.Row():
|
| 177 |
-
total_income = gr.Number(label="Total Income", interactive=False)
|
| 178 |
-
total_expenses = gr.Number(label="Total Expenses", interactive=False)
|
| 179 |
-
net_cashflow = gr.Number(label="Net Cash Flow", interactive=False)
|
| 180 |
-
transaction_count = gr.Number(label="Total Transactions", interactive=False)
|
| 181 |
-
|
| 182 |
-
# Charts
|
| 183 |
-
with gr.Row():
|
| 184 |
-
with gr.Column():
|
| 185 |
-
spending_by_category = gr.Plot(label="Spending by Category")
|
| 186 |
-
monthly_trends = gr.Plot(label="Monthly Trends")
|
| 187 |
-
|
| 188 |
-
with gr.Column():
|
| 189 |
-
budget_alerts = gr.HTML(label="Budget Alerts")
|
| 190 |
-
recommendations = gr.HTML(label="Recommendations")
|
| 191 |
-
|
| 192 |
-
# Detailed data
|
| 193 |
-
with gr.Accordion("Detailed Transaction Data", open=False):
|
| 194 |
-
transaction_table = gr.Dataframe(
|
| 195 |
-
headers=["Date", "Description", "Amount", "Category"],
|
| 196 |
-
interactive=False,
|
| 197 |
-
label="Recent Transactions"
|
| 198 |
-
)
|
| 199 |
-
|
| 200 |
-
# Event handlers
|
| 201 |
-
refresh_btn.click(
|
| 202 |
-
fn=self._refresh_dashboard,
|
| 203 |
-
outputs=[total_income, total_expenses, net_cashflow, transaction_count,
|
| 204 |
-
spending_by_category, monthly_trends, budget_alerts, recommendations,
|
| 205 |
-
transaction_table]
|
| 206 |
-
)
|
| 207 |
-
|
| 208 |
-
export_btn.click(
|
| 209 |
-
fn=self._export_analysis,
|
| 210 |
-
outputs=[gr.File(label="Analysis Export")]
|
| 211 |
-
)
|
| 212 |
-
|
| 213 |
-
def _create_chat_tab(self):
|
| 214 |
-
"""Create AI chat tab"""
|
| 215 |
-
gr.Markdown("## 🤖 AI Financial Advisor")
|
| 216 |
-
gr.Markdown("*Ask questions about your spending patterns and get personalized advice*")
|
| 217 |
-
|
| 218 |
-
with gr.Row():
|
| 219 |
-
with gr.Column(scale=3):
|
| 220 |
-
chatbot = gr.Chatbot(
|
| 221 |
-
label="Financial Advisor Chat",
|
| 222 |
-
height=400,
|
| 223 |
-
show_label=True
|
| 224 |
-
)
|
| 225 |
-
|
| 226 |
-
with gr.Row():
|
| 227 |
-
msg_input = gr.Textbox(
|
| 228 |
-
placeholder="Ask about your spending patterns, budgets, or financial goals...",
|
| 229 |
-
label="Your Question",
|
| 230 |
-
scale=4
|
| 231 |
-
)
|
| 232 |
-
send_btn = gr.Button("Send", variant="primary", scale=1)
|
| 233 |
-
|
| 234 |
-
# Quick question buttons
|
| 235 |
-
with gr.Row():
|
| 236 |
-
gr.Button("💰 Budget Analysis", size="sm").click(
|
| 237 |
-
lambda: "How am I doing with my budget this month?",
|
| 238 |
-
outputs=[msg_input]
|
| 239 |
-
)
|
| 240 |
-
gr.Button("📈 Spending Trends", size="sm").click(
|
| 241 |
-
lambda: "What are my spending trends over the last few months?",
|
| 242 |
-
outputs=[msg_input]
|
| 243 |
-
)
|
| 244 |
-
gr.Button("💡 Save Money Tips", size="sm").click(
|
| 245 |
-
lambda: "What are some specific ways I can save money based on my spending?",
|
| 246 |
-
outputs=[msg_input]
|
| 247 |
-
)
|
| 248 |
-
gr.Button("🚨 Unusual Activity", size="sm").click(
|
| 249 |
-
lambda: "Are there any unusual transactions I should be aware of?",
|
| 250 |
-
outputs=[msg_input]
|
| 251 |
-
)
|
| 252 |
-
|
| 253 |
-
with gr.Column(scale=1):
|
| 254 |
-
chat_status = gr.HTML()
|
| 255 |
-
|
| 256 |
-
# Analysis context
|
| 257 |
-
gr.Markdown("### Current Analysis Context")
|
| 258 |
-
context_info = gr.JSON(
|
| 259 |
-
label="Available Data",
|
| 260 |
-
value={"status": "No analysis loaded"}
|
| 261 |
-
)
|
| 262 |
-
|
| 263 |
-
# Event handlers
|
| 264 |
-
send_btn.click(
|
| 265 |
-
fn=self._handle_chat_message,
|
| 266 |
-
inputs=[msg_input, chatbot],
|
| 267 |
-
outputs=[chatbot, msg_input, chat_status]
|
| 268 |
-
)
|
| 269 |
-
|
| 270 |
-
msg_input.submit(
|
| 271 |
-
fn=self._handle_chat_message,
|
| 272 |
-
inputs=[msg_input, chatbot],
|
| 273 |
-
outputs=[chatbot, msg_input, chat_status]
|
| 274 |
-
)
|
| 275 |
-
|
| 276 |
-
def _create_settings_tab(self):
|
| 277 |
-
"""Create settings tab"""
|
| 278 |
-
gr.Markdown("## ⚙️ Settings & Configuration")
|
| 279 |
-
|
| 280 |
-
with gr.Tabs():
|
| 281 |
-
with gr.TabItem("Budget Settings"):
|
| 282 |
-
gr.Markdown("### Set Monthly Budget Limits")
|
| 283 |
-
|
| 284 |
-
with gr.Row():
|
| 285 |
-
with gr.Column():
|
| 286 |
-
budget_categories = gr.CheckboxGroup(
|
| 287 |
-
choices=["Food & Dining", "Shopping", "Gas & Transport",
|
| 288 |
-
"Utilities", "Entertainment", "Healthcare", "Other"],
|
| 289 |
-
label="Categories to Budget",
|
| 290 |
-
value=["Food & Dining", "Shopping", "Gas & Transport"]
|
| 291 |
-
)
|
| 292 |
-
|
| 293 |
-
budget_amounts = gr.JSON(
|
| 294 |
-
label="Budget Amounts ($)",
|
| 295 |
-
value={
|
| 296 |
-
"Food & Dining": 500,
|
| 297 |
-
"Shopping": 300,
|
| 298 |
-
"Gas & Transport": 200,
|
| 299 |
-
"Utilities": 150,
|
| 300 |
-
"Entertainment": 100,
|
| 301 |
-
"Healthcare": 200,
|
| 302 |
-
"Other": 100
|
| 303 |
-
}
|
| 304 |
-
)
|
| 305 |
-
|
| 306 |
-
save_budgets_btn = gr.Button("💾 Save Budget Settings", variant="primary")
|
| 307 |
-
|
| 308 |
-
with gr.Column():
|
| 309 |
-
budget_status = gr.HTML()
|
| 310 |
-
current_budgets = gr.JSON(label="Current Budget Settings")
|
| 311 |
-
|
| 312 |
-
with gr.TabItem("Email Settings"):
|
| 313 |
-
gr.Markdown("### Email Configuration")
|
| 314 |
-
|
| 315 |
-
with gr.Row():
|
| 316 |
-
with gr.Column():
|
| 317 |
-
email_provider_setting = gr.Dropdown(
|
| 318 |
-
choices=["Gmail", "Outlook", "Yahoo", "Custom"],
|
| 319 |
-
label="Email Provider",
|
| 320 |
-
value="Gmail"
|
| 321 |
-
)
|
| 322 |
-
|
| 323 |
-
imap_server = gr.Textbox(
|
| 324 |
-
label="IMAP Server",
|
| 325 |
-
value="imap.gmail.com",
|
| 326 |
-
placeholder="imap.gmail.com"
|
| 327 |
-
)
|
| 328 |
-
|
| 329 |
-
imap_port = gr.Number(
|
| 330 |
-
label="IMAP Port",
|
| 331 |
-
value=993,
|
| 332 |
-
precision=0
|
| 333 |
-
)
|
| 334 |
-
|
| 335 |
-
auto_process = gr.Checkbox(
|
| 336 |
-
label="Auto-process new statements",
|
| 337 |
-
value=False
|
| 338 |
-
)
|
| 339 |
-
|
| 340 |
-
save_email_btn = gr.Button("💾 Save Email Settings", variant="primary")
|
| 341 |
-
|
| 342 |
-
with gr.Column():
|
| 343 |
-
email_test_btn = gr.Button("🧪 Test Email Connection")
|
| 344 |
-
email_test_status = gr.HTML()
|
| 345 |
-
|
| 346 |
-
with gr.TabItem("Export Settings"):
|
| 347 |
-
gr.Markdown("### Data Export Options")
|
| 348 |
-
|
| 349 |
-
export_format = gr.Radio(
|
| 350 |
-
choices=["JSON", "CSV", "Excel"],
|
| 351 |
-
label="Export Format",
|
| 352 |
-
value="JSON"
|
| 353 |
-
)
|
| 354 |
-
|
| 355 |
-
include_raw_data = gr.Checkbox(
|
| 356 |
-
label="Include raw transaction data",
|
| 357 |
-
value=True
|
| 358 |
-
)
|
| 359 |
-
|
| 360 |
-
include_analysis = gr.Checkbox(
|
| 361 |
-
label="Include analysis results",
|
| 362 |
-
value=True
|
| 363 |
-
)
|
| 364 |
-
|
| 365 |
-
export_settings_btn = gr.Button("📤 Export Current Analysis")
|
| 366 |
-
|
| 367 |
-
# Event handlers
|
| 368 |
-
save_budgets_btn.click(
|
| 369 |
-
fn=self._save_budget_settings,
|
| 370 |
-
inputs=[budget_categories, budget_amounts],
|
| 371 |
-
outputs=[budget_status, current_budgets]
|
| 372 |
-
)
|
| 373 |
-
|
| 374 |
-
save_email_btn.click(
|
| 375 |
-
fn=self._save_email_settings,
|
| 376 |
-
inputs=[email_provider_setting, imap_server, imap_port, auto_process],
|
| 377 |
-
outputs=[email_test_status]
|
| 378 |
-
)
|
| 379 |
-
|
| 380 |
-
email_test_btn.click(
|
| 381 |
-
fn=self._test_email_connection,
|
| 382 |
-
inputs=[email_provider_setting, imap_server, imap_port],
|
| 383 |
-
outputs=[email_test_status]
|
| 384 |
-
)
|
| 385 |
-
|
| 386 |
-
# Implementation methods
|
| 387 |
-
def _process_email_statements(self, provider, email, password, days_back):
|
| 388 |
-
"""Process bank statements from email"""
|
| 389 |
-
try:
|
| 390 |
-
# Update status
|
| 391 |
-
status_html = '<div class="status-box warning-box">🔄 Processing email statements...</div>'
|
| 392 |
-
|
| 393 |
-
# Configure email settings
|
| 394 |
-
email_config = {
|
| 395 |
-
'email': email,
|
| 396 |
-
'password': password,
|
| 397 |
-
'imap_server': self._get_imap_server(provider)
|
| 398 |
-
}
|
| 399 |
-
|
| 400 |
-
# For now, simulate the Modal function call
|
| 401 |
-
# In production, this would call the actual Modal function
|
| 402 |
-
try:
|
| 403 |
-
# Simulate processing
|
| 404 |
-
import time
|
| 405 |
-
time.sleep(1) # Simulate processing time
|
| 406 |
-
|
| 407 |
-
# Mock result for demonstration
|
| 408 |
-
result = {
|
| 409 |
-
'processed_statements': [
|
| 410 |
-
{
|
| 411 |
-
'filename': 'statement1.pdf',
|
| 412 |
-
'bank': 'Chase',
|
| 413 |
-
'account': '****1234',
|
| 414 |
-
'transaction_count': 25,
|
| 415 |
-
'status': 'success'
|
| 416 |
-
}
|
| 417 |
-
],
|
| 418 |
-
'total_transactions': 25,
|
| 419 |
-
'analysis': {
|
| 420 |
-
'financial_summary': {
|
| 421 |
-
'total_income': 3000.0,
|
| 422 |
-
'total_expenses': 1500.0,
|
| 423 |
-
'net_cash_flow': 1500.0
|
| 424 |
-
},
|
| 425 |
-
'spending_insights': [
|
| 426 |
-
{
|
| 427 |
-
'category': 'Food & Dining',
|
| 428 |
-
'total_amount': 400.0,
|
| 429 |
-
'transaction_count': 12,
|
| 430 |
-
'percentage_of_total': 26.7
|
| 431 |
-
}
|
| 432 |
-
],
|
| 433 |
-
'recommendations': ['Consider reducing dining out expenses'],
|
| 434 |
-
'transaction_count': 25
|
| 435 |
-
}
|
| 436 |
-
}
|
| 437 |
-
|
| 438 |
-
status_html = f'<div class="status-box success-box">✅ Processed {result["total_transactions"]} transactions</div>'
|
| 439 |
-
password_inputs_visible = gr.update(visible=False)
|
| 440 |
-
|
| 441 |
-
# Store analysis for dashboard
|
| 442 |
-
self.current_analysis = result.get('analysis', {})
|
| 443 |
-
|
| 444 |
-
return status_html, result, password_inputs_visible
|
| 445 |
-
|
| 446 |
-
except Exception as modal_error:
|
| 447 |
-
# Fallback to local processing if Modal is not available
|
| 448 |
-
self.logger.warning(f"Modal processing failed, using local fallback: {modal_error}")
|
| 449 |
-
return self._process_email_local(email_config, days_back)
|
| 450 |
-
|
| 451 |
-
except Exception as e:
|
| 452 |
-
error_html = f'<div class="status-box error-box">❌ Error: {str(e)}</div>'
|
| 453 |
-
return error_html, {}, gr.update(visible=False)
|
| 454 |
-
|
| 455 |
-
def _retry_with_passwords(self, provider, email, password, days_back, pdf_passwords):
|
| 456 |
-
"""Retry processing with PDF passwords"""
|
| 457 |
-
try:
|
| 458 |
-
status_html = '<div class="status-box warning-box">🔄 Retrying with passwords...</div>'
|
| 459 |
-
|
| 460 |
-
email_config = {
|
| 461 |
-
'email': email,
|
| 462 |
-
'password': password,
|
| 463 |
-
'imap_server': self._get_imap_server(provider)
|
| 464 |
-
}
|
| 465 |
-
|
| 466 |
-
# Mock retry with passwords
|
| 467 |
-
result = {
|
| 468 |
-
'processed_statements': [
|
| 469 |
-
{
|
| 470 |
-
'filename': 'protected_statement.pdf',
|
| 471 |
-
'bank': 'Bank of America',
|
| 472 |
-
'account': '****5678',
|
| 473 |
-
'transaction_count': 30,
|
| 474 |
-
'status': 'success'
|
| 475 |
-
}
|
| 476 |
-
],
|
| 477 |
-
'total_transactions': 30,
|
| 478 |
-
'analysis': {
|
| 479 |
-
'financial_summary': {
|
| 480 |
-
'total_income': 3500.0,
|
| 481 |
-
'total_expenses': 1800.0,
|
| 482 |
-
'net_cash_flow': 1700.0
|
| 483 |
-
},
|
| 484 |
-
'spending_insights': [],
|
| 485 |
-
'recommendations': [],
|
| 486 |
-
'transaction_count': 30
|
| 487 |
-
}
|
| 488 |
-
}
|
| 489 |
-
|
| 490 |
-
status_html = f'<div class="status-box success-box">✅ Processed {result["total_transactions"]} transactions</div>'
|
| 491 |
-
self.current_analysis = result.get('analysis', {})
|
| 492 |
-
|
| 493 |
-
return status_html, result
|
| 494 |
-
|
| 495 |
-
except Exception as e:
|
| 496 |
-
error_html = f'<div class="status-box error-box">❌ Error: {str(e)}</div>'
|
| 497 |
-
return error_html, {}
|
| 498 |
-
|
| 499 |
-
def _analyze_pdf_files(self, files, passwords):
|
| 500 |
-
"""Analyze uploaded PDF files"""
|
| 501 |
-
try:
|
| 502 |
-
if not files:
|
| 503 |
-
return '<div class="status-box error-box">❌ No files uploaded</div>', {}
|
| 504 |
-
|
| 505 |
-
status_html = '<div class="status-box warning-box">🔄 Analyzing PDF files...</div>'
|
| 506 |
-
|
| 507 |
-
# Mock PDF analysis
|
| 508 |
-
result = {
|
| 509 |
-
'processed_files': [],
|
| 510 |
-
'total_transactions': 0,
|
| 511 |
-
'analysis': {
|
| 512 |
-
'financial_summary': {
|
| 513 |
-
'total_income': 0,
|
| 514 |
-
'total_expenses': 0,
|
| 515 |
-
'net_cash_flow': 0
|
| 516 |
-
},
|
| 517 |
-
'spending_insights': [],
|
| 518 |
-
'recommendations': [],
|
| 519 |
-
'transaction_count': 0
|
| 520 |
-
}
|
| 521 |
-
}
|
| 522 |
-
|
| 523 |
-
# Process each file
|
| 524 |
-
for file in files:
|
| 525 |
-
try:
|
| 526 |
-
# Mock processing
|
| 527 |
-
file_result = {
|
| 528 |
-
'filename': file.name,
|
| 529 |
-
'bank': 'Unknown Bank',
|
| 530 |
-
'transaction_count': 15,
|
| 531 |
-
'status': 'success'
|
| 532 |
-
}
|
| 533 |
-
result['processed_files'].append(file_result)
|
| 534 |
-
result['total_transactions'] += 15
|
| 535 |
-
|
| 536 |
-
except Exception as file_error:
|
| 537 |
-
result['processed_files'].append({
|
| 538 |
-
'filename': file.name,
|
| 539 |
-
'status': 'error',
|
| 540 |
-
'error': str(file_error)
|
| 541 |
-
})
|
| 542 |
-
|
| 543 |
-
if result['total_transactions'] > 0:
|
| 544 |
-
status_html = f'<div class="status-box success-box">✅ Analyzed {result["total_transactions"]} transactions</div>'
|
| 545 |
-
self.current_analysis = result.get('analysis', {})
|
| 546 |
-
else:
|
| 547 |
-
status_html = '<div class="status-box warning-box">⚠️ No transactions found in uploaded files</div>'
|
| 548 |
-
|
| 549 |
-
return status_html, result
|
| 550 |
-
|
| 551 |
-
except Exception as e:
|
| 552 |
-
error_html = f'<div class="status-box error-box">❌ Error: {str(e)}</div>'
|
| 553 |
-
return error_html, {}
|
| 554 |
-
|
| 555 |
-
def _process_email_local(self, email_config, days_back):
|
| 556 |
-
"""Local fallback for email processing"""
|
| 557 |
-
# This would use the local email_processor module
|
| 558 |
-
status_html = '<div class="status-box warning-box">⚠️ Using local processing (Modal unavailable)</div>'
|
| 559 |
-
|
| 560 |
-
# Mock local processing result
|
| 561 |
-
result = {
|
| 562 |
-
'processed_statements': [],
|
| 563 |
-
'total_transactions': 0,
|
| 564 |
-
'analysis': {
|
| 565 |
-
'financial_summary': {
|
| 566 |
-
'total_income': 0,
|
| 567 |
-
'total_expenses': 0,
|
| 568 |
-
'net_cash_flow': 0
|
| 569 |
-
},
|
| 570 |
-
'spending_insights': [],
|
| 571 |
-
'recommendations': ['Please configure Modal deployment for full functionality'],
|
| 572 |
-
'transaction_count': 0
|
| 573 |
-
}
|
| 574 |
-
}
|
| 575 |
-
|
| 576 |
-
return status_html, result, gr.update(visible=False)
|
| 577 |
-
|
| 578 |
-
def _refresh_dashboard(self):
|
| 579 |
-
"""Refresh dashboard with current analysis"""
|
| 580 |
-
if not self.current_analysis:
|
| 581 |
-
return (0, 0, 0, 0, None, None,
|
| 582 |
-
'<div class="status-box warning-box">⚠️ No analysis data available</div>',
|
| 583 |
-
'<div class="status-box warning-box">⚠️ Process statements first</div>',
|
| 584 |
-
pd.DataFrame())
|
| 585 |
-
|
| 586 |
-
try:
|
| 587 |
-
summary = self.current_analysis.get('financial_summary', {})
|
| 588 |
-
insights = self.current_analysis.get('spending_insights', [])
|
| 589 |
-
|
| 590 |
-
# Summary metrics
|
| 591 |
-
total_income = summary.get('total_income', 0)
|
| 592 |
-
total_expenses = summary.get('total_expenses', 0)
|
| 593 |
-
net_cashflow = summary.get('net_cash_flow', 0)
|
| 594 |
-
transaction_count = self.current_analysis.get('transaction_count', 0)
|
| 595 |
-
|
| 596 |
-
# Create spending by category chart
|
| 597 |
-
if insights:
|
| 598 |
-
categories = [insight['category'] for insight in insights]
|
| 599 |
-
amounts = [insight['total_amount'] for insight in insights]
|
| 600 |
-
|
| 601 |
-
spending_chart = px.pie(
|
| 602 |
-
values=amounts,
|
| 603 |
-
names=categories,
|
| 604 |
-
title="Spending by Category"
|
| 605 |
-
)
|
| 606 |
-
else:
|
| 607 |
-
spending_chart = None
|
| 608 |
-
|
| 609 |
-
# Create monthly trends chart
|
| 610 |
-
monthly_trends = summary.get('monthly_trends', {})
|
| 611 |
-
if monthly_trends:
|
| 612 |
-
trends_chart = px.line(
|
| 613 |
-
x=list(monthly_trends.keys()),
|
| 614 |
-
y=list(monthly_trends.values()),
|
| 615 |
-
title="Monthly Spending Trends"
|
| 616 |
-
)
|
| 617 |
-
else:
|
| 618 |
-
trends_chart = None
|
| 619 |
-
|
| 620 |
-
# Budget alerts
|
| 621 |
-
alerts = self.current_analysis.get('budget_alerts', [])
|
| 622 |
-
if alerts:
|
| 623 |
-
alert_html = '<div class="status-box warning-box"><h4>Budget Alerts:</h4><ul>'
|
| 624 |
-
for alert in alerts:
|
| 625 |
-
alert_html += f'<li>{alert["category"]}: {alert["percentage_used"]:.1f}% used</li>'
|
| 626 |
-
alert_html += '</ul></div>'
|
| 627 |
-
else:
|
| 628 |
-
alert_html = '<div class="status-box success-box">✅ All budgets on track</div>'
|
| 629 |
-
|
| 630 |
-
# Recommendations
|
| 631 |
-
recommendations = self.current_analysis.get('recommendations', [])
|
| 632 |
-
if recommendations:
|
| 633 |
-
rec_html = '<div class="status-box"><h4>Recommendations:</h4><ul>'
|
| 634 |
-
for rec in recommendations[:3]: # Show top 3
|
| 635 |
-
rec_html += f'<li>{rec}</li>'
|
| 636 |
-
rec_html += '</ul></div>'
|
| 637 |
-
else:
|
| 638 |
-
rec_html = '<div class="status-box">No specific recommendations at this time.</div>'
|
| 639 |
-
|
| 640 |
-
# Transaction table (sample recent transactions)
|
| 641 |
-
transaction_df = pd.DataFrame() # Would populate with actual transaction data
|
| 642 |
-
|
| 643 |
-
return (total_income, total_expenses, net_cashflow, transaction_count,
|
| 644 |
-
spending_chart, trends_chart, alert_html, rec_html, transaction_df)
|
| 645 |
-
|
| 646 |
-
except Exception as e:
|
| 647 |
-
error_msg = f'<div class="status-box error-box">❌ Dashboard error: {str(e)}</div>'
|
| 648 |
-
return (0, 0, 0, 0, None, None, error_msg, error_msg, pd.DataFrame())
|
| 649 |
-
|
| 650 |
-
def _export_analysis(self):
|
| 651 |
-
"""Export current analysis data"""
|
| 652 |
-
if not self.current_analysis:
|
| 653 |
-
return None
|
| 654 |
-
|
| 655 |
-
try:
|
| 656 |
-
import tempfile
|
| 657 |
-
import json
|
| 658 |
-
|
| 659 |
-
# Create temporary file
|
| 660 |
-
with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f:
|
| 661 |
-
json.dump(self.current_analysis, f, indent=2, default=str)
|
| 662 |
-
return f.name
|
| 663 |
-
|
| 664 |
-
except Exception as e:
|
| 665 |
-
self.logger.error(f"Export error: {e}")
|
| 666 |
-
return None
|
| 667 |
-
|
| 668 |
-
def _handle_chat_message(self, message, chat_history):
|
| 669 |
-
"""Handle chat messages with AI advisor"""
|
| 670 |
-
if not message.strip():
|
| 671 |
-
return chat_history, "", '<div class="status-box warning-box">⚠️ Please enter a message</div>'
|
| 672 |
-
|
| 673 |
-
try:
|
| 674 |
-
# Add user message to chat
|
| 675 |
-
chat_history = chat_history or []
|
| 676 |
-
chat_history.append([message, None])
|
| 677 |
-
|
| 678 |
-
status_html = '<div class="status-box warning-box">🤖 AI is thinking...</div>'
|
| 679 |
-
|
| 680 |
-
# Mock AI response for now (would use Claude API in production)
|
| 681 |
-
if self.current_analysis:
|
| 682 |
-
# Generate a contextual response based on the analysis
|
| 683 |
-
summary = self.current_analysis.get('financial_summary', {})
|
| 684 |
-
insights = self.current_analysis.get('spending_insights', [])
|
| 685 |
-
recommendations = self.current_analysis.get('recommendations', [])
|
| 686 |
-
|
| 687 |
-
if 'budget' in message.lower():
|
| 688 |
-
ai_response = f"Based on your current spending analysis, you have a net cash flow of ${summary.get('net_cash_flow', 0):.2f}. Your total expenses are ${summary.get('total_expenses', 0):.2f} against an income of ${summary.get('total_income', 0):.2f}."
|
| 689 |
-
elif 'trend' in message.lower():
|
| 690 |
-
if insights:
|
| 691 |
-
top_category = insights[0]
|
| 692 |
-
ai_response = f"Your top spending category is {top_category['category']} at ${top_category['total_amount']:.2f} ({top_category['percentage_of_total']:.1f}% of total spending). This represents {top_category['transaction_count']} transactions."
|
| 693 |
-
else:
|
| 694 |
-
ai_response = "I need more transaction data to analyze your spending trends effectively."
|
| 695 |
-
elif 'save' in message.lower() or 'tip' in message.lower():
|
| 696 |
-
if recommendations:
|
| 697 |
-
ai_response = f"Here are some personalized recommendations: {'. '.join(recommendations[:2])}"
|
| 698 |
-
else:
|
| 699 |
-
ai_response = "Based on your spending patterns, consider tracking your largest expense categories and setting monthly budgets for better financial control."
|
| 700 |
-
elif 'unusual' in message.lower() or 'activity' in message.lower():
|
| 701 |
-
ai_response = "I've analyzed your transactions for unusual patterns. Currently, your spending appears consistent with normal patterns. I'll alert you if I detect any anomalies."
|
| 702 |
-
else:
|
| 703 |
-
ai_response = f"I can see you have {self.current_analysis.get('transaction_count', 0)} transactions analyzed. Feel free to ask about your budget, spending trends, saving tips, or unusual activity. What specific aspect of your finances would you like to explore?"
|
| 704 |
-
|
| 705 |
-
status_html = '<div class="status-box success-box">✅ Response generated</div>'
|
| 706 |
-
else:
|
| 707 |
-
ai_response = "I don't have any financial data to analyze yet. Please process your bank statements first using the Email Processing or PDF Upload tabs."
|
| 708 |
-
status_html = '<div class="status-box warning-box">⚠️ No data available</div>'
|
| 709 |
-
|
| 710 |
-
# Update chat history with AI response
|
| 711 |
-
chat_history[-1][1] = ai_response
|
| 712 |
-
|
| 713 |
-
return chat_history, "", status_html
|
| 714 |
-
|
| 715 |
-
except Exception as e:
|
| 716 |
-
error_response = f"I'm sorry, I encountered an error: {str(e)}"
|
| 717 |
-
if chat_history:
|
| 718 |
-
chat_history[-1][1] = error_response
|
| 719 |
-
return chat_history, "", '<div class="status-box error-box">❌ Chat Error</div>'
|
| 720 |
-
|
| 721 |
-
def _save_budget_settings(self, categories, amounts):
|
| 722 |
-
"""Save budget settings"""
|
| 723 |
-
try:
|
| 724 |
-
# Filter amounts for selected categories
|
| 725 |
-
budget_settings = {cat: amounts.get(cat, 0) for cat in categories}
|
| 726 |
-
|
| 727 |
-
# Store in user session (in real app, would save to database)
|
| 728 |
-
self.user_sessions['budgets'] = budget_settings
|
| 729 |
-
|
| 730 |
-
status_html = '<div class="status-box success-box">✅ Budget settings saved</div>'
|
| 731 |
-
return status_html, budget_settings
|
| 732 |
-
|
| 733 |
-
except Exception as e:
|
| 734 |
-
error_html = f'<div class="status-box error-box">❌ Error saving budgets: {str(e)}</div>'
|
| 735 |
-
return error_html, {}
|
| 736 |
-
|
| 737 |
-
def _save_email_settings(self, provider, server, port, auto_process):
|
| 738 |
-
"""Save email settings"""
|
| 739 |
-
try:
|
| 740 |
-
email_settings = {
|
| 741 |
-
'provider': provider,
|
| 742 |
-
'imap_server': server,
|
| 743 |
-
'imap_port': port,
|
| 744 |
-
'auto_process': auto_process
|
| 745 |
-
}
|
| 746 |
-
|
| 747 |
-
self.user_sessions['email_settings'] = email_settings
|
| 748 |
-
|
| 749 |
-
return '<div class="status-box success-box">✅ Email settings saved</div>'
|
| 750 |
-
|
| 751 |
-
except Exception as e:
|
| 752 |
-
return f'<div class="status-box error-box">❌ Error saving settings: {str(e)}</div>'
|
| 753 |
-
|
| 754 |
-
def _test_email_connection(self, provider, server, port):
|
| 755 |
-
"""Test email connection"""
|
| 756 |
-
try:
|
| 757 |
-
# This would test the actual connection in a real implementation
|
| 758 |
-
return '<div class="status-box success-box">✅ Email connection test successful</div>'
|
| 759 |
-
|
| 760 |
-
except Exception as e:
|
| 761 |
-
return f'<div class="status-box error-box">❌ Connection test failed: {str(e)}</div>'
|
| 762 |
-
|
| 763 |
-
def _get_imap_server(self, provider):
|
| 764 |
-
"""Get IMAP server for email provider"""
|
| 765 |
-
servers = {
|
| 766 |
-
'Gmail': 'imap.gmail.com',
|
| 767 |
-
'Outlook': 'outlook.office365.com',
|
| 768 |
-
'Yahoo': 'imap.mail.yahoo.com',
|
| 769 |
-
'Other': 'imap.gmail.com' # Default
|
| 770 |
-
}
|
| 771 |
-
return servers.get(provider, 'imap.gmail.com')
|
| 772 |
-
|
| 773 |
-
# Launch the interface
|
| 774 |
-
def launch_interface():
|
| 775 |
-
"""Launch the Gradio interface"""
|
| 776 |
-
interface = SpendAnalyzerInterface()
|
| 777 |
-
app = interface.create_interface()
|
| 778 |
-
|
| 779 |
-
app.launch(
|
| 780 |
-
server_name="0.0.0.0",
|
| 781 |
-
server_port=7860,
|
| 782 |
-
share=False,
|
| 783 |
-
debug=True,
|
| 784 |
-
show_error=True
|
| 785 |
-
)
|
| 786 |
-
|
| 787 |
-
if __name__ == "__main__":
|
| 788 |
-
launch_interface()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
gradio_interface_local.py
DELETED
|
@@ -1,627 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
Gradio Web Interface for Spend Analyzer MCP - Local Version
|
| 3 |
-
"""
|
| 4 |
-
import gradio as gr
|
| 5 |
-
import pandas as pd
|
| 6 |
-
import plotly.express as px
|
| 7 |
-
import plotly.graph_objects as go
|
| 8 |
-
import json
|
| 9 |
-
import os
|
| 10 |
-
from typing import Dict, List, Optional, Tuple
|
| 11 |
-
from datetime import datetime, timedelta
|
| 12 |
-
import logging
|
| 13 |
-
import time
|
| 14 |
-
|
| 15 |
-
class SpendAnalyzerInterface:
|
| 16 |
-
def __init__(self):
|
| 17 |
-
self.current_analysis = None
|
| 18 |
-
self.user_sessions = {}
|
| 19 |
-
self.logger = logging.getLogger(__name__)
|
| 20 |
-
logging.basicConfig(level=logging.INFO)
|
| 21 |
-
|
| 22 |
-
# Load demo data if available
|
| 23 |
-
self.demo_data = self._load_demo_data()
|
| 24 |
-
|
| 25 |
-
def _load_demo_data(self):
|
| 26 |
-
"""Load demo data for testing"""
|
| 27 |
-
try:
|
| 28 |
-
if os.path.exists('demo_data.json'):
|
| 29 |
-
with open('demo_data.json', 'r') as f:
|
| 30 |
-
return json.load(f)
|
| 31 |
-
except Exception as e:
|
| 32 |
-
self.logger.warning(f"Could not load demo data: {e}")
|
| 33 |
-
|
| 34 |
-
# Fallback demo data
|
| 35 |
-
return {
|
| 36 |
-
"transactions": [
|
| 37 |
-
{
|
| 38 |
-
"date": "2024-01-15",
|
| 39 |
-
"description": "STARBUCKS COFFEE",
|
| 40 |
-
"amount": -4.50,
|
| 41 |
-
"category": "Food & Dining"
|
| 42 |
-
},
|
| 43 |
-
{
|
| 44 |
-
"date": "2024-01-14",
|
| 45 |
-
"description": "AMAZON PURCHASE",
|
| 46 |
-
"amount": -29.99,
|
| 47 |
-
"category": "Shopping"
|
| 48 |
-
},
|
| 49 |
-
{
|
| 50 |
-
"date": "2024-01-13",
|
| 51 |
-
"description": "SALARY DEPOSIT",
|
| 52 |
-
"amount": 3000.00,
|
| 53 |
-
"category": "Income"
|
| 54 |
-
},
|
| 55 |
-
{
|
| 56 |
-
"date": "2024-01-12",
|
| 57 |
-
"description": "GROCERY STORE",
|
| 58 |
-
"amount": -85.67,
|
| 59 |
-
"category": "Food & Dining"
|
| 60 |
-
},
|
| 61 |
-
{
|
| 62 |
-
"date": "2024-01-11",
|
| 63 |
-
"description": "GAS STATION",
|
| 64 |
-
"amount": -45.00,
|
| 65 |
-
"category": "Gas & Transport"
|
| 66 |
-
}
|
| 67 |
-
]
|
| 68 |
-
}
|
| 69 |
-
|
| 70 |
-
def create_interface(self):
|
| 71 |
-
"""Create the main Gradio interface"""
|
| 72 |
-
|
| 73 |
-
with gr.Blocks(
|
| 74 |
-
title="Spend Analyzer MCP - Local Demo",
|
| 75 |
-
css="""
|
| 76 |
-
.main-header { text-align: center; margin: 20px 0; }
|
| 77 |
-
.status-box { padding: 10px; border-radius: 5px; margin: 10px 0; }
|
| 78 |
-
.success-box { background-color: #d4edda; border: 1px solid #c3e6cb; }
|
| 79 |
-
.error-box { background-color: #f8d7da; border: 1px solid #f5c6cb; }
|
| 80 |
-
.warning-box { background-color: #fff3cd; border: 1px solid #ffeaa7; }
|
| 81 |
-
.demo-box { background-color: #e7f3ff; border: 1px solid #b3d9ff; }
|
| 82 |
-
"""
|
| 83 |
-
) as interface:
|
| 84 |
-
|
| 85 |
-
gr.Markdown("# 💰 Spend Analyzer MCP - Local Demo", elem_classes=["main-header"])
|
| 86 |
-
gr.Markdown("*Analyze your bank statements with AI-powered insights*")
|
| 87 |
-
|
| 88 |
-
# Demo notice
|
| 89 |
-
gr.HTML('<div class="demo-box">🚀 <strong>Demo Mode:</strong> This is a local demonstration. Upload real PDFs or use the demo data to explore features.</div>')
|
| 90 |
-
|
| 91 |
-
with gr.Tabs():
|
| 92 |
-
# Tab 1: Demo Data
|
| 93 |
-
with gr.TabItem("🎯 Demo Data"):
|
| 94 |
-
self._create_demo_tab()
|
| 95 |
-
|
| 96 |
-
# Tab 2: PDF Upload
|
| 97 |
-
with gr.TabItem("📄 PDF Upload"):
|
| 98 |
-
self._create_pdf_tab()
|
| 99 |
-
|
| 100 |
-
# Tab 3: Analysis Dashboard
|
| 101 |
-
with gr.TabItem("📊 Analysis Dashboard"):
|
| 102 |
-
self._create_dashboard_tab()
|
| 103 |
-
|
| 104 |
-
# Tab 4: AI Chat
|
| 105 |
-
with gr.TabItem("🤖 AI Financial Advisor"):
|
| 106 |
-
self._create_chat_tab()
|
| 107 |
-
|
| 108 |
-
# Tab 5: Settings
|
| 109 |
-
with gr.TabItem("⚙️ Settings"):
|
| 110 |
-
self._create_settings_tab()
|
| 111 |
-
|
| 112 |
-
return interface
|
| 113 |
-
|
| 114 |
-
def _create_demo_tab(self):
|
| 115 |
-
"""Create demo data tab"""
|
| 116 |
-
gr.Markdown("## 🎯 Demo Data & Quick Start")
|
| 117 |
-
gr.Markdown("*Load sample financial data to explore the features*")
|
| 118 |
-
|
| 119 |
-
with gr.Row():
|
| 120 |
-
with gr.Column():
|
| 121 |
-
gr.Markdown("### Sample Transactions")
|
| 122 |
-
demo_transactions = gr.JSON(
|
| 123 |
-
value=self.demo_data["transactions"],
|
| 124 |
-
label="Demo Transaction Data"
|
| 125 |
-
)
|
| 126 |
-
|
| 127 |
-
load_demo_btn = gr.Button("📊 Load Demo Data", variant="primary", size="lg")
|
| 128 |
-
|
| 129 |
-
with gr.Column():
|
| 130 |
-
demo_status = gr.HTML()
|
| 131 |
-
|
| 132 |
-
gr.Markdown("### Features to Try")
|
| 133 |
-
gr.Markdown("""
|
| 134 |
-
1. **Load Demo Data** - Click the button to analyze sample transactions
|
| 135 |
-
2. **View Dashboard** - See charts and financial summaries
|
| 136 |
-
3. **Chat with AI** - Ask questions about spending patterns
|
| 137 |
-
4. **Upload PDFs** - Try uploading your own bank statements
|
| 138 |
-
5. **Configure Settings** - Set budgets and preferences
|
| 139 |
-
""")
|
| 140 |
-
|
| 141 |
-
# Event handler
|
| 142 |
-
load_demo_btn.click(
|
| 143 |
-
fn=self._load_demo_data_handler,
|
| 144 |
-
outputs=[demo_status]
|
| 145 |
-
)
|
| 146 |
-
|
| 147 |
-
def _create_pdf_tab(self):
|
| 148 |
-
"""Create PDF upload tab"""
|
| 149 |
-
gr.Markdown("## 📄 Upload Bank Statement PDFs")
|
| 150 |
-
gr.Markdown("*Upload your bank statement PDFs for analysis*")
|
| 151 |
-
|
| 152 |
-
with gr.Row():
|
| 153 |
-
with gr.Column():
|
| 154 |
-
pdf_upload = gr.File(
|
| 155 |
-
label="Upload Bank Statement PDFs",
|
| 156 |
-
file_count="multiple",
|
| 157 |
-
file_types=[".pdf"]
|
| 158 |
-
)
|
| 159 |
-
|
| 160 |
-
analyze_pdf_btn = gr.Button("📊 Analyze PDFs", variant="primary")
|
| 161 |
-
|
| 162 |
-
with gr.Column():
|
| 163 |
-
pdf_status = gr.HTML()
|
| 164 |
-
pdf_results = gr.JSON(label="Analysis Results", visible=False)
|
| 165 |
-
|
| 166 |
-
# Event handler
|
| 167 |
-
analyze_pdf_btn.click(
|
| 168 |
-
fn=self._analyze_pdf_files,
|
| 169 |
-
inputs=[pdf_upload],
|
| 170 |
-
outputs=[pdf_status, pdf_results]
|
| 171 |
-
)
|
| 172 |
-
|
| 173 |
-
def _create_dashboard_tab(self):
|
| 174 |
-
"""Create analysis dashboard tab"""
|
| 175 |
-
gr.Markdown("## 📊 Financial Analysis Dashboard")
|
| 176 |
-
|
| 177 |
-
with gr.Row():
|
| 178 |
-
refresh_btn = gr.Button("🔄 Refresh Dashboard")
|
| 179 |
-
export_btn = gr.Button("📤 Export Analysis")
|
| 180 |
-
|
| 181 |
-
# Summary cards
|
| 182 |
-
with gr.Row():
|
| 183 |
-
total_income = gr.Number(label="Total Income ($)", interactive=False)
|
| 184 |
-
total_expenses = gr.Number(label="Total Expenses ($)", interactive=False)
|
| 185 |
-
net_cashflow = gr.Number(label="Net Cash Flow ($)", interactive=False)
|
| 186 |
-
transaction_count = gr.Number(label="Total Transactions", interactive=False)
|
| 187 |
-
|
| 188 |
-
# Charts
|
| 189 |
-
with gr.Row():
|
| 190 |
-
with gr.Column():
|
| 191 |
-
spending_by_category = gr.Plot(label="Spending by Category")
|
| 192 |
-
monthly_trends = gr.Plot(label="Daily Spending Trends")
|
| 193 |
-
|
| 194 |
-
with gr.Column():
|
| 195 |
-
budget_alerts = gr.HTML(label="Budget Alerts")
|
| 196 |
-
recommendations = gr.HTML(label="Recommendations")
|
| 197 |
-
|
| 198 |
-
# Detailed data
|
| 199 |
-
with gr.Accordion("Detailed Transaction Data", open=False):
|
| 200 |
-
transaction_table = gr.Dataframe(
|
| 201 |
-
headers=["Date", "Description", "Amount", "Category"],
|
| 202 |
-
interactive=False,
|
| 203 |
-
label="Recent Transactions"
|
| 204 |
-
)
|
| 205 |
-
|
| 206 |
-
# Event handlers
|
| 207 |
-
refresh_btn.click(
|
| 208 |
-
fn=self._refresh_dashboard,
|
| 209 |
-
outputs=[total_income, total_expenses, net_cashflow, transaction_count,
|
| 210 |
-
spending_by_category, monthly_trends, budget_alerts, recommendations,
|
| 211 |
-
transaction_table]
|
| 212 |
-
)
|
| 213 |
-
|
| 214 |
-
export_btn.click(
|
| 215 |
-
fn=self._export_analysis,
|
| 216 |
-
outputs=[gr.File(label="Analysis Export")]
|
| 217 |
-
)
|
| 218 |
-
|
| 219 |
-
def _create_chat_tab(self):
|
| 220 |
-
"""Create AI chat tab"""
|
| 221 |
-
gr.Markdown("## 🤖 AI Financial Advisor")
|
| 222 |
-
gr.Markdown("*Ask questions about your spending patterns and get insights*")
|
| 223 |
-
|
| 224 |
-
with gr.Row():
|
| 225 |
-
with gr.Column(scale=3):
|
| 226 |
-
chatbot = gr.Chatbot(
|
| 227 |
-
label="Financial Advisor Chat",
|
| 228 |
-
height=400,
|
| 229 |
-
show_label=True,
|
| 230 |
-
type="messages"
|
| 231 |
-
)
|
| 232 |
-
|
| 233 |
-
with gr.Row():
|
| 234 |
-
msg_input = gr.Textbox(
|
| 235 |
-
placeholder="Ask about your spending patterns, budgets, or financial goals...",
|
| 236 |
-
label="Your Question",
|
| 237 |
-
scale=4
|
| 238 |
-
)
|
| 239 |
-
send_btn = gr.Button("Send", variant="primary", scale=1)
|
| 240 |
-
|
| 241 |
-
# Quick question buttons
|
| 242 |
-
with gr.Row():
|
| 243 |
-
budget_btn = gr.Button("💰 Budget Analysis", size="sm")
|
| 244 |
-
trends_btn = gr.Button("📈 Spending Trends", size="sm")
|
| 245 |
-
tips_btn = gr.Button("💡 Save Money Tips", size="sm")
|
| 246 |
-
unusual_btn = gr.Button("🚨 Unusual Activity", size="sm")
|
| 247 |
-
|
| 248 |
-
with gr.Column(scale=1):
|
| 249 |
-
chat_status = gr.HTML()
|
| 250 |
-
|
| 251 |
-
# Analysis context
|
| 252 |
-
gr.Markdown("### Current Analysis Context")
|
| 253 |
-
context_info = gr.JSON(
|
| 254 |
-
label="Available Data",
|
| 255 |
-
value={"status": "Load demo data or upload PDFs to start"}
|
| 256 |
-
)
|
| 257 |
-
|
| 258 |
-
# Event handlers
|
| 259 |
-
send_btn.click(
|
| 260 |
-
fn=self._handle_chat_message,
|
| 261 |
-
inputs=[msg_input, chatbot],
|
| 262 |
-
outputs=[chatbot, msg_input, chat_status]
|
| 263 |
-
)
|
| 264 |
-
|
| 265 |
-
msg_input.submit(
|
| 266 |
-
fn=self._handle_chat_message,
|
| 267 |
-
inputs=[msg_input, chatbot],
|
| 268 |
-
outputs=[chatbot, msg_input, chat_status]
|
| 269 |
-
)
|
| 270 |
-
|
| 271 |
-
# Quick question handlers
|
| 272 |
-
budget_btn.click(
|
| 273 |
-
lambda: "How am I doing with my budget this month?",
|
| 274 |
-
outputs=[msg_input]
|
| 275 |
-
)
|
| 276 |
-
trends_btn.click(
|
| 277 |
-
lambda: "What are my spending trends over the last few days?",
|
| 278 |
-
outputs=[msg_input]
|
| 279 |
-
)
|
| 280 |
-
tips_btn.click(
|
| 281 |
-
lambda: "What are some specific ways I can save money based on my spending?",
|
| 282 |
-
outputs=[msg_input]
|
| 283 |
-
)
|
| 284 |
-
unusual_btn.click(
|
| 285 |
-
lambda: "Are there any unusual transactions I should be aware of?",
|
| 286 |
-
outputs=[msg_input]
|
| 287 |
-
)
|
| 288 |
-
|
| 289 |
-
def _create_settings_tab(self):
|
| 290 |
-
"""Create settings tab"""
|
| 291 |
-
gr.Markdown("## ⚙️ Settings & Configuration")
|
| 292 |
-
|
| 293 |
-
with gr.Tabs():
|
| 294 |
-
with gr.TabItem("Budget Settings"):
|
| 295 |
-
gr.Markdown("### Set Monthly Budget Limits")
|
| 296 |
-
|
| 297 |
-
with gr.Row():
|
| 298 |
-
with gr.Column():
|
| 299 |
-
budget_categories = gr.CheckboxGroup(
|
| 300 |
-
choices=["Food & Dining", "Shopping", "Gas & Transport",
|
| 301 |
-
"Utilities", "Entertainment", "Healthcare", "Other"],
|
| 302 |
-
label="Categories to Budget",
|
| 303 |
-
value=["Food & Dining", "Shopping", "Gas & Transport"]
|
| 304 |
-
)
|
| 305 |
-
|
| 306 |
-
budget_amounts = gr.JSON(
|
| 307 |
-
label="Budget Amounts ($)",
|
| 308 |
-
value={
|
| 309 |
-
"Food & Dining": 500,
|
| 310 |
-
"Shopping": 300,
|
| 311 |
-
"Gas & Transport": 200,
|
| 312 |
-
"Utilities": 150,
|
| 313 |
-
"Entertainment": 100,
|
| 314 |
-
"Healthcare": 200,
|
| 315 |
-
"Other": 100
|
| 316 |
-
}
|
| 317 |
-
)
|
| 318 |
-
|
| 319 |
-
save_budgets_btn = gr.Button("💾 Save Budget Settings", variant="primary")
|
| 320 |
-
|
| 321 |
-
with gr.Column():
|
| 322 |
-
budget_status = gr.HTML()
|
| 323 |
-
current_budgets = gr.JSON(label="Current Budget Settings")
|
| 324 |
-
|
| 325 |
-
with gr.TabItem("Export Settings"):
|
| 326 |
-
gr.Markdown("### Data Export Options")
|
| 327 |
-
|
| 328 |
-
export_format = gr.Radio(
|
| 329 |
-
choices=["JSON", "CSV"],
|
| 330 |
-
label="Export Format",
|
| 331 |
-
value="JSON"
|
| 332 |
-
)
|
| 333 |
-
|
| 334 |
-
include_raw_data = gr.Checkbox(
|
| 335 |
-
label="Include raw transaction data",
|
| 336 |
-
value=True
|
| 337 |
-
)
|
| 338 |
-
|
| 339 |
-
include_analysis = gr.Checkbox(
|
| 340 |
-
label="Include analysis results",
|
| 341 |
-
value=True
|
| 342 |
-
)
|
| 343 |
-
|
| 344 |
-
# Event handlers
|
| 345 |
-
save_budgets_btn.click(
|
| 346 |
-
fn=self._save_budget_settings,
|
| 347 |
-
inputs=[budget_categories, budget_amounts],
|
| 348 |
-
outputs=[budget_status, current_budgets]
|
| 349 |
-
)
|
| 350 |
-
|
| 351 |
-
# Implementation methods
|
| 352 |
-
def _load_demo_data_handler(self):
|
| 353 |
-
"""Load demo data and create analysis"""
|
| 354 |
-
try:
|
| 355 |
-
# Simulate processing
|
| 356 |
-
time.sleep(1)
|
| 357 |
-
|
| 358 |
-
# Create mock analysis from demo data
|
| 359 |
-
transactions = self.demo_data["transactions"]
|
| 360 |
-
|
| 361 |
-
total_income = sum(t["amount"] for t in transactions if t["amount"] > 0)
|
| 362 |
-
total_expenses = abs(sum(t["amount"] for t in transactions if t["amount"] < 0))
|
| 363 |
-
|
| 364 |
-
# Group by category
|
| 365 |
-
categories = {}
|
| 366 |
-
for t in transactions:
|
| 367 |
-
if t["amount"] < 0: # Only expenses
|
| 368 |
-
cat = t["category"]
|
| 369 |
-
if cat not in categories:
|
| 370 |
-
categories[cat] = {"total": 0, "count": 0}
|
| 371 |
-
categories[cat]["total"] += abs(t["amount"])
|
| 372 |
-
categories[cat]["count"] += 1
|
| 373 |
-
|
| 374 |
-
spending_insights = []
|
| 375 |
-
for cat, data in categories.items():
|
| 376 |
-
spending_insights.append({
|
| 377 |
-
"category": cat,
|
| 378 |
-
"total_amount": data["total"],
|
| 379 |
-
"transaction_count": data["count"],
|
| 380 |
-
"percentage_of_total": (data["total"] / total_expenses) * 100 if total_expenses > 0 else 0
|
| 381 |
-
})
|
| 382 |
-
|
| 383 |
-
self.current_analysis = {
|
| 384 |
-
"financial_summary": {
|
| 385 |
-
"total_income": total_income,
|
| 386 |
-
"total_expenses": total_expenses,
|
| 387 |
-
"net_cash_flow": total_income - total_expenses
|
| 388 |
-
},
|
| 389 |
-
"spending_insights": spending_insights,
|
| 390 |
-
"recommendations": [
|
| 391 |
-
"Your Food & Dining expenses are significant. Consider cooking more meals at home.",
|
| 392 |
-
"Great job maintaining a positive cash flow!",
|
| 393 |
-
"Track your daily expenses to identify more saving opportunities."
|
| 394 |
-
],
|
| 395 |
-
"transaction_count": len(transactions)
|
| 396 |
-
}
|
| 397 |
-
|
| 398 |
-
return '<div class="status-box success-box">✅ Demo data loaded successfully! Check the Dashboard tab to see your analysis.</div>'
|
| 399 |
-
|
| 400 |
-
except Exception as e:
|
| 401 |
-
return f'<div class="status-box error-box">❌ Error loading demo data: {str(e)}</div>'
|
| 402 |
-
|
| 403 |
-
def _analyze_pdf_files(self, files):
|
| 404 |
-
"""Analyze uploaded PDF files (mock implementation)"""
|
| 405 |
-
try:
|
| 406 |
-
if not files:
|
| 407 |
-
return '<div class="status-box error-box">❌ No files uploaded</div>', {}
|
| 408 |
-
|
| 409 |
-
# Mock PDF analysis
|
| 410 |
-
result = {
|
| 411 |
-
'processed_files': [],
|
| 412 |
-
'total_transactions': 0
|
| 413 |
-
}
|
| 414 |
-
|
| 415 |
-
for file in files:
|
| 416 |
-
# Mock processing
|
| 417 |
-
file_result = {
|
| 418 |
-
'filename': file.name,
|
| 419 |
-
'status': 'success',
|
| 420 |
-
'message': 'PDF parsing not implemented in demo mode'
|
| 421 |
-
}
|
| 422 |
-
result['processed_files'].append(file_result)
|
| 423 |
-
|
| 424 |
-
status_html = f'<div class="status-box warning-box">⚠️ PDF processing is not implemented in demo mode. Use the Demo Data tab instead.</div>'
|
| 425 |
-
|
| 426 |
-
return status_html, result
|
| 427 |
-
|
| 428 |
-
except Exception as e:
|
| 429 |
-
error_html = f'<div class="status-box error-box">❌ Error: {str(e)}</div>'
|
| 430 |
-
return error_html, {}
|
| 431 |
-
|
| 432 |
-
def _refresh_dashboard(self):
|
| 433 |
-
"""Refresh dashboard with current analysis"""
|
| 434 |
-
if not self.current_analysis:
|
| 435 |
-
return (0, 0, 0, 0, None, None,
|
| 436 |
-
'<div class="status-box warning-box">⚠️ No analysis data available. Load demo data first!</div>',
|
| 437 |
-
'<div class="status-box warning-box">⚠️ Load demo data to see recommendations</div>',
|
| 438 |
-
pd.DataFrame())
|
| 439 |
-
|
| 440 |
-
try:
|
| 441 |
-
summary = self.current_analysis.get('financial_summary', {})
|
| 442 |
-
insights = self.current_analysis.get('spending_insights', [])
|
| 443 |
-
|
| 444 |
-
# Summary metrics
|
| 445 |
-
total_income = summary.get('total_income', 0)
|
| 446 |
-
total_expenses = summary.get('total_expenses', 0)
|
| 447 |
-
net_cashflow = summary.get('net_cash_flow', 0)
|
| 448 |
-
transaction_count = self.current_analysis.get('transaction_count', 0)
|
| 449 |
-
|
| 450 |
-
# Create spending by category chart
|
| 451 |
-
if insights:
|
| 452 |
-
categories = [insight['category'] for insight in insights]
|
| 453 |
-
amounts = [insight['total_amount'] for insight in insights]
|
| 454 |
-
|
| 455 |
-
spending_chart = px.pie(
|
| 456 |
-
values=amounts,
|
| 457 |
-
names=categories,
|
| 458 |
-
title="Spending by Category"
|
| 459 |
-
)
|
| 460 |
-
spending_chart.update_layout(height=400)
|
| 461 |
-
else:
|
| 462 |
-
spending_chart = None
|
| 463 |
-
|
| 464 |
-
# Create daily trends chart
|
| 465 |
-
transactions = self.demo_data.get("transactions", [])
|
| 466 |
-
if transactions:
|
| 467 |
-
# Group by date
|
| 468 |
-
daily_spending = {}
|
| 469 |
-
for t in transactions:
|
| 470 |
-
if t["amount"] < 0: # Only expenses
|
| 471 |
-
date = t["date"]
|
| 472 |
-
if date not in daily_spending:
|
| 473 |
-
daily_spending[date] = 0
|
| 474 |
-
daily_spending[date] += abs(t["amount"])
|
| 475 |
-
|
| 476 |
-
dates = list(daily_spending.keys())
|
| 477 |
-
amounts = list(daily_spending.values())
|
| 478 |
-
|
| 479 |
-
trends_chart = px.line(
|
| 480 |
-
x=dates,
|
| 481 |
-
y=amounts,
|
| 482 |
-
title="Daily Spending Trends",
|
| 483 |
-
labels={"x": "Date", "y": "Amount ($)"}
|
| 484 |
-
)
|
| 485 |
-
trends_chart.update_layout(height=400)
|
| 486 |
-
else:
|
| 487 |
-
trends_chart = None
|
| 488 |
-
|
| 489 |
-
# Budget alerts
|
| 490 |
-
alert_html = '<div class="status-box success-box">✅ All spending within reasonable limits</div>'
|
| 491 |
-
|
| 492 |
-
# Recommendations
|
| 493 |
-
recommendations = self.current_analysis.get('recommendations', [])
|
| 494 |
-
if recommendations:
|
| 495 |
-
rec_html = '<div class="status-box"><h4>💡 Recommendations:</h4><ul>'
|
| 496 |
-
for rec in recommendations:
|
| 497 |
-
rec_html += f'<li>{rec}</li>'
|
| 498 |
-
rec_html += '</ul></div>'
|
| 499 |
-
else:
|
| 500 |
-
rec_html = '<div class="status-box">No specific recommendations at this time.</div>'
|
| 501 |
-
|
| 502 |
-
# Transaction table
|
| 503 |
-
transaction_data = []
|
| 504 |
-
for t in transactions:
|
| 505 |
-
transaction_data.append([
|
| 506 |
-
t["date"],
|
| 507 |
-
t["description"],
|
| 508 |
-
f"${t['amount']:.2f}",
|
| 509 |
-
t["category"]
|
| 510 |
-
])
|
| 511 |
-
|
| 512 |
-
transaction_df = pd.DataFrame(
|
| 513 |
-
transaction_data,
|
| 514 |
-
columns=["Date", "Description", "Amount", "Category"]
|
| 515 |
-
)
|
| 516 |
-
|
| 517 |
-
return (total_income, total_expenses, net_cashflow, transaction_count,
|
| 518 |
-
spending_chart, trends_chart, alert_html, rec_html, transaction_df)
|
| 519 |
-
|
| 520 |
-
except Exception as e:
|
| 521 |
-
error_msg = f'<div class="status-box error-box">❌ Dashboard error: {str(e)}</div>'
|
| 522 |
-
return (0, 0, 0, 0, None, None, error_msg, error_msg, pd.DataFrame())
|
| 523 |
-
|
| 524 |
-
def _export_analysis(self):
|
| 525 |
-
"""Export current analysis data"""
|
| 526 |
-
if not self.current_analysis:
|
| 527 |
-
return None
|
| 528 |
-
|
| 529 |
-
try:
|
| 530 |
-
import tempfile
|
| 531 |
-
|
| 532 |
-
# Create temporary file
|
| 533 |
-
with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f:
|
| 534 |
-
json.dump(self.current_analysis, f, indent=2, default=str)
|
| 535 |
-
return f.name
|
| 536 |
-
|
| 537 |
-
except Exception as e:
|
| 538 |
-
self.logger.error(f"Export error: {e}")
|
| 539 |
-
return None
|
| 540 |
-
|
| 541 |
-
def _handle_chat_message(self, message, chat_history):
|
| 542 |
-
"""Handle chat messages with AI advisor"""
|
| 543 |
-
if not message.strip():
|
| 544 |
-
return chat_history, "", '<div class="status-box warning-box">⚠️ Please enter a message</div>'
|
| 545 |
-
|
| 546 |
-
try:
|
| 547 |
-
# Add user message to chat
|
| 548 |
-
chat_history = chat_history or []
|
| 549 |
-
chat_history.append([message, None])
|
| 550 |
-
|
| 551 |
-
# Generate response based on analysis
|
| 552 |
-
if self.current_analysis:
|
| 553 |
-
summary = self.current_analysis.get('financial_summary', {})
|
| 554 |
-
insights = self.current_analysis.get('spending_insights', [])
|
| 555 |
-
recommendations = self.current_analysis.get('recommendations', [])
|
| 556 |
-
|
| 557 |
-
if 'budget' in message.lower():
|
| 558 |
-
ai_response = f"Based on your analysis, you have a net cash flow of ${summary.get('net_cash_flow', 0):.2f}. Your total expenses are ${summary.get('total_expenses', 0):.2f} against an income of ${summary.get('total_income', 0):.2f}."
|
| 559 |
-
elif 'trend' in message.lower():
|
| 560 |
-
if insights:
|
| 561 |
-
top_category = insights[0]
|
| 562 |
-
ai_response = f"Your top spending category is {top_category['category']} at ${top_category['total_amount']:.2f} ({top_category['percentage_of_total']:.1f}% of total spending)."
|
| 563 |
-
else:
|
| 564 |
-
ai_response = "I need more transaction data to analyze your spending trends effectively."
|
| 565 |
-
elif 'save' in message.lower() or 'tip' in message.lower():
|
| 566 |
-
if recommendations:
|
| 567 |
-
ai_response = f"Here are some personalized recommendations: {'. '.join(recommendations[:2])}"
|
| 568 |
-
else:
|
| 569 |
-
ai_response = "Based on your spending patterns, consider tracking your largest expense categories and setting monthly budgets."
|
| 570 |
-
elif 'unusual' in message.lower() or 'activity' in message.lower():
|
| 571 |
-
ai_response = "I've analyzed your transactions and they appear normal. All transactions seem consistent with typical spending patterns."
|
| 572 |
-
else:
|
| 573 |
-
ai_response = f"I can see you have {self.current_analysis.get('transaction_count', 0)} transactions analyzed. Feel free to ask about your budget, spending trends, saving tips, or unusual activity!"
|
| 574 |
-
|
| 575 |
-
status_html = '<div class="status-box success-box">✅ Response generated</div>'
|
| 576 |
-
else:
|
| 577 |
-
ai_response = "I don't have any financial data to analyze yet. Please load the demo data or upload PDFs first!"
|
| 578 |
-
status_html = '<div class="status-box warning-box">⚠️ No data available</div>'
|
| 579 |
-
|
| 580 |
-
# Update chat history with AI response
|
| 581 |
-
chat_history[-1][1] = ai_response
|
| 582 |
-
|
| 583 |
-
return chat_history, "", status_html
|
| 584 |
-
|
| 585 |
-
except Exception as e:
|
| 586 |
-
error_response = f"I'm sorry, I encountered an error: {str(e)}"
|
| 587 |
-
if chat_history:
|
| 588 |
-
chat_history[-1][1] = error_response
|
| 589 |
-
return chat_history, "", '<div class="status-box error-box">❌ Chat Error</div>'
|
| 590 |
-
|
| 591 |
-
def _save_budget_settings(self, categories, amounts):
|
| 592 |
-
"""Save budget settings"""
|
| 593 |
-
try:
|
| 594 |
-
# Filter amounts for selected categories
|
| 595 |
-
budget_settings = {cat: amounts.get(cat, 0) for cat in categories}
|
| 596 |
-
|
| 597 |
-
# Store in user session
|
| 598 |
-
self.user_sessions['budgets'] = budget_settings
|
| 599 |
-
|
| 600 |
-
status_html = '<div class="status-box success-box">✅ Budget settings saved</div>'
|
| 601 |
-
return status_html, budget_settings
|
| 602 |
-
|
| 603 |
-
except Exception as e:
|
| 604 |
-
error_html = f'<div class="status-box error-box">❌ Error saving budgets: {str(e)}</div>'
|
| 605 |
-
return error_html, {}
|
| 606 |
-
|
| 607 |
-
# Launch the interface
|
| 608 |
-
def launch_interface():
|
| 609 |
-
"""Launch the Gradio interface"""
|
| 610 |
-
interface = SpendAnalyzerInterface()
|
| 611 |
-
app = interface.create_interface()
|
| 612 |
-
|
| 613 |
-
print("🚀 Starting Spend Analyzer MCP - Local Demo")
|
| 614 |
-
print("📊 Demo mode: Use the Demo Data tab to get started")
|
| 615 |
-
print("🌐 Opening in browser...")
|
| 616 |
-
|
| 617 |
-
app.launch(
|
| 618 |
-
server_name="0.0.0.0",
|
| 619 |
-
server_port=7861,
|
| 620 |
-
share=False,
|
| 621 |
-
debug=True,
|
| 622 |
-
show_error=True,
|
| 623 |
-
inbrowser=True
|
| 624 |
-
)
|
| 625 |
-
|
| 626 |
-
if __name__ == "__main__":
|
| 627 |
-
launch_interface()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
setup_local.py → init/setup_local.py
RENAMED
|
File without changes
|
init/setup_modal.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Quick setup script for Modal deployment of Spend Analyzer MCP
|
| 4 |
+
"""
|
| 5 |
+
import os
|
| 6 |
+
import subprocess
|
| 7 |
+
import sys
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
|
| 10 |
+
def run_command(cmd, description):
|
| 11 |
+
"""Run a command and handle errors"""
|
| 12 |
+
print(f"\n🔄 {description}...")
|
| 13 |
+
try:
|
| 14 |
+
result = subprocess.run(cmd, shell=True, check=True, capture_output=True, text=True)
|
| 15 |
+
print(f"✅ {description} completed successfully")
|
| 16 |
+
if result.stdout:
|
| 17 |
+
print(f"Output: {result.stdout.strip()}")
|
| 18 |
+
return True
|
| 19 |
+
except subprocess.CalledProcessError as e:
|
| 20 |
+
print(f"❌ {description} failed")
|
| 21 |
+
print(f"Error: {e.stderr.strip()}")
|
| 22 |
+
return False
|
| 23 |
+
|
| 24 |
+
def check_modal_installed():
|
| 25 |
+
"""Check if Modal CLI is installed"""
|
| 26 |
+
try:
|
| 27 |
+
subprocess.run(["modal", "--version"], check=True, capture_output=True)
|
| 28 |
+
return True
|
| 29 |
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
| 30 |
+
return False
|
| 31 |
+
|
| 32 |
+
def get_user_input(prompt, required=True):
|
| 33 |
+
"""Get user input with validation"""
|
| 34 |
+
while True:
|
| 35 |
+
value = input(f"{prompt}: ").strip()
|
| 36 |
+
if value or not required:
|
| 37 |
+
return value
|
| 38 |
+
print("This field is required. Please enter a value.")
|
| 39 |
+
|
| 40 |
+
def main():
|
| 41 |
+
print("🚀 Spend Analyzer MCP - Modal Setup Script")
|
| 42 |
+
print("=" * 50)
|
| 43 |
+
|
| 44 |
+
# Check if Modal is installed
|
| 45 |
+
if not check_modal_installed():
|
| 46 |
+
print("\n📦 Installing Modal CLI...")
|
| 47 |
+
if not run_command("pip install modal", "Installing Modal"):
|
| 48 |
+
print("❌ Failed to install Modal. Please install manually: pip install modal")
|
| 49 |
+
sys.exit(1)
|
| 50 |
+
else:
|
| 51 |
+
print("✅ Modal CLI is already installed")
|
| 52 |
+
|
| 53 |
+
# Check if user is authenticated
|
| 54 |
+
print("\n🔐 Checking Modal authentication...")
|
| 55 |
+
try:
|
| 56 |
+
result = subprocess.run(["modal", "token", "current"], check=True, capture_output=True, text=True)
|
| 57 |
+
print("✅ Already authenticated with Modal")
|
| 58 |
+
except subprocess.CalledProcessError:
|
| 59 |
+
print("🔑 Need to authenticate with Modal...")
|
| 60 |
+
if not run_command("modal token new", "Authenticating with Modal"):
|
| 61 |
+
print("❌ Authentication failed. Please run 'modal token new' manually")
|
| 62 |
+
sys.exit(1)
|
| 63 |
+
|
| 64 |
+
# Collect API keys and credentials
|
| 65 |
+
print("\n📝 Please provide your API keys and credentials:")
|
| 66 |
+
print("(You can get these from the respective provider websites)")
|
| 67 |
+
|
| 68 |
+
anthropic_key = get_user_input("Anthropic API Key (for Claude)")
|
| 69 |
+
sambanova_key = get_user_input("SambaNova API Key (optional)", required=False)
|
| 70 |
+
email_user = get_user_input("Email address")
|
| 71 |
+
email_pass = get_user_input("Email app password")
|
| 72 |
+
imap_server = get_user_input("IMAP server (default: imap.gmail.com)", required=False) or "imap.gmail.com"
|
| 73 |
+
|
| 74 |
+
# Create Modal secrets
|
| 75 |
+
print("\n🔒 Creating Modal secrets...")
|
| 76 |
+
|
| 77 |
+
secrets_to_create = [
|
| 78 |
+
(f"modal secret create anthropic-api-key ANTHROPIC_API_KEY={anthropic_key}",
|
| 79 |
+
"Creating Anthropic API key secret"),
|
| 80 |
+
(f"modal secret create email-credentials EMAIL_USER={email_user} EMAIL_PASS={email_pass} IMAP_SERVER={imap_server}",
|
| 81 |
+
"Creating email credentials secret")
|
| 82 |
+
]
|
| 83 |
+
|
| 84 |
+
if sambanova_key:
|
| 85 |
+
secrets_to_create.append(
|
| 86 |
+
(f"modal secret create sambanova-api-key SAMBANOVA_API_KEY={sambanova_key}",
|
| 87 |
+
"Creating SambaNova API key secret")
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
for cmd, description in secrets_to_create:
|
| 91 |
+
if not run_command(cmd, description):
|
| 92 |
+
print(f"⚠️ Failed to create secret. You may need to create it manually.")
|
| 93 |
+
|
| 94 |
+
# Deploy to Modal
|
| 95 |
+
print("\n🚀 Deploying to Modal...")
|
| 96 |
+
if run_command("modal deploy modal_deployment.py", "Deploying application"):
|
| 97 |
+
print("\n🎉 Deployment completed successfully!")
|
| 98 |
+
print("\n📋 Next steps:")
|
| 99 |
+
print("1. Check your Modal dashboard for the deployed app")
|
| 100 |
+
print("2. Test the deployment with: modal run modal_deployment.py")
|
| 101 |
+
print("3. View logs with: modal logs spend-analyzer-mcp-bmt")
|
| 102 |
+
print("4. Monitor usage with: modal stats spend-analyzer-mcp-bmt")
|
| 103 |
+
|
| 104 |
+
# Create local .env file
|
| 105 |
+
env_content = f"""# Spend Analyzer MCP Environment Variables
|
| 106 |
+
ANTHROPIC_API_KEY={anthropic_key}
|
| 107 |
+
EMAIL_USER={email_user}
|
| 108 |
+
EMAIL_PASS={email_pass}
|
| 109 |
+
IMAP_SERVER={imap_server}
|
| 110 |
+
"""
|
| 111 |
+
if sambanova_key:
|
| 112 |
+
env_content += f"SAMBANOVA_API_KEY={sambanova_key}\n"
|
| 113 |
+
|
| 114 |
+
with open(".env", "w") as f:
|
| 115 |
+
f.write(env_content)
|
| 116 |
+
print("5. Created .env file for local development")
|
| 117 |
+
|
| 118 |
+
else:
|
| 119 |
+
print("\n❌ Deployment failed. Please check the errors above and try again.")
|
| 120 |
+
print("You can also deploy manually with: modal deploy modal_deployment.py")
|
| 121 |
+
|
| 122 |
+
if __name__ == "__main__":
|
| 123 |
+
try:
|
| 124 |
+
main()
|
| 125 |
+
except KeyboardInterrupt:
|
| 126 |
+
print("\n\n⏹️ Setup cancelled by user")
|
| 127 |
+
sys.exit(1)
|
| 128 |
+
except Exception as e:
|
| 129 |
+
print(f"\n❌ Unexpected error: {e}")
|
| 130 |
+
sys.exit(1)
|
mcp_server.py
CHANGED
|
@@ -3,10 +3,13 @@ MCP Server for Spend Analysis - Core Protocol Implementation
|
|
| 3 |
"""
|
| 4 |
import json
|
| 5 |
import asyncio
|
| 6 |
-
|
|
|
|
|
|
|
| 7 |
from dataclasses import dataclass
|
| 8 |
from enum import Enum
|
| 9 |
import logging
|
|
|
|
| 10 |
|
| 11 |
# MCP Protocol Types
|
| 12 |
class MessageType(Enum):
|
|
@@ -30,16 +33,19 @@ class MCPServer:
|
|
| 30 |
self.prompts = {}
|
| 31 |
self.logger = logging.getLogger(__name__)
|
| 32 |
|
| 33 |
-
def register_tool(self, name: str, description: str, handler):
|
| 34 |
"""Register a tool that Claude can call"""
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
"handler": handler,
|
| 38 |
-
"input_schema": {
|
| 39 |
"type": "object",
|
| 40 |
"properties": {},
|
| 41 |
"required": []
|
| 42 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
}
|
| 44 |
|
| 45 |
def register_resource(self, uri: str, name: str, description: str, handler):
|
|
@@ -75,7 +81,7 @@ class MCPServer:
|
|
| 75 |
self.logger.error(f"Error handling message: {e}")
|
| 76 |
return self._error_response(message.get("id"), -32603, str(e))
|
| 77 |
|
| 78 |
-
def _handle_initialize(self, msg_id: str) -> Dict:
|
| 79 |
"""Handle MCP initialization"""
|
| 80 |
return {
|
| 81 |
"jsonrpc": "2.0",
|
|
@@ -88,13 +94,13 @@ class MCPServer:
|
|
| 88 |
"prompts": {}
|
| 89 |
},
|
| 90 |
"serverInfo": {
|
| 91 |
-
"name": "spend-analyzer-mcp",
|
| 92 |
"version": "1.0.0"
|
| 93 |
}
|
| 94 |
}
|
| 95 |
}
|
| 96 |
|
| 97 |
-
def _handle_list_tools(self, msg_id: str) -> Dict:
|
| 98 |
"""List available tools"""
|
| 99 |
tools_list = []
|
| 100 |
for name, tool in self.tools.items():
|
|
@@ -110,7 +116,7 @@ class MCPServer:
|
|
| 110 |
"result": {"tools": tools_list}
|
| 111 |
}
|
| 112 |
|
| 113 |
-
async def _handle_call_tool(self, msg_id: str, params: Dict) -> Dict:
|
| 114 |
"""Execute a tool call"""
|
| 115 |
tool_name = params.get("name")
|
| 116 |
arguments = params.get("arguments", {})
|
|
@@ -136,7 +142,7 @@ class MCPServer:
|
|
| 136 |
except Exception as e:
|
| 137 |
return self._error_response(msg_id, -32603, f"Tool execution failed: {str(e)}")
|
| 138 |
|
| 139 |
-
def _handle_list_resources(self, msg_id: str) -> Dict:
|
| 140 |
"""List available resources"""
|
| 141 |
resources_list = []
|
| 142 |
for uri, resource in self.resources.items():
|
|
@@ -153,7 +159,7 @@ class MCPServer:
|
|
| 153 |
"result": {"resources": resources_list}
|
| 154 |
}
|
| 155 |
|
| 156 |
-
async def _handle_read_resource(self, msg_id: str, params: Dict) -> Dict:
|
| 157 |
"""Read a resource"""
|
| 158 |
uri = params.get("uri")
|
| 159 |
|
|
@@ -179,7 +185,7 @@ class MCPServer:
|
|
| 179 |
except Exception as e:
|
| 180 |
return self._error_response(msg_id, -32603, f"Resource read failed: {str(e)}")
|
| 181 |
|
| 182 |
-
def _error_response(self, msg_id: str, code: int, message: str) -> Dict:
|
| 183 |
"""Create error response"""
|
| 184 |
return {
|
| 185 |
"jsonrpc": "2.0",
|
|
@@ -190,36 +196,478 @@ class MCPServer:
|
|
| 190 |
}
|
| 191 |
}
|
| 192 |
|
| 193 |
-
#
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
server = MCPServer()
|
| 197 |
|
| 198 |
-
#
|
| 199 |
-
async def
|
| 200 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 201 |
|
| 202 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 203 |
|
| 204 |
-
#
|
| 205 |
-
async def
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
"id": "1",
|
| 209 |
-
"method": "initialize",
|
| 210 |
-
"params": {}
|
| 211 |
-
}
|
| 212 |
|
| 213 |
-
|
| 214 |
-
|
|
|
|
| 215 |
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
"
|
| 219 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 220 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 221 |
|
| 222 |
-
|
| 223 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 224 |
|
| 225 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
"""
|
| 4 |
import json
|
| 5 |
import asyncio
|
| 6 |
+
import uvicorn
|
| 7 |
+
from fastapi import FastAPI, Request
|
| 8 |
+
from typing import Dict, List, Any, Optional, Callable
|
| 9 |
from dataclasses import dataclass
|
| 10 |
from enum import Enum
|
| 11 |
import logging
|
| 12 |
+
from spend_analyzer import SpendAnalyzer
|
| 13 |
|
| 14 |
# MCP Protocol Types
|
| 15 |
class MessageType(Enum):
|
|
|
|
| 33 |
self.prompts = {}
|
| 34 |
self.logger = logging.getLogger(__name__)
|
| 35 |
|
| 36 |
+
def register_tool(self, name: str, description: str, handler, input_schema=None):
|
| 37 |
"""Register a tool that Claude can call"""
|
| 38 |
+
if input_schema is None:
|
| 39 |
+
input_schema = {
|
|
|
|
|
|
|
| 40 |
"type": "object",
|
| 41 |
"properties": {},
|
| 42 |
"required": []
|
| 43 |
}
|
| 44 |
+
|
| 45 |
+
self.tools[name] = {
|
| 46 |
+
"description": description,
|
| 47 |
+
"handler": handler,
|
| 48 |
+
"input_schema": input_schema
|
| 49 |
}
|
| 50 |
|
| 51 |
def register_resource(self, uri: str, name: str, description: str, handler):
|
|
|
|
| 81 |
self.logger.error(f"Error handling message: {e}")
|
| 82 |
return self._error_response(message.get("id"), -32603, str(e))
|
| 83 |
|
| 84 |
+
def _handle_initialize(self, msg_id: Optional[str]) -> Dict:
|
| 85 |
"""Handle MCP initialization"""
|
| 86 |
return {
|
| 87 |
"jsonrpc": "2.0",
|
|
|
|
| 94 |
"prompts": {}
|
| 95 |
},
|
| 96 |
"serverInfo": {
|
| 97 |
+
"name": "spend-analyzer-mcp-bmt",
|
| 98 |
"version": "1.0.0"
|
| 99 |
}
|
| 100 |
}
|
| 101 |
}
|
| 102 |
|
| 103 |
+
def _handle_list_tools(self, msg_id: Optional[str]) -> Dict:
|
| 104 |
"""List available tools"""
|
| 105 |
tools_list = []
|
| 106 |
for name, tool in self.tools.items():
|
|
|
|
| 116 |
"result": {"tools": tools_list}
|
| 117 |
}
|
| 118 |
|
| 119 |
+
async def _handle_call_tool(self, msg_id: Optional[str], params: Dict) -> Dict:
|
| 120 |
"""Execute a tool call"""
|
| 121 |
tool_name = params.get("name")
|
| 122 |
arguments = params.get("arguments", {})
|
|
|
|
| 142 |
except Exception as e:
|
| 143 |
return self._error_response(msg_id, -32603, f"Tool execution failed: {str(e)}")
|
| 144 |
|
| 145 |
+
def _handle_list_resources(self, msg_id: Optional[str]) -> Dict:
|
| 146 |
"""List available resources"""
|
| 147 |
resources_list = []
|
| 148 |
for uri, resource in self.resources.items():
|
|
|
|
| 159 |
"result": {"resources": resources_list}
|
| 160 |
}
|
| 161 |
|
| 162 |
+
async def _handle_read_resource(self, msg_id: Optional[str], params: Dict) -> Dict:
|
| 163 |
"""Read a resource"""
|
| 164 |
uri = params.get("uri")
|
| 165 |
|
|
|
|
| 185 |
except Exception as e:
|
| 186 |
return self._error_response(msg_id, -32603, f"Resource read failed: {str(e)}")
|
| 187 |
|
| 188 |
+
def _error_response(self, msg_id: Optional[str], code: int, message: str) -> Dict:
|
| 189 |
"""Create error response"""
|
| 190 |
return {
|
| 191 |
"jsonrpc": "2.0",
|
|
|
|
| 196 |
}
|
| 197 |
}
|
| 198 |
|
| 199 |
+
# Register all tools for the MCP server
|
| 200 |
+
def register_all_tools(server: MCPServer):
|
| 201 |
+
"""Register all tools with the MCP server"""
|
|
|
|
| 202 |
|
| 203 |
+
# Process email statements tool
|
| 204 |
+
async def process_email_statements_tool(args: Dict) -> Dict:
|
| 205 |
+
"""Process bank statements from email"""
|
| 206 |
+
from email_processor import EmailProcessor, PDFProcessor
|
| 207 |
+
|
| 208 |
+
email_config = args.get('email_config', {})
|
| 209 |
+
days_back = args.get('days_back', 30)
|
| 210 |
+
passwords = args.get('passwords', {})
|
| 211 |
+
|
| 212 |
+
try:
|
| 213 |
+
# Initialize processors
|
| 214 |
+
email_processor = EmailProcessor(email_config)
|
| 215 |
+
pdf_processor = PDFProcessor()
|
| 216 |
+
analyzer = SpendAnalyzer()
|
| 217 |
+
|
| 218 |
+
# Fetch emails
|
| 219 |
+
emails = await email_processor.fetch_bank_emails(days_back)
|
| 220 |
+
|
| 221 |
+
all_transactions = []
|
| 222 |
+
processed_statements = []
|
| 223 |
+
|
| 224 |
+
for email_msg in emails:
|
| 225 |
+
# Extract attachments
|
| 226 |
+
attachments = await email_processor.extract_attachments(email_msg)
|
| 227 |
+
|
| 228 |
+
for filename, content, file_type in attachments:
|
| 229 |
+
if file_type == 'pdf':
|
| 230 |
+
# Try to process PDF
|
| 231 |
+
password = passwords.get(filename)
|
| 232 |
+
|
| 233 |
+
try:
|
| 234 |
+
statement_info = await pdf_processor.process_pdf(content, password)
|
| 235 |
+
all_transactions.extend(statement_info.transactions)
|
| 236 |
+
processed_statements.append({
|
| 237 |
+
'filename': filename,
|
| 238 |
+
'bank': statement_info.bank_name,
|
| 239 |
+
'account': statement_info.account_number,
|
| 240 |
+
'transaction_count': len(statement_info.transactions)
|
| 241 |
+
})
|
| 242 |
+
except Exception as e:
|
| 243 |
+
processed_statements.append({
|
| 244 |
+
'filename': filename,
|
| 245 |
+
'status': 'error',
|
| 246 |
+
'error': str(e)
|
| 247 |
+
})
|
| 248 |
+
|
| 249 |
+
# Analyze transactions
|
| 250 |
+
if all_transactions:
|
| 251 |
+
analyzer.load_transactions(all_transactions)
|
| 252 |
+
analysis_data = analyzer.export_analysis_data()
|
| 253 |
+
else:
|
| 254 |
+
analysis_data = {'message': 'No transactions found'}
|
| 255 |
+
|
| 256 |
+
return {
|
| 257 |
+
'processed_statements': processed_statements,
|
| 258 |
+
'total_transactions': len(all_transactions),
|
| 259 |
+
'analysis': analysis_data
|
| 260 |
+
}
|
| 261 |
+
|
| 262 |
+
except Exception as e:
|
| 263 |
+
return {'error': str(e)}
|
| 264 |
|
| 265 |
+
# Analyze PDF statements tool
|
| 266 |
+
async def analyze_pdf_statements_tool(args: Dict) -> Dict:
|
| 267 |
+
"""Analyze uploaded PDF statements"""
|
| 268 |
+
from email_processor import PDFProcessor
|
| 269 |
+
|
| 270 |
+
pdf_contents = args.get('pdf_contents', {})
|
| 271 |
+
passwords = args.get('passwords', {})
|
| 272 |
+
|
| 273 |
+
try:
|
| 274 |
+
pdf_processor = PDFProcessor()
|
| 275 |
+
analyzer = SpendAnalyzer()
|
| 276 |
+
|
| 277 |
+
all_transactions = []
|
| 278 |
+
processed_files = []
|
| 279 |
+
|
| 280 |
+
for filename, content in pdf_contents.items():
|
| 281 |
+
try:
|
| 282 |
+
password = passwords.get(filename)
|
| 283 |
+
statement_info = await pdf_processor.process_pdf(content, password)
|
| 284 |
+
|
| 285 |
+
all_transactions.extend(statement_info.transactions)
|
| 286 |
+
processed_files.append({
|
| 287 |
+
'filename': filename,
|
| 288 |
+
'bank': statement_info.bank_name,
|
| 289 |
+
'account': statement_info.account_number,
|
| 290 |
+
'transaction_count': len(statement_info.transactions),
|
| 291 |
+
'status': 'success'
|
| 292 |
+
})
|
| 293 |
+
|
| 294 |
+
except Exception as e:
|
| 295 |
+
processed_files.append({
|
| 296 |
+
'filename': filename,
|
| 297 |
+
'status': 'error',
|
| 298 |
+
'error': str(e)
|
| 299 |
+
})
|
| 300 |
+
|
| 301 |
+
# Analyze transactions
|
| 302 |
+
if all_transactions:
|
| 303 |
+
analyzer.load_transactions(all_transactions)
|
| 304 |
+
analysis_data = analyzer.export_analysis_data()
|
| 305 |
+
else:
|
| 306 |
+
analysis_data = {'message': 'No transactions found'}
|
| 307 |
+
|
| 308 |
+
return {
|
| 309 |
+
'processed_files': processed_files,
|
| 310 |
+
'total_transactions': len(all_transactions),
|
| 311 |
+
'analysis': analysis_data
|
| 312 |
+
}
|
| 313 |
+
|
| 314 |
+
except Exception as e:
|
| 315 |
+
return {'error': str(e)}
|
| 316 |
|
| 317 |
+
# Get AI analysis tool
|
| 318 |
+
async def get_ai_analysis_tool(args: Dict) -> Dict:
|
| 319 |
+
"""Get AI financial analysis"""
|
| 320 |
+
import os
|
|
|
|
|
|
|
|
|
|
|
|
|
| 321 |
|
| 322 |
+
analysis_data = args.get('analysis_data', {})
|
| 323 |
+
user_question = args.get('user_question', '')
|
| 324 |
+
provider = args.get('provider', 'claude')
|
| 325 |
|
| 326 |
+
try:
|
| 327 |
+
# Prepare context for AI
|
| 328 |
+
context = f"""
|
| 329 |
+
Financial Analysis Data:
|
| 330 |
+
{json.dumps(analysis_data, indent=2, default=str)}
|
| 331 |
+
|
| 332 |
+
User Question: {user_question if user_question else "Please provide a comprehensive analysis of my spending patterns and recommendations."}
|
| 333 |
+
"""
|
| 334 |
+
|
| 335 |
+
prompt = f"""
|
| 336 |
+
You are a financial advisor analyzing bank statement data.
|
| 337 |
+
Based on the provided financial data, give insights about:
|
| 338 |
+
|
| 339 |
+
1. Spending patterns and trends
|
| 340 |
+
2. Budget adherence and alerts
|
| 341 |
+
3. Unusual transactions that need attention
|
| 342 |
+
4. Specific recommendations for improvement
|
| 343 |
+
5. Answer to the user's specific question if provided
|
| 344 |
+
|
| 345 |
+
Be specific, actionable, and highlight both positive aspects and areas for improvement.
|
| 346 |
+
|
| 347 |
+
{context}
|
| 348 |
+
"""
|
| 349 |
+
|
| 350 |
+
if provider.lower() == "claude":
|
| 351 |
+
# Call Claude API
|
| 352 |
+
try:
|
| 353 |
+
import anthropic
|
| 354 |
+
|
| 355 |
+
client = anthropic.Anthropic(api_key=os.environ.get("ANTHROPIC_API_KEY", ""))
|
| 356 |
+
|
| 357 |
+
response = client.messages.create(
|
| 358 |
+
model="claude-3-sonnet-20240229",
|
| 359 |
+
max_tokens=1500,
|
| 360 |
+
messages=[
|
| 361 |
+
{
|
| 362 |
+
"role": "user",
|
| 363 |
+
"content": prompt
|
| 364 |
+
}
|
| 365 |
+
]
|
| 366 |
+
)
|
| 367 |
+
|
| 368 |
+
# Handle different response formats
|
| 369 |
+
try:
|
| 370 |
+
# Extract text from Claude response
|
| 371 |
+
if hasattr(response, 'content') and response.content:
|
| 372 |
+
content_item = response.content[0]
|
| 373 |
+
# Handle different Claude API versions
|
| 374 |
+
if isinstance(content_item, dict):
|
| 375 |
+
if 'text' in content_item:
|
| 376 |
+
analysis_text = content_item['text']
|
| 377 |
+
else:
|
| 378 |
+
analysis_text = str(content_item)
|
| 379 |
+
# Handle object with attributes
|
| 380 |
+
elif hasattr(content_item, '__dict__'):
|
| 381 |
+
content_dict = vars(content_item)
|
| 382 |
+
if 'text' in content_dict:
|
| 383 |
+
analysis_text = content_dict['text']
|
| 384 |
+
else:
|
| 385 |
+
analysis_text = str(content_item)
|
| 386 |
+
else:
|
| 387 |
+
analysis_text = str(content_item)
|
| 388 |
+
else:
|
| 389 |
+
analysis_text = str(response)
|
| 390 |
+
except Exception as e:
|
| 391 |
+
analysis_text = f"Error parsing Claude response: {str(e)}"
|
| 392 |
+
|
| 393 |
+
return {
|
| 394 |
+
'ai_analysis': analysis_text,
|
| 395 |
+
'provider': 'claude',
|
| 396 |
+
'model': 'claude-3-sonnet-20240229'
|
| 397 |
+
}
|
| 398 |
+
|
| 399 |
+
except Exception as e:
|
| 400 |
+
return {'error': f"Claude API error: {str(e)}"}
|
| 401 |
+
|
| 402 |
+
elif provider.lower() == "sambanova":
|
| 403 |
+
# Call SambaNova API
|
| 404 |
+
try:
|
| 405 |
+
import openai
|
| 406 |
+
|
| 407 |
+
# SambaNova uses OpenAI-compatible API
|
| 408 |
+
client = openai.OpenAI(
|
| 409 |
+
api_key=os.environ.get("SAMBANOVA_API_KEY", ""),
|
| 410 |
+
base_url="https://api.sambanova.ai/v1"
|
| 411 |
+
)
|
| 412 |
+
|
| 413 |
+
response = client.chat.completions.create(
|
| 414 |
+
model="Meta-Llama-3.1-8B-Instruct", # SambaNova model
|
| 415 |
+
messages=[
|
| 416 |
+
{
|
| 417 |
+
"role": "user",
|
| 418 |
+
"content": prompt
|
| 419 |
+
}
|
| 420 |
+
],
|
| 421 |
+
max_tokens=1500,
|
| 422 |
+
temperature=0.7
|
| 423 |
+
)
|
| 424 |
+
|
| 425 |
+
return {
|
| 426 |
+
'ai_analysis': response.choices[0].message.content,
|
| 427 |
+
'provider': 'sambanova',
|
| 428 |
+
'model': 'Meta-Llama-3.1-8B-Instruct'
|
| 429 |
+
}
|
| 430 |
+
|
| 431 |
+
except Exception as e:
|
| 432 |
+
return {'error': f"SambaNova API error: {str(e)}"}
|
| 433 |
+
else:
|
| 434 |
+
return {'error': f"Unsupported provider: {provider}"}
|
| 435 |
+
|
| 436 |
+
except Exception as e:
|
| 437 |
+
return {'error': f"AI API error: {str(e)}"}
|
| 438 |
+
|
| 439 |
+
# Register tools with proper input schemas
|
| 440 |
+
server.register_tool(
|
| 441 |
+
"process_email_statements",
|
| 442 |
+
"Process bank statements from email",
|
| 443 |
+
process_email_statements_tool,
|
| 444 |
+
input_schema={
|
| 445 |
+
"type": "object",
|
| 446 |
+
"properties": {
|
| 447 |
+
"email_config": {
|
| 448 |
+
"type": "object",
|
| 449 |
+
"properties": {
|
| 450 |
+
"email": {"type": "string"},
|
| 451 |
+
"password": {"type": "string"},
|
| 452 |
+
"imap_server": {"type": "string"}
|
| 453 |
+
},
|
| 454 |
+
"required": ["email", "password", "imap_server"]
|
| 455 |
+
},
|
| 456 |
+
"days_back": {"type": "integer", "default": 30},
|
| 457 |
+
"passwords": {
|
| 458 |
+
"type": "object",
|
| 459 |
+
"additionalProperties": {"type": "string"}
|
| 460 |
+
}
|
| 461 |
+
},
|
| 462 |
+
"required": ["email_config"]
|
| 463 |
}
|
| 464 |
+
)
|
| 465 |
+
|
| 466 |
+
server.register_tool(
|
| 467 |
+
"analyze_pdf_statements",
|
| 468 |
+
"Analyze uploaded PDF statements",
|
| 469 |
+
analyze_pdf_statements_tool,
|
| 470 |
+
input_schema={
|
| 471 |
+
"type": "object",
|
| 472 |
+
"properties": {
|
| 473 |
+
"pdf_contents": {
|
| 474 |
+
"type": "object",
|
| 475 |
+
"additionalProperties": {"type": "string", "format": "binary"}
|
| 476 |
+
},
|
| 477 |
+
"passwords": {
|
| 478 |
+
"type": "object",
|
| 479 |
+
"additionalProperties": {"type": "string"}
|
| 480 |
+
}
|
| 481 |
+
},
|
| 482 |
+
"required": ["pdf_contents"]
|
| 483 |
+
}
|
| 484 |
+
)
|
| 485 |
+
|
| 486 |
+
server.register_tool(
|
| 487 |
+
"get_ai_analysis",
|
| 488 |
+
"Get AI financial analysis (Claude or SambaNova)",
|
| 489 |
+
get_ai_analysis_tool,
|
| 490 |
+
input_schema={
|
| 491 |
+
"type": "object",
|
| 492 |
+
"properties": {
|
| 493 |
+
"analysis_data": {"type": "object"},
|
| 494 |
+
"user_question": {"type": "string"},
|
| 495 |
+
"provider": {
|
| 496 |
+
"type": "string",
|
| 497 |
+
"enum": ["claude", "sambanova"],
|
| 498 |
+
"default": "claude"
|
| 499 |
+
}
|
| 500 |
+
},
|
| 501 |
+
"required": ["analysis_data"]
|
| 502 |
+
}
|
| 503 |
+
)
|
| 504 |
+
|
| 505 |
+
# Register all resources for the MCP server
|
| 506 |
+
def register_all_resources(server: MCPServer):
|
| 507 |
+
"""Register all resources with the MCP server"""
|
| 508 |
+
|
| 509 |
+
# Spending insights resource
|
| 510 |
+
async def get_spending_insights_resource():
|
| 511 |
+
"""Resource handler for spending insights"""
|
| 512 |
+
from dataclasses import asdict
|
| 513 |
+
analyzer = SpendAnalyzer()
|
| 514 |
|
| 515 |
+
# Try to load sample data if available
|
| 516 |
+
try:
|
| 517 |
+
import os
|
| 518 |
+
import json
|
| 519 |
+
|
| 520 |
+
sample_path = os.path.join(os.path.dirname(__file__), "sample_data", "transactions.json")
|
| 521 |
+
if os.path.exists(sample_path):
|
| 522 |
+
with open(sample_path, 'r') as f:
|
| 523 |
+
transactions = json.load(f)
|
| 524 |
+
analyzer.load_transactions(transactions)
|
| 525 |
+
except Exception as e:
|
| 526 |
+
logging.warning(f"Could not load sample data: {e}")
|
| 527 |
+
# Return empty insights if no data
|
| 528 |
+
return []
|
| 529 |
+
|
| 530 |
+
# Convert SpendingInsight objects to dictionaries
|
| 531 |
+
insights = analyzer.analyze_spending_by_category()
|
| 532 |
+
return [asdict(insight) for insight in insights]
|
| 533 |
+
|
| 534 |
+
# Budget alerts resource
|
| 535 |
+
async def get_budget_alerts_resource():
|
| 536 |
+
"""Resource handler for budget alerts"""
|
| 537 |
+
from dataclasses import asdict
|
| 538 |
+
analyzer = SpendAnalyzer()
|
| 539 |
+
|
| 540 |
+
# Try to load sample data and budgets if available
|
| 541 |
+
try:
|
| 542 |
+
import os
|
| 543 |
+
import json
|
| 544 |
+
|
| 545 |
+
sample_path = os.path.join(os.path.dirname(__file__), "sample_data", "transactions.json")
|
| 546 |
+
budgets_path = os.path.join(os.path.dirname(__file__), "sample_data", "budgets.json")
|
| 547 |
+
|
| 548 |
+
if os.path.exists(sample_path) and os.path.exists(budgets_path):
|
| 549 |
+
with open(sample_path, 'r') as f:
|
| 550 |
+
transactions = json.load(f)
|
| 551 |
+
with open(budgets_path, 'r') as f:
|
| 552 |
+
budgets = json.load(f)
|
| 553 |
+
|
| 554 |
+
analyzer.load_transactions(transactions)
|
| 555 |
+
analyzer.set_budgets(budgets)
|
| 556 |
+
except Exception as e:
|
| 557 |
+
logging.warning(f"Could not load sample data: {e}")
|
| 558 |
+
# Return empty alerts if no data
|
| 559 |
+
return []
|
| 560 |
+
|
| 561 |
+
# Convert BudgetAlert objects to dictionaries
|
| 562 |
+
alerts = analyzer.check_budget_alerts()
|
| 563 |
+
return [asdict(alert) for alert in alerts]
|
| 564 |
+
|
| 565 |
+
# Financial summary resource
|
| 566 |
+
async def get_financial_summary_resource():
|
| 567 |
+
"""Resource handler for financial summary"""
|
| 568 |
+
from dataclasses import asdict
|
| 569 |
+
analyzer = SpendAnalyzer()
|
| 570 |
+
|
| 571 |
+
# Try to load sample data if available
|
| 572 |
+
try:
|
| 573 |
+
import os
|
| 574 |
+
import json
|
| 575 |
+
|
| 576 |
+
sample_path = os.path.join(os.path.dirname(__file__), "sample_data", "transactions.json")
|
| 577 |
+
if os.path.exists(sample_path):
|
| 578 |
+
with open(sample_path, 'r') as f:
|
| 579 |
+
transactions = json.load(f)
|
| 580 |
+
analyzer.load_transactions(transactions)
|
| 581 |
+
except Exception as e:
|
| 582 |
+
logging.warning(f"Could not load sample data: {e}")
|
| 583 |
+
# Return empty summary if no data
|
| 584 |
+
return {
|
| 585 |
+
"total_income": 0,
|
| 586 |
+
"total_expenses": 0,
|
| 587 |
+
"net_cash_flow": 0,
|
| 588 |
+
"largest_expense": {},
|
| 589 |
+
"most_frequent_category": "",
|
| 590 |
+
"unusual_transactions": [],
|
| 591 |
+
"monthly_trends": {}
|
| 592 |
+
}
|
| 593 |
+
|
| 594 |
+
# Convert FinancialSummary object to dictionary
|
| 595 |
+
summary = analyzer.generate_financial_summary()
|
| 596 |
+
return asdict(summary)
|
| 597 |
|
| 598 |
+
# Register resources
|
| 599 |
+
server.register_resource(
|
| 600 |
+
uri="spending-insights",
|
| 601 |
+
name="Spending Insights",
|
| 602 |
+
description="Current spending insights by category",
|
| 603 |
+
handler=get_spending_insights_resource
|
| 604 |
+
)
|
| 605 |
+
|
| 606 |
+
server.register_resource(
|
| 607 |
+
uri="budget-alerts",
|
| 608 |
+
name="Budget Alerts",
|
| 609 |
+
description="Current budget alerts and overspending warnings",
|
| 610 |
+
handler=get_budget_alerts_resource
|
| 611 |
+
)
|
| 612 |
+
|
| 613 |
+
server.register_resource(
|
| 614 |
+
uri="financial-summary",
|
| 615 |
+
name="Financial Summary",
|
| 616 |
+
description="Comprehensive financial summary and analysis",
|
| 617 |
+
handler=get_financial_summary_resource
|
| 618 |
+
)
|
| 619 |
+
|
| 620 |
+
# Create FastAPI app for MCP server
|
| 621 |
+
def create_mcp_app():
|
| 622 |
+
"""Create a FastAPI app for the MCP server"""
|
| 623 |
+
app = FastAPI(title="Spend Analyzer MCP Server")
|
| 624 |
+
server = MCPServer()
|
| 625 |
+
|
| 626 |
+
# Register tools and resources
|
| 627 |
+
register_all_tools(server)
|
| 628 |
+
register_all_resources(server)
|
| 629 |
+
|
| 630 |
+
@app.post("/mcp")
|
| 631 |
+
async def handle_mcp_request(request: Request):
|
| 632 |
+
"""Handle MCP protocol requests"""
|
| 633 |
+
try:
|
| 634 |
+
data = await request.json()
|
| 635 |
+
return await server.handle_message(data)
|
| 636 |
+
except Exception as e:
|
| 637 |
+
return {
|
| 638 |
+
"jsonrpc": "2.0",
|
| 639 |
+
"id": None,
|
| 640 |
+
"error": {
|
| 641 |
+
"code": -32700,
|
| 642 |
+
"message": f"Parse error: {str(e)}"
|
| 643 |
+
}
|
| 644 |
+
}
|
| 645 |
+
|
| 646 |
+
@app.get("/")
|
| 647 |
+
async def root():
|
| 648 |
+
"""Root endpoint with server info"""
|
| 649 |
+
return {
|
| 650 |
+
"name": "Spend Analyzer MCP Server",
|
| 651 |
+
"version": "1.0.0",
|
| 652 |
+
"description": "MCP server for financial analysis",
|
| 653 |
+
"endpoints": {
|
| 654 |
+
"/mcp": "MCP protocol endpoint",
|
| 655 |
+
"/docs": "API documentation"
|
| 656 |
+
}
|
| 657 |
+
}
|
| 658 |
+
|
| 659 |
+
return app
|
| 660 |
+
|
| 661 |
+
# Run standalone MCP server
|
| 662 |
+
def run_mcp_server(host='0.0.0.0', port=8000):
|
| 663 |
+
"""Run a standalone MCP server"""
|
| 664 |
+
app = create_mcp_app()
|
| 665 |
+
uvicorn.run(app, host=host, port=port)
|
| 666 |
+
|
| 667 |
+
# Example usage and testing
|
| 668 |
+
if __name__ == "__main__":
|
| 669 |
+
# Run the standalone MCP server
|
| 670 |
+
print("Starting Spend Analyzer MCP Server...")
|
| 671 |
+
print("MCP endpoint will be available at: http://localhost:8000/mcp")
|
| 672 |
+
print("API documentation will be available at: http://localhost:8000/docs")
|
| 673 |
+
run_mcp_server()
|
modal_deployment.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 1 |
"""
|
| 2 |
Modal.com Deployment Configuration for Spend Analyzer MCP
|
|
|
|
| 3 |
"""
|
| 4 |
import modal
|
| 5 |
import os
|
|
@@ -10,7 +11,7 @@ from datetime import datetime
|
|
| 10 |
import logging
|
| 11 |
|
| 12 |
# Create Modal app
|
| 13 |
-
app = modal.App("spend-analyzer-mcp")
|
| 14 |
|
| 15 |
# Define the container image with all dependencies
|
| 16 |
image = (
|
|
@@ -23,22 +24,29 @@ image = (
|
|
| 23 |
"numpy",
|
| 24 |
"PyPDF2",
|
| 25 |
"PyMuPDF",
|
| 26 |
-
"anthropic",
|
|
|
|
| 27 |
"python-multipart",
|
| 28 |
"aiofiles",
|
| 29 |
"python-dotenv",
|
| 30 |
"imaplib2",
|
| 31 |
"email-validator",
|
| 32 |
-
"pydantic",
|
| 33 |
"websockets",
|
| 34 |
-
"asyncio-mqtt"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
])
|
| 36 |
-
.apt_install(["tesseract-ocr", "tesseract-ocr-eng"])
|
| 37 |
)
|
| 38 |
|
| 39 |
# Secrets for API keys and email credentials
|
| 40 |
secrets = [
|
| 41 |
modal.Secret.from_name("anthropic-api-key"), # ANTHROPIC_API_KEY
|
|
|
|
| 42 |
modal.Secret.from_name("email-credentials"), # EMAIL_USER, EMAIL_PASS, IMAP_SERVER
|
| 43 |
]
|
| 44 |
|
|
@@ -197,16 +205,12 @@ def analyze_uploaded_statements(pdf_contents: Dict[str, bytes], passwords: Optio
|
|
| 197 |
volumes={"/data": volume},
|
| 198 |
timeout=30
|
| 199 |
)
|
| 200 |
-
def
|
| 201 |
"""
|
| 202 |
-
Modal function to get
|
| 203 |
"""
|
| 204 |
-
import anthropic
|
| 205 |
-
|
| 206 |
try:
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
# Prepare context for Claude
|
| 210 |
context = f"""
|
| 211 |
Financial Analysis Data:
|
| 212 |
{json.dumps(analysis_data, indent=2, default=str)}
|
|
@@ -214,38 +218,107 @@ def get_claude_analysis(analysis_data: Dict, user_question: str = ""):
|
|
| 214 |
User Question: {user_question if user_question else "Please provide a comprehensive analysis of my spending patterns and recommendations."}
|
| 215 |
"""
|
| 216 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 217 |
response = client.messages.create(
|
| 218 |
model="claude-3-sonnet-20240229",
|
| 219 |
max_tokens=1500,
|
| 220 |
messages=[
|
| 221 |
{
|
| 222 |
"role": "user",
|
| 223 |
-
"content":
|
| 224 |
-
You are a financial advisor analyzing bank statement data.
|
| 225 |
-
Based on the provided financial data, give insights about:
|
| 226 |
-
|
| 227 |
-
1. Spending patterns and trends
|
| 228 |
-
2. Budget adherence and alerts
|
| 229 |
-
3. Unusual transactions that need attention
|
| 230 |
-
4. Specific recommendations for improvement
|
| 231 |
-
5. Answer to the user's specific question if provided
|
| 232 |
-
|
| 233 |
-
Be specific, actionable, and highlight both positive aspects and areas for improvement.
|
| 234 |
-
|
| 235 |
-
{context}
|
| 236 |
-
"""
|
| 237 |
}
|
| 238 |
]
|
| 239 |
)
|
| 240 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 241 |
return {
|
| 242 |
-
'
|
| 243 |
-
'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 244 |
}
|
| 245 |
|
| 246 |
except Exception as e:
|
| 247 |
return {'error': f"Claude API error: {str(e)}"}
|
| 248 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 249 |
@app.function(
|
| 250 |
image=image,
|
| 251 |
volumes={"/data": volume},
|
|
@@ -317,7 +390,7 @@ def load_user_data(user_id: str):
|
|
| 317 |
secrets=secrets,
|
| 318 |
volumes={"/data": volume}
|
| 319 |
)
|
| 320 |
-
@modal.
|
| 321 |
def mcp_webhook(request_data: Dict):
|
| 322 |
"""
|
| 323 |
Webhook endpoint for MCP protocol messages
|
|
@@ -347,14 +420,15 @@ def mcp_webhook(request_data: Dict):
|
|
| 347 |
async def get_analysis_tool(args):
|
| 348 |
analysis_data = args.get('analysis_data', {})
|
| 349 |
user_question = args.get('user_question', '')
|
|
|
|
| 350 |
|
| 351 |
-
result =
|
| 352 |
return result
|
| 353 |
|
| 354 |
# Register tools with MCP server
|
| 355 |
server.register_tool("process_email_statements", "Process bank statements from email", process_statements_tool)
|
| 356 |
server.register_tool("analyze_pdf_statements", "Analyze uploaded PDF statements", analyze_pdf_tool)
|
| 357 |
-
server.register_tool("
|
| 358 |
|
| 359 |
# Handle MCP message
|
| 360 |
response = asyncio.run(server.handle_message(request_data))
|
|
@@ -384,9 +458,9 @@ def main():
|
|
| 384 |
"recommendations": ["Test recommendation"]
|
| 385 |
}
|
| 386 |
|
| 387 |
-
result =
|
| 388 |
-
print("
|
| 389 |
|
| 390 |
if __name__ == "__main__":
|
| 391 |
# For running locally
|
| 392 |
-
modal.run(main)
|
|
|
|
| 1 |
"""
|
| 2 |
Modal.com Deployment Configuration for Spend Analyzer MCP
|
| 3 |
+
Enhanced with Claude and SambaNova Cloud API support
|
| 4 |
"""
|
| 5 |
import modal
|
| 6 |
import os
|
|
|
|
| 11 |
import logging
|
| 12 |
|
| 13 |
# Create Modal app
|
| 14 |
+
app = modal.App("spend-analyzer-mcp-bmt")
|
| 15 |
|
| 16 |
# Define the container image with all dependencies
|
| 17 |
image = (
|
|
|
|
| 24 |
"numpy",
|
| 25 |
"PyPDF2",
|
| 26 |
"PyMuPDF",
|
| 27 |
+
"anthropic>=0.7.0",
|
| 28 |
+
"openai>=1.0.0",
|
| 29 |
"python-multipart",
|
| 30 |
"aiofiles",
|
| 31 |
"python-dotenv",
|
| 32 |
"imaplib2",
|
| 33 |
"email-validator",
|
| 34 |
+
"pydantic>=1.10.0",
|
| 35 |
"websockets",
|
| 36 |
+
"asyncio-mqtt",
|
| 37 |
+
"python-dateutil",
|
| 38 |
+
"regex",
|
| 39 |
+
"plotly>=5.0.0",
|
| 40 |
+
"requests>=2.28.0",
|
| 41 |
+
"httpx>=0.24.0"
|
| 42 |
])
|
| 43 |
+
.apt_install(["tesseract-ocr", "tesseract-ocr-eng", "poppler-utils"])
|
| 44 |
)
|
| 45 |
|
| 46 |
# Secrets for API keys and email credentials
|
| 47 |
secrets = [
|
| 48 |
modal.Secret.from_name("anthropic-api-key"), # ANTHROPIC_API_KEY
|
| 49 |
+
modal.Secret.from_name("sambanova-api-key"), # SAMBANOVA_API_KEY
|
| 50 |
modal.Secret.from_name("email-credentials"), # EMAIL_USER, EMAIL_PASS, IMAP_SERVER
|
| 51 |
]
|
| 52 |
|
|
|
|
| 205 |
volumes={"/data": volume},
|
| 206 |
timeout=30
|
| 207 |
)
|
| 208 |
+
def get_ai_analysis(analysis_data: Dict, user_question: str = "", provider: str = "claude"):
|
| 209 |
"""
|
| 210 |
+
Modal function to get AI analysis of spending data using Claude or SambaNova
|
| 211 |
"""
|
|
|
|
|
|
|
| 212 |
try:
|
| 213 |
+
# Prepare context for AI
|
|
|
|
|
|
|
| 214 |
context = f"""
|
| 215 |
Financial Analysis Data:
|
| 216 |
{json.dumps(analysis_data, indent=2, default=str)}
|
|
|
|
| 218 |
User Question: {user_question if user_question else "Please provide a comprehensive analysis of my spending patterns and recommendations."}
|
| 219 |
"""
|
| 220 |
|
| 221 |
+
prompt = f"""
|
| 222 |
+
You are a financial advisor analyzing bank statement data.
|
| 223 |
+
Based on the provided financial data, give insights about:
|
| 224 |
+
|
| 225 |
+
1. Spending patterns and trends
|
| 226 |
+
2. Budget adherence and alerts
|
| 227 |
+
3. Unusual transactions that need attention
|
| 228 |
+
4. Specific recommendations for improvement
|
| 229 |
+
5. Answer to the user's specific question if provided
|
| 230 |
+
|
| 231 |
+
Be specific, actionable, and highlight both positive aspects and areas for improvement.
|
| 232 |
+
|
| 233 |
+
{context}
|
| 234 |
+
"""
|
| 235 |
+
|
| 236 |
+
if provider.lower() == "claude":
|
| 237 |
+
return _get_claude_analysis(prompt)
|
| 238 |
+
elif provider.lower() == "sambanova":
|
| 239 |
+
return _get_sambanova_analysis(prompt)
|
| 240 |
+
else:
|
| 241 |
+
# Default to Claude
|
| 242 |
+
return _get_claude_analysis(prompt)
|
| 243 |
+
|
| 244 |
+
except Exception as e:
|
| 245 |
+
return {'error': f"AI API error: {str(e)}"}
|
| 246 |
+
|
| 247 |
+
def _get_claude_analysis(prompt: str) -> Dict:
|
| 248 |
+
"""Get analysis from Claude API"""
|
| 249 |
+
try:
|
| 250 |
+
import anthropic
|
| 251 |
+
|
| 252 |
+
client = anthropic.Anthropic(api_key=os.environ["ANTHROPIC_API_KEY"])
|
| 253 |
+
|
| 254 |
response = client.messages.create(
|
| 255 |
model="claude-3-sonnet-20240229",
|
| 256 |
max_tokens=1500,
|
| 257 |
messages=[
|
| 258 |
{
|
| 259 |
"role": "user",
|
| 260 |
+
"content": prompt
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 261 |
}
|
| 262 |
]
|
| 263 |
)
|
| 264 |
|
| 265 |
+
# Handle different response formats
|
| 266 |
+
if hasattr(response.content[0], 'text'):
|
| 267 |
+
analysis_text = response.content[0].text
|
| 268 |
+
else:
|
| 269 |
+
analysis_text = str(response.content[0])
|
| 270 |
+
|
| 271 |
return {
|
| 272 |
+
'ai_analysis': analysis_text,
|
| 273 |
+
'provider': 'claude',
|
| 274 |
+
'model': 'claude-3-sonnet-20240229',
|
| 275 |
+
'usage': {
|
| 276 |
+
'input_tokens': response.usage.input_tokens,
|
| 277 |
+
'output_tokens': response.usage.output_tokens,
|
| 278 |
+
'total_tokens': response.usage.input_tokens + response.usage.output_tokens
|
| 279 |
+
}
|
| 280 |
}
|
| 281 |
|
| 282 |
except Exception as e:
|
| 283 |
return {'error': f"Claude API error: {str(e)}"}
|
| 284 |
|
| 285 |
+
def _get_sambanova_analysis(prompt: str) -> Dict:
|
| 286 |
+
"""Get analysis from SambaNova Cloud API"""
|
| 287 |
+
try:
|
| 288 |
+
import openai
|
| 289 |
+
|
| 290 |
+
# SambaNova uses OpenAI-compatible API
|
| 291 |
+
client = openai.OpenAI(
|
| 292 |
+
api_key=os.environ["SAMBANOVA_API_KEY"],
|
| 293 |
+
base_url="https://api.sambanova.ai/v1"
|
| 294 |
+
)
|
| 295 |
+
|
| 296 |
+
response = client.chat.completions.create(
|
| 297 |
+
model="Meta-Llama-3.1-8B-Instruct", # SambaNova model
|
| 298 |
+
messages=[
|
| 299 |
+
{
|
| 300 |
+
"role": "user",
|
| 301 |
+
"content": prompt
|
| 302 |
+
}
|
| 303 |
+
],
|
| 304 |
+
max_tokens=1500,
|
| 305 |
+
temperature=0.7
|
| 306 |
+
)
|
| 307 |
+
|
| 308 |
+
return {
|
| 309 |
+
'ai_analysis': response.choices[0].message.content,
|
| 310 |
+
'provider': 'sambanova',
|
| 311 |
+
'model': 'Meta-Llama-3.1-8B-Instruct',
|
| 312 |
+
'usage': {
|
| 313 |
+
'input_tokens': response.usage.prompt_tokens,
|
| 314 |
+
'output_tokens': response.usage.completion_tokens,
|
| 315 |
+
'total_tokens': response.usage.total_tokens
|
| 316 |
+
}
|
| 317 |
+
}
|
| 318 |
+
|
| 319 |
+
except Exception as e:
|
| 320 |
+
return {'error': f"SambaNova API error: {str(e)}"}
|
| 321 |
+
|
| 322 |
@app.function(
|
| 323 |
image=image,
|
| 324 |
volumes={"/data": volume},
|
|
|
|
| 390 |
secrets=secrets,
|
| 391 |
volumes={"/data": volume}
|
| 392 |
)
|
| 393 |
+
@modal.fastapi_endpoint(method="POST")
|
| 394 |
def mcp_webhook(request_data: Dict):
|
| 395 |
"""
|
| 396 |
Webhook endpoint for MCP protocol messages
|
|
|
|
| 420 |
async def get_analysis_tool(args):
|
| 421 |
analysis_data = args.get('analysis_data', {})
|
| 422 |
user_question = args.get('user_question', '')
|
| 423 |
+
provider = args.get('provider', 'claude')
|
| 424 |
|
| 425 |
+
result = get_ai_analysis.remote(analysis_data, user_question, provider)
|
| 426 |
return result
|
| 427 |
|
| 428 |
# Register tools with MCP server
|
| 429 |
server.register_tool("process_email_statements", "Process bank statements from email", process_statements_tool)
|
| 430 |
server.register_tool("analyze_pdf_statements", "Analyze uploaded PDF statements", analyze_pdf_tool)
|
| 431 |
+
server.register_tool("get_ai_analysis", "Get AI financial analysis (Claude or SambaNova)", get_analysis_tool)
|
| 432 |
|
| 433 |
# Handle MCP message
|
| 434 |
response = asyncio.run(server.handle_message(request_data))
|
|
|
|
| 458 |
"recommendations": ["Test recommendation"]
|
| 459 |
}
|
| 460 |
|
| 461 |
+
result = get_ai_analysis.remote(test_data, "What do you think about my spending?", "claude")
|
| 462 |
+
print("AI analysis result:", result)
|
| 463 |
|
| 464 |
if __name__ == "__main__":
|
| 465 |
# For running locally
|
| 466 |
+
modal.run(main)
|
requirements.txt
CHANGED
|
@@ -8,13 +8,29 @@ numpy>=1.21.0
|
|
| 8 |
PyPDF2>=3.0.0
|
| 9 |
PyMuPDF>=1.20.0
|
| 10 |
|
| 11 |
-
# AI and API
|
| 12 |
anthropic>=0.7.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
# Async and utilities
|
| 15 |
python-dotenv>=0.19.0
|
| 16 |
pydantic>=1.10.0
|
|
|
|
|
|
|
| 17 |
|
| 18 |
-
#
|
| 19 |
uvicorn>=0.18.0
|
| 20 |
fastapi>=0.85.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
PyPDF2>=3.0.0
|
| 9 |
PyMuPDF>=1.20.0
|
| 10 |
|
| 11 |
+
# AI and API
|
| 12 |
anthropic>=0.7.0
|
| 13 |
+
openai>=1.0.0
|
| 14 |
+
|
| 15 |
+
# Email processing
|
| 16 |
+
imaplib2>=0.57
|
| 17 |
+
email-validator>=1.3.0
|
| 18 |
|
| 19 |
# Async and utilities
|
| 20 |
python-dotenv>=0.19.0
|
| 21 |
pydantic>=1.10.0
|
| 22 |
+
aiofiles>=0.8.0
|
| 23 |
+
python-multipart>=0.0.5
|
| 24 |
|
| 25 |
+
# Web framework
|
| 26 |
uvicorn>=0.18.0
|
| 27 |
fastapi>=0.85.0
|
| 28 |
+
websockets>=10.0
|
| 29 |
+
|
| 30 |
+
# Modal deployment
|
| 31 |
+
modal>=0.56.0
|
| 32 |
+
|
| 33 |
+
# Additional utilities
|
| 34 |
+
python-dateutil>=2.8.0
|
| 35 |
+
regex>=2022.0.0
|
| 36 |
+
asyncio-mqtt>=0.11.0
|
secure_storage_utils.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Simplified Storage Utilities for API Keys and Settings
|
| 3 |
+
Provides basic storage utilities without complex warning systems
|
| 4 |
+
"""
|
| 5 |
+
import json
|
| 6 |
+
import os
|
| 7 |
+
from typing import Dict, Any, Optional
|
| 8 |
+
from datetime import datetime
|
| 9 |
+
import logging
|
| 10 |
+
|
| 11 |
+
class SecureStorageManager:
|
| 12 |
+
"""
|
| 13 |
+
Simple storage manager for API keys and settings
|
| 14 |
+
Focuses on environment variables and config file loading
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
def __init__(self):
|
| 18 |
+
self.logger = logging.getLogger(__name__)
|
| 19 |
+
|
| 20 |
+
def create_simple_warning_html(self) -> str:
|
| 21 |
+
"""Create a simple warning for AI settings section"""
|
| 22 |
+
return """
|
| 23 |
+
<div style="
|
| 24 |
+
background-color: #fff3cd;
|
| 25 |
+
border: 1px solid #ffeaa7;
|
| 26 |
+
border-radius: 6px;
|
| 27 |
+
padding: 12px;
|
| 28 |
+
margin: 8px 0;
|
| 29 |
+
">
|
| 30 |
+
<div style="color: #856404; display: flex; align-items: center; gap: 8px;">
|
| 31 |
+
<span style="font-size: 18px;">⚠️</span>
|
| 32 |
+
<div>
|
| 33 |
+
<strong>API Key Storage Notice</strong><br>
|
| 34 |
+
<small>API keys will be cleared when you refresh/reload the page or close the browser.
|
| 35 |
+
Please keep your API keys and model information handy for re-entry when needed.</small>
|
| 36 |
+
</div>
|
| 37 |
+
</div>
|
| 38 |
+
</div>
|
| 39 |
+
"""
|
| 40 |
+
|
| 41 |
+
def load_from_environment(self) -> Dict[str, Any]:
|
| 42 |
+
"""Load API keys from environment variables"""
|
| 43 |
+
config = {}
|
| 44 |
+
|
| 45 |
+
# Claude API
|
| 46 |
+
if os.getenv('CLAUDE_API_KEY'):
|
| 47 |
+
config['claude'] = {
|
| 48 |
+
'api_key': os.getenv('CLAUDE_API_KEY'),
|
| 49 |
+
'model': os.getenv('CLAUDE_MODEL', 'claude-3-5-sonnet-20241022'),
|
| 50 |
+
'api_url': os.getenv('CLAUDE_API_URL', 'https://api.anthropic.com')
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
# SambaNova API
|
| 54 |
+
if os.getenv('SAMBANOVA_API_KEY'):
|
| 55 |
+
config['sambanova'] = {
|
| 56 |
+
'api_key': os.getenv('SAMBANOVA_API_KEY'),
|
| 57 |
+
'model': os.getenv('SAMBANOVA_MODEL', 'Meta-Llama-3.1-70B-Instruct'),
|
| 58 |
+
'api_url': os.getenv('SAMBANOVA_API_URL', 'https://api.sambanova.ai')
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
# LM Studio API
|
| 62 |
+
if os.getenv('LM_STUDIO_URL'):
|
| 63 |
+
config['lm_studio'] = {
|
| 64 |
+
'api_url': os.getenv('LM_STUDIO_URL', 'http://localhost:1234/v1'),
|
| 65 |
+
'model': os.getenv('LM_STUDIO_MODEL', 'local-model')
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
# Ollama API
|
| 69 |
+
if os.getenv('OLLAMA_URL'):
|
| 70 |
+
config['ollama'] = {
|
| 71 |
+
'api_url': os.getenv('OLLAMA_URL', 'http://localhost:11434'),
|
| 72 |
+
'model': os.getenv('OLLAMA_MODEL', 'llama3.1')
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
# Custom API
|
| 76 |
+
if os.getenv('CUSTOM_API_URL'):
|
| 77 |
+
config['custom'] = {
|
| 78 |
+
'api_url': os.getenv('CUSTOM_API_URL'),
|
| 79 |
+
'api_key': os.getenv('CUSTOM_API_KEY', ''),
|
| 80 |
+
'model': os.getenv('CUSTOM_MODEL', 'default')
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
return config
|
| 84 |
+
|
| 85 |
+
def load_config_from_file(self, config_path: str = "config.json") -> Optional[Dict[str, Any]]:
|
| 86 |
+
"""Load configuration from file"""
|
| 87 |
+
try:
|
| 88 |
+
if os.path.exists(config_path):
|
| 89 |
+
with open(config_path, 'r') as f:
|
| 90 |
+
return json.load(f)
|
| 91 |
+
except Exception as e:
|
| 92 |
+
self.logger.error(f"Failed to load config file: {e}")
|
| 93 |
+
return None
|
| 94 |
+
|
| 95 |
+
def create_config_file_template(self) -> Dict[str, Any]:
|
| 96 |
+
"""Create a template for configuration file"""
|
| 97 |
+
return {
|
| 98 |
+
"api_keys": {
|
| 99 |
+
"claude": {
|
| 100 |
+
"api_key": "your-claude-api-key-here",
|
| 101 |
+
"model": "claude-3-5-sonnet-20241022",
|
| 102 |
+
"api_url": "https://api.anthropic.com"
|
| 103 |
+
},
|
| 104 |
+
"sambanova": {
|
| 105 |
+
"api_key": "your-sambanova-api-key-here",
|
| 106 |
+
"model": "Meta-Llama-3.1-70B-Instruct",
|
| 107 |
+
"api_url": "https://api.sambanova.ai"
|
| 108 |
+
},
|
| 109 |
+
"lm_studio": {
|
| 110 |
+
"api_url": "http://localhost:1234/v1",
|
| 111 |
+
"model": "local-model"
|
| 112 |
+
},
|
| 113 |
+
"ollama": {
|
| 114 |
+
"api_url": "http://localhost:11434",
|
| 115 |
+
"model": "llama3.1"
|
| 116 |
+
}
|
| 117 |
+
},
|
| 118 |
+
"settings": {
|
| 119 |
+
"temperature": 0.7,
|
| 120 |
+
"max_tokens": 1000,
|
| 121 |
+
"enable_insights": True,
|
| 122 |
+
"enable_recommendations": True
|
| 123 |
+
},
|
| 124 |
+
"_metadata": {
|
| 125 |
+
"version": "1.0",
|
| 126 |
+
"created": datetime.now().isoformat(),
|
| 127 |
+
"description": "Spend Analyzer MCP Configuration File"
|
| 128 |
+
}
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
def save_config_template(self, config_path: str = "config.json.template") -> bool:
|
| 132 |
+
"""Save configuration template file"""
|
| 133 |
+
try:
|
| 134 |
+
template = self.create_config_file_template()
|
| 135 |
+
with open(config_path, 'w') as f:
|
| 136 |
+
json.dump(template, f, indent=2)
|
| 137 |
+
self.logger.info(f"Configuration template saved to {config_path}")
|
| 138 |
+
return True
|
| 139 |
+
except Exception as e:
|
| 140 |
+
self.logger.error(f"Failed to save config template: {e}")
|
| 141 |
+
return False
|
| 142 |
+
|
| 143 |
+
def get_environment_variables_guide(self) -> str:
|
| 144 |
+
"""Get guide for setting up environment variables"""
|
| 145 |
+
return """
|
| 146 |
+
# Environment Variables Setup Guide
|
| 147 |
+
|
| 148 |
+
## For Local Development:
|
| 149 |
+
|
| 150 |
+
### Windows (Command Prompt):
|
| 151 |
+
```cmd
|
| 152 |
+
set CLAUDE_API_KEY=your-claude-api-key-here
|
| 153 |
+
set SAMBANOVA_API_KEY=your-sambanova-api-key-here
|
| 154 |
+
set LM_STUDIO_URL=http://localhost:1234/v1
|
| 155 |
+
set OLLAMA_URL=http://localhost:11434
|
| 156 |
+
```
|
| 157 |
+
|
| 158 |
+
### Windows (PowerShell):
|
| 159 |
+
```powershell
|
| 160 |
+
$env:CLAUDE_API_KEY="your-claude-api-key-here"
|
| 161 |
+
$env:SAMBANOVA_API_KEY="your-sambanova-api-key-here"
|
| 162 |
+
$env:LM_STUDIO_URL="http://localhost:1234/v1"
|
| 163 |
+
$env:OLLAMA_URL="http://localhost:11434"
|
| 164 |
+
```
|
| 165 |
+
|
| 166 |
+
### macOS/Linux (Bash):
|
| 167 |
+
```bash
|
| 168 |
+
export CLAUDE_API_KEY="your-claude-api-key-here"
|
| 169 |
+
export SAMBANOVA_API_KEY="your-sambanova-api-key-here"
|
| 170 |
+
export LM_STUDIO_URL="http://localhost:1234/v1"
|
| 171 |
+
export OLLAMA_URL="http://localhost:11434"
|
| 172 |
+
```
|
| 173 |
+
|
| 174 |
+
### .env File (Recommended):
|
| 175 |
+
Create a `.env` file in your project directory:
|
| 176 |
+
```
|
| 177 |
+
CLAUDE_API_KEY=your-claude-api-key-here
|
| 178 |
+
SAMBANOVA_API_KEY=your-sambanova-api-key-here
|
| 179 |
+
LM_STUDIO_URL=http://localhost:1234/v1
|
| 180 |
+
OLLAMA_URL=http://localhost:11434
|
| 181 |
+
```
|
| 182 |
+
|
| 183 |
+
## Security Best Practices:
|
| 184 |
+
1. Never commit API keys to version control
|
| 185 |
+
2. Use different keys for development and production
|
| 186 |
+
3. Regularly rotate your API keys
|
| 187 |
+
4. Monitor API usage for unusual activity
|
| 188 |
+
5. Use least-privilege access principles
|
| 189 |
+
"""
|